From b65d227c35aa313e128b5009c6e17758a600ea82 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 17 Nov 2025 11:51:25 +0000 Subject: [PATCH 001/212] Make method static. [ci skip] --- src/somd2/runner/_repex.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 4e4644f4..b02d4e8c 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -505,7 +505,8 @@ def get_swaps(self): """ return self._num_swaps - def _check_device_memory(self, index): + @staticmethod + def _check_device_memory(index): """ Check the memory usage of the specified CUDA device. From 5b36617e198496640dcc1ee00efd1082977519e9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 17 Nov 2025 14:16:01 +0000 Subject: [PATCH 002/212] Wrap NVML calls inside try/except. --- src/somd2/runner/_repex.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index b02d4e8c..00718a0d 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -516,18 +516,23 @@ def _check_device_memory(index): index: int The index of the CUDA device. """ - from pynvml import ( - nvmlInit, - nvmlShutdown, - nvmlDeviceGetHandleByIndex, - nvmlDeviceGetMemoryInfo, - ) + try: + from pynvml import ( + nvmlInit, + nvmlShutdown, + nvmlDeviceGetHandleByIndex, + nvmlDeviceGetMemoryInfo, + ) + + nvmlInit() + handle = nvmlDeviceGetHandleByIndex(index) + info = nvmlDeviceGetMemoryInfo(handle) + result = (info.used, info.free, info.total) + nvmlShutdown() + except Exception as e: + msg = f"Could not determine memory usage for device {index}: {e}" + _logger.error(msg) - nvmlInit() - handle = nvmlDeviceGetHandleByIndex(index) - info = nvmlDeviceGetMemoryInfo(handle) - result = (info.used, info.free, info.total) - nvmlShutdown() return result From a8ee2e76c380b293b0092fa7fef0446e7bb1d69d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 20 Nov 2025 17:50:53 +0000 Subject: [PATCH 003/212] Account for additional GCMC waters when comparing systems. --- src/somd2/runner/_base.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 40ce7b9b..984e13a9 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1127,8 +1127,14 @@ def _check_restart(self): _logger.error(msg) raise ValueError(msg) else: + if self._config.gcmc: + num_gcmc_waters = self._config.gcmc_num_waters + else: + num_gcmc_waters = 0 # Check the system is the same as the reference system. - are_same, reason = self._systems_are_same(self._system, system) + are_same, reason = self._systems_are_same( + self._system, system, num_gcmc_waters=num_gcmc_waters + ) if not are_same: raise ValueError( f"Checkpoint file does not match system for the following reason: {reason}." @@ -1313,7 +1319,7 @@ def get_last_config(output_directory): self._compare_configs(self._last_config, config) @staticmethod - def _systems_are_same(system0, system1): + def _systems_are_same(system0, system1, num_gcmc_waters=0): """ Check for equivalence between a pair of sire systems. @@ -1326,6 +1332,9 @@ def _systems_are_same(system0, system1): system1: sire.system.System The second system to be compared. + num_gcmc_waters: int + The number of GCMC ghost waters to ignore in the comparison. + Returns ------- @@ -1337,18 +1346,25 @@ def _systems_are_same(system0, system1): if not isinstance(system1, _System): raise TypeError("'system1' must be of type 'sire.system.System'") + try: + num_point = system0["water"].molecules()[0].num_atoms() + except: + num_point = 0 + # Check for matching number of molecules. - if not len(system0.molecules()) == len(system1.molecules()): + if not len(system0.molecules()) == len(system1.molecules()) - num_gcmc_waters: reason = "number of molecules do not match" return False, reason # Check for matching number of residues. - if not len(system0.residues()) == len(system1.residues()): + if not len(system0.residues()) == len(system1.residues()) - num_gcmc_waters: reason = "number of residues do not match" return False, reason # Check for matching number of atoms. - if not len(system0.atoms()) == len(system1.atoms()): + if not len(system0.atoms()) == len(system1.atoms()) - ( + num_gcmc_waters * num_point + ): reason = "number of atoms do not match" return False, reason From 5d79bf50b9ae4cd2754a57e1f1e3059a9f71eb7f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 20 Nov 2025 18:22:34 +0000 Subject: [PATCH 004/212] Fix GCMC restart handling. --- src/somd2/runner/_base.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 984e13a9..7916b906 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -581,9 +581,13 @@ def __init__(self, system, config): # Make sure the selection is valid. if self._config.gcmc_selection is not None: + if isinstance(self._system, list): + mols = self._system[0] + else: + mols = self._system try: atoms = _sr.mol.selection_to_atoms( - self._system, self._config.gcmc_selection + mols, self._config.gcmc_selection ) except: msg = "Invalid 'gcmc_selection' value." @@ -1175,12 +1179,14 @@ def _check_restart(self): self._restart_ghost_waters = [] # List to store the current positions. self._restart_positions = [] - _logger.info("Removing existing ghost waters from GCMC checkpoint systems") + _logger.info( + "Determining existing ghost waters from GCMC checkpoint systems" + ) for i, system in enumerate(systems): # Store the positions of all atoms. self._restart_positions.append(_sr.io.get_coords_array(system)) if system is not None: - # Remove the ghost waters from the system. + # Find and log the current ghost waters. try: # Get the water molecule indices. waters = system.molecules().find(system["water"].molecules()) @@ -1195,14 +1201,16 @@ def _check_restart(self): idxs.append(waters.index(index)) self._restart_ghost_waters.append(idxs) - for mol in system["property is_ghost_water"].molecules(): - _logger.debug( - f"Removing ghost water molecule {mol.number()} for {_lam_sym}={self._lambda_values[i]:.5f}" - ) - system.remove(mol) except: pass + # Remove the additional GCMC waters from the end of the system. + for mol in system.molecules()[-self._config.gcmc_num_waters :]: + _logger.debug( + f"Removing GCMC water molecule {mol.number()} for {_lam_sym}={self._lambda_values[i]:.5f}" + ) + system.remove(mol) + return True, systems @staticmethod @@ -1265,6 +1273,9 @@ def _compare_configs(config1, config2): if (v1 == None and v2 == False) or (v2 == None and v1 == False): continue + # The GCMC frequency will be automaticall set if None. + elif key == "gcmc_frequency" and v1 is None: + continue elif v1 != v2: raise ValueError( f"{key} has changed since the last run. This is not " From c44b1beb0e93e5878feb846b5c19dade0af35006 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 21 Nov 2025 09:17:35 +0000 Subject: [PATCH 005/212] Make sure to backup and use lock for final checkpoint. --- src/somd2/runner/_runner.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 6cf44590..fe355d9d 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -938,16 +938,22 @@ def generate_lam_vals(lambda_base, increment=0.001): # Calculate the speed in nanoseconds per day. speed = time.to("ns") / days - # Checkpoint. - self._checkpoint( - system, - index, - 0, - speed, - is_final_block=True, - lambda_energy=lambda_energy, - lambda_grad=lambda_grad, - ) + # Acquire the file lock to ensure that the checkpoint files are + # in a consistent state if read by another process. + with lock.acquire(timeout=self._config.timeout.to("seconds")): + # Backup any existing checkpoint files. + self._backup_checkpoint(index) + + # Write the checkpoint files. + self._checkpoint( + system, + index, + 0, + speed, + lambda_energy=lambda_energy, + lambda_grad=lambda_grad, + is_final_block=True, + ) _logger.success( f"{_lam_sym} = {lambda_value:.5f} complete, speed = {speed:.2f} ns day-1" From 41625d848d2e81d4084a3c700dc87fa5adec278b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 21 Nov 2025 12:09:04 +0000 Subject: [PATCH 006/212] Pass through restart flag to Loch. --- src/somd2/runner/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 7916b906..5426552b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -704,6 +704,7 @@ def __init__(self, system, config): "shift_delta": str(self._config.shift_delta), "swap_end_states": self._config.swap_end_states, "tolerance": self._config.gcmc_tolerance, + "restart": self._is_restart, "overwrite": self._config.overwrite, "no_logger": True, } From 9693902b0baf60b8b0f25a772363719f97441e4f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 24 Nov 2025 11:19:29 +0000 Subject: [PATCH 007/212] Always save the GCMC topology at the final checkpoint. --- src/somd2/runner/_base.py | 42 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 5426552b..44ca71a1 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1562,27 +1562,6 @@ def _checkpoint( from somd2 import __version__, _sire_version, _sire_revisionid - # Save the end-state GCMC topologies for trajectory analysis and visualisation. - if self._config.gcmc: - # Only save for first block. - if block == 0: - mols0 = _sr.morph.link_to_reference(system) - mols1 = _sr.morph.link_to_perturbed(system) - - # Save to AMBER format. - _sr.save(mols0, self._filenames["topology0"]) - _sr.save(mols1, self._filenames["topology1"]) - - # Save to PDB format. - _sr.save( - mols0, - self._filenames["topology0"].replace(".prm7", ".pdb"), - ) - _sr.save( - mols1, - self._filenames["topology1"].replace(".prm7", ".pdb"), - ) - # Get the lambda value. lam = self._lambda_values[index] @@ -1608,6 +1587,27 @@ def _checkpoint( metadata["lambda_grad"] = lambda_grad if is_final_block: + # Save the end-state GCMC topologies for trajectory analysis and visualisation. + # This topology contains additional water molecules that are used for GCMC + # insertion moves. + if self._config.gcmc: + mols0 = _sr.morph.link_to_reference(system) + mols1 = _sr.morph.link_to_perturbed(system) + + # Save to AMBER format. + _sr.save(mols0, self._filenames["topology0"]) + _sr.save(mols1, self._filenames["topology1"]) + + # Save to PDB format. + _sr.save( + mols0, + self._filenames["topology0"].replace(".prm7", ".pdb"), + ) + _sr.save( + mols1, + self._filenames["topology1"].replace(".prm7", ".pdb"), + ) + # Assemble and save the final trajectory. if self._config.save_trajectories: # Save the final trajectory chunk to file. From cb21d09a19362b90f7e089483d6aaf511291919a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 25 Nov 2025 14:07:25 +0000 Subject: [PATCH 008/212] Move timer start outside of conditional. --- src/somd2/runner/_runner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index fe355d9d..241a190f 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -631,6 +631,9 @@ def generate_lam_vals(lambda_base, increment=0.001): # Store the checkpoint time in nanoseconds. checkpoint_interval = self._config.checkpoint_frequency.to("ns") + # Store the start time. + start = _timer() + # Run the simulation, checkpointing in blocks. if self._config.checkpoint_frequency.value() > 0.0: @@ -645,9 +648,6 @@ def generate_lam_vals(lambda_base, increment=0.001): num_blocks = int(frac) rem = round(frac - num_blocks, 12) - # Store the star time. - start = _timer() - # Run the dynamics in blocks. for block in range(int(num_blocks)): # Add the start block number. From a881ab2823ca2c3ad3f68021a73850a67ca909a7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 25 Nov 2025 14:07:48 +0000 Subject: [PATCH 009/212] Create a lock before acquiring. --- src/somd2/runner/_runner.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 241a190f..0e424739 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -938,6 +938,9 @@ def generate_lam_vals(lambda_base, increment=0.001): # Calculate the speed in nanoseconds per day. speed = time.to("ns") / days + # Create the lock. + lock = _FileLock(self._lock_file) + # Acquire the file lock to ensure that the checkpoint files are # in a consistent state if read by another process. with lock.acquire(timeout=self._config.timeout.to("seconds")): From 63398b48a9e17577962280ef6b9a7e330fa1d83b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 26 Nov 2025 19:01:22 +0000 Subject: [PATCH 010/212] Add missing nvidia-ml-py requirement. --- recipes/somd2/template.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/recipes/somd2/template.yaml b/recipes/somd2/template.yaml index 2df39a6c..0cd298a6 100644 --- a/recipes/somd2/template.yaml +++ b/recipes/somd2/template.yaml @@ -21,6 +21,7 @@ requirements: - pip - python - numba + - nvidia-ml-py # [linux] - setuptools - sire - versioningit @@ -30,6 +31,7 @@ requirements: - loch # [linux] - loguru - numba + - nvidia-ml-py # [linux] - python - sire From 96460b321b62cb27e50bc4288267a833f6e0ff94 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 27 Nov 2025 17:25:11 +0000 Subject: [PATCH 011/212] Always reset the system clock following equilibration. --- src/somd2/runner/_repex.py | 2 ++ src/somd2/runner/_runner.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 00718a0d..aa337608 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1387,6 +1387,8 @@ def _equilibrate(self, index): # Reset the timer. if self._initial_time[index].value() != 0: system.set_time(self._initial_time[index]) + else: + system.set_time(_sr.u("0ps")) # Delete the dynamics object. self._dynamics_cache.delete(index) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 0e424739..3ba968b3 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -523,6 +523,8 @@ def generate_lam_vals(lambda_base, increment=0.001): # Reset the timer. if self._initial_time[index].value() != 0: system.set_time(self._initial_time[index]) + else: + system.set_time(_sr.u("0ps")) except Exception as e: try: From aaab4a76b8a82d47755abab2242eb1d67056d7dc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Nov 2025 11:33:06 +0000 Subject: [PATCH 012/212] Allow user to control whether perturbable zero sigmas are fixed. --- src/somd2/_utils/_somd1.py | 64 ++++++++++++++++++++----------------- src/somd2/config/_config.py | 15 +++++++++ src/somd2/runner/_base.py | 9 ++++-- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index d95046c3..a4fa418f 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -84,7 +84,7 @@ def apply_pert(system, pert_file): return system -def make_compatible(system): +def make_compatible(system, fix_perturbable_zero_sigams=False): """ Makes a perturbation SOMD1 compatible. @@ -94,6 +94,9 @@ def make_compatible(system): system : sire.system.System, sire.legacy.System.System The system containing the molecules to be perturbed. + fix_perturbable_zero_sigams : bool + Whether to prevent LJ sigma values being perturbed to zero. + Returns ------- @@ -107,6 +110,9 @@ def make_compatible(system): "'system' must of type 'sire.system.System' or 'sire.legacy.System.System'" ) + if not isinstance(fix_perturbable_zero_sigams, bool): + raise TypeError("'fix_perturbable_zero_sigams' must be of type 'bool'.") + # Extract the legacy system. if isinstance(system, _LegacySystem): system = _System(system) @@ -132,36 +138,36 @@ def make_compatible(system): ################################## # First fix zero LJ sigmas values. ################################## - - # Tolerance for zero sigma values. - null_lj_sigma = 1e-9 - - for atom in mol.atoms(): - # Get the end state LJ sigma values. - lj0 = atom.property("LJ0") - lj1 = atom.property("LJ1") - - # Lambda = 0 state has a zero sigma value. - if abs(lj0.sigma().value()) <= null_lj_sigma: - # Use the sigma value from the lambda = 1 state. - edit_mol = ( - edit_mol.atom(atom.index()) - .set_property( - "LJ0", _SireMM.LJParameter(lj1.sigma(), lj0.epsilon()) + if fix_perturbable_zero_sigams: + # Tolerance for zero sigma values. + null_lj_sigma = 1e-9 + + for atom in mol.atoms(): + # Get the end state LJ sigma values. + lj0 = atom.property("LJ0") + lj1 = atom.property("LJ1") + + # Lambda = 0 state has a zero sigma value. + if abs(lj0.sigma().value()) <= null_lj_sigma: + # Use the sigma value from the lambda = 1 state. + edit_mol = ( + edit_mol.atom(atom.index()) + .set_property( + "LJ0", _SireMM.LJParameter(lj1.sigma(), lj0.epsilon()) + ) + .molecule() ) - .molecule() - ) - - # Lambda = 1 state has a zero sigma value. - if abs(lj1.sigma().value()) <= null_lj_sigma: - # Use the sigma value from the lambda = 0 state. - edit_mol = ( - edit_mol.atom(atom.index()) - .set_property( - "LJ1", _SireMM.LJParameter(lj0.sigma(), lj1.epsilon()) + + # Lambda = 1 state has a zero sigma value. + if abs(lj1.sigma().value()) <= null_lj_sigma: + # Use the sigma value from the lambda = 0 state. + edit_mol = ( + edit_mol.atom(atom.index()) + .set_property( + "LJ1", _SireMM.LJParameter(lj0.sigma(), lj1.epsilon()) + ) + .molecule() ) - .molecule() - ) ######################## # Now process the bonds. diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 2ed53075..590b2f2c 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -109,6 +109,7 @@ def __init__( include_constrained_energies=False, dynamic_constraints=True, ghost_modifications=True, + fix_perturbable_zero_sigmas=True, charge_difference=None, coalchemical_restraint_dist=None, com_reset_frequency=10, @@ -258,6 +259,9 @@ def __init__( sampling of non-physical conformations. We implement the recommended modifcations from https://pubs.acs.org/doi/10.1021/acs.jctc.0c01328 + fix_perturbable_zero_sigmas: bool + Whether to prevent LJ sigma values being perturbed to zero. + charge_difference: int The charge difference between the two end states. (Perturbed minus reference.) If None, then alchemical ions will automatically be @@ -498,6 +502,7 @@ def __init__( self.include_constrained_energies = include_constrained_energies self.dynamic_constraints = dynamic_constraints self.ghost_modifications = ghost_modifications + self.fix_perturbable_zero_sigmas = fix_perturbable_zero_sigmas self.charge_difference = charge_difference self.coalchemical_restraint_dist = coalchemical_restraint_dist self.com_reset_frequency = com_reset_frequency @@ -1145,6 +1150,16 @@ def ghost_modifications(self, ghost_modifications): raise ValueError("'ghost_modifications' must be of type 'bool'") self._ghost_modifications = ghost_modifications + @property + def fix_perturbable_zero_sigmas(self): + return self._fix_perturbable_zero_sigmas + + @fix_perturbable_zero_sigmas.setter + def fix_perturbable_zero_sigmas(self, fix_perturbable_zero_sigmas): + if not isinstance(fix_perturbable_zero_sigmas, bool): + raise ValueError("'fix_perturbable_zero_sigmas' must be of type 'bool'") + self._fix_perturbable_zero_sigmas = fix_perturbable_zero_sigmas + @property def charge_difference(self): return self._charge_difference diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 44ca71a1..2b8bbc34 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -190,7 +190,9 @@ def __init__(self, system, config): self._config._extra_args["check_for_h_by_ambertype"] = False # Make sure that perturbable LJ sigmas aren't scaled to zero. - self._config._extra_args["fix_perturbable_zero_sigmas"] = True + self._config._extra_args["fix_perturbable_zero_sigmas"] = ( + config.fix_perturbable_zero_sigmas + ) # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: @@ -199,7 +201,10 @@ def __init__(self, system, config): # First, try to make the perturbation SOMD1 compatible. _logger.info("Applying SOMD1 perturbation compatibility.") - self._system = make_compatible(self._system) + self._system = make_compatible( + self._system, + fix_perturbable_zero_sigmas=self.config.fix_perturbable_zero_sigmas, + ) self._system = _sr.morph.link_to_reference(self._system) # Next, swap the water topology so that it is in AMBER format. From 6407c103c7e339c1c07d118399da8cdd8013c3c4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Nov 2025 11:45:17 +0000 Subject: [PATCH 013/212] Add option to disable exceptions for minimisation convergence failures. --- src/somd2/config/_config.py | 15 +++++++++++++++ src/somd2/runner/_repex.py | 10 ++++++---- src/somd2/runner/_runner.py | 6 +++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 590b2f2c..a6134b42 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -115,6 +115,7 @@ def __init__( com_reset_frequency=10, minimise=True, minimisation_constraints=False, + minimisation_errors=False, equilibration_time="0 ps", equilibration_timestep="2 fs", equilibration_constraints=True, @@ -289,6 +290,9 @@ def __init__( constraints will be used. If True, then the use of constraints will be determined based on the value of 'equilibration_constraints'. + minimisation_errors: bool + Whether to raise an exception if a minimisation fails to converge. + equilibration_time: str Time interval for equilibration. Only simulations starting from scratch will be equilibrated. @@ -508,6 +512,7 @@ def __init__( self.com_reset_frequency = com_reset_frequency self.minimise = minimise self.minimisation_constraints = minimisation_constraints + self.minimisation_errors = minimisation_errors self.equilibration_time = equilibration_time self.equilibration_timestep = equilibration_timestep self.equilibration_constraints = equilibration_constraints @@ -1237,6 +1242,16 @@ def minimisation_constraints(self, minimisation_constraints): raise ValueError("'minimisation_constraints' must be of type 'bool'") self._minimisation_constraints = minimisation_constraints + @property + def minimisation_errors(self): + return self._minimisation_errors + + @minimisation_errors.setter + def minimisation_errors(self, minimisation_errors): + if not isinstance(minimisation_errors, bool): + raise ValueError("'minimisation_errors' must be of type 'bool'") + self._minimisation_errors = minimisation_errors + @property def equilibration_time(self): return self._equilibration_time diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index aa337608..01694caa 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -824,10 +824,12 @@ def run(self): replica_list[i * num_workers : (i + 1) * num_workers], ): if not success: - _logger.error( - f"Minimisation failed for {_lam_sym} = {self._lambda_values[index]:.5f}: {e}" - ) - raise e + msg = f"Minimisation failed for {_lam_sym} = {self._lambda_values[index]:.5f}: {e}" + if self.config.minimisation_errors: + _logger.error(msg) + raise e + else: + _logger.warning(msg) except KeyboardInterrupt: _logger.error("Minimisation cancelled. Exiting.") _sys.exit(1) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 3ba968b3..88bfbcdd 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -460,7 +460,11 @@ def generate_lam_vals(lambda_base, increment=0.001): perturbable_constraint=perturbable_constraint, ) except Exception as e: - raise RuntimeError(f"Minimisation failed: {e}") + msg = f"Minimisation failed for {_lam_sym} = {lambda_value:.5f}: {e}" + if self.confing.minimisation_errors: + raise RuntimeError(msg) + else: + _logger.warning(msg) # Equilibration. is_equilibrated = False From 5d123723891515e5d8be28d53f8fe60894e5522d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Nov 2025 12:20:47 +0000 Subject: [PATCH 014/212] Change default for fix_perturbable_zero_sigmas. [ci skip] --- src/somd2/config/_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index a6134b42..e75f1efd 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -109,7 +109,7 @@ def __init__( include_constrained_energies=False, dynamic_constraints=True, ghost_modifications=True, - fix_perturbable_zero_sigmas=True, + fix_perturbable_zero_sigmas=False, charge_difference=None, coalchemical_restraint_dist=None, com_reset_frequency=10, From 971774f4f9320e835c83f3e6eea889b91f11815c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 28 Nov 2025 16:13:46 +0000 Subject: [PATCH 015/212] Fix typos. --- src/somd2/_utils/_somd1.py | 10 +++++----- src/somd2/runner/_base.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index a4fa418f..44ae8d5e 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -84,7 +84,7 @@ def apply_pert(system, pert_file): return system -def make_compatible(system, fix_perturbable_zero_sigams=False): +def make_compatible(system, fix_perturbable_zero_sigmas=False): """ Makes a perturbation SOMD1 compatible. @@ -94,7 +94,7 @@ def make_compatible(system, fix_perturbable_zero_sigams=False): system : sire.system.System, sire.legacy.System.System The system containing the molecules to be perturbed. - fix_perturbable_zero_sigams : bool + fix_perturbable_zero_sigmas : bool Whether to prevent LJ sigma values being perturbed to zero. Returns @@ -110,8 +110,8 @@ def make_compatible(system, fix_perturbable_zero_sigams=False): "'system' must of type 'sire.system.System' or 'sire.legacy.System.System'" ) - if not isinstance(fix_perturbable_zero_sigams, bool): - raise TypeError("'fix_perturbable_zero_sigams' must be of type 'bool'.") + if not isinstance(fix_perturbable_zero_sigmas, bool): + raise TypeError("'fix_perturbable_zero_sigmas' must be of type 'bool'.") # Extract the legacy system. if isinstance(system, _LegacySystem): @@ -138,7 +138,7 @@ def make_compatible(system, fix_perturbable_zero_sigams=False): ################################## # First fix zero LJ sigmas values. ################################## - if fix_perturbable_zero_sigams: + if fix_perturbable_zero_sigmas: # Tolerance for zero sigma values. null_lj_sigma = 1e-9 diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 2b8bbc34..4309ec73 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -191,7 +191,7 @@ def __init__(self, system, config): # Make sure that perturbable LJ sigmas aren't scaled to zero. self._config._extra_args["fix_perturbable_zero_sigmas"] = ( - config.fix_perturbable_zero_sigmas + self._config.fix_perturbable_zero_sigmas ) # We're running in SOMD1 compatibility mode. @@ -203,7 +203,7 @@ def __init__(self, system, config): _logger.info("Applying SOMD1 perturbation compatibility.") self._system = make_compatible( self._system, - fix_perturbable_zero_sigmas=self.config.fix_perturbable_zero_sigmas, + fix_perturbable_zero_sigmas=self._config.fix_perturbable_zero_sigmas, ) self._system = _sr.morph.link_to_reference(self._system) From 29eef4f4d00b945835427a24010a008a0b9ef293 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 09:00:00 +0000 Subject: [PATCH 016/212] Make sure box vectors are reduced. [ci skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 16fa454d..0096a2dc 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ follows. (This assumes that the output has a prefix `somd1`.) import BioSimSpace as BSS # Load the lambda = 0 state from prepareFEP.py -system = BSS.IO.readMolecules(["somd1.prm7", "somd1.rst7"]) +system = BSS.IO.readMolecules(["somd1.prm7", "somd1.rst7"], reduce_box=True) # Write a stream file. BSS.Stream.save(system, "somd1") From dd01c71c918ab93440c4c243d54abd44498cdf60 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 14:25:17 +0000 Subject: [PATCH 017/212] Update to new ghostly API. --- src/somd2/runner/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 4309ec73..44eb11e0 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -264,7 +264,7 @@ def __init__(self, system, config): from ghostly import modify _logger.info("Applying modifications to ghost atom bonded terms") - self._system = modify(self._system) + self._system, self._modifications = modify(self._system) # Check for a periodic space. self._has_space = self._check_space() From b76519e0368abc32c6faaf48ef843a8103e4b954 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 15:41:17 +0000 Subject: [PATCH 018/212] Add save_trajectories option to conditional. --- src/somd2/runner/_base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 44eb11e0..1ff76a62 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -124,7 +124,8 @@ def __init__(self, system, config): # Flag whether frames are being saved. if ( - self._config.frame_frequency > 0 + self.config.save_trajectories + and self._config.frame_frequency > 0 and self._config.frame_frequency <= self._config.runtime ): self._save_frames = True From b07ed494ee6872cefc708a623663c3f62de3dfda Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 15:41:43 +0000 Subject: [PATCH 019/212] Remove incorect configuration option. --- src/somd2/runner/_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 1ff76a62..5302739d 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1242,7 +1242,6 @@ def _compare_configs(config1, config2): "equilibration_timestep", "equilibration_constraints", "energy_frequency", - "save_trajectory", "frame_frequency", "save_velocities", "platform", From cd2ff9f619f2aabf59a945cf7a4398fc2aab4de4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 15:42:01 +0000 Subject: [PATCH 020/212] Make sure trajectory chunks exist prior to trajectory reconstruction. --- src/somd2/runner/_base.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 5302739d..42f27384 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1646,15 +1646,17 @@ def _checkpoint( _copyfile(traj_filename, f"{traj_filename}.prev") traj_chunks = [f"{traj_filename}.prev"] + traj_chunks - # Load the topology and chunked trajectory files. - mols = _sr.load([topology0] + traj_chunks) + # Make sure there are trajectory chunks to process. + if len(traj_chunks) > 0: + # Load the topology and chunked trajectory files. + mols = _sr.load([topology0] + traj_chunks) - # Save the final trajectory to a single file. - _sr.save(mols.trajectory(), traj_filename, format=["DCD"]) + # Save the final trajectory to a single file. + _sr.save(mols.trajectory(), traj_filename, format=["DCD"]) - # Now remove the chunked trajectory files. - for chunk in traj_chunks: - _Path(chunk).unlink() + # Now remove the chunked trajectory files. + for chunk in traj_chunks: + _Path(chunk).unlink() # Add config and lambda value to the system properties. system.set_property("config", self._config.as_dict(sire_compatible=True)) From 3be5d28487a7eb5e214ae58c57f2971df9d64b33 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 15:56:28 +0000 Subject: [PATCH 021/212] Typo. --- src/somd2/runner/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 42f27384..50665589 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -124,7 +124,7 @@ def __init__(self, system, config): # Flag whether frames are being saved. if ( - self.config.save_trajectories + self._config.save_trajectories and self._config.frame_frequency > 0 and self._config.frame_frequency <= self._config.runtime ): From 3aa0cbaf9d942a6d373fd4dba99a1618b770dda9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 16:33:50 +0000 Subject: [PATCH 022/212] Remove AmberParams properties prior to extracting end states. --- src/somd2/_utils/_somd1.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index 44ae8d5e..760bb755 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -660,6 +660,15 @@ def reconstruct_system(system): # Loop over all perturbable molecules. for mol in pert_mols: + # Delete an AmberParams properties. + try: + cursor = mol.cursor() + del cursor["parameters0"] + del cursor["parameters1"] + mol = cursor.commit() + except: + pass + # Extract the end states. ref = _morph.extract_reference(mol) pert = _morph.extract_perturbed(mol) From 37d23e6906edbd9704e6c865999628fc8297e9a7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 2 Dec 2025 16:57:11 +0000 Subject: [PATCH 023/212] Typo. [ci skip] --- src/somd2/_utils/_somd1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index 760bb755..64144b09 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -660,7 +660,7 @@ def reconstruct_system(system): # Loop over all perturbable molecules. for mol in pert_mols: - # Delete an AmberParams properties. + # Delete any AmberParams properties. try: cursor = mol.cursor() del cursor["parameters0"] From fe6c4502ed5e3549f1eaaf0a24bd0778660a9c76 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Dec 2025 14:19:05 +0000 Subject: [PATCH 024/212] Fix typos. --- src/somd2/runner/_repex.py | 2 +- src/somd2/runner/_runner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 01694caa..0a0711de 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -825,7 +825,7 @@ def run(self): ): if not success: msg = f"Minimisation failed for {_lam_sym} = {self._lambda_values[index]:.5f}: {e}" - if self.config.minimisation_errors: + if self._config.minimisation_errors: _logger.error(msg) raise e else: diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 88bfbcdd..0e0e4482 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -461,7 +461,7 @@ def generate_lam_vals(lambda_base, increment=0.001): ) except Exception as e: msg = f"Minimisation failed for {_lam_sym} = {lambda_value:.5f}: {e}" - if self.confing.minimisation_errors: + if self._config.minimisation_errors: raise RuntimeError(msg) else: _logger.warning(msg) From 01c78ab8d24cebd27d57bea5ced683e31752cbf5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 11 Dec 2025 10:22:38 +0000 Subject: [PATCH 025/212] Expose missing save_crash_report dynamics option. --- src/somd2/config/_config.py | 15 +++++++++++++++ src/somd2/runner/_repex.py | 2 ++ src/somd2/runner/_runner.py | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index e75f1efd..d6eb3583 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -152,6 +152,7 @@ def __init__( overwrite=False, somd1_compatibility=False, pert_file=None, + save_crash_report=False, save_energy_components=False, page_size=None, timeout="300 s", @@ -451,6 +452,9 @@ def __init__( The path to a SOMD1 perturbation file to apply to the reference system. When set, this will automatically set 'somd1_compatibility' to True. + save_crash_report: bool + Whether to save a crash report if the simulation crashes. + save_energy_components: bool Whether to save the energy contribution for each force when checkpointing. This is useful when debugging crashes. @@ -544,6 +548,7 @@ def __init__( self.use_backup = use_backup self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file + self.save_crash_report = save_crash_report self.save_energy_components = save_energy_components self.timeout = timeout self.num_energy_neighbours = num_energy_neighbours @@ -1876,6 +1881,16 @@ def pert_file(self, pert_file): self._pert_file = pert_file + @property + def save_crash_report(self): + return self._save_crash_report + + @save_crash_report.setter + def save_crash_report(self, save_crash_report): + if not isinstance(save_crash_report, bool): + raise ValueError("'save_crash_report' must be of type 'bool'") + self._save_crash_report = save_crash_report + @property def save_energy_components(self): return self._save_energy_components diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 0a0711de..b459676f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1144,6 +1144,7 @@ def _run_block( auto_fix_minimise=True, num_energy_neighbours=self._config.num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, # GCMC specific options. excess_chemical_potential=( self._mu_ex if gcmc_sampler is not None else None @@ -1381,6 +1382,7 @@ def _equilibrate(self, index): frame_frequency=0, save_velocities=False, auto_fix_minimise=True, + save_crash_report=self._config.save_crash_report, ) # Commit the system. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 0e0e4482..da7f3fac 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -519,6 +519,7 @@ def generate_lam_vals(lambda_base, increment=0.001): frame_frequency=0, save_velocities=False, auto_fix_minimise=True, + save_crash_report=self._config.save_crash_report, ) # Commit the system. @@ -686,6 +687,7 @@ def generate_lam_vals(lambda_base, increment=0.001): auto_fix_minimise=True, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, # GCMC specific options. excess_chemical_potential=( self._mu_ex if gcmc_sampler is not None else None @@ -723,6 +725,7 @@ def generate_lam_vals(lambda_base, increment=0.001): auto_fix_minimise=True, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, ) except Exception as e: try: @@ -820,6 +823,7 @@ def generate_lam_vals(lambda_base, increment=0.001): auto_fix_minimise=True, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, ) # Save the energy contribution for each force. @@ -895,6 +899,7 @@ def generate_lam_vals(lambda_base, increment=0.001): auto_fix_minimise=True, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, ) # Perform a GCMC move. @@ -922,6 +927,7 @@ def generate_lam_vals(lambda_base, increment=0.001): auto_fix_minimise=True, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, ) except Exception as e: try: From 0afe1584d651df35fef657e5b40d71315adb69f1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 11 Dec 2025 14:13:00 +0000 Subject: [PATCH 026/212] Log REST2 scale factors and selection atoms. --- src/somd2/runner/_base.py | 51 ++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 50665589..2f274162 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -363,6 +363,7 @@ def __init__(self, system, config): from math import isclose # Set the REST2 scale factors. + is_rest2 = False if self._config.rest2_scale is not None: # Single value. Interpolate between 1.0 at the end states and rest2_scale # at lambda = 0.5. @@ -396,6 +397,39 @@ def __init__(self, system, config): raise ValueError(msg) self._rest2_scale_factors = self._config.rest2_scale + # If there are any non-zero REST2 scale factors, then log it. + if any( + not isclose(factor, 1.0, abs_tol=1e-4) + for factor in self._rest2_scale_factors + ): + is_rest2 = True + _logger.info(f"REST2 scaling factors: {self._rest2_scale_factors}") + + # Make sure the REST2 selection is valid. + if self._config.rest2_selection is not None: + + try: + atoms = _sr.mol.selection_to_atoms( + self._system, self._config.rest2_selection + ) + except: + msg = "Invalid 'rest2_selection' value." + _logger.error(msg) + raise ValueError(msg) + + # Make sure the user hasn't selected all atoms. + if len(atoms) == self._system.num_atoms(): + msg = "REST2 selection cannot contain all atoms in the system." + _logger.error(msg) + raise ValueError(msg) + else: + atoms = _sr.mol.selection_to_atoms(self._system, "property is_perturbable") + + # Log the atom indices in the REST2 selection. + if is_rest2: + idxs = self._system.atoms().find(atoms) + _logger.info(f"REST2 selection contains {len(atoms)} atoms: {idxs}") + # Apply hydrogen mass repartitioning. if self._config.hmr: # Work out the current hydrogen mass factor. @@ -444,23 +478,6 @@ def __init__(self, system, config): self._system, self._config.h_mass_factor ) - # Make sure the REST2 selection is valid. - if self._config.rest2_selection is not None: - from sire.mol import selection_to_atoms - - try: - atoms = selection_to_atoms(self._system, self._config.rest2_selection) - except: - msg = "Invalid 'rest2_selection' value." - _logger.error(msg) - raise ValueError(msg) - - # Make sure the user hasn't selected all atoms. - if len(atoms) == self._system.num_atoms(): - msg = "REST2 selection cannot contain all atoms in the system." - _logger.error(msg) - raise ValueError(msg) - # Flag whether this is a GPU simulation. self._is_gpu = self._config.platform in ["cuda", "opencl", "hip"] From 5d236c571f7d82b28a83007727897fc12fd68202 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 11 Dec 2025 15:05:46 +0000 Subject: [PATCH 027/212] Catch ghostly angle optimisation failures. --- src/somd2/runner/_base.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 2f274162..b35570fa 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -265,7 +265,18 @@ def __init__(self, system, config): from ghostly import modify _logger.info("Applying modifications to ghost atom bonded terms") - self._system, self._modifications = modify(self._system) + try: + self._system, self._modifications = modify(self._system) + # Angle optimisation can sometimes fail. + except Exception as e1: + try: + self._system, self._modifications = modify( + self_system, optimise_angles=False + ) + except Exception as e2: + msg = f"Unable to apply modifications to ghost atom bonded terms: {e1}; {e2}" + _logger.error(msg) + raise RuntimeError(msg) # Check for a periodic space. self._has_space = self._check_space() From c2d2f822af9646bb7ef759f19fc6f5c8cae2ce44 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 11 Dec 2025 16:45:28 +0000 Subject: [PATCH 028/212] Fix logging of perturbable atoms in REST2 selection. --- src/somd2/runner/_base.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index b35570fa..25585747 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -172,7 +172,8 @@ def __init__(self, system, config): # Make sure the system contains perturbable molecules. try: - self._system.molecules("property is_perturbable") + atoms = self._system["property is_perturbable"].atoms() + pert_idxs = self._system.atoms().find(atoms) except KeyError: msg = "No perturbable molecules in the system" _logger.error(msg) @@ -433,12 +434,18 @@ def __init__(self, system, config): msg = "REST2 selection cannot contain all atoms in the system." _logger.error(msg) raise ValueError(msg) + + # Get the atom indices. + idxs = self._system.atoms().find(atoms) + + # If no indices are in the perturbable region, then add them. + if not any(i in pert_idxs for i in idxs): + idxs = sorted(pert_idxs + idxs) else: - atoms = _sr.mol.selection_to_atoms(self._system, "property is_perturbable") + idxs = pert_idxs # Log the atom indices in the REST2 selection. if is_rest2: - idxs = self._system.atoms().find(atoms) _logger.info(f"REST2 selection contains {len(atoms)} atoms: {idxs}") # Apply hydrogen mass repartitioning. From 9aefda49d0804146eddd3121a87674b537a38cac Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Dec 2025 11:21:55 +0000 Subject: [PATCH 029/212] Update fix_perturbable_zero_sigmas default. --- src/somd2/config/_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index d6eb3583..d5d562c3 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -109,7 +109,7 @@ def __init__( include_constrained_energies=False, dynamic_constraints=True, ghost_modifications=True, - fix_perturbable_zero_sigmas=False, + fix_perturbable_zero_sigmas=True, charge_difference=None, coalchemical_restraint_dist=None, com_reset_frequency=10, From e60dd058651f98ff0c92637a5cea3ce2549d330d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Dec 2025 12:21:52 +0000 Subject: [PATCH 030/212] Divide Sire threads between number of GPU workers. --- src/somd2/config/_config.py | 21 +++++++++++++ src/somd2/runner/_base.py | 59 +++++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index d5d562c3..7bb53323 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -130,6 +130,7 @@ def __init__( platform="auto", max_threads=None, max_gpus=None, + max_sire_threads=None, opencl_platform_index=0, oversubscription_factor=1, replica_exchange=False, @@ -346,6 +347,11 @@ def __init__( Maximum number of GPUs to use for simulation (Default None, uses all available.) Does nothing if platform is set to CPU. + max_sire_threads: int + Maximum number of CPU threads to use within Sire (e.g. for I/O operations). + (Default None, divides the total available threads between the number of + GPUs multiplied by the oversubscription factor.) + opencl_platform_index: int The OpenCL platform index to use when multiple OpenCL implementations are available on the system. @@ -529,6 +535,7 @@ def __init__( self.platform = platform self.max_threads = max_threads self.max_gpus = max_gpus + self.max_sire_threads = max_sire_threads self.opencl_platform_index = opencl_platform_index self.oversubscription_factor = oversubscription_factor self.replica_exchange = replica_exchange @@ -1552,6 +1559,20 @@ def max_gpus(self, max_gpus): "CPU platform requested but max_gpus set - ignoring max_gpus" ) + @property + def max_sire_threads(self): + return self._max_sire_threads + + @max_sire_threads.setter + def max_sire_threads(self, max_sire_threads): + if max_sire_threads is not None: + try: + self._max_sire_threads = int(max_sire_threads) + except: + raise ValueError("'max_sire_threads' must be of type 'int'") + else: + self._max_sire_threads = None + @property def opencl_platform_index(self): return self._opencl_platform_index diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 25585747..6bdcc5da 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -752,6 +752,43 @@ def __init__(self, system, config): else: self._gcmc_kwargs = None + # Limit the number of CPU threads available to Sire when running in parallel. + if self._is_gpu: + # First get the total number of threads that are available to Sire. + total_threads = _sr.legacy.Base.get_max_num_threads() + + # Get the number of GPU devices. + devices = self._get_gpu_devices( + self._config.platform, + log=False, + ) + + # Work out the number of GPU workers. + num_gpu_workers = len(devices) * self._config.oversubscription_factor + + # Adjust based on the maximum number of GPUs. + if self._config.max_gpus is not None: + num_gpu_workers = min( + self._config.max_gpus * self._config.oversubscription_factor, + num_gpu_workers, + ) + + # Divide the threads by the number of GPUs and oversubscribe factor. + sire_threads = max(1, total_threads // num_gpu_workers) + + if self._config.max_sire_threads is not None: + if self._config.max_sire_threads > sire_threads: + _logger.warning( + f"Requested 'max_sire_threads' of {self._config.max_sire_threads} exceeds " + f"the calculated maximum of {sire_threads}" + ) + sire_threads = self._config.max_sire_threads + + _logger.info(f"Setting maximum Sire CPU threads to {sire_threads}") + + # Update the maximum number of threads. + _sr.legacy.Base.set_max_num_threads(sire_threads) + def _check_space(self): """ Check if the system has a periodic space. @@ -1423,7 +1460,7 @@ def _systems_are_same(system0, system1, num_gcmc_waters=0): return True, None @staticmethod - def _get_gpu_devices(platform, oversubscription_factor=1): + def _get_gpu_devices(platform, oversubscription_factor=1, log=True): """ Get list of available GPUs from CUDA_VISIBLE_DEVICES, OPENCL_VISIBLE_DEVICES, or HIP_VISIBLE_DEVICES. @@ -1437,6 +1474,9 @@ def _get_gpu_devices(platform, oversubscription_factor=1): oversubscription_factor: int The number of concurrent workers per GPU. Default is 1. + log: bool + Whether to log the available devices. Default is True. + Returns -------- @@ -1459,23 +1499,30 @@ def _get_gpu_devices(platform, oversubscription_factor=1): raise ValueError("CUDA_VISIBLE_DEVICES not set") else: available_devices = _os.environ.get("CUDA_VISIBLE_DEVICES").split(",") - _logger.info(f"CUDA_VISIBLE_DEVICES set to {available_devices}") + if log: + _logger.info(f"CUDA_VISIBLE_DEVICES set to {available_devices}") elif platform == "opencl": if _os.environ.get("OPENCL_VISIBLE_DEVICES") is None: raise ValueError("OPENCL_VISIBLE_DEVICES not set") else: available_devices = _os.environ.get("OPENCL_VISIBLE_DEVICES").split(",") - _logger.info(f"OPENCL_VISIBLE_DEVICES set to {available_devices}") + if log: + _logger.info(f"OPENCL_VISIBLE_DEVICES set to {available_devices}") elif platform == "hip": if _os.environ.get("HIP_VISIBLE_DEVICES") is None: raise ValueError("HIP_VISIBLE_DEVICES not set") else: available_devices = _os.environ.get("HIP_VISIBLE_DEVICES").split(",") - _logger.info(f"HIP_VISIBLE_DEVICES set to {available_devices}") + if log: + _logger.info(f"HIP_VISIBLE_DEVICES set to {available_devices}") num_gpus = len(available_devices) - _logger.info(f"Number of GPUs available: {num_gpus}") - _logger.info(f"Number of concurrent workers per GPU: {oversubscription_factor}") + + if log: + _logger.info(f"Number of GPUs available: {num_gpus}") + _logger.info( + f"Number of concurrent workers per GPU: {oversubscription_factor}" + ) return available_devices From 3d337c6a0dedf47d33d230750c3c28181eee99b0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Dec 2025 13:04:15 +0000 Subject: [PATCH 031/212] Update macOS environment file. [ci skip] --- environment_macos.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/environment_macos.yaml b/environment_macos.yaml index 750a5f24..19083a3c 100644 --- a/environment_macos.yaml +++ b/environment_macos.yaml @@ -6,9 +6,8 @@ channels: dependencies: - biosimspace - - git + - ghostly - filelock - loguru - numba - - pip: - - git+https://github.com/openbiosim/ghostly + - versioningit From 6877398b0c25b6dbfbd66bdaa2f90580bbd87167 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Dec 2025 14:40:07 +0000 Subject: [PATCH 032/212] Allow use_backup option to change on restarts. --- src/somd2/runner/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 6bdcc5da..b45f9acc 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1320,6 +1320,7 @@ def _compare_configs(config1, config2): "max_threads", "max_gpus", "restart", + "use_backup", "save_trajectories", "write_config", "log_level", From 2009c2a3853f3d02939b27262f9ff655b98ad9a6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 7 Jan 2026 13:53:59 +0000 Subject: [PATCH 033/212] Log the initial GCMC sphere position. --- src/somd2/runner/_repex.py | 8 ++++++++ src/somd2/runner/_runner.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index b459676f..aaea733d 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -346,6 +346,14 @@ def _create_dynamics( f"Created dynamics object for lambda {lam:.5f} on device {device}" ) + # Print the initial GCMC sphere position. + if gcmc_kwargs is not None and self._gcmc_samplers[0]._reference is not None: + positions = _sr.io.get_coords_array(mols) + target = self._gcmc_samplers[0]._get_target_position(positions) + _logger.info( + f"Initial GCMC sphere center: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] Å" + ) + def get(self, index): """ Get the dynamics object (and GCMC sampler) for a given index. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index da7f3fac..6f08253f 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -431,6 +431,14 @@ def generate_lam_vals(lambda_base, increment=0.001): # Get the GCMC system. system = gcmc_sampler.system() + # Print the initial GCMC sphere position. + if gcmc_sampler._reference is not None: + positions = _sr.io.get_coords_array(system) + target = gcmc_sampler._get_target_position(positions) + _logger.info( + f"Initial GCMC sphere center: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] Å" + ) + else: gcmc_sampler = None From 2acbc12bff2dd96cdff0ea699574a26d18e049e8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 7 Jan 2026 14:31:20 +0000 Subject: [PATCH 034/212] Update copyright range. [ci skip] --- src/somd2/__init__.py | 2 +- src/somd2/_utils/__init__.py | 2 +- src/somd2/_utils/_somd1.py | 2 +- src/somd2/app/__init__.py | 2 +- src/somd2/app/_cli.py | 2 +- src/somd2/config/__init__.py | 2 +- src/somd2/config/_config.py | 2 +- src/somd2/io/__init__.py | 2 +- src/somd2/io/_io.py | 2 +- src/somd2/runner/__init__.py | 2 +- src/somd2/runner/_base.py | 2 +- src/somd2/runner/_repex.py | 2 +- src/somd2/runner/_runner.py | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/somd2/__init__.py b/src/somd2/__init__.py index 5b24bb5d..6a45080f 100644 --- a/src/somd2/__init__.py +++ b/src/somd2/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/_utils/__init__.py b/src/somd2/_utils/__init__.py index f2fea7a0..ec25a367 100644 --- a/src/somd2/_utils/__init__.py +++ b/src/somd2/_utils/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index 64144b09..aa379f7b 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/app/__init__.py b/src/somd2/app/__init__.py index 5bbf9e5a..150b1c1a 100644 --- a/src/somd2/app/__init__.py +++ b/src/somd2/app/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/app/_cli.py b/src/somd2/app/_cli.py index 5f74c846..27b165cd 100644 --- a/src/somd2/app/_cli.py +++ b/src/somd2/app/_cli.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/config/__init__.py b/src/somd2/config/__init__.py index e5fb3f05..3507337a 100644 --- a/src/somd2/config/__init__.py +++ b/src/somd2/config/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 7bb53323..faac9ad9 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/io/__init__.py b/src/somd2/io/__init__.py index 90005255..efd2cf20 100644 --- a/src/somd2/io/__init__.py +++ b/src/somd2/io/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/io/_io.py b/src/somd2/io/_io.py index 0b4518db..6b664977 100644 --- a/src/somd2/io/_io.py +++ b/src/somd2/io/_io.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/runner/__init__.py b/src/somd2/runner/__init__.py index 1e755b47..69dd52ae 100644 --- a/src/somd2/runner/__init__.py +++ b/src/somd2/runner/__init__.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index b45f9acc..f1c88952 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index aaea733d..6bc9cc9b 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 6f08253f..82271f04 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -1,7 +1,7 @@ ###################################################################### # SOMD2: GPU accelerated alchemical free-energy engine. # -# Copyright: 2023-2025 +# Copyright: 2023-2026 # # Authors: The OpenBioSim Team # From e4f88d38f026e59492b8a266036a6313226798f4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 8 Jan 2026 10:56:01 +0000 Subject: [PATCH 035/212] Log GCMC sphere centre for all replicas. --- src/somd2/runner/_repex.py | 16 ++++++++-------- src/somd2/runner/_runner.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 6bc9cc9b..d2820224 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -281,6 +281,14 @@ def _create_dynamics( f"Created GCMC sampler for lambda {lam:.5f} on device {device}" ) + # Log the initial position of the GCMC sphere. + if self._gcmc_samplers[i]._reference is not None: + positions = _sr.io.get_coords_array(mols) + target = self._gcmc_samplers[i]._get_target_position(positions) + _logger.info( + f"Initial GCMC sphere centre: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" + ) + # Create the dynamics object. try: dynamics = mols.dynamics(**dynamics_kwargs) @@ -346,14 +354,6 @@ def _create_dynamics( f"Created dynamics object for lambda {lam:.5f} on device {device}" ) - # Print the initial GCMC sphere position. - if gcmc_kwargs is not None and self._gcmc_samplers[0]._reference is not None: - positions = _sr.io.get_coords_array(mols) - target = self._gcmc_samplers[0]._get_target_position(positions) - _logger.info( - f"Initial GCMC sphere center: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] Å" - ) - def get(self, index): """ Get the dynamics object (and GCMC sampler) for a given index. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 82271f04..ebfc2f17 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -431,12 +431,12 @@ def generate_lam_vals(lambda_base, increment=0.001): # Get the GCMC system. system = gcmc_sampler.system() - # Print the initial GCMC sphere position. + # Log the initial position of the GCMC sphere. if gcmc_sampler._reference is not None: positions = _sr.io.get_coords_array(system) target = gcmc_sampler._get_target_position(positions) _logger.info( - f"Initial GCMC sphere center: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] Å" + f"Initial GCMC sphere centre: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" ) else: From 1e7bfb6e12cc928b95daf97012de702c773358b3 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 8 Jan 2026 11:24:29 +0000 Subject: [PATCH 036/212] Fix error handling for backups and checkpoints. --- src/somd2/runner/_base.py | 281 ++++++++++++++++++++---------------- src/somd2/runner/_repex.py | 22 ++- src/somd2/runner/_runner.py | 24 ++- 3 files changed, 191 insertions(+), 136 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index f1c88952..fa857c7b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1646,151 +1646,175 @@ def _checkpoint( is_final_block: bool Whether this is the final block of the simulation. - """ - from somd2 import __version__, _sire_version, _sire_revisionid + Returns + ------- - # Get the lambda value. - lam = self._lambda_values[index] + result: bool + Whether the checkpoint was successful. - # Get the energy trajectory. - df = system.energy_trajectory(to_alchemlyb=True, energy_unit="kT") + index: int + The index of the window or replica. - # Set the lambda values at which energies were sampled. - if lambda_energy is None: - lambda_energy = self._lambda_values + error: Exception + The exception raised during the checkpoint, if any. + """ - # Create the metadata. - metadata = { - "attrs": df.attrs, - "somd2 version": __version__, - "sire version": f"{_sire_version}+{_sire_revisionid}", - "lambda": str(lam), - "speed": speed, - "temperature": str(self._config.temperature.value()), - } + try: + from somd2 import __version__, _sire_version, _sire_revisionid + + # Get the lambda value. + lam = self._lambda_values[index] + + # Get the energy trajectory. + df = system.energy_trajectory(to_alchemlyb=True, energy_unit="kT") + + # Set the lambda values at which energies were sampled. + if lambda_energy is None: + lambda_energy = self._lambda_values + + # Create the metadata. + metadata = { + "attrs": df.attrs, + "somd2 version": __version__, + "sire version": f"{_sire_version}+{_sire_revisionid}", + "lambda": str(lam), + "speed": speed, + "temperature": str(self._config.temperature.value()), + } - # Add the lambda gradient if available. - if lambda_grad is not None: - metadata["lambda_grad"] = lambda_grad - - if is_final_block: - # Save the end-state GCMC topologies for trajectory analysis and visualisation. - # This topology contains additional water molecules that are used for GCMC - # insertion moves. - if self._config.gcmc: - mols0 = _sr.morph.link_to_reference(system) - mols1 = _sr.morph.link_to_perturbed(system) - - # Save to AMBER format. - _sr.save(mols0, self._filenames["topology0"]) - _sr.save(mols1, self._filenames["topology1"]) - - # Save to PDB format. - _sr.save( - mols0, - self._filenames["topology0"].replace(".prm7", ".pdb"), - ) - _sr.save( - mols1, - self._filenames["topology1"].replace(".prm7", ".pdb"), - ) + # Add the lambda gradient if available. + if lambda_grad is not None: + metadata["lambda_grad"] = lambda_grad + + if is_final_block: + # Save the end-state GCMC topologies for trajectory analysis and visualisation. + # This topology contains additional water molecules that are used for GCMC + # insertion moves. + if self._config.gcmc: + mols0 = _sr.morph.link_to_reference(system) + mols1 = _sr.morph.link_to_perturbed(system) - # Assemble and save the final trajectory. - if self._config.save_trajectories: - # Save the final trajectory chunk to file. - if self._save_frames and system.num_frames() > 0: - traj_filename = ( - self._filenames[index]["trajectory_chunk"] + f"{block:05d}.dcd" + # Save to AMBER format. + _sr.save(mols0, self._filenames["topology0"]) + _sr.save(mols1, self._filenames["topology1"]) + + # Save to PDB format. + _sr.save( + mols0, + self._filenames["topology0"].replace(".prm7", ".pdb"), ) _sr.save( - system.trajectory(), - traj_filename, - format=["DCD"], + mols1, + self._filenames["topology1"].replace(".prm7", ".pdb"), ) - # Create the final topology file name. - topology0 = self._filenames["topology0"] - - # Create the final trajectory file name. - traj_filename = self._filenames[index]["trajectory"] + # Assemble and save the final trajectory. + if self._config.save_trajectories: + # Save the final trajectory chunk to file. + if self._save_frames and system.num_frames() > 0: + traj_filename = ( + self._filenames[index]["trajectory_chunk"] + + f"{block:05d}.dcd" + ) + _sr.save( + system.trajectory(), + traj_filename, + format=["DCD"], + ) - # Glob for the trajectory chunks. - traj_chunks = sorted( - _glob(f"{self._filenames[index]['trajectory_chunk']}*") - ) + # Create the final topology file name. + topology0 = self._filenames["topology0"] - # If this is a restart, then we need to check for an existing - # trajectory file with the same name. If it exists and is non-empty, - # then copy it to a backup file and prepend it to the list of chunks. - if self._config.restart: - path = _Path(traj_filename) - if path.exists() and path.stat().st_size > 0: - _copyfile(traj_filename, f"{traj_filename}.prev") - traj_chunks = [f"{traj_filename}.prev"] + traj_chunks - - # Make sure there are trajectory chunks to process. - if len(traj_chunks) > 0: - # Load the topology and chunked trajectory files. - mols = _sr.load([topology0] + traj_chunks) - - # Save the final trajectory to a single file. - _sr.save(mols.trajectory(), traj_filename, format=["DCD"]) - - # Now remove the chunked trajectory files. - for chunk in traj_chunks: - _Path(chunk).unlink() - - # Add config and lambda value to the system properties. - system.set_property("config", self._config.as_dict(sire_compatible=True)) - system.set_property("lambda", lam) - - # Stream the final system to file. - _sr.stream.save(system, self._filenames[index]["checkpoint"]) - - # Create the final parquet file. - _dataframe_to_parquet( - df, - metadata=metadata, - filename=self._filenames[index]["energy_traj"], - ) + # Create the final trajectory file name. + traj_filename = self._filenames[index]["trajectory"] - else: - # Update the starting block if necessary. - if block == 0: - block = self._start_block - - # Save the current trajectory chunk to file. - if self._config.save_trajectories: - if self._save_frames and system.num_frames() > 0: - traj_filename = ( - self._filenames[index]["trajectory_chunk"] + f"{block:05d}.dcd" - ) - _sr.save( - system.trajectory(), - traj_filename, - format=["DCD"], + # Glob for the trajectory chunks. + traj_chunks = sorted( + _glob(f"{self._filenames[index]['trajectory_chunk']}*") ) - # Encode the configuration and lambda value as system properties. - system.set_property("config", self._config.as_dict(sire_compatible=True)) - system.set_property("lambda", lam) + # If this is a restart, then we need to check for an existing + # trajectory file with the same name. If it exists and is non-empty, + # then copy it to a backup file and prepend it to the list of chunks. + if self._config.restart: + path = _Path(traj_filename) + if path.exists() and path.stat().st_size > 0: + _copyfile(traj_filename, f"{traj_filename}.prev") + traj_chunks = [f"{traj_filename}.prev"] + traj_chunks + + # Make sure there are trajectory chunks to process. + if len(traj_chunks) > 0: + # Load the topology and chunked trajectory files. + mols = _sr.load([topology0] + traj_chunks) + + # Save the final trajectory to a single file. + _sr.save(mols.trajectory(), traj_filename, format=["DCD"]) + + # Now remove the chunked trajectory files. + for chunk in traj_chunks: + _Path(chunk).unlink() + + # Add config and lambda value to the system properties. + system.set_property( + "config", self._config.as_dict(sire_compatible=True) + ) + system.set_property("lambda", lam) - # Stream the checkpoint to file. - _sr.stream.save(system, self._filenames[index]["checkpoint"]) + # Stream the final system to file. + _sr.stream.save(system, self._filenames[index]["checkpoint"]) - # Create the parquet file name. - filename = self._filenames[index]["energy_traj"] + # Create the final parquet file. + _dataframe_to_parquet( + df, + metadata=metadata, + filename=self._filenames[index]["energy_traj"], + ) - # Create the parquet file. - if block == self._start_block: - _dataframe_to_parquet(df, metadata=metadata, filename=filename) - # Append to the parquet file. else: - _parquet_append( - filename, - df.iloc[-self._energy_per_block :], + # Update the starting block if necessary. + if block == 0: + block = self._start_block + + # Save the current trajectory chunk to file. + if self._config.save_trajectories: + if self._save_frames and system.num_frames() > 0: + traj_filename = ( + self._filenames[index]["trajectory_chunk"] + + f"{block:05d}.dcd" + ) + _sr.save( + system.trajectory(), + traj_filename, + format=["DCD"], + ) + + # Encode the configuration and lambda value as system properties. + system.set_property( + "config", self._config.as_dict(sire_compatible=True) ) + system.set_property("lambda", lam) + + # Stream the checkpoint to file. + _sr.stream.save(system, self._filenames[index]["checkpoint"]) + + # Create the parquet file name. + filename = self._filenames[index]["energy_traj"] + + # Create the parquet file. + if block == self._start_block: + _dataframe_to_parquet(df, metadata=metadata, filename=filename) + # Append to the parquet file. + else: + _parquet_append( + filename, + df.iloc[-self._energy_per_block :], + ) + + except Exception as e: + return index, e + + return index, None def _backup_checkpoint(self, index): """ @@ -1801,6 +1825,9 @@ def _backup_checkpoint(self, index): index : int The index of the window or replica. + + error: Exception + The exception raised during the backup, if any. """ try: @@ -1813,7 +1840,7 @@ def _backup_checkpoint(self, index): ) traj_filename = self._filenames[index]["trajectory"] except Exception as e: - return False, e + return index, e try: # Backup the existing energy trajectory file, if it exists. @@ -1824,9 +1851,9 @@ def _backup_checkpoint(self, index): str(self._filenames[index]["energy_traj"]) + ".bak", ) except Exception as e: - return False, e + return index, e - return True, None + return index, None def _save_energy_components(self, index, context): """ diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index d2820224..a408c59b 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -948,7 +948,7 @@ def run(self): ] with ThreadPoolExecutor(max_workers=num_workers) as executor: try: - for result, error in executor.map( + for index, error in executor.map( self._backup_checkpoint, replicas, ): @@ -972,7 +972,7 @@ def run(self): ] with ThreadPoolExecutor(max_workers=num_workers) as executor: try: - for result, error in executor.map( + for index, error in executor.map( self._checkpoint, replicas, repeat(self._lambda_values), @@ -1533,6 +1533,15 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): is_final_block: bool Whether this is the final block. + + Returns + ------- + + index: int + The index of the replica. + + exception: Exception + The exception if the checkpoint failed. """ try: # Get the lambda value. @@ -1553,10 +1562,13 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Call the base class checkpoint method to save the system state. with self._lock: - super()._checkpoint( + index, error = super()._checkpoint( system, index, block, speed, is_final_block=is_final_block ) + if error is not None: + return index, error + # Delete all trajectory frames from the Sire system within the # dynamics object. dynamics._d._sire_mols.delete_all_frames() @@ -1582,10 +1594,10 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): if is_final_block: _logger.success(f"{_lam_sym} = {lam:.5f} complete") - return True, None + return index, None except Exception as e: - return False, e + return index, e @staticmethod @_njit diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index ebfc2f17..b6ac9526 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -778,10 +778,13 @@ def generate_lam_vals(lambda_base, increment=0.001): # in a consistent state if read by another process. with lock.acquire(timeout=self._config.timeout.to("seconds")): # Backup any existing checkpoint files. - self._backup_checkpoint(index) + index, error = self._backup_checkpoint(index) + + if error is not None: + raise error # Write the checkpoint files. - self._checkpoint( + index, error = self._checkpoint( system, index, block, @@ -791,6 +794,9 @@ def generate_lam_vals(lambda_base, increment=0.001): is_final_block=is_final_block, ) + if error is not None: + raise error + # Delete all trajectory frames from the Sire system within the # dynamics object. dynamics._d._sire_mols.delete_all_frames() @@ -965,10 +971,15 @@ def generate_lam_vals(lambda_base, increment=0.001): # in a consistent state if read by another process. with lock.acquire(timeout=self._config.timeout.to("seconds")): # Backup any existing checkpoint files. - self._backup_checkpoint(index) + index, error = self._backup_checkpoint(index) + + if error is not None: + msg = f"Checkpoint backup failed for {_lam_sym} = {lambda_value:.5f}: {error}" + _logger.error(msg) + raise RuntimeError(msg) # Write the checkpoint files. - self._checkpoint( + index, error = self._checkpoint( system, index, 0, @@ -978,6 +989,11 @@ def generate_lam_vals(lambda_base, increment=0.001): is_final_block=True, ) + if error is not None: + msg = f"Checkpoint failed for {_lam_sym} = {lambda_value:.5f}: {error}" + _logger.error(msg) + raise RuntimeError(msg) + _logger.success( f"{_lam_sym} = {lambda_value:.5f} complete, speed = {speed:.2f} ns day-1" ) From 76d5b992e64ed0f08b7ee021e7ef6cb299c443d7 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 9 Jan 2026 09:18:57 +0000 Subject: [PATCH 037/212] Add lambda value to GCMC sphere centre log. --- src/somd2/runner/_repex.py | 3 ++- src/somd2/runner/_runner.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index a408c59b..4f28c3f8 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -286,7 +286,8 @@ def _create_dynamics( positions = _sr.io.get_coords_array(mols) target = self._gcmc_samplers[i]._get_target_position(positions) _logger.info( - f"Initial GCMC sphere centre: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" + f"Initial GCMC sphere centre for lambda {lam:.5f} on device {device}: " + f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" ) # Create the dynamics object. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index b6ac9526..b7632255 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -436,7 +436,8 @@ def generate_lam_vals(lambda_base, increment=0.001): positions = _sr.io.get_coords_array(system) target = gcmc_sampler._get_target_position(positions) _logger.info( - f"Initial GCMC sphere centre: [{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" + f"Initial GCMC sphere centre at {_lam_sym} = {lambda_value:.5f}: " + f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" ) else: From 612edafbe00b88d9e05893b7b51c930ccedb74e0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sat, 10 Jan 2026 13:53:12 +0000 Subject: [PATCH 038/212] Add note regarding setting nvcc for pycuda. [ci skip] --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 0096a2dc..ab45be7b 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,11 @@ somd2 --help | grep -A2 ' --gcmc' > GCMC is currently only supported when using the CUDA platform and isn't > available on macOS, where the `pycuda` package is not available. +Make sure that `nvcc` is in your `PATH`. If you require a different `nvcc` to that +provided by conda, you can set the `PYCUDA_NVCC` environment variable to point +to the desired `nvcc` binary. Depending on your setup, you may also need to install +the `cuda-nvvm` package from `conda-forge`. + ## Analysis Simulation output will be written to the directory specified using the From 46faa8676a43f309f76f52749a784edb3003b15d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 16 Jan 2026 10:59:51 +0000 Subject: [PATCH 039/212] Allow perturbed system option to be a Sire system and a string. --- src/somd2/config/_config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index faac9ad9..52babacd 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1624,7 +1624,10 @@ def perturbed_system(self): @perturbed_system.setter def perturbed_system(self, perturbed_system): if perturbed_system is not None: - if isinstance(perturbed_system, str): + if isinstance(perturbed_system, _sr.system.System): + self._perturbed_system = perturbed_system + self._perturbed_system_file = None + elif isinstance(perturbed_system, str): import os if not os.path.exists(perturbed_system): @@ -1640,7 +1643,9 @@ def perturbed_system(self, perturbed_system): f"Unable to load 'perturbed_system' stream file: {e}" ) else: - raise TypeError("'perturbed_system' must be of type 'str'") + raise TypeError( + "'perturbed_system' must be of type 'sr.system.System' or 'str'" + ) else: self._perturbed_system = None self._perturbed_system_file = None From e3bab68e34ee5385a2faac2772af0431a21f8f7d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 14 Jan 2026 14:54:31 +0000 Subject: [PATCH 040/212] Add support for GCMC sampling on the OpenCL platform. --- src/somd2/runner/_base.py | 5 +- src/somd2/runner/_repex.py | 108 +++++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index fa857c7b..87f0d0c4 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -535,8 +535,8 @@ def __init__(self, system, config): # GCMC specific validation. if self._config.gcmc: - if self._config.platform != "cuda": - msg = "GCMC simulations require the CUDA platform." + if self._config.platform not in ["cuda", "opencl"]: + msg = "GCMC simulations require the CUDA or OpenCL platform." _logger.error(msg) raise ValueError(msg) @@ -747,6 +747,7 @@ def __init__(self, system, config): "tolerance": self._config.gcmc_tolerance, "restart": self._is_restart, "overwrite": self._config.overwrite, + "platform": config.platform, "no_logger": True, } else: diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 4f28c3f8..e6bd4f54 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -329,15 +329,15 @@ def _create_dynamics( # Work out the memory used by this dynamics object and GCMC sampler. mem_used = used_mem - used_mem_before - # Work out the estimate for all replicas on this device. - est_total = mem_used * contexts_per_device[device] + # Work out the estimated total after all replicas have been created. + est_total = mem_used * contexts_per_device[device] + used_mem_before # If this exceeds the total memory, raise an error. if est_total > total_mem: msg = ( f"Not enough memory on device {device} for all assigned replicas. " - f"Estimated memory usage: {est_total / 1e9:.2f} GB, " - f"Available memory: {total_mem / 1e9:.2f} GB." + f"Estimated memory usage: {est_total / (1024**3):.2f} GB, " + f"Available memory: {total_mem / (1024**3):.2f} GB." ) _logger.error(msg) raise MemoryError(msg) @@ -347,8 +347,15 @@ def _create_dynamics( _logger.warning( f"Device {device} will have less than 20% free memory " f"after creating all assigned replicas. " - f"{est_total / 1e9:.2f} GB, " - f"Available memory: {total_mem / 1e9:.2f} GB." + f"{est_total / (1024**3):.2f} GB, " + f"Available memory: {total_mem / (1024**3):.2f} GB." + ) + + else: + _logger.info( + f"Estimated memory usage on device {device} after creating all replicas: " + f"{est_total / (1024**3):.2f} GB, " + f"Available memory: {total_mem / (1024**3):.2f} GB." ) _logger.info( @@ -515,34 +522,78 @@ def get_swaps(self): return self._num_swaps @staticmethod - def _check_device_memory(index): + def _check_device_memory(device_index=0): """ - Check the memory usage of the specified CUDA device. + Check the memory usage of the specified GPU device. Parameters ---------- index: int - The index of the CUDA device. + The index of the GPU device. """ - try: - from pynvml import ( - nvmlInit, - nvmlShutdown, - nvmlDeviceGetHandleByIndex, - nvmlDeviceGetMemoryInfo, - ) + import pyopencl as cl - nvmlInit() - handle = nvmlDeviceGetHandleByIndex(index) - info = nvmlDeviceGetMemoryInfo(handle) - result = (info.used, info.free, info.total) - nvmlShutdown() - except Exception as e: - msg = f"Could not determine memory usage for device {index}: {e}" + # Get the device. + platforms = cl.get_platforms() + all_devices = [] + for platform in platforms: + try: + devices = platform.get_devices(device_type=cl.device_type.GPU) + all_devices.extend(devices) + except: + continue + + if device_index >= len(all_devices): + msg = f"Device index {device_index} out of range. Found {len(all_devices)} GPU(s)." _logger.error(msg) + raise IndexError(msg) - return result + device = all_devices[device_index] + total = device.global_mem_size + + # NVIDIA: Use pynvml + if "NVIDIA" in device.vendor: + try: + import pynvml + + pynvml.nvmlInit() + + # Find matching device by name + device_count = pynvml.nvmlDeviceGetCount() + for i in range(device_count): + handle = pynvml.nvmlDeviceGetHandleByIndex(i) + name = pynvml.nvmlDeviceGetName(handle) + + if name in device.name or device.name in name: + memory = pynvml.nvmlDeviceGetMemoryInfo(handle) + pynvml.nvmlShutdown() + return (memory.used, memory.free, memory.total) + + pynvml.nvmlShutdown() + except Exception as e: + msg = f"Could not get NVIDIA GPU memory info for device {device_index}: {e}" + _logger.error(msg) + raise RuntimeError(msg) from e + + # AMD: Use OpenCL extension + elif "AMD" in device.vendor or "Advanced Micro Devices" in device.vendor: + try: + free_memory_info = device.get_info(0x4038) + free_kb = ( + free_memory_info[0] + if isinstance(free_memory_info, list) + else free_memory_info + ) + free = free_kb * 1024 + used = total - free + return (used, free, total) + except Exception as e: + msg = ( + f"Could not get AMD GPU memory info for device {device_index}: {e}" + ) + _logger.error(msg) + raise RuntimeError(msg) from e class RepexRunner(_RunnerBase): @@ -582,9 +633,12 @@ def __init__(self, system, config): # Call the base class constructor. super().__init__(system, config) - # Make sure we're using the CUDA platform. - if self._config.platform != "cuda": - msg = "Currently replica exchange simulations can only be run on the CUDA platform." + # Make sure we're using the CUDA or OpenCL platform. + if self._config.platform not in ["cuda", "opencl"]: + msg = ( + "Currently replica exchange simulations can only be " + "run on the CUDA and OpenCL platforms." + ) _logger.error(msg) raise ValueError(msg) From 4fa35410504a36f19d3ab1f808dd4c34f05ea427 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 15 Jan 2026 16:24:50 +0000 Subject: [PATCH 041/212] Update GCMC platform requirement. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ab45be7b..f8174c8c 100644 --- a/README.md +++ b/README.md @@ -145,13 +145,13 @@ somd2 --help | grep -A2 ' --gcmc' ``` > [!NOTE] -> GCMC is currently only supported when using the CUDA platform and isn't -> available on macOS, where the `pycuda` package is not available. +> GCMC is only supported when using the CUDA or OpenCL platforms. -Make sure that `nvcc` is in your `PATH`. If you require a different `nvcc` to that -provided by conda, you can set the `PYCUDA_NVCC` environment variable to point -to the desired `nvcc` binary. Depending on your setup, you may also need to install -the `cuda-nvvm` package from `conda-forge`. +When using the CUDA platform, make sure that `nvcc` is in your `PATH`. If you +require a different `nvcc` to that provided by conda, you can set the +`PYCUDA_NVCC` environment variable to point to the desired `nvcc` binary. +Depending on your setup, you may also need to install the `cuda-nvvm` package +from `conda-forge`. ## Analysis From 62399dca1ac1954852c01ff0b97e4622a34096b4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 21 Jan 2026 15:36:09 +0000 Subject: [PATCH 042/212] Remove linux selector from loch dependency. --- recipes/somd2/template.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recipes/somd2/template.yaml b/recipes/somd2/template.yaml index 0cd298a6..27c3f78f 100644 --- a/recipes/somd2/template.yaml +++ b/recipes/somd2/template.yaml @@ -16,7 +16,7 @@ requirements: host: - biosimspace - ghostly - - loch # [linux] + - loch - loguru - pip - python @@ -28,7 +28,7 @@ requirements: run: - biosimspace - ghostly - - loch # [linux] + - loch - loguru - numba - nvidia-ml-py # [linux] From f753b3bfe6723a4d097f469eed0322849434907e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 26 Jan 2026 11:10:48 +0000 Subject: [PATCH 043/212] Use length of index list when reporting size of REST2 region. --- src/somd2/runner/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 87f0d0c4..9e0eaf7f 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -446,7 +446,7 @@ def __init__(self, system, config): # Log the atom indices in the REST2 selection. if is_rest2: - _logger.info(f"REST2 selection contains {len(atoms)} atoms: {idxs}") + _logger.info(f"REST2 selection contains {len(idxs)} atoms: {idxs}") # Apply hydrogen mass repartitioning. if self._config.hmr: From 041bdf3dd26f45d53d55ceb3dab7b7556f0d45c2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 13:41:04 +0000 Subject: [PATCH 044/212] Log restarts when running replica exchange. --- src/somd2/runner/_repex.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index e6bd4f54..98ef0dc3 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -721,11 +721,17 @@ def __init__(self, system, config): output_directory=self._config.output_directory, ) else: + _logger.debug("Restarting from file") + # Check to see if the simulation is already complete. time = self._system[0].time() if time > self._config.runtime - self._config.timestep: - _logger.success(f"Simulation already complete. Exiting.") + _logger.success("Simulation already complete. Exiting.") _sys.exit(0) + else: + _logger.info( + f"Restarting at time {time}, time remaining = {self._config.runtime - time}" + ) try: with open(self._repex_state, "rb") as f: From de0949823eb7a353c76d4b62f484748f55f364b2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 13:41:53 +0000 Subject: [PATCH 045/212] Fix variable name. --- src/somd2/runner/_repex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 98ef0dc3..e4d84d2e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1041,7 +1041,7 @@ def run(self): repeat(num_blocks + int(rem > 0)), repeat(i == cycles - 1), ): - if not result: + if error: _logger.error( f"Checkpoint failed for {_lam_sym} = " f"{self._lambda_values[index]:.5f}: {error}" From d2b36a048d5e94f2d1790da388070a2eaa0338a2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 13:42:23 +0000 Subject: [PATCH 046/212] Delete trajectory frames before streaming to file. --- src/somd2/runner/_base.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 9e0eaf7f..c401fc97 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1762,6 +1762,9 @@ def _checkpoint( ) system.set_property("lambda", lam) + # Delete all frames from the system. + system.delete_all_frames() + # Stream the final system to file. _sr.stream.save(system, self._filenames[index]["checkpoint"]) @@ -1796,6 +1799,9 @@ def _checkpoint( ) system.set_property("lambda", lam) + # Delete all frames from the system. + system.delete_all_frames() + # Stream the checkpoint to file. _sr.stream.save(system, self._filenames[index]["checkpoint"]) From d9efdaebeb7e441998f8d1b2b8a24e3c49580a23 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 13:46:06 +0000 Subject: [PATCH 047/212] Delete existing trajectory frames prior to simulation. --- src/somd2/runner/_repex.py | 3 +++ src/somd2/runner/_runner.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index e4d84d2e..ecd7907d 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -247,6 +247,9 @@ def _create_dynamics( else: mols = system + # Delete an existing trajectory frames. + mols.delete_all_frames() + # Overload the device and lambda value. dynamics_kwargs["device"] = device dynamics_kwargs["lambda_value"] = lam diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index b7632255..89b3f759 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -292,6 +292,9 @@ def run_window(self, index): else: system = self._system.clone() + # Delete an existing trajectory frames. + system.delete_all_frames() + # GPU platform. if self._is_gpu: # Get a GPU from the pool. From 9bda42219feb32ce571adbaa43712de0a273f242 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 14:10:09 +0000 Subject: [PATCH 048/212] Only modify checkpoint frequency in local scope. --- src/somd2/runner/_repex.py | 16 ++++++++-------- src/somd2/runner/_runner.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index ecd7907d..34303f15 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -836,28 +836,28 @@ def run(self): else: cycles = int(ceil(cycles)) - if self._config.checkpoint_frequency.value() > 0.0: + # Store the current checkpoint frequency. + checkpoint_frequency = self._config.checkpoint_frequency + + if checkpoint_frequency.value() > 0.0: # Calculate the number of blocks and the remainder time. - frac = (self._config.runtime / self._config.checkpoint_frequency).value() + frac = (self._config.runtime / checkpoint_frequency).value() # Handle the case where the runtime is less than the checkpoint frequency. if frac < 1.0: frac = 1.0 - self._config.checkpoint_frequency = str(self._config.runtime) + checkpoint_frequency = self._config.runtime num_blocks = int(frac) rem = round(frac - num_blocks, 12) # Work out the number of repex cycles per block. - frac = ( - self._config.checkpoint_frequency.value() - / self._config.energy_frequency.value() - ) + frac = (checkpoint_frequency / self._config.energy_frequency).value() # Handle the case where the checkpoint frequency is less than the energy frequency. if frac < 1.0: frac = 1.0 - self._config.checkpoint_frequency = str(self._config.energy_frequency) + checkpoint_frequency = self._config.energy_frequency # Store the number of repex cycles per block. cycles_per_checkpoint = int(frac) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 89b3f759..8b56e97b 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -647,22 +647,25 @@ def generate_lam_vals(lambda_base, increment=0.001): else: num_energy_neighbours = None + # Store the current checkpoint frequency. + checkpoint_frequency = self._config.checkpoint_frequency + # Store the checkpoint time in nanoseconds. - checkpoint_interval = self._config.checkpoint_frequency.to("ns") + checkpoint_interval = checkpoint_frequency.to("ns") # Store the start time. start = _timer() # Run the simulation, checkpointing in blocks. - if self._config.checkpoint_frequency.value() > 0.0: + if checkpoint_frequency.value() > 0.0: # Calculate the number of blocks and the remainder time. - frac = (time / self._config.checkpoint_frequency).value() + frac = (time / checkpoint_frequency).value() # Handle the case where the runtime is less than the checkpoint frequency. if frac < 1.0: frac = 1.0 - self._config.checkpoint_frequency = f"{time} ps" + checkpoint_frequency = _sr.u(f"{time} ps") num_blocks = int(frac) rem = round(frac - num_blocks, 12) @@ -687,7 +690,7 @@ def generate_lam_vals(lambda_base, increment=0.001): next_frame = self._config.frame_frequency # Loop until we reach the runtime. - while runtime <= self._config.checkpoint_frequency: + while runtime <= checkpoint_frequency: # Run the dynamics in blocks of the GCMC frequency. dynamics.run( self._config.gcmc_frequency, @@ -728,7 +731,7 @@ def generate_lam_vals(lambda_base, increment=0.001): else: dynamics.run( - self._config.checkpoint_frequency, + checkpoint_frequency, energy_frequency=self._config.energy_frequency, frame_frequency=self._config.frame_frequency, lambda_windows=lambda_array, From 66354b8a0b89cd1725b1e4fc85dbec8c1b1e3d65 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 28 Jan 2026 14:17:15 +0000 Subject: [PATCH 049/212] Update checkpoint interval if frequency is adjusted. --- src/somd2/runner/_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 8b56e97b..12a637c6 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -666,6 +666,7 @@ def generate_lam_vals(lambda_base, increment=0.001): if frac < 1.0: frac = 1.0 checkpoint_frequency = _sr.u(f"{time} ps") + checkpoint_interval = checkpoint_frequency.to("ns") num_blocks = int(frac) rem = round(frac - num_blocks, 12) From 2f3a846f036bccf34165a8fc69bbd78d664d4759 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 2 Feb 2026 16:49:35 +0000 Subject: [PATCH 050/212] Account for shared context when estimating GPU memory footprint. --- src/somd2/runner/_repex.py | 57 ++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 34303f15..8cc05c31 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -215,8 +215,8 @@ def _create_dynamics( # Initialise the dynamics object list. self._dynamics = [] - # A set of visited device indices. - devices = set() + # Per-device memory tracking for estimation. + device_mem = {} # Determine whether there is a remainder in the number of replicas. remainder = num_replicas % num_gpus @@ -233,12 +233,14 @@ def _create_dynamics( # Work out the device index. device = i % num_gpus - # If we've not seen this device before then get the memory statistics - # prior to creating the dynamics object and GCMC sampler. - if device not in devices: - used_mem_before, free_mem_before, total_mem = self._check_device_memory( - device - ) + # Record baseline memory before the first replica on this device. + if device not in device_mem: + used_before, _, total_mem = self._check_device_memory(device) + device_mem[device] = { + "before": used_before, + "total": total_mem, + "count": 0, + } # This is a restart, get the system for this replica. if isinstance(system, list): @@ -321,19 +323,38 @@ def _create_dynamics( # Append the dynamics object. self._dynamics.append(dynamics) - # Check the memory footprint for this device. - if not device in devices: - # Add the device to the set of visited devices. - devices.add(device) + # Track memory footprint for this device. + info = device_mem[device] + info["count"] += 1 + num_contexts = contexts_per_device[device] - # Get the current memory usage. - used_mem, free_mem, total_mem = self._check_device_memory(device) + # Estimate memory after the first or second replica. + if info["count"] == 1: + used_mem, _, _ = self._check_device_memory(device) + info["after_first"] = used_mem - # Work out the memory used by this dynamics object and GCMC sampler. - mem_used = used_mem - used_mem_before + if num_contexts == 1: + # Only one replica on this device, use actual measurement. + est_total = used_mem + else: + # Wait for the second replica to get the marginal cost. + est_total = None + + elif info["count"] == 2: + used_mem, _, _ = self._check_device_memory(device) + # The first replica includes one-time context overhead. + # The marginal cost of subsequent replicas is the difference + # between the second and first. + first_cost = info["after_first"] - info["before"] + marginal_cost = used_mem - info["after_first"] + est_total = ( + info["before"] + first_cost + marginal_cost * (num_contexts - 1) + ) + else: + est_total = None - # Work out the estimated total after all replicas have been created. - est_total = mem_used * contexts_per_device[device] + used_mem_before + if est_total is not None: + total_mem = info["total"] # If this exceeds the total memory, raise an error. if est_total > total_mem: From a062741982577ac1c92eb1e278729f5a10b65d90 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 2 Feb 2026 16:53:14 +0000 Subject: [PATCH 051/212] Log primary and marginal GPU memory cost separately. --- src/somd2/runner/_repex.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 8cc05c31..f63241ae 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -350,6 +350,11 @@ def _create_dynamics( est_total = ( info["before"] + first_cost + marginal_cost * (num_contexts - 1) ) + _logger.info( + f"Memory per replica on device {device}: " + f"first = {first_cost / (1024**2):.0f} MiB, " + f"marginal = {marginal_cost / (1024**2):.0f} MiB" + ) else: est_total = None From 410f8f787bcc9f52c4ce5d6d78cb8d9ef164708f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 3 Feb 2026 11:53:42 +0000 Subject: [PATCH 052/212] Fixed bug in calculation of contexts per device. --- src/somd2/runner/_repex.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index f63241ae..1aefa654 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -218,15 +218,14 @@ def _create_dynamics( # Per-device memory tracking for estimation. device_mem = {} - # Determine whether there is a remainder in the number of replicas. + # Work out how many replicas are assigned to each device. + # Replicas are assigned round-robin, so the first (num_replicas % num_gpus) + # devices get one extra replica. + base = floor(num_replicas / num_gpus) remainder = num_replicas % num_gpus - - # Store the number of contexts for each device. The last device will - # have remainder contexts, while all others have - contexts_per_device = num_replicas * [floor(num_replicas / num_gpus)] - - # Set the last device to have the remainder contexts. - contexts_per_device[-1] = remainder + contexts_per_device = [ + base + (1 if i < remainder else 0) for i in range(num_gpus) + ] # Create the dynamics objects in serial. for i, (lam, scale) in enumerate(zip(lambdas, rest2_scale_factors)): From 2d433199ff4c796706d2c570cac5fd821fc5080e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 3 Feb 2026 12:06:39 +0000 Subject: [PATCH 053/212] Get GPU device by index, not name. --- src/somd2/runner/_repex.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 1aefa654..c6025a9f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -587,18 +587,10 @@ def _check_device_memory(device_index=0): pynvml.nvmlInit() - # Find matching device by name - device_count = pynvml.nvmlDeviceGetCount() - for i in range(device_count): - handle = pynvml.nvmlDeviceGetHandleByIndex(i) - name = pynvml.nvmlDeviceGetName(handle) - - if name in device.name or device.name in name: - memory = pynvml.nvmlDeviceGetMemoryInfo(handle) - pynvml.nvmlShutdown() - return (memory.used, memory.free, memory.total) - + handle = pynvml.nvmlDeviceGetHandleByIndex(device_index) + memory = pynvml.nvmlDeviceGetMemoryInfo(handle) pynvml.nvmlShutdown() + return (memory.used, memory.free, memory.total) except Exception as e: msg = f"Could not get NVIDIA GPU memory info for device {device_index}: {e}" _logger.error(msg) From b5974042b7664c729429a2eca2eeacbb0b59bf90 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 4 Feb 2026 10:05:16 +0000 Subject: [PATCH 054/212] Log ghostly and loch versions. --- src/somd2/__init__.py | 6 ++++++ src/somd2/runner/_base.py | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/somd2/__init__.py b/src/somd2/__init__.py index 6a45080f..ac5eef1a 100644 --- a/src/somd2/__init__.py +++ b/src/somd2/__init__.py @@ -37,3 +37,9 @@ # Store the sire version. from sire import __version__ as _sire_version from sire import __revisionid__ as _sire_revisionid + +# Store the ghostly version. +from ghostly import __version__ as _ghostly_version + +# Store the loch version. +from loch import __version__ as _loch_version diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index c401fc97..cbf8f43b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -117,10 +117,20 @@ def __init__(self, system, config): self._perturbed_box = None # Log the versions of somd2 and sire. - from somd2 import __version__, _sire_version, _sire_revisionid + from somd2 import ( + __version__, + _sire_version, + _sire_revisionid, + _ghostly_version, + _loch_version, + ) _logger.info(f"somd2 version: {__version__}") _logger.info(f"sire version: {_sire_version}+{_sire_revisionid}") + if self._config.ghost_modifications: + _logger.info(f"ghostly version: {_ghostly_version}") + if self._config.gcmc: + _logger.info(f"loch version: {_loch_version}") # Flag whether frames are being saved. if ( From b44c0845dab82da8dbe6e92405416d1f814a7a35 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 4 Feb 2026 17:19:09 +0000 Subject: [PATCH 055/212] Fix typo. --- src/somd2/runner/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index cbf8f43b..14912eb5 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -282,7 +282,7 @@ def __init__(self, system, config): except Exception as e1: try: self._system, self._modifications = modify( - self_system, optimise_angles=False + self._system, optimise_angles=False ) except Exception as e2: msg = f"Unable to apply modifications to ghost atom bonded terms: {e1}; {e2}" From d5c903c79c722e2ddaec617c799143ecbaaf3a07 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Feb 2026 10:26:41 +0000 Subject: [PATCH 056/212] Add GCMC ghost waters to perturbed positions array. --- src/somd2/runner/_repex.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index c6025a9f..1be0c5ea 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -310,6 +310,21 @@ def _create_dynamics( ): from openmm.unit import angstrom + # Get the positions from the context. + positions = ( + dynamics.context() + .getState(getPositions=True) + .getPositions(asNumpy=True) + ) + + # The positions array also contains the ghost water atoms that + # were added during the GCMC setup. We need to make sure that + # we copy these over to the perturbed positions array. + diff = len(positions) - len(perturbed_positions) + perturbed_positions = _np.concatenate( + [perturbed_positions, positions[-diff:]] + ) + dynamics.context().setPeriodicBoxVectors(*perturbed_box * angstrom) dynamics.context().setPositions(perturbed_positions * angstrom) From 7cbae0749838606617c6d43e6a0c39dd134ef958 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Feb 2026 11:38:33 +0000 Subject: [PATCH 057/212] Make position units consistent. --- src/somd2/runner/_repex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 1be0c5ea..553335b0 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -315,7 +315,7 @@ def _create_dynamics( dynamics.context() .getState(getPositions=True) .getPositions(asNumpy=True) - ) + ) / angstrom # The positions array also contains the ghost water atoms that # were added during the GCMC setup. We need to make sure that From 9e836185a18607ea3030aa722184c63a3c1a8b1d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Feb 2026 12:52:44 +0000 Subject: [PATCH 058/212] Don't append positions when diff is zero. --- src/somd2/runner/_repex.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 553335b0..fb424011 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -321,9 +321,10 @@ def _create_dynamics( # were added during the GCMC setup. We need to make sure that # we copy these over to the perturbed positions array. diff = len(positions) - len(perturbed_positions) - perturbed_positions = _np.concatenate( - [perturbed_positions, positions[-diff:]] - ) + if diff != 0: + perturbed_positions = _np.concatenate( + [perturbed_positions, positions[-diff:]] + ) dynamics.context().setPeriodicBoxVectors(*perturbed_box * angstrom) dynamics.context().setPositions(perturbed_positions * angstrom) From cf31cc4cf5c1b4fed5f8be9557b5ba96c8650b51 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Feb 2026 10:22:29 +0000 Subject: [PATCH 059/212] Switch to pixi and rattler build. --- .github/workflows/devel.yaml | 59 ++++++++++-------- .github/workflows/main.yaml | 57 ++++++++++------- .github/workflows/pr.yaml | 56 +++++++++-------- README.md | 55 ++++++++++++---- actions/update_recipe.py | 90 +++++++++++++++------------ actions/upload_package.py | 60 ++++++++---------- environment.yaml | 15 ----- environment_macos.yaml | 13 ---- pixi.toml | 25 ++++++++ recipes/somd2/conda_build_config.yaml | 3 - recipes/somd2/recipe.yaml | 68 ++++++++++++++++++++ recipes/somd2/template.yaml | 80 ------------------------ 12 files changed, 306 insertions(+), 275 deletions(-) delete mode 100644 environment.yaml delete mode 100644 environment_macos.yaml create mode 100644 pixi.toml delete mode 100644 recipes/somd2/conda_build_config.yaml create mode 100644 recipes/somd2/recipe.yaml delete mode 100644 recipes/somd2/template.yaml diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index d25fb76b..48b62033 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -3,7 +3,7 @@ name: Release Devel on: workflow_dispatch: push: - branches: [ devel ] + branches: [devel] jobs: build: @@ -18,11 +18,9 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: - # Exclude all but the latest Python from all - # but Linux - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... We want 3.10 and 3.11 + python-version: "3.12" # MacOS can't run 3.12 yet... We want 3.10 and 3.11 environment: name: somd2-build defaults: @@ -32,30 +30,37 @@ jobs: SIRE_DONT_PHONEHOME: 1 SIRE_SILENT_PHONEHOME: 1 steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: somd2_build - miniforge-version: latest -# - - name: Clone the devel branch - run: git clone -b devel https://github.com/openbiosim/somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build - run: conda build -c conda-forge -c openbiosim/label/dev ${{ github.workspace }}/somd2/recipes/somd2 -# - - name: Upload Conda package - run: python ${{ github.workspace }}/somd2/actions/upload_package.py + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + run: python -m pip install anaconda-client + # + - name: Upload package + run: python ${{ github.workspace }}/actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: dev diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 088186ed..3ad83844 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -23,7 +23,7 @@ jobs: exclude: - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + python-version: "3.12" # MacOS can't run 3.12 yet... environment: name: somd2-build defaults: @@ -33,30 +33,39 @@ jobs: SIRE_DONT_PHONEHOME: 1 SIRE_SILENT_PHONEHOME: 1 steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: somd2_build - miniforge-version: latest -# - - name: Clone the main branch - run: git clone -b main https://github.com/openbiosim/somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build - run: conda build -c conda-forge -c openbiosim/label/main ${{ github.workspace }}/somd2/recipes/somd2 -# - - name: Upload Conda package - run: python ${{ github.workspace }}/somd2/actions/upload_package.py + ref: main + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Install anaconda-client + run: python -m pip install anaconda-client + if: github.event.inputs.upload_packages == 'true' + # + - name: Upload package + run: python ${{ github.workspace }}/actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: main diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 6191ca56..b6ed02ea 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -17,14 +17,12 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: - # Exclude all but the latest Python from all - # but Linux - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } python-version: "3.10" - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + python-version: "3.12" # MacOS can't run 3.12 yet... environment: name: somd2-build defaults: @@ -33,31 +31,35 @@ jobs: env: SIRE_DONT_PHONEHOME: 1 SIRE_SILENT_PHONEHOME: 1 - REPO: "${{ github.event.pull_request.head.repo.full_name || github.repository }}" steps: - - uses: conda-incubator/setup-miniconda@v3 + # + - uses: actions/checkout@v4 with: - auto-update-conda: true - python-version: ${{ matrix.python-version }} - activate-environment: somd2_build - miniforge-version: latest -# - - name: Clone the feature branch - run: git clone -b ${{ github.head_ref }} --single-branch https://github.com/${{ env.REPO }} somd2 -# - - name: Setup Conda - run: conda install -y -c conda-forge boa anaconda-client packaging -# - - name: Update Conda recipe - run: python ${{ github.workspace }}/somd2/actions/update_recipe.py -# - - name: Prepare build location - run: mkdir ${{ github.workspace }}/build -# - - name: Build Conda package using conda build using main channel + fetch-depth: 0 + # + - name: Compute version info + run: python ${{ github.workspace }}/actions/update_recipe.py + # + - name: Create sdist + run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz + working-directory: ${{ github.workspace }} + # + - name: Install rattler-build + uses: prefix-dev/rattler-build-action@v0.2.34 + with: + tool-version: latest + build-args: --help + # + - name: Write Python variant config + shell: bash + run: printf 'python:\n - "${{ matrix.python-version }}"\n' > "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build (main channel) if: ${{ github.base_ref == 'main' }} - run: conda build -c conda-forge -c openbiosim/label/main ${{ github.workspace }}/somd2/recipes/somd2 -# - - name: Build Conda package using conda build using dev channel + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" + # + - name: Build package using rattler-build (dev channel) if: ${{ github.base_ref != 'main' }} - run: conda build -c conda-forge -c openbiosim/label/dev ${{ github.workspace }}/somd2/recipes/somd2 + shell: bash + run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" diff --git a/README.md b/README.md index f8174c8c..3493b618 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,66 @@ simulations. Built on top of [Sire](https://github.com/OpenBioSim/sire) and [Ope ## Installation -First create a conda environment using the provided environment file: +### Conda package + +Install `somd2` directly from the `openbiosim` channel: ``` -conda env create -f environment.yaml +conda install -c conda-forge -c openbiosim somd2 ``` -(We recommend using [Miniforge](https://github.com/conda-forge/miniforge).) +Or, for the development version: -> [!NOTE] -> On macOS, you will need to use the `environment_macos.yaml` file instead. +``` +conda install -c conda-forge -c openbiosim/label/dev somd2 +``` -Now install `somd2` into the environment: +### Installing from source (standalone) + +To install from source using [pixi](https://pixi.sh), which will +automatically create an environment with all required dependencies +(including pre-built [Sire](https://github.com/OpenBioSim/sire), +[BioSimSpace](https://github.com/OpenBioSim/biosimspace), +[Ghostly](https://github.com/OpenBioSim/ghostly), and +[Loch](https://github.com/OpenBioSim/loch)): ``` -conda activate somd2 -pip install --editable . +git clone https://github.com/openbiosim/somd2 +cd somd2 +pixi install +pixi shell +pip install -e . ``` -Alternatively, to install into an existing conda environment: +### Installing from source (full OpenBioSim development) + +If you are developing across the full OpenBioSim stack, first install +[Sire](https://github.com/OpenBioSim/sire) from source by following the +instructions [here](https://github.com/OpenBioSim/sire#installation), then +activate its pixi environment: ``` -conda install -c conda-forge -c openbiosim somd2 +pixi shell --manifest-path /path/to/sire/pixi.toml -e dev ``` -Or, for the development version: +You may also need to install other packages from source, e.g. +[BioSimSpace](https://github.com/OpenBioSim/biosimspace), +[Ghostly](https://github.com/OpenBioSim/ghostly), and +[Loch](https://github.com/OpenBioSim/loch): ``` -conda install -c conda-forge -c openbiosim/label/dev somd2 +pip install -e /path/to/biosimspace/python +pip install -e /path/to/ghostly +pip install -e /path/to/loch +``` + +Then install `somd2` into the environment: + ``` +pip install -e . +``` + +### Testing You should now have a `somd2` executable in your path. To test, run: diff --git a/actions/update_recipe.py b/actions/update_recipe.py index ac3ff966..629369a0 100644 --- a/actions/update_recipe.py +++ b/actions/update_recipe.py @@ -1,50 +1,58 @@ -import sys +"""Compute git version info for rattler-build. + +This script computes GIT_DESCRIBE_TAG and GIT_DESCRIBE_NUMBER from the +git history and outputs them in GitHub Actions format for setting +environment variables. + +It also writes a _version.py file so that versioningit has a fallback +when .git is not available (e.g., when rattler-build excludes it). +""" + import os import subprocess +import sys -# Get the name of the script. script = os.path.abspath(sys.argv[0]) - -# we want to import the 'get_requirements' package from this directory -sys.path.insert(0, os.path.dirname(script)) - -# go up one directories to get the source directory -# (this script is in BioSimSpace/actions/) srcdir = os.path.dirname(os.path.dirname(script)) - -condadir = os.path.join(srcdir, "recipes", "somd2") - -print(f"conda recipe in {condadir}") - -# Store the name of the recipe and template YAML files. -recipe = os.path.join(condadir, "meta.yaml") -template = os.path.join(condadir, "template.yaml") - gitdir = os.path.join(srcdir, ".git") def run_cmd(cmd): - p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) - return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() - - -# Get the remote. -remote = run_cmd( - f"git --git-dir={gitdir} --work-tree={srcdir} config --get remote.origin.url" -) -print(remote) - -# Get the branch. -branch = run_cmd( - f"git --git-dir={gitdir} --work-tree={srcdir} rev-parse --abbrev-ref HEAD" -) -print(branch) - -lines = open(template, "r").readlines() - -with open(recipe, "w") as FILE: - for line in lines: - line = line.replace("SOMD2_REMOTE", remote) - line = line.replace("SOMD2_BRANCH", branch) - - FILE.write(line) + p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) + stdout, _ = p.communicate() + return stdout.decode("utf-8").strip() + + +# Get the full git describe output (e.g., "2024.1.0-5-gabcdef" or "2024.1.0") +describe = run_cmd(f"git --git-dir={gitdir} --work-tree={srcdir} describe --tags") + +if "-" in describe: + # Format: tag-number-hash (e.g., "2024.1.0-5-gabcdef") + parts = describe.rsplit("-", 2) + tag = parts[0] + number = parts[1] + rev = parts[2] # e.g., "gabcdef" + version = f"{tag}+{number}.{rev}" +else: + # Exactly on a tag + tag = describe + number = "0" + version = tag + +print(f"GIT_DESCRIBE_TAG={tag}") +print(f"GIT_DESCRIBE_NUMBER={number}") +print(f"Version={version}") + +# Write to GITHUB_ENV if running in GitHub Actions +github_env = os.environ.get("GITHUB_ENV") +if github_env: + with open(github_env, "a") as f: + f.write(f"GIT_DESCRIBE_TAG={tag}\n") + f.write(f"GIT_DESCRIBE_NUMBER={number}\n") + print("Exported to GITHUB_ENV") + +# Write _version.py for versioningit fallback +version_file = os.path.join(srcdir, "src", "somd2", "_version.py") +with open(version_file, "w") as f: + f.write(f'__version__ = "{version}"\n') +print(f"Wrote {version_file}") diff --git a/actions/upload_package.py b/actions/upload_package.py index 799638cb..22547adc 100644 --- a/actions/upload_package.py +++ b/actions/upload_package.py @@ -1,16 +1,18 @@ +"""Upload built packages to the openbiosim Anaconda Cloud channel.""" + import os import sys import glob +import subprocess script = os.path.abspath(sys.argv[0]) -# go up one directories to get the source directory -# (this script is in somd2/actions/) +# Go up one directory to get the source directory. srcdir = os.path.dirname(os.path.dirname(script)) print(f"SOMD2 source is in {srcdir}\n") -# Get the anaconda token to authorise uploads +# Get the anaconda token to authorise uploads. if "ANACONDA_TOKEN" in os.environ: conda_token = os.environ["ANACONDA_TOKEN"] else: @@ -22,42 +24,30 @@ else: conda_label = "dev" -# get the root conda directory -conda = os.environ["CONDA"] - -# Set the path to the conda-bld directory. -conda_bld = os.path.join(conda, "envs", "somd2_build", "conda-bld") - -print(f"conda_bld = {conda_bld}") +# Search for rattler-build output first. +packages = glob.glob(os.path.join("output", "**", "*.conda"), recursive=True) -# Find the packages to upload -somd2_pkg = glob.glob(os.path.join(conda_bld, "*-*", "somd2-*.tar.bz2")) +# Fall back to conda-bld output. +if not packages: + if "CONDA" in os.environ: + conda = os.environ["CONDA"] + conda_bld = os.path.join(conda, "envs", "somd2_build", "conda-bld") + packages = glob.glob( + os.path.join(conda_bld, "**", "somd2-*.tar.bz2"), recursive=True + ) -if len(somd2_pkg) == 0: +if not packages: print("No somd2 packages to upload?") sys.exit(-1) -packages = somd2_pkg - -print(f"Uploading packages:") -print(" * ", "\n * ".join(packages)) - -packages = " ".join(packages) - - -def run_cmd(cmd): - import subprocess - - p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) - return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() +print("Uploading packages:") +for pkg in packages: + print(f" * {pkg}") - -gitdir = os.path.join(srcdir, ".git") - -tag = run_cmd(f"git --git-dir={gitdir} --work-tree={srcdir} tag --contains") +packages_str = " ".join(packages) # Upload the packages to the openbiosim channel on Anaconda Cloud. -cmd = f"anaconda --token {conda_token} upload --user openbiosim --label {conda_label} --force {packages}" +cmd = f"anaconda --token {conda_token} upload --user openbiosim --label {conda_label} --force {packages_str}" print(f"\nUpload command:\n\n{cmd}\n") @@ -65,8 +55,12 @@ def run_cmd(cmd): print("Not uploading as the ANACONDA_TOKEN is not set!") sys.exit(-1) -output = run_cmd(cmd) -print(output) +def run_cmd(cmd): + p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE) + return str(p.stdout.read().decode("utf-8")).lstrip().rstrip() + +output = run_cmd(cmd) +print(output) print("Package uploaded!") diff --git a/environment.yaml b/environment.yaml deleted file mode 100644 index e509e02b..00000000 --- a/environment.yaml +++ /dev/null @@ -1,15 +0,0 @@ -name: somd2 - -channels: - - conda-forge - - openbiosim/label/dev - -dependencies: - - biosimspace - - ghostly - - filelock - - loch - - loguru - - numba - - nvidia-ml-py - - versioningit diff --git a/environment_macos.yaml b/environment_macos.yaml deleted file mode 100644 index 19083a3c..00000000 --- a/environment_macos.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: somd2 - -channels: - - conda-forge - - openbiosim/label/dev - -dependencies: - - biosimspace - - ghostly - - filelock - - loguru - - numba - - versioningit diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 00000000..6058f494 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,25 @@ +[workspace] +name = "somd2" +channels = ["conda-forge", "openbiosim/label/dev"] +# No Windows - depends on loch which requires pycuda/pyopencl +platforms = ["linux-64", "osx-arm64"] + +[dependencies] +python = ">=3.10" +biosimspace = "*" +ghostly = "*" +loch = "*" +loguru = "*" +numba = "*" + +[target.linux-64.dependencies] +nvidia-ml-py = "*" + +[feature.test.dependencies] +pytest = "*" +black = "*" + +[environments] +default = [] +test = ["test"] +dev = ["test"] diff --git a/recipes/somd2/conda_build_config.yaml b/recipes/somd2/conda_build_config.yaml deleted file mode 100644 index 3e8e203d..00000000 --- a/recipes/somd2/conda_build_config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -pin_run_as_build: - sire: - max_pin: x.x diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml new file mode 100644 index 00000000..34bfd3f8 --- /dev/null +++ b/recipes/somd2/recipe.yaml @@ -0,0 +1,68 @@ +context: + name: somd2 + +package: + name: ${{ name }} + version: ${{ env.get('GIT_DESCRIBE_TAG', default='PR') }} + +source: + path: ../../somd2-source.tar.gz + +build: + number: ${{ env.get('GIT_DESCRIBE_NUMBER', default='0') }} + script: python -m pip install . --no-deps --ignore-installed -vv + +requirements: + host: + - pip + - python + - setuptools + - versioningit + run: + - biosimspace + - ghostly + - loch + - loguru + - numba + - numpy <2.3 # Remove when nglview >=4.1 is released + - python + - if: linux + then: + - nvidia-ml-py + +tests: + - python: + imports: + - if: linux + then: + - somd2 + - script: + - if: linux and x86_64 + then: + - pytest -vvv --color=yes --black src/somd2 + - if: linux + then: + - pytest -vvv --color=yes --import-mode=importlib ./tests + files: + source: + - src/somd2/ + - tests/ + requirements: + run: + - pytest + - if: linux and x86_64 + then: + - black ==25 + - pytest-black + +about: + homepage: https://github.com/openbiosim/somd2 + license: GPL-3.0-or-later + license_file: LICENSE + summary: "GPU accelerated free-energy perturbation simulation engine." + repository: https://github.com/openbiosim/somd2 + documentation: https://github.com/openbiosim/somd2 + +extra: + recipe-maintainers: + - lohedges diff --git a/recipes/somd2/template.yaml b/recipes/somd2/template.yaml deleted file mode 100644 index 27c3f78f..00000000 --- a/recipes/somd2/template.yaml +++ /dev/null @@ -1,80 +0,0 @@ -{% set name = "somd2" %} - -package: - name: {{ name }} - version: {{ environ.get('GIT_DESCRIBE_TAG', 'PR') }} - -source: - git_url: SOMD2_REMOTE - git_tag: SOMD2_BRANCH - -build: - number: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} - script: {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vv - -requirements: - host: - - biosimspace - - ghostly - - loch - - loguru - - pip - - python - - numba - - nvidia-ml-py # [linux] - - setuptools - - sire - - versioningit - run: - - biosimspace - - ghostly - - loch - - loguru - - numba - - nvidia-ml-py # [linux] - - python - - sire - -test: - script_env: - - SIRE_DONT_PHONEHOME - - SIRE_SILENT_PHONEHOME - requires: - - black == 25 # [linux and x86_64 and py==311] - - pytest - - pytest-black # [linux and x86_64 and py==311] - imports: - - somd2 # [linux] - source_files: - - src/somd2 - - tests - commands: - - pytest -vvv --color=yes --black src/somd2 # [linux and x86_64 and py==311] - - pytest -vvv --color=yes --import-mode=importlib tests # [linux] - -about: - home: https://github.com/openbiosim/somd2 - license: GPL-3.0-or-later - license_file: '{{ environ["RECIPE_DIR"] }}/LICENSE' - summary: "GPU accelerated free-energy pertubation simulation engine" - dev_url: https://github.com/openbiosim/somd2 - doc_url: https://github.com/openbiosim/somd2 - description: | - somd2 is an open-source GPU accelerated molecular dynamics engine for - alchemical free-energy calculations. Built on top of Sire, BioSimSpace, - and OpenMM. - - `conda install -c conda-forge -c openbiosim somd2` - - To install the development version: - - `conda install -c conda-forge -c openbiosim/label/dev somd2` - - When updating the development version it is generally advised to - update Sire at the same time: - - `conda install -c conda-forge -c openbiosim/label/dev somd2 sire` - -extra: - recipe-maintainers: - - lohedges From ec644dbb4e73d7e5ad2ba2d52411e9dbcd9e683e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Feb 2026 14:10:26 +0000 Subject: [PATCH 060/212] Add auto-generated version file to gitignore. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e1a8d070..dd1d5701 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ output.yaml # Conda recipe (it is auto-generated) recipes/somd2/meta.yaml + +# Auto-generated version file +src/somd2/_version.py From 814cd4720b9acfe23ed5ffda3416663bd238a265 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Feb 2026 20:09:18 +0000 Subject: [PATCH 061/212] Save the perturbed system to the output directory. --- src/somd2/runner/_base.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 14912eb5..0c9db05b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -528,7 +528,17 @@ def __init__(self, system, config): self._is_restart = False self._cleanup() - # Save config whenever 'configure' is called to keep it up to date. + if self._config.replica_exchange and self._config.perturbed_system is not None: + # Check whether the perturbed system was loaded from file. If not + # we need to save to the output directory and update the config to + # point to the new file. + if self._config._perturbed_system_file is None: + filename = _Path(self._config.output_directory) / "perturbed_system.s3" + _sr.stream.save(perturbed_system, perturbed_system_file) + self._config._perturbed_system_file = str(filename) + _logger.info(f"Saving perturbed system to {perturbed_system_file}") + + # Write YAML configuration file to the output directory. if self._config.write_config: _dict_to_yaml( self._config.as_dict(), From de4a6a32e0a62de73e54789bdad59274b71e05e4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Feb 2026 20:09:53 +0000 Subject: [PATCH 062/212] Only convert if a filename has been set. --- src/somd2/config/_config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 52babacd..fed794c6 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -655,7 +655,10 @@ def as_dict(self, sire_compatible=False): # Use the path for the perturbed_system option, since the system # isn't serializable. - if self.perturbed_system is not None: + if ( + self.perturbed_system is not None + and self._perturbed_system_file is not None + ): d["perturbed_system"] = str(self._perturbed_system_file) d.pop("perturbed_system_file", None) From 2ac817034b686166b1abbf185e68409a828e32b3 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Feb 2026 09:13:04 +0000 Subject: [PATCH 063/212] Fix install path. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3493b618..3bd2805b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ You may also need to install other packages from source, e.g. [Loch](https://github.com/OpenBioSim/loch): ``` -pip install -e /path/to/biosimspace/python +pip install -e /path/to/biosimspace pip install -e /path/to/ghostly pip install -e /path/to/loch ``` From b34748f73a84c5bb7b371efcc4cd0a971bdef827 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Feb 2026 09:43:27 +0000 Subject: [PATCH 064/212] Remove numpy pin. --- recipes/somd2/recipe.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index 34bfd3f8..b57509b7 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -24,7 +24,6 @@ requirements: - loch - loguru - numba - - numpy <2.3 # Remove when nglview >=4.1 is released - python - if: linux then: From 08f08796dde6ca1c102cd15b2ce015b489b6475d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Feb 2026 12:57:14 +0000 Subject: [PATCH 065/212] Fix shell quoting. --- .github/workflows/devel.yaml | 7 +++++-- .github/workflows/main.yaml | 7 +++++-- .github/workflows/pr.yaml | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index 48b62033..c9360330 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -36,7 +36,8 @@ jobs: fetch-depth: 0 # - name: Compute version info - run: python ${{ github.workspace }}/actions/update_recipe.py + shell: bash + run: python "${{ github.workspace }}/actions/update_recipe.py" # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz @@ -57,10 +58,12 @@ jobs: run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/dev --variant-config "${{ github.workspace }}/python_variant.yaml" # - name: Install anaconda-client + shell: bash run: python -m pip install anaconda-client # - name: Upload package - run: python ${{ github.workspace }}/actions/upload_package.py + shell: bash + run: python "${{ github.workspace }}/actions/upload_package.py" env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: dev diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3ad83844..5cdcc46f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -40,7 +40,8 @@ jobs: fetch-depth: 0 # - name: Compute version info - run: python ${{ github.workspace }}/actions/update_recipe.py + shell: bash + run: python "${{ github.workspace }}/actions/update_recipe.py" # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz @@ -61,11 +62,13 @@ jobs: run: rattler-build build --recipe "${{ github.workspace }}/recipes/somd2" -c conda-forge -c openbiosim/label/main --variant-config "${{ github.workspace }}/python_variant.yaml" # - name: Install anaconda-client + shell: bash run: python -m pip install anaconda-client if: github.event.inputs.upload_packages == 'true' # - name: Upload package - run: python ${{ github.workspace }}/actions/upload_package.py + shell: bash + run: python "${{ github.workspace }}/actions/upload_package.py" env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: main diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b6ed02ea..0cab3e2f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -38,7 +38,8 @@ jobs: fetch-depth: 0 # - name: Compute version info - run: python ${{ github.workspace }}/actions/update_recipe.py + shell: bash + run: python "${{ github.workspace }}/actions/update_recipe.py" # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz From 749f31b6ffe7c008afa3a96f4e1fdee02e55df39 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Feb 2026 13:03:27 +0000 Subject: [PATCH 066/212] Use relative paths to avoid path mangling. --- .github/workflows/devel.yaml | 4 ++-- .github/workflows/main.yaml | 4 ++-- .github/workflows/pr.yaml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index c9360330..5248fd19 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -37,7 +37,7 @@ jobs: # - name: Compute version info shell: bash - run: python "${{ github.workspace }}/actions/update_recipe.py" + run: python actions/update_recipe.py # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz @@ -63,7 +63,7 @@ jobs: # - name: Upload package shell: bash - run: python "${{ github.workspace }}/actions/upload_package.py" + run: python actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: dev diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 5cdcc46f..685826e4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -41,7 +41,7 @@ jobs: # - name: Compute version info shell: bash - run: python "${{ github.workspace }}/actions/update_recipe.py" + run: python actions/update_recipe.py # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz @@ -68,7 +68,7 @@ jobs: # - name: Upload package shell: bash - run: python "${{ github.workspace }}/actions/upload_package.py" + run: python actions/upload_package.py env: ANACONDA_TOKEN: ${{ secrets.ANACONDA_TOKEN }} ANACONDA_LABEL: main diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0cab3e2f..d5a75316 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -39,7 +39,7 @@ jobs: # - name: Compute version info shell: bash - run: python "${{ github.workspace }}/actions/update_recipe.py" + run: python actions/update_recipe.py # - name: Create sdist run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz From d375246d40f562210684c3a2742fca0602fcffc6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 09:26:45 +0000 Subject: [PATCH 067/212] Add linting tools to dev environment. --- pixi.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pixi.toml b/pixi.toml index 6058f494..ccc4f008 100644 --- a/pixi.toml +++ b/pixi.toml @@ -19,7 +19,11 @@ nvidia-ml-py = "*" pytest = "*" black = "*" +[feature.lint.dependencies] +pre-commit = "*" +ruff = "*" + [environments] default = [] test = ["test"] -dev = ["test"] +dev = ["test", "lint"] From eef379073c616c360ab27d32703e743a0b33eb74 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 11:00:09 +0000 Subject: [PATCH 068/212] Add pre-commit. --- .pre-commit-config.yaml | 23 +++++++++++++++++++++++ README.md | 18 ++++++++++++++++++ pyproject.toml | 7 +++++++ 3 files changed, 48 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..0043ca49 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +files: ^(src|tests)/ +exclude: ^tests/(input|output)/ + +repos: + # General file quality checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-added-large-files + args: [--maxkb=1000] # Prevent files larger than 1MB + - id: check-merge-conflict + + # Python formatting and linting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + # Run the formatter + - id: ruff-format + # Run the linter (optional - remove if too strict) + - id: ruff + args: [--fix, --exit-zero] # Auto-fix but don't block commits diff --git a/README.md b/README.md index 3bd2805b..d5ababc1 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,24 @@ You should now have a `somd2` executable in your path. To test, run: somd2 --help ``` +## Development + +Pre-commit hooks are used to ensure consistent code formatting and linting. +To set up pre-commit in your development environment: + +``` +pixi shell -e dev +pre-commit install +``` + +This will run [ruff](https://docs.astral.sh/ruff/) formatting and linting +checks automatically on each commit. To run the checks manually against all +files: + +``` +pre-commit run --all-files +``` + ## Usage In order to run an alchemical free-energy simulation you will need to diff --git a/pyproject.toml b/pyproject.toml index fd326a62..10a39fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,10 @@ distance-dirty = "{base_version}+{distance}.{vcs}{rev}.dirty" [tool.versioningit.write] file = "src/somd2/_version.py" + +[tool.ruff.lint] +ignore = ["E402"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["F841"] + From a57d00f02aee308f00f902c1e702258e4ca0a767 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 11:08:28 +0000 Subject: [PATCH 069/212] Autoformat and lint with ruff. --- src/somd2/_utils/_somd1.py | 3 +-- src/somd2/config/_config.py | 1 - src/somd2/runner/_base.py | 5 ++--- src/somd2/runner/_repex.py | 7 ++----- src/somd2/runner/_runner.py | 1 - tests/runner/test_config.py | 1 - tests/runner/test_lambda_values.py | 2 -- tests/runner/test_repex.py | 3 --- 8 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index aa379f7b..d25b795b 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -548,7 +548,7 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): for idx0 in impropers0_idx.keys(): if idx1.equivalent(idx0): # Don't store duplicates. - if not idx0 in impropers_shared_idx.keys(): + if idx0 not in impropers_shared_idx.keys(): impropers_shared_idx[idx1] = ( impropers0_idx[idx0], impropers1_idx[idx1], @@ -659,7 +659,6 @@ def reconstruct_system(system): # Loop over all perturbable molecules. for mol in pert_mols: - # Delete any AmberParams properties. try: cursor = mol.cursor() diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index fed794c6..f8c29860 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1459,7 +1459,6 @@ def platform(self): @platform.setter def platform(self, platform): import os as _os - import sys as _sys if not isinstance(platform, str): raise TypeError("'platform' must be of type 'str'") diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 0c9db05b..91febaa2 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -402,8 +402,8 @@ def __init__(self, system, config): if len(self._config.rest2_scale) != len(self._lambda_energy): msg = f"Length of 'rest2_scale' must match the number of {_lam_sym} values." if is_missing: - msg += f"If you have omitted some 'lambda_values` from `lambda_energy`, please " - f"add them to `lambda_energy`, along with the corresponding `rest2_scale` values." + msg += "If you have omitted some 'lambda_values` from `lambda_energy`, please " + "add them to `lambda_energy`, along with the corresponding `rest2_scale` values." _logger.error(msg) raise ValueError(msg) # Make sure the end states are close to 1.0. @@ -429,7 +429,6 @@ def __init__(self, system, config): # Make sure the REST2 selection is valid. if self._config.rest2_selection is not None: - try: atoms = _sr.mol.selection_to_atoms( self._system, self._config.rest2_selection diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index fb424011..cdc18516 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -461,7 +461,6 @@ def save_openmm_state(self, index): index: int The index of the replica. """ - from openmm.unit import angstrom # Get the current OpenMM state. state = ( @@ -1039,8 +1038,7 @@ def run(self): for j in range(num_checkpoint_batches): # Get the indices of the replicas in this batch. replicas = replica_list[ - j - * num_checkpoint_workers : (j + 1) + j * num_checkpoint_workers : (j + 1) * num_checkpoint_workers ] with ThreadPoolExecutor(max_workers=num_workers) as executor: @@ -1063,8 +1061,7 @@ def run(self): for j in range(num_checkpoint_batches): # Get the indices of the replicas in this batch. replicas = replica_list[ - j - * num_checkpoint_workers : (j + 1) + j * num_checkpoint_workers : (j + 1) * num_checkpoint_workers ] with ThreadPoolExecutor(max_workers=num_workers) as executor: diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 12a637c6..561bc138 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -658,7 +658,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Run the simulation, checkpointing in blocks. if checkpoint_frequency.value() > 0.0: - # Calculate the number of blocks and the remainder time. frac = (time / checkpoint_frequency).value() diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index cfb14be6..05a2f0b9 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -1,4 +1,3 @@ -import pytest import tempfile import sire as sr diff --git a/tests/runner/test_lambda_values.py b/tests/runner/test_lambda_values.py index ec6267f1..ca63d7ab 100644 --- a/tests/runner/test_lambda_values.py +++ b/tests/runner/test_lambda_values.py @@ -1,9 +1,7 @@ from pathlib import Path import tempfile -import pytest -import sire as sr from somd2.runner import Runner from somd2.config import Config diff --git a/tests/runner/test_repex.py b/tests/runner/test_repex.py index a21eb9d7..9336053f 100644 --- a/tests/runner/test_repex.py +++ b/tests/runner/test_repex.py @@ -17,7 +17,6 @@ def test_repex_output(ethane_methanol): Validate that repex specific simulation output is generated. """ with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, @@ -92,7 +91,6 @@ def test_rest2_scale(ethane_methanol, rest2_scale, is_valid): """Validate the REST2 scale factor handling.""" with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, @@ -130,7 +128,6 @@ def test_rest2_selection(ethane_methanol, rest2_selection, is_valid): """Validate the REST2 selection handling.""" with tempfile.TemporaryDirectory() as tmpdir: - config = { "runtime": "12fs", "restart": False, From 71f22e28fb2730ce2d8c375cff9e571419c6011c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 11:40:07 +0000 Subject: [PATCH 070/212] Remove redundant sections of recipe. --- recipes/somd2/recipe.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index b57509b7..d485c89a 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -44,15 +44,10 @@ tests: - pytest -vvv --color=yes --import-mode=importlib ./tests files: source: - - src/somd2/ - tests/ requirements: run: - pytest - - if: linux and x86_64 - then: - - black ==25 - - pytest-black about: homepage: https://github.com/openbiosim/somd2 From 8d289706f2c77b1c76f02624dc561b7f84e11e30 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 12:06:18 +0000 Subject: [PATCH 071/212] Update gitignore. --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index dd1d5701..00840853 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ setup.err dist/ build/ somd2.egg-info -src/somd2/_version.py # Test output. output.yaml @@ -35,8 +34,5 @@ output.yaml # VSCode config .vscode/ -# Conda recipe (it is auto-generated) -recipes/somd2/meta.yaml - # Auto-generated version file src/somd2/_version.py From e117d0ec36567adf3bc5f440c7591878943525a1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 14:04:38 +0000 Subject: [PATCH 072/212] Add rattler-build to development environment. --- pixi.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pixi.toml b/pixi.toml index ccc4f008..9559a6e4 100644 --- a/pixi.toml +++ b/pixi.toml @@ -21,6 +21,7 @@ black = "*" [feature.lint.dependencies] pre-commit = "*" +rattler-build = "*" ruff = "*" [environments] From 45789e490ed05a4195253f1ed27588b0ec99f787 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Feb 2026 15:07:14 +0000 Subject: [PATCH 073/212] Use pixi install of rattler-build. --- .github/workflows/devel.yaml | 11 +++++++---- .github/workflows/main.yaml | 11 +++++++---- .github/workflows/pr.yaml | 11 +++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index 5248fd19..e45d9e15 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -43,11 +43,14 @@ jobs: run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz working-directory: ${{ github.workspace }} # - - name: Install rattler-build - uses: prefix-dev/rattler-build-action@v0.2.34 + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 with: - tool-version: latest - build-args: --help + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build # - name: Write Python variant config shell: bash diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 685826e4..354264e0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -47,11 +47,14 @@ jobs: run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz working-directory: ${{ github.workspace }} # - - name: Install rattler-build - uses: prefix-dev/rattler-build-action@v0.2.34 + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 with: - tool-version: latest - build-args: --help + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build # - name: Write Python variant config shell: bash diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index d5a75316..1069e0a7 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -45,11 +45,14 @@ jobs: run: pip install build && python -m build --sdist && mv dist/*.tar.gz somd2-source.tar.gz working-directory: ${{ github.workspace }} # - - name: Install rattler-build - uses: prefix-dev/rattler-build-action@v0.2.34 + - name: Install pixi + uses: prefix-dev/setup-pixi@v0.9.4 with: - tool-version: latest - build-args: --help + run-install: false + # + - name: Install rattler-build + shell: bash + run: pixi global install rattler-build # - name: Write Python variant config shell: bash From 6b254d7a398c0b048f2cd749a79a39e9ad2a733f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 09:08:44 +0000 Subject: [PATCH 074/212] Remove redundant setup.py file. --- setup.py | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 setup.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 60684932..00000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() From 47d80e4ee91b08b3c9bd4458ecd59cc26efbd516 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 12:48:04 +0000 Subject: [PATCH 075/212] Add YAML serialisation for custom lambda schedules and restraints. --- src/somd2/config/_config.py | 97 ++++++++++++++++++++++++++++++++++-- src/somd2/runner/_base.py | 70 ++++++++++++++++++++++++-- src/somd2/runner/_runner.py | 2 +- tests/runner/test_restart.py | 47 +++++++++++++++++ 4 files changed, 207 insertions(+), 9 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index f8c29860..49ce7a00 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -652,6 +652,14 @@ def as_dict(self, sire_compatible=False): self._charge_scale_factor ): d["lambda_schedule"] = "charge_scaled_morph" + else: + d["lambda_schedule"] = self._serialise_object(self.lambda_schedule) + + # Serialise restraints. + if self.restraints is not None: + d["restraints"] = [ + self._serialise_object(restraint) for restraint in self.restraints + ] # Use the path for the perturbed_system option, since the system # isn't serializable. @@ -984,14 +992,21 @@ def lambda_schedule(self, lambda_schedule): if isinstance(lambda_schedule, str): # Strip whitespace and convert to lower case. lambda_schedule = lambda_schedule.strip().lower() - if lambda_schedule not in self._choices["lambda_schedule"]: - raise ValueError( - f"Lambda schedule not recognised. Valid lambda schedules are: {self._choices['lambda_schedule']}" - ) if lambda_schedule == "standard_morph": self._lambda_schedule = _LambdaSchedule.standard_morph() elif lambda_schedule == "charge_scaled_morph": self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) + else: + try: + self._lambda_schedule = self._deserialise_object( + lambda_schedule + ) + except Exception: + raise ValueError( + "Unable to deserialise 'lambda_schedule'. Ensure that this is a " + "hex string representation of a valid LambdaSchedule object, or " + f"one of the following strings: {', '.join(self._choices['lambda_schedule'])}" + ) else: self._lambda_schedule = lambda_schedule else: @@ -1094,12 +1109,27 @@ def restraints(self, restraints): restraints = [restraints] # Check that all restraints are of the correct type. + deserialised_restraints = [] for restraint in restraints: - if not isinstance(restraint, _sr.mm._MM.Restraints): + if isinstance(restraint, _sr.mm._MM.Restraints): + continue + elif isinstance(restraint, str): + try: + restraint = self._deserialise_object(restraint) + except Exception: + raise ValueError( + "Unable to deserialise restraint. Ensure that this " + "is a hex string representation of a valid sire.mm._MM.Restraints object." + ) + deserialised_restraints.append(restraint) + else: raise ValueError( "'restraints' must be a sire.mm._MM.Restraints object, or a list of these objects." ) + if len(deserialised_restraints) > 0: + restraints = deserialised_restraints + self._restraints = restraints @property @@ -2082,6 +2112,63 @@ def overwrite(self, overwrite): raise ValueError("'overwrite' must be of type 'bool'") self._overwrite = overwrite + @staticmethod + def _serialise_object(obj): + """ + Internal method to serialise a Sire object to a hex string representation + for storage in the YAML config file. + + Parameters + ---------- + + obj: object + The Sire object to serialise. + + Returns + -------- + + hex: + The hex string representation of the Sire object. + """ + + from sire.stream import save + from sire.legacy.Qt import QByteArray + + try: + hex = QByteArray(save(obj)).to_hex().data() + except Exception as e: + raise ValueError(f"Unable to serialise object: {e}") + + return hex + + @staticmethod + def _deserialise_object(hex): + """ + Internal method to deserialise a Sire object from a hex string representation. + + Parameters + ---------- + + hex: str + The hex string representation of the Sire object. + + Returns + ------- + + obj: + The deserialised Sire object. + """ + from sire.stream import load + from sire.legacy.Qt import QByteArray + + try: + hex_byte_arrary = QByteArray.from_raw_data(hex, len(hex)) + obj = load(QByteArray.from_hex(hex_byte_arrary)) + except Exception as e: + raise ValueError(f"Unable to deserialise object: {e}") + + return obj + @classmethod def _create_parser(cls): """ diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 91febaa2..b6b8c897 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1354,6 +1354,70 @@ def _compare_configs(config1, config2): v1 = config1[key] v2 = config2[key] + # None config options stored as a Sire property are converted + # to False, so None and Fasle are equivalent for the purposes of + # comparison. + if v1 is None and not v2: + continue + if v2 is None and not v1: + continue + + # Early exit equivalence check. + if v1 == v2: + continue + + # Custom lambda schedules are stored as a hexademical string of + # serialised object. We need to deserialise them before comparison. + if key == "lambda_schedule": + # Standard schedules are stored as strings, so we can compare these directly. + if v1 == v2: + continue + else: + try: + v1 = _Config._deserialise_object(v1) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config1: {str(e)}" + ) + try: + v2 = _Config._deserialise_object(v2) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config2: {str(e)}" + ) + if v1 != v2: + raise ValueError( + f"{key} has changed since the last run. This is not " + "allowed when using the restart option." + ) + else: + continue + + # Restraints are stored as a list of hexadecimal strings of serialised objects. + # We need to deserialise them before comparison. + elif key == "restraints": + if v1 and v2: + for r1, r2 in zip(v1, v2): + try: + r1 = _Config._deserialise_object(r1) + except Exception as e: + raise ValueError( + f"Unable to deserialise restraint from config1: {str(e)}" + ) + try: + r2 = _Config._deserialise_object(r2) + except Exception as e: + raise ValueError( + f"Unable to deserialise restraint from config2: {str(e)}" + ) + if r1 != r2: + raise ValueError( + f"{key} has changed since the last run. This is not " + "allowed when using the restart option." + ) + else: + continue + # Convert GeneralUnits to strings for comparison. if isinstance(v1, _GeneralUnit): v1 = str(v1) @@ -1363,14 +1427,14 @@ def _compare_configs(config1, config2): # Convert Sire containers to lists for comparison. try: v1 = v1.to_list() - except: + except Exception: pass try: v2 = v2.to_list() - except: + except Exception: pass - if (v1 == None and v2 == False) or (v2 == None and v1 == False): + if (v1 is None and v2 == False) or (v2 is None and v1 == False): continue # The GCMC frequency will be automaticall set if None. elif key == "gcmc_frequency" and v1 is None: diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 561bc138..931b5344 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -155,7 +155,7 @@ def _initialise_gpu_devices(num_devices, oversubscription_factor=1): Returns ------- - devices : [(str, int)] + devices: [(str, int)] List of available device numbers with oversubscription factor. """ devices = [] diff --git a/tests/runner/test_restart.py b/tests/runner/test_restart.py index 5f9639df..bfb5fd0a 100644 --- a/tests/runner/test_restart.py +++ b/tests/runner/test_restart.py @@ -228,3 +228,50 @@ def test_restart(mols, request): # Load the new checkpoint file and make sure the restart fails with pytest.raises(ValueError): runner_badconfig = Runner(mols, Config(**config_new)) + + +def test_restart_custom_schedule(ethane_methanol): + """ + Test that a restart works when using a non-standard lambda schedule. + """ + mols = ethane_methanol.clone() + schedule = sr.cas.LambdaSchedule.standard_decouple() + + with tempfile.TemporaryDirectory() as tmpdir: + config = { + "runtime": "12fs", + "restart": False, + "output_directory": tmpdir, + "energy_frequency": "4fs", + "checkpoint_frequency": "4fs", + "frame_frequency": "4fs", + "lambda_schedule": schedule, + "platform": "CPU", + "max_threads": 1, + "num_lambda": 2, + } + + # Instantiate a runner using the config defined above. + runner = Runner(mols, Config(**config)) + + del runner + + config_new = { + "runtime": "24fs", + "restart": True, + "output_directory": tmpdir, + "energy_frequency": "4fs", + "checkpoint_frequency": "4fs", + "frame_frequency": "4fs", + "lambda_schedule": schedule, + "platform": "CPU", + "max_threads": 1, + "num_lambda": 2, + "overwrite": True, + "log_level": "DEBUG", + } + + runner2 = Runner(mols, Config(**config_new)) + + # Run the simulation. + runner2.run() From 5c768502a375c4d95fb697b1f6a2893e35a055ff Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 15:34:02 +0000 Subject: [PATCH 076/212] Fix handling of different perturbed_system formats. --- src/somd2/runner/_base.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index b6b8c897..7d3469b8 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -532,10 +532,12 @@ def __init__(self, system, config): # we need to save to the output directory and update the config to # point to the new file. if self._config._perturbed_system_file is None: - filename = _Path(self._config.output_directory) / "perturbed_system.s3" - _sr.stream.save(perturbed_system, perturbed_system_file) - self._config._perturbed_system_file = str(filename) - _logger.info(f"Saving perturbed system to {perturbed_system_file}") + filename = str( + _Path(self._config.output_directory) / "perturbed_system.s3" + ) + _sr.stream.save(self._config.perturbed_system, filename) + self._config._perturbed_system_file = filename + _logger.info(f"Saving perturbed system to {filename}") # Write YAML configuration file to the output directory. if self._config.write_config: From d92fa7d3109240a35eb0b2b7dde1440095a11185 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 15:50:36 +0000 Subject: [PATCH 077/212] nvidia-ml-py is noarch. --- pixi.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pixi.toml b/pixi.toml index 9559a6e4..7a3ffa41 100644 --- a/pixi.toml +++ b/pixi.toml @@ -11,8 +11,6 @@ ghostly = "*" loch = "*" loguru = "*" numba = "*" - -[target.linux-64.dependencies] nvidia-ml-py = "*" [feature.test.dependencies] From 21085824be79d7ccd3586b53e5cfeea96c991f8b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 15:51:14 +0000 Subject: [PATCH 078/212] Fix recipe. --- recipes/somd2/recipe.yaml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index d485c89a..ed01f17f 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -24,24 +24,15 @@ requirements: - loch - loguru - numba + - nvidia-ml-py - python - - if: linux - then: - - nvidia-ml-py tests: - python: imports: - - if: linux - then: - - somd2 + - somd2 - script: - - if: linux and x86_64 - then: - - pytest -vvv --color=yes --black src/somd2 - - if: linux - then: - - pytest -vvv --color=yes --import-mode=importlib ./tests + - pytest -vvv --color=yes --import-mode=importlib ./tests files: source: - tests/ From 00416d37b0bfd340677ff0a76e7f574af73e3342 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 16:02:00 +0000 Subject: [PATCH 079/212] Skip tests on macOS. --- recipes/somd2/recipe.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index ed01f17f..f97ceb6b 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -30,9 +30,13 @@ requirements: tests: - python: imports: - - somd2 + - if: linux + then: + - somd2 - script: - - pytest -vvv --color=yes --import-mode=importlib ./tests + - if: linux + then: + - pytest -vvv --color=yes --import-mode=importlib ./tests files: source: - tests/ From f571d026befa43dce3ac7bcf24842205be96d311 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 16:08:07 +0000 Subject: [PATCH 080/212] Add MANIFEST for tests files and refactor recipe. --- MANIFEST.in | 1 + recipes/somd2/recipe.yaml | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..96737d30 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +graft tests diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index f97ceb6b..2f66f795 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -29,9 +29,9 @@ requirements: tests: - python: - imports: - - if: linux - then: + - if: linux + then: + imports: - somd2 - script: - if: linux From cc4c0c6e60ef638821598e12f9303624587dd535 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 16:09:49 +0000 Subject: [PATCH 081/212] Remove import tests since they don't work with conditionals. --- recipes/somd2/recipe.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index 2f66f795..ac9c510d 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -28,11 +28,6 @@ requirements: - python tests: - - python: - - if: linux - then: - imports: - - somd2 - script: - if: linux then: From 48c5df95034f61db3a5519b353eed8e2b248b57e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 16:16:00 +0000 Subject: [PATCH 082/212] Fix recipe conditional formatting. --- recipes/somd2/recipe.yaml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index ac9c510d..02170745 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -28,16 +28,19 @@ requirements: - python tests: - - script: - - if: linux - then: - - pytest -vvv --color=yes --import-mode=importlib ./tests - files: - source: - - tests/ - requirements: - run: - - pytest + - if: linux + then: + - python: + imports: + - somd2 + - script: + - PYTHONPATH=. pytest -vvv --color=yes --import-mode=importlib ./tests + files: + source: + - tests/ + requirements: + run: + - pytest about: homepage: https://github.com/openbiosim/somd2 From 5dc49bc1353bcf91621a810d61c2f5a5f8078163 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Feb 2026 16:46:23 +0000 Subject: [PATCH 083/212] Update method names for clarity. [ci skip] --- src/somd2/config/_config.py | 16 ++++++---------- src/somd2/runner/_base.py | 8 ++++---- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 49ce7a00..4812a83f 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -653,13 +653,11 @@ def as_dict(self, sire_compatible=False): ): d["lambda_schedule"] = "charge_scaled_morph" else: - d["lambda_schedule"] = self._serialise_object(self.lambda_schedule) + d["lambda_schedule"] = self._to_hex(self.lambda_schedule) # Serialise restraints. if self.restraints is not None: - d["restraints"] = [ - self._serialise_object(restraint) for restraint in self.restraints - ] + d["restraints"] = [self._to_hex(restraint) for restraint in self.restraints] # Use the path for the perturbed_system option, since the system # isn't serializable. @@ -998,9 +996,7 @@ def lambda_schedule(self, lambda_schedule): self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) else: try: - self._lambda_schedule = self._deserialise_object( - lambda_schedule - ) + self._lambda_schedule = self._from_hex(lambda_schedule) except Exception: raise ValueError( "Unable to deserialise 'lambda_schedule'. Ensure that this is a " @@ -1115,7 +1111,7 @@ def restraints(self, restraints): continue elif isinstance(restraint, str): try: - restraint = self._deserialise_object(restraint) + restraint = self._from_hex(restraint) except Exception: raise ValueError( "Unable to deserialise restraint. Ensure that this " @@ -2113,7 +2109,7 @@ def overwrite(self, overwrite): self._overwrite = overwrite @staticmethod - def _serialise_object(obj): + def _to_hex(obj): """ Internal method to serialise a Sire object to a hex string representation for storage in the YAML config file. @@ -2142,7 +2138,7 @@ def _serialise_object(obj): return hex @staticmethod - def _deserialise_object(hex): + def _from_hex(hex): """ Internal method to deserialise a Sire object from a hex string representation. diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 7d3469b8..3cef0390 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1376,13 +1376,13 @@ def _compare_configs(config1, config2): continue else: try: - v1 = _Config._deserialise_object(v1) + v1 = _Config._from_hex(v1) except Exception as e: raise ValueError( f"Unable to deserialise lambda schedule from config1: {str(e)}" ) try: - v2 = _Config._deserialise_object(v2) + v2 = _Config._from_hex(v2) except Exception as e: raise ValueError( f"Unable to deserialise lambda schedule from config2: {str(e)}" @@ -1401,13 +1401,13 @@ def _compare_configs(config1, config2): if v1 and v2: for r1, r2 in zip(v1, v2): try: - r1 = _Config._deserialise_object(r1) + r1 = _Config._from_hex(r1) except Exception as e: raise ValueError( f"Unable to deserialise restraint from config1: {str(e)}" ) try: - r2 = _Config._deserialise_object(r2) + r2 = _Config._from_hex(r2) except Exception as e: raise ValueError( f"Unable to deserialise restraint from config2: {str(e)}" From 735d73546937b8b2daf5f16ecef2a1619081d1d0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Feb 2026 09:40:41 +0000 Subject: [PATCH 084/212] Remove black from test depdendencies. [ci skip] --- pixi.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pixi.toml b/pixi.toml index 7a3ffa41..fe82f7c5 100644 --- a/pixi.toml +++ b/pixi.toml @@ -15,7 +15,6 @@ nvidia-ml-py = "*" [feature.test.dependencies] pytest = "*" -black = "*" [feature.lint.dependencies] pre-commit = "*" From acbe13a244576f56b129beef856b3a50f38b24af Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Feb 2026 10:35:16 +0000 Subject: [PATCH 085/212] Add note about OpenCL activation. --- README.md | 12 ++++++++++++ pixi.toml | 3 +++ 2 files changed, 15 insertions(+) diff --git a/README.md b/README.md index d5ababc1..bbc83046 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,18 @@ Then install `somd2` into the environment: pip install -e . ``` +> [!Note] +> Pixi does not run conda post-link scripts, so the `ocl-icd-system` +> symlink needed for OpenCL won't be created automatically. Add the +> following to your `pixi.sh` activation script to fix this: +> +> ```bash +> # Create OpenCL ICD symlink (pixi doesn't run post-link scripts) +> if [ -d /etc/OpenCL/vendors ] && [ ! -e "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" ]; then +> ln -s /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" 2>/dev/null || true +> fi +> ``` + ### Testing You should now have a `somd2` executable in your path. To test, run: diff --git a/pixi.toml b/pixi.toml index fe82f7c5..01e13aa4 100644 --- a/pixi.toml +++ b/pixi.toml @@ -25,3 +25,6 @@ ruff = "*" default = [] test = ["test"] dev = ["test", "lint"] + +[activation] +scripts = ["pixi.sh"] From 383bed6c150302c353400794b5b29b87a0f198e0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sun, 15 Feb 2026 15:43:56 +0000 Subject: [PATCH 086/212] Update OpenCL activation instructions. [ci skip] --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bbc83046..cb55685a 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,12 @@ pip install -e . > [!Note] > Pixi does not run conda post-link scripts, so the `ocl-icd-system` -> symlink needed for OpenCL won't be created automatically. Add the -> following to your `pixi.sh` activation script to fix this: +> symlink needed for OpenCL won't be created automatically. After +> creating the environment, run the following once to fix this: > > ```bash -> # Create OpenCL ICD symlink (pixi doesn't run post-link scripts) -> if [ -d /etc/OpenCL/vendors ] && [ ! -e "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" ]; then -> ln -s /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" 2>/dev/null || true -> fi +> pixi shell +> ln -s /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" > ``` ### Testing From fc46fc72db6fd40a4c8e2475d5b08751ffabe025 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 17 Feb 2026 14:12:50 +0000 Subject: [PATCH 087/212] Python 3.12 macOS builds are now available. [ci skip] --- .github/workflows/devel.yaml | 6 +++++- .github/workflows/main.yaml | 5 +---- .github/workflows/pr.yaml | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/devel.yaml b/.github/workflows/devel.yaml index e45d9e15..c88ed73a 100644 --- a/.github/workflows/devel.yaml +++ b/.github/workflows/devel.yaml @@ -18,9 +18,13 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: + # Exclude all but the latest Python from macOS - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... We want 3.10 and 3.11 + python-version: "3.10" + - platform: + { name: "macos", os: "macos-latest", shell: "bash -l {0}" } + python-version: "3.11" environment: name: somd2-build defaults: diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 354264e0..a2e2fa50 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -20,10 +20,7 @@ jobs: platform: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - exclude: - - platform: - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + # No exclusions - release builds all combinations environment: name: somd2-build defaults: diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 1069e0a7..47cbc9f1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -17,12 +17,13 @@ jobs: - { name: "linux", os: "ubuntu-latest", shell: "bash -l {0}" } - { name: "macos", os: "macos-latest", shell: "bash -l {0}" } exclude: + # Exclude all but the latest Python from macOS - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } python-version: "3.10" - platform: { name: "macos", os: "macos-latest", shell: "bash -l {0}" } - python-version: "3.12" # MacOS can't run 3.12 yet... + python-version: "3.11" environment: name: somd2-build defaults: From 7255e06437795ca20ab64ec5324bf5153b43df0e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 17 Feb 2026 14:52:40 +0000 Subject: [PATCH 088/212] Ignore perturbed system config options on restart. --- src/somd2/runner/_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 3cef0390..aabfaaf8 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1338,6 +1338,8 @@ def _compare_configs(config1, config2): "energy_frequency", "frame_frequency", "save_velocities", + "perturbed_system", + "perturbed_system_file", "platform", "max_threads", "max_gpus", From 8e446775303f01bd8ce1ae0c00a6be0a8eceabc9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 3 Mar 2026 12:59:44 +0000 Subject: [PATCH 089/212] Convert StringProperty to string. --- src/somd2/config/_config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 4812a83f..0dbdb83b 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -2158,6 +2158,11 @@ def _from_hex(hex): from sire.legacy.Qt import QByteArray try: + # Convert StringProperty to string. + try: + hex = hex.value() + except Exception: + pass hex_byte_arrary = QByteArray.from_raw_data(hex, len(hex)) obj = load(QByteArray.from_hex(hex_byte_arrary)) except Exception as e: From aecfa95b3b73f086164d8bc01da254ae5bfe08df Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 4 Mar 2026 09:26:13 +0000 Subject: [PATCH 090/212] Handle missing OpenCL ICD loader during GPU platform detection. --- src/somd2/runner/_repex.py | 61 +++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index cdc18516..f6ba67b3 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -575,46 +575,59 @@ def _check_device_memory(device_index=0): index: int The index of the GPU device. """ - import pyopencl as cl - # Get the device. - platforms = cl.get_platforms() - all_devices = [] - for platform in platforms: - try: - devices = platform.get_devices(device_type=cl.device_type.GPU) - all_devices.extend(devices) - except: - continue - - if device_index >= len(all_devices): - msg = f"Device index {device_index} out of range. Found {len(all_devices)} GPU(s)." - _logger.error(msg) - raise IndexError(msg) + # Try to use pyopencl to detect the GPU vendor. + vendor = None + ocl_device = None + try: + import pyopencl as cl - device = all_devices[device_index] - total = device.global_mem_size + platforms = cl.get_platforms() + all_devices = [] + for platform in platforms: + try: + devices = platform.get_devices(device_type=cl.device_type.GPU) + all_devices.extend(devices) + except Exception: + continue + + if device_index < len(all_devices): + ocl_device = all_devices[device_index] + vendor = ocl_device.vendor + else: + msg = f"Device index {device_index} out of range. Found {len(all_devices)} GPU(s)." + _logger.error(msg) + raise IndexError(msg) + except IndexError: + raise + except Exception: + _logger.warning( + "Could not query GPU platform via OpenCL; falling back to pynvml for NVIDIA detection." + ) - # NVIDIA: Use pynvml - if "NVIDIA" in device.vendor: + # NVIDIA: Use pynvml (also used as fallback when OpenCL is unavailable). + if vendor is None or "NVIDIA" in vendor: try: import pynvml pynvml.nvmlInit() - handle = pynvml.nvmlDeviceGetHandleByIndex(device_index) memory = pynvml.nvmlDeviceGetMemoryInfo(handle) pynvml.nvmlShutdown() return (memory.used, memory.free, memory.total) except Exception as e: - msg = f"Could not get NVIDIA GPU memory info for device {device_index}: {e}" + if vendor is None: + msg = f"Could not get GPU memory info for device {device_index} via OpenCL or pynvml: {e}" + else: + msg = f"Could not get NVIDIA GPU memory info for device {device_index}: {e}" _logger.error(msg) raise RuntimeError(msg) from e - # AMD: Use OpenCL extension - elif "AMD" in device.vendor or "Advanced Micro Devices" in device.vendor: + # AMD: Use OpenCL extension. + elif "AMD" in vendor or "Advanced Micro Devices" in vendor: try: - free_memory_info = device.get_info(0x4038) + total = ocl_device.global_mem_size + free_memory_info = ocl_device.get_info(0x4038) free_kb = ( free_memory_info[0] if isinstance(free_memory_info, list) From 2f2e50bf60af2e1f1d7ed811bab4abb5b502fe30 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 4 Mar 2026 13:53:47 +0000 Subject: [PATCH 091/212] Don't return entire energy trajectory after each cycle. --- src/somd2/runner/_repex.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index f6ba67b3..85e67c0f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1283,11 +1283,8 @@ def _run_block( gcmc_sampler.write_ghost_residues() # Get the energy at each lambda value. - energies = ( - dynamics._d.energy_trajectory() - .to_pandas(to_alchemlyb=True, energy_unit="kcal/mol") - .iloc[-1, :] - .to_numpy() + energies = _np.array( + [e.value() for e in list(dynamics.current_energies().values())[2:]] ) except Exception as e: From 2230dacee1df4b0ec80632e93185e8c1ac4244dd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 4 Mar 2026 19:05:10 +0000 Subject: [PATCH 092/212] Update OpenCL instructions. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb55685a..a7899d89 100644 --- a/README.md +++ b/README.md @@ -71,11 +71,12 @@ pip install -e . > [!Note] > Pixi does not run conda post-link scripts, so the `ocl-icd-system` > symlink needed for OpenCL won't be created automatically. After -> creating the environment, run the following once to fix this: +> creating the environment (or after a pixi update), run the following +> to fix this: > > ```bash > pixi shell -> ln -s /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" +> ln -sfn /etc/OpenCL/vendors "${CONDA_PREFIX}/etc/OpenCL/vendors/ocl-icd-system" > ``` ### Testing From 2918d7cc07ba718baa17c60c0210d9db2c34ff98 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 09:25:54 +0000 Subject: [PATCH 093/212] Update pre-commit. --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0043ca49..61e8edfb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: # Python formatting and linting - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + rev: v0.15.4 hooks: # Run the formatter - id: ruff-format From b0165b6cf4c5f27082e08edc6125c61dbd03104b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 09:28:06 +0000 Subject: [PATCH 094/212] Autoformat. --- src/somd2/config/_config.py | 2 +- src/somd2/io/_io.py | 2 +- src/somd2/runner/_base.py | 4 ++-- src/somd2/runner/_repex.py | 4 ++-- src/somd2/runner/_runner.py | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 0dbdb83b..a799f63d 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1813,7 +1813,7 @@ def gcmc_radius(self, gcmc_radius): gcmc_r = _sr.u(gcmc_radius) except: raise ValueError( - "Unable to parse 'gcmc_radius' " f"as a Sire GeneralUnit: {gcmc_radius}" + f"Unable to parse 'gcmc_radius' as a Sire GeneralUnit: {gcmc_radius}" ) if not gcmc_r.has_same_units(angstrom): diff --git a/src/somd2/io/_io.py b/src/somd2/io/_io.py index 6b664977..e845e6ab 100644 --- a/src/somd2/io/_io.py +++ b/src/somd2/io/_io.py @@ -74,7 +74,7 @@ def dataframe_to_parquet(df, metadata, filepath=None, filename=None): table = table.replace_schema_metadata(combined_meta) if filename is None: if "lambda" in metadata and "temperature" in metadata: - filename = f"Lam_{metadata['lambda'].replace('.','')[:5]}_T_{metadata['temperature']}.parquet" + filename = f"Lam_{metadata['lambda'].replace('.', '')[:5]}_T_{metadata['temperature']}.parquet" else: filename = "output.parquet" if not filename.endswith(".parquet"): diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index aabfaaf8..ab23e6de 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1985,8 +1985,8 @@ def _save_energy_components(self, index, context): state = new_context.getState(getEnergy=True, groups={i}) name = f.getName() name_len = len(name) - header += f"{f.getName():>{name_len+2}}" - record += f"{state.getPotentialEnergy().value_in_unit(openmm.unit.kilocalories_per_mole):>{name_len+2}.2f}" + header += f"{f.getName():>{name_len + 2}}" + record += f"{state.getPotentialEnergy().value_in_unit(openmm.unit.kilocalories_per_mole):>{name_len + 2}.2f}" # Write to file. if self._nrg_sample == 0: diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 85e67c0f..cefce028 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -993,7 +993,7 @@ def run(self): # Perform the replica exchange simulation. for i in range(cycles): - _logger.info(f"Running dynamics for cycle {i+1} of {cycles}") + _logger.info(f"Running dynamics for cycle {i + 1} of {cycles}") # Log the states. This is the replica index for the state (positions # and velocities) used to seed each replica for the current cycle. @@ -1678,7 +1678,7 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): dynamics._d._sire_mols.delete_all_frames() _logger.info( - f"Finished block {block+1} of {self._start_block + num_blocks} " + f"Finished block {block + 1} of {self._start_block + num_blocks} " f"for {_lam_sym} = {lam:.5f}" ) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 931b5344..f5fcf7f2 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -748,7 +748,7 @@ def generate_lam_vals(lambda_base, increment=0.001): except: pass raise RuntimeError( - f"Dynamics block {block+1} for {_lam_sym} = {lambda_value:.5f} failed: {e}" + f"Dynamics block {block + 1} for {_lam_sym} = {lambda_value:.5f} failed: {e}" ) # Checkpoint. @@ -809,7 +809,7 @@ def generate_lam_vals(lambda_base, increment=0.001): dynamics._d._sire_mols.delete_all_frames() _logger.info( - f"Finished block {block+1} of {self._start_block + num_blocks} " + f"Finished block {block + 1} of {self._start_block + num_blocks} " f"for {_lam_sym} = {lambda_value:.5f}" ) @@ -884,7 +884,7 @@ def generate_lam_vals(lambda_base, increment=0.001): dynamics._d._sire_mols.delete_all_frames() _logger.info( - f"Finished block {block+1} of {self._start_block + num_blocks} " + f"Finished block {block + 1} of {self._start_block + num_blocks} " f"for {_lam_sym} = {lambda_value:.5f}" ) From c576a120891074f69d94fe685e096e13d50f6b11 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 5 Mar 2026 16:16:07 +0000 Subject: [PATCH 095/212] Update to use raw NumPy float array. --- src/somd2/runner/_repex.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index cefce028..99e86cbd 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1283,9 +1283,7 @@ def _run_block( gcmc_sampler.write_ghost_residues() # Get the energy at each lambda value. - energies = _np.array( - [e.value() for e in list(dynamics.current_energies().values())[2:]] - ) + energies = dynamics._current_energy_array() except Exception as e: try: From 18e58670facbce71bc84ce092768ed10882f6518 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Mar 2026 12:19:19 +0000 Subject: [PATCH 096/212] Fix off-by-one error. --- src/somd2/runner/_repex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index f6ba67b3..bf4d15a8 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1008,13 +1008,13 @@ def run(self): results = [] # Whether to checkpoint. - is_checkpoint = i > 0 and i % cycles_per_checkpoint == 0 + is_checkpoint = (i + 1) % cycles_per_checkpoint == 0 # Whether to perform a GCMC move before the dynamics block. is_gcmc = i % cycles_per_gcmc == 0 # Whether a frame is saved at the end of the cycle. - write_gcmc_ghosts = i > 0 and i % cycles_per_frame == 0 + write_gcmc_ghosts = (i + 1) % cycles_per_frame == 0 # Run a dynamics block for each replica, making sure only each GPU is only # oversubscribed by a factor of self._config.oversubscription_factor. From 8134c434f62f17a6d4c658593b6b66f6f08e1682 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Mar 2026 12:43:47 +0000 Subject: [PATCH 097/212] Make GCMC residue index write self-consistent. --- src/somd2/runner/_repex.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index bf4d15a8..8e3c5a0f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1011,7 +1011,7 @@ def run(self): is_checkpoint = (i + 1) % cycles_per_checkpoint == 0 # Whether to perform a GCMC move before the dynamics block. - is_gcmc = i % cycles_per_gcmc == 0 + is_gcmc = (i + 1) % cycles_per_gcmc == 0 # Whether a frame is saved at the end of the cycle. write_gcmc_ghosts = (i + 1) % cycles_per_frame == 0 @@ -1248,6 +1248,14 @@ def _run_block( # Remove the PyCUDA context from the stack. gcmc_sampler.pop() + # A frame was saved at the end of the last cycle, so write + # the current ghost water residue indices to file. This is + # done here, immediately after the GCMC move, since the + # sampler state is only updated during GCMC moves and waters + # may have moved in/out of the GCMC sphere during dynamics. + if write_gcmc_ghosts: + gcmc_sampler.write_ghost_residues() + # Run the dynamics. dynamics.run( self._config.energy_frequency, @@ -1277,10 +1285,6 @@ def _run_block( # Save the GCMC state. if gcmc_sampler is not None: self._dynamics_cache.save_gcmc_state(index) - # The frame frequency was hit, so write the indices of the - # current ghost water residues to file. - if write_gcmc_ghosts: - gcmc_sampler.write_ghost_residues() # Get the energy at each lambda value. energies = ( From a5dc8ec949c27798fb948c7387eb57cad67ab533 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Mar 2026 13:16:43 +0000 Subject: [PATCH 098/212] Handle fractional frequencies correctly. --- src/somd2/runner/_repex.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 8e3c5a0f..a07bb3c2 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -904,12 +904,12 @@ def run(self): frac = 1.0 checkpoint_frequency = self._config.energy_frequency - # Store the number of repex cycles per block. - cycles_per_checkpoint = int(frac) + # Store the number of repex cycles per block (may be fractional). + cycles_per_checkpoint = frac # Otherwise, we don't checkpoint. else: - cycles_per_checkpoint = cycles + cycles_per_checkpoint = float(cycles) num_blocks = 1 rem = 0 @@ -991,6 +991,10 @@ def run(self): else: cycles_per_gcmc = cycles + 1 + # Initialise the threshold for the next checkpoint cycle. This is a float + # to handle non-integer ratios between the checkpoint and energy frequencies. + next_checkpoint = cycles_per_checkpoint + # Perform the replica exchange simulation. for i in range(cycles): _logger.info(f"Running dynamics for cycle {i+1} of {cycles}") @@ -1007,8 +1011,9 @@ def run(self): # Clear the results list. results = [] - # Whether to checkpoint. - is_checkpoint = (i + 1) % cycles_per_checkpoint == 0 + # Whether to checkpoint. Use a float threshold to correctly handle + # non-integer ratios between the checkpoint and energy frequencies. + is_checkpoint = (i + 1) >= next_checkpoint - 1e-10 # Whether to perform a GCMC move before the dynamics block. is_gcmc = (i + 1) % cycles_per_gcmc == 0 @@ -1119,6 +1124,9 @@ def run(self): # Update the block number. block += 1 + # Advance the checkpoint threshold. + next_checkpoint += cycles_per_checkpoint + # Guard the repex state and transition matrix saving with a file lock. lock = _FileLock(self._lock_file) with lock.acquire(timeout=self._config.timeout.to("seconds")): From 0e6a2d889162873ab15062c6ad6899f5b53c4c50 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 6 Mar 2026 17:22:53 +0000 Subject: [PATCH 099/212] Add rest2_selection to GCMC kwargs. --- src/somd2/runner/_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index ab23e6de..eda74126 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -764,6 +764,7 @@ def __init__(self, system, config): "coulomb_power": self._config.coulomb_power, "shift_coulomb": str(self._config.shift_coulomb), "shift_delta": str(self._config.shift_delta), + "rest2_selection": self._config.rest2_selection, "swap_end_states": self._config.swap_end_states, "tolerance": self._config.gcmc_tolerance, "restart": self._is_restart, From e718e4e5b6e840277f0b38f174827e0b0ef6eb7e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Mar 2026 09:58:50 +0000 Subject: [PATCH 100/212] Refactor shared options into common_kwargs dictionary. --- src/somd2/runner/_base.py | 75 +++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index eda74126..0db8f728 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -718,59 +718,56 @@ def __init__(self, system, config): self._initial_constraint = self._config.constraint self._initial_perturbable_constraint = self._config.perturbable_constraint + # Common kwargs shared by both dynamics and GCMC sampling. + self._common_kwargs = { + "coulomb_power": self._config.coulomb_power, + "cutoff": self._config.cutoff, + "cutoff_type": self._config.cutoff_type, + "platform": self._config.platform, + "rest2_selection": self._config.rest2_selection, + "shift_coulomb": self._config.shift_coulomb, + "shift_delta": self._config.shift_delta, + "swap_end_states": self._config.swap_end_states, + "temperature": self._config.temperature, + } + # Create the default dynamics kwargs dictionary. These can be overloaded # as needed. self._dynamics_kwargs = { - "integrator": config.integrator, - "temperature": config.temperature, - "pressure": config.pressure if self._has_water else None, - "surface_tension": config.surface_tension, - "barostat_frequency": config.barostat_frequency, - "timestep": config.timestep, - "restraints": config.restraints, - "cutoff_type": config.cutoff_type, - "cutoff": config.cutoff, - "schedule": config.lambda_schedule, - "platform": config.platform, - "constraint": config.constraint, - "perturbable_constraint": config.perturbable_constraint, - "include_constrained_energies": config.include_constrained_energies, - "dynamic_constraints": config.dynamic_constraints, - "swap_end_states": config.swap_end_states, - "com_reset_frequency": config.com_reset_frequency, + **self._common_kwargs, + "barostat_frequency": self._config.barostat_frequency, + "com_reset_frequency": self._config.com_reset_frequency, + "constraint": self._config.constraint, + "dynamic_constraints": self._config.dynamic_constraints, + "include_constrained_energies": self._config.include_constrained_energies, + "integrator": self._config.integrator, + "map": self._config._extra_args, + "perturbable_constraint": self._config.perturbable_constraint, + "pressure": self._config.pressure if self._has_water else None, + "restraints": self._config.restraints, + "schedule": self._config.lambda_schedule, + "surface_tension": self._config.surface_tension, + "timestep": self._config.timestep, "vacuum": not self._has_space, - "coulomb_power": config.coulomb_power, - "shift_coulomb": config.shift_coulomb, - "shift_delta": config.shift_delta, - "rest2_selection": config.rest2_selection, - "map": config._extra_args, } # Create the GCMC specific kwargs dictionary. if self._config.gcmc: self._gcmc_kwargs = { - "reference": self._config.gcmc_selection, + **self._common_kwargs, + "bulk_sampling_probability": self._config.gcmc_bulk_sampling_probability, "excess_chemical_potential": str( self._config.gcmc_excess_chemical_potential ), - "standard_volume": str(self._config.gcmc_standard_volume), - "radius": str(self._config.gcmc_radius), - "num_ghost_waters": self._config.gcmc_num_waters, - "bulk_sampling_probability": self._config.gcmc_bulk_sampling_probability, - "cutoff_type": self._config.cutoff_type, - "cutoff": str(self._config.cutoff), - "temperature": str(self._config.temperature), "lambda_schedule": self._config.lambda_schedule, - "coulomb_power": self._config.coulomb_power, - "shift_coulomb": str(self._config.shift_coulomb), - "shift_delta": str(self._config.shift_delta), - "rest2_selection": self._config.rest2_selection, - "swap_end_states": self._config.swap_end_states, - "tolerance": self._config.gcmc_tolerance, - "restart": self._is_restart, - "overwrite": self._config.overwrite, - "platform": config.platform, "no_logger": True, + "num_ghost_waters": self._config.gcmc_num_waters, + "overwrite": self._config.overwrite, + "radius": str(self._config.gcmc_radius), + "reference": self._config.gcmc_selection, + "restart": self._is_restart, + "standard_volume": str(self._config.gcmc_standard_volume), + "tolerance": self._config.gcmc_tolerance, } else: self._gcmc_kwargs = None From 9da47cb11600ce1d38b5002338dd11913818591e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Mar 2026 11:12:33 +0000 Subject: [PATCH 101/212] Add logo. --- .img/somd2.png | Bin 0 -> 41858 bytes README.md | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 .img/somd2.png diff --git a/.img/somd2.png b/.img/somd2.png new file mode 100644 index 0000000000000000000000000000000000000000..df14e918c4c8b448b1f821cc30291ba925b62edc GIT binary patch literal 41858 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 p(;`m_E9mJ_-$a(>D2k#jYepg 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 bJJbADKv$o4TJWjxKNE6NN{||`anOGN29+++ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index a7899d89..bd212329 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +

+ + SOMD + +

+ # SOMD2 [![GitHub Actions](https://github.com/openbiosim/somd2/actions/workflows/devel.yaml/badge.svg)](https://github.com/openbiosim/somd2/actions/workflows/devel.yaml) From ce6448cfecab6715697867d504f1ffed50d94d50 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 9 Mar 2026 16:12:28 +0000 Subject: [PATCH 102/212] Comment tweak. [ci skip] --- src/somd2/runner/_base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 0db8f728..1c650c94 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -731,8 +731,7 @@ def __init__(self, system, config): "temperature": self._config.temperature, } - # Create the default dynamics kwargs dictionary. These can be overloaded - # as needed. + # Create the default dynamics kwargs dictionary. self._dynamics_kwargs = { **self._common_kwargs, "barostat_frequency": self._config.barostat_frequency, From 0ecc72117dc900faca73c845b81933eeefdc2acc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 10 Mar 2026 10:50:16 +0000 Subject: [PATCH 103/212] Synchronise trajectory frames with GCMC water residue state. --- src/somd2/runner/_repex.py | 41 +++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 84e01f99..107d9298 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1242,28 +1242,6 @@ def _run_block( # Draw new velocities from the Maxwell-Boltzmann distribution. dynamics.randomise_velocities() - # Perform a GCMC move. For repex this needs to be done before the - # dynamics block so that the final energies, which are used in the - # repex acceptance criteria, are correct. - if is_gcmc and gcmc_sampler is not None: - # Push the PyCUDA context on top of the stack. - gcmc_sampler.push() - - # Perform the GCMC move. - _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") - gcmc_sampler.move(dynamics.context()) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() - - # A frame was saved at the end of the last cycle, so write - # the current ghost water residue indices to file. This is - # done here, immediately after the GCMC move, since the - # sampler state is only updated during GCMC moves and waters - # may have moved in/out of the GCMC sphere during dynamics. - if write_gcmc_ghosts: - gcmc_sampler.write_ghost_residues() - # Run the dynamics. dynamics.run( self._config.energy_frequency, @@ -1290,8 +1268,25 @@ def _run_block( # Set the state. self._dynamics_cache.save_openmm_state(index) - # Save the GCMC state. + # Perform a GCMC move and write ghost water residue indices after + # dynamics so that the ghost state is temporally consistent with + # the saved frame. if gcmc_sampler is not None: + if is_gcmc: + # Push the PyCUDA context on top of the stack. + gcmc_sampler.push() + + # Perform the GCMC move. + _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") + gcmc_sampler.move(dynamics.context()) + + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() + + if write_gcmc_ghosts: + gcmc_sampler.write_ghost_residues() + + # Save the GCMC state. self._dynamics_cache.save_gcmc_state(index) # Get the energy at each lambda value. From 36a4bdf70d5a8105b1829600bd69008ad910a76d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 10 Mar 2026 16:43:57 +0000 Subject: [PATCH 104/212] Save the OpenMM state after a GCMC move. --- src/somd2/runner/_repex.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 107d9298..7c635784 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1265,9 +1265,6 @@ def _run_block( ), ) - # Set the state. - self._dynamics_cache.save_openmm_state(index) - # Perform a GCMC move and write ghost water residue indices after # dynamics so that the ghost state is temporally consistent with # the saved frame. @@ -1289,6 +1286,9 @@ def _run_block( # Save the GCMC state. self._dynamics_cache.save_gcmc_state(index) + # Save the OpenMM state after any GCMC move so the context is consistent. + self._dynamics_cache.save_openmm_state(index) + # Get the energy at each lambda value. energies = dynamics._current_energy_array() From c41a12541f63c7a460fcde707f3c329184e68156 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Mar 2026 12:04:47 +0000 Subject: [PATCH 105/212] Write GCMC residues after frame is saved. --- src/somd2/runner/_repex.py | 12 ++++++------ src/somd2/runner/_runner.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 7c635784..b0d56637 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1265,10 +1265,13 @@ def _run_block( ), ) - # Perform a GCMC move and write ghost water residue indices after - # dynamics so that the ghost state is temporally consistent with - # the saved frame. if gcmc_sampler is not None: + # Write ghost residues before the GCMC move so the ghost state + # is consistent with the saved frame (which is also captured + # before the GCMC move). + if write_gcmc_ghosts: + gcmc_sampler.write_ghost_residues() + if is_gcmc: # Push the PyCUDA context on top of the stack. gcmc_sampler.push() @@ -1280,9 +1283,6 @@ def _run_block( # Remove the PyCUDA context from the stack. gcmc_sampler.pop() - if write_gcmc_ghosts: - gcmc_sampler.write_ghost_residues() - # Save the GCMC state. self._dynamics_cache.save_gcmc_state(index) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index f5fcf7f2..9ecaae20 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -714,21 +714,22 @@ def generate_lam_vals(lambda_base, increment=0.001): ), ) - # Perform a GCMC move. - _logger.info( - f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" - ) - gcmc_sampler.move(dynamics.context()) - # Update the runtime. runtime += self._config.energy_frequency - # If a frame is saved, then we need to save current indices - # of the ghost water residues. + # If a frame is saved, write the ghost residue indices + # before the GCMC move so the ghost state is consistent + # with the saved frame. if save_frames and runtime >= next_frame: gcmc_sampler.write_ghost_residues() next_frame += self._config.frame_frequency + # Perform a GCMC move. + _logger.info( + f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + ) + gcmc_sampler.move(dynamics.context()) + else: dynamics.run( checkpoint_frequency, From e313956423242e5387a1832bde84c306e98afc4f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Mar 2026 14:07:32 +0000 Subject: [PATCH 106/212] Fix while loop conditional. --- src/somd2/runner/_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 9ecaae20..fb8b6253 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -690,7 +690,7 @@ def generate_lam_vals(lambda_base, increment=0.001): next_frame = self._config.frame_frequency # Loop until we reach the runtime. - while runtime <= checkpoint_frequency: + while runtime < checkpoint_frequency: # Run the dynamics in blocks of the GCMC frequency. dynamics.run( self._config.gcmc_frequency, From dd1eaeda70a2bb08fcc0101777f6566db690cb0c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 11 Mar 2026 14:22:41 +0000 Subject: [PATCH 107/212] Update logo with lightened text. --- .img/somd2.png | Bin 41858 -> 41911 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.img/somd2.png b/.img/somd2.png index df14e918c4c8b448b1f821cc30291ba925b62edc..b2e9c40d4c7f6aceb8dae1a6732c2e61181d21a5 100644 GIT binary patch literal 41911 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 p(;`m_E9mJ_-$a(>D2k#jYepg zf~69`NclPoIIngP4SdA=l%`ya7;#Wyk*h}s#wwiXetAf?{=Qkta zx}MZlhGIRW@V^URkMH9PzOP?rDmgV6(@xgL?k4nv&0l)F2F z*?^?$dr;e_4)8u07g6?;o1dv^yB(!Ej+c8-#JCbR6%-^vPWvwZ1LikUoTnOye&eak z`)V1%hImgW^OUQ%Z*fE$4}T%sZK{uO5rhi-pp8H;&(AL?C_&sdM@&axiAcYJWFZuD zoeKfsc%S(>!lm|!N+%~`!Jyr+!yW$fNtW;^g4)96pdC7GY5e61?QAfTn%- zoEWqPjF(0D6uPqz((4Jo73%&eEN;FeL6R0Bma@wX7nx0tsutpij|-h(V%*C)<(Cje z_SBZc2Gc2KV6y^3`Uf(p>gAawNo13-Fm3T4vydBS`kK)^-{YH(Tx7Zb;-nK87$bBT zJI{uKAN$&_f`wvZ`?;un)v=5uT#4KwQ$)|8eKXWFpIfInVUgPXUDinHpJ+-}3wnRx zg^0YEQ1W|!|D2+1VV(^AVD(%#tP=dgC6@NBAG z`@U8Fn{yH#g&-IS{A@w=;u9^Lq#?2UD--~)YijJn>C|^c0K(o}sc0l;ma*euOikn3 zU4DqyrwKvsQNjuyl8{))u=*zjHa1U_+}vg#Pu{Xp3I|OWn!-1}$H*?fG5-jj*Wd_~zz?+rzC+ zF~{nod#tjV(5UY*E7}8N{9Fap7?duHT858rdsvT6RHkb$8Km(?*QTs2=Kz+f2-ZuV zjc`4h)Zp-z)KI4*a^hOLa3A;_tH>(E0Cx#2lm9(9PfMe1q9~+mynNM>M=`<&W702@ zE5F?S$8eW>)iL>S)uES2E{z-{L~RQ>kW&Ez{Wd)11SM*%u39I{kkG@x;prZD znpnb@0PhGCr`|xZ9_M95D>R{{Dmah@4Y$v}xjpU7UipI!H-o0E#}xW23(;jcYroL)gD-;$LYs4pmxb`v;Zh9~yoGU5Z! zthahlZza_r&@a4SKPOI`=6!Orjo_>Ci&OIekP;lW#cZua=N&cVM)f&~ibY!Ix8 z>(W=2U%jNaRE83}s?O5^$SvPxmA_U%_;HSp?U&qDU5t_<%p4s>o=*X{6ttg4NV2F6 zMyw*;b|h=9Y_JN4|m^)&*se_iYc()yF*p=<6MYUayi64OLGWeR=2DgJgo8 zB04E}iZj}v%jx1+%F5TPJ2@u}n{&fQ580xC?=zT{ zWK$sUz_JE6ttz9aCtUg*l+v7j zgQ(X6nPx=ukPQ6z7`IK@;mSR8_%St`mop{j82v(qB-V4W^>kAjmVb7(^X@POzZhAd zDB063km<+p`D0)k+%dgb9a>yf2IEZoJ)|2cp$mF;GWA;I9J9$DUyDA#Z#20|S3?6!B*bd_Ca5&nUbz-g zVm`W5JHMr56K;)kCq69wXs=baEG!WIPypGza`gA}<6JZd3) zFM$EUdWmufQ6cPnHZMR?juIY=Y%cq8B8DPq%U9UnTaf}}%*vY`nzVDZC0EaN`X|Bvchv9DHLt9k`rD0od~D2K$i&lIILcQ zA?0nXd?Kx#k&Fv{XQo|hNK9Uh{` zTnu{=n8w73PM3zq1IJF~tddv*e<0(eylO$aO63skS0-d$*FH+ezIC7q+Nrz+vu07H zMtG3p;CweNJJ4%VsTe^o!0bUJFt=`&&T)MAP|?o6@ZNb_mO2+6(DNe;5Rpcjvj&ij z8@j%e=G_Z5kBLUxqmyI>oB|~@yF@7>iYWAX6I&pqdR8@Bzwz-erVqn25g<6ucIzg( zE4{T5L;6wZ!Q%H+AO+gXyXm_x4F(Ca5PU2!4_CxERLuaudWYhMzRvhmCh^f9&QVVl zxd~Y_>uppMVUBP${RGLm{9YH zd;nF(kuIMu>N`5nAF2leZpT&hy7{g@Z+${6vRHd&2=LunZT;$oCvZRUymz8Q=1%Yp zm{lndW6evNnfbd>n$es$v17O@NG^FTLS^wYA~HG6J5IC+K_@V5%_nmip=zxGDTv5) zi1y_p0WN0sB1&I>;I_#$m2PZ^^k)Z#C4Ic@bXNOF*YoU}6|{i@jY^LC-PlosvqMS! zrOdoXkT7m;o_4nHA}7TgPF38)4YLKW#HPk{E4hRT%R5g|?6>72@n19cEhgI@(v~I< zU(J=`Kgc7Q6Z}w~Mpjt$ZIZ$&Aa-AH62>g1jMZ}R=T7<(^NpQ1%faBgN`FNd>557y z2P{3YgY8Y?ug}wwJD2rSPRMBedUS8eUqeWvI zZK$1z{JfW7N4O2;L7?Kun8s9gv>SBFr1;GfW(}m&F09q=Xy0zR4ecHAO&=VadVj{| zFH`I#BQqD6Q-vH4EkzgCUElN^8CdNbLu%zI8+D$E)zeFlYS{SanuLkQ10A|(W9=$R zp-X2txmZV#9c6csO|J|T)&2MIw?dmX^|Gx-%L=7KcNdeIAE^Oa!G(RzT`56ep~GnY z4>m2MX^5*Xx2(A9Qsd)Z;{N2`N^NGk<>}>Qt~Z+TfCya?a=~-~F_19Y2$6dOGR*2P4PaOOb!) z@dRVzOv7dw#=&LDvGziw6-W@>CUt=&PB4{{2;HC4Z(|vme!pl&|9T%h&74*A(7X&9 z1IK5Nwq-xwX^adeLCUqEk{lSs+CzobP1_dd zmmtu~O<+n>J790ZW118v4GsQz#+I*n`kHnne)M`2WpzP_zh*ri?$hm+3ltpK7|tea z=tQd%%$MMP^V;vx5-f7-sUh=49`b~&NyFm7(JT%+-CaQad_nk{Hg$?3GA<+zl9yZ54OMBRX7~JSZW~bCd=p!Y)14 zRO^Ut-ygW5rq8Yr$|SrOA}rN$6n$Tl{qk43nk7-mFZhaq^bg2JIrC7j(-8F+I(CF$ zYgpu41L>s(yA=&I0li9tyT65_yAd6>CRqVj=YABDfDnJo(C>MC&ZdN=3S>oVIRIP_ zk9?FZY^D~Xqa}T@b~Q+_d5|h9iI&lO4qqw2biMmal8gj(R*{2eUBKCGW{K~uNQVK? zlyeXfOb|L71)BCRSa{c=L;`9xW(xKMiN};{OgcPsFWvBF#Su%5-q6P|O44I1FR7yD zqa^6=#&ek;Kv~THsYH>)U#JenWhywmD{U6L7<&;Z{^H}au=<3>3H>>=kol?Zfn$yI zVvG4$(c5es%&MES*MD~x!SaEfW2sf&KA*ZgA3u)A&reTUl8dcRR_P8;9%0bzz}}_!oxRfPC$HmiX`HYMpA_E_{=W z0;ezB+{=zHt~VWCb%Hg*UG#*-t+p{NX7Fm$fDfN0h4MUB56}+j9q{ue&i=gjJAW+a zE`s)CQ;1tV?Y0d&OeIJoMsQ~JOMPd*5#RTQoxdRuKCc0ZxhUTU!3c^gD>jK5iw${A z*!4N4QKdjTmUUoic=Y2(KjZ?oySFiqz}x*7xR7tjyqf3>vsUsSOW3U{dEceARt^i7 zX7bhk?@{;5y5B3Uf=rRw>}^>@{&ibOt7{si%|vB$z4`e_b`l?Pz%rk|Qcw4k049zA!%(bCw+tfwEb~pHEDbj{#ezOTmvUMRR*40Pw`zD1MCLqoF{XBzx zA-WY}w({G^8HP=VBmeP4qDa8T&DtKDf6@e*7uv=|E|XX$>&ITEDN8?~o?PuG5X&VT z3n!GhzJ6DB^~~Yldt*O6y%hrr9}N@xpG@>;?|r#0FY~{^>8@STPd-Je2DF-AJ}ktG z!+p3*OA^6jcB~=1TLwZzQTbS4(7GyqHDwfwBH>dSFEt-9BAWIaL?wj%vDyhw_TyoH zhjAMkt+m|vplPKg7(U0Wch)e%Z!vla{=7MH&CrqhZi6ylR4tN@6)lSZ`_*3%lo$=e zdmRdokQfN}tK=t7=(=OsBp<3{r$YXHLr$e?*Cpgdu~`UvQuTC+T9Ic<{ni)YkP(kR z>IICn5!=Fa4KIVWU7#y7jbHAXMicjh9Fb~k*cM~J^<$l%)09K^(){?y)S+3h+m&nEQp0r-&NSQ} zPgZ;JF}EM9icx=xN6oz_%$0JMIT^fg73O`9Xd zLM+u6%d0)|t(RO>2jkp0o^va_aVSa{4Mv?E`F+8=fptN%G;dWf;@dh?7PkFllQygG z)5a%Y263XCx=_>)s!uIj*`4P5EXhLT4R2>W>^T*VU-uIsMwCWXR7aXE1lY6?-hZxj zLDTGV1t`-O_rKty>O7{`6uiR5?ukR>}gLT&ZyE7{B)E?|aPh5OaR7 z>C`W9o#)YpgrT3|sHhM3$6Q=oU-u_EDF_LL;wZnG$r3+3fk`I~J5OB&$(6W%1-$_$ zL^nK+oIdtoZsrrya&z;}*_FJ&gCG#7Fg)Aoih}DV92v}!eg=VJxsFk<8ozwP;9;zf z+uQ-Qs?){`;94q+K{HmH56vV763^CW__DOen0LeaXMFr7l@-T?n%rSWlQjt$o69T- zv>hcSVl5G!X-Hmt+$V2ZjVJ_#58Hby*urmik5E{3F0cxSqDa1vrfKDDufWp&GW*Tx zsc1J=+WsEW^PcJR;m2{sJTHxPcwSgjRQAK5W$cdd&;tD&;o8}Ja828>y)1PV=IYcl z6Q80I4ROU>^(!S{C_jC`$77g(wY*@aO{5CHJU^_Rd>LmGM^J{r-d=i8R1oWR97RkIN-gMTu(k9+Z1Z}*mOcmbRF|f zly!rvZ-}o%pS$Lx+S8w(Iy^6wU(yZLf>Jdc-F+^@oaaZbgq~Wq$S(Q)8r(M`=j!rc z17qF2?Kp`)=dy}T>i+WW5SD0FtpGuK8EQ>mT9-#3!cgwHsYVMZIP*asMzDQ7F>67W zx!l`xq74j;VYdzrp)W=9qe}b_O--K5!xz}vbykzh&XA03kESI??`Wx~2u}LjcWGwU zvhYAFQfC*{{50~%M9=DmhSG4_(G?*GhTClBCDO;v@$-{r`!DlVTR49gOZ-Z397V4_ zT*P=R^6inWGgWuJe`PrN zzn4DUQvsQE<3?hz93n0x8^+{vj+N}T>G*lp*thiCldYHZ~( zrH|=eP^~;{(Y=xJ$23Toz>mQf(2w^Z#1_#3<8CJIL_5Do3zRH-50}g44Eorzkx;Pc zQh3sG{VwDi0zYydWeNJo@++0rb-8=QP?hn14~Q?KeNMQunwXl<3ENL2eIV<4B13)S zrF7w0y6v~|xG3L+>OP<&B0XSlFK|#Al6KsovR8ukveb<%gTuDw`p-$jFxDi}Unw{p z>RxYZpD?%_nIw;+wOi^p@HNduP@)U-m1oM!JhC)cE~w-(e!A>kSg;z$SHTxDPKaJ8 z|1PXZl-6XpVD3lmShN~bZMSDr-5?N|-a<-Pqsv)r(R@j_Z97nG;?v%s=`Gm^{Ia7} zMsfF;BbPfhIU%7p56MUG5fsW}*t*}LtQ7m3`K8N}IW9SS!!t}qPB163V5Bd2z)dgl za}}CQRIB&f&Ohr3dnJ`IdNZQW0{1lWItHvO9c$_-U1Zp5H$Pj}T(U{KTjb+-CuZB# z2P-PA;*!Zmhez{F)uf5Mzxd}IUc&b7U9efTnIZ>h=-^Khn2bDDkvy%Q9?GC}a%pvlma8bMHQhdO+vr%_U703lBGU zm!lNN$FB!2XfQ==9J*X~e;0o!8Ei`lGuNL+z>&Ji)r^70RQj&5J#6Uhvb25DrmT;- zu50yvH?e-UkK@CdM#aYEV_~UiU|?WIp{ClvD#XQxHIgpak&pIM?K9(LZWZ*NZ^@EI zq;)NYh+d5!3lT51la7*8BUxqhBml7vv!ID<=r*6z??91YO?v>EEA!M6-q>EU~B6PgePSeR*qrEV1;n_l0xyqZ%< zj{=qyHaSbvNONd5z7C@v|Jmfk_sx(2lZIh1r9ZYM@@5=qBsr~zZT>htCQ7T?Tvqk5 z!P0>5ziBg}x>an;j~Qf9)w{k#_V?pRwF-52?PSN+eu1sC_f&dN&?|C0=4vXTM-#8DCc bJJbADKv$o4TJWjxKNE6NN{||`anOGN29+++ From d2bf110365bf2255c37453f582d443c3f388df3b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Mar 2026 14:49:09 +0000 Subject: [PATCH 108/212] Remove redundant and outdated code. [ci skip] --- demo/bubble_tyk2.ipynb | 179 ------------- demo/first_example.ipynb | 226 ---------------- demo/lambda_schedule.ipynb | 272 -------------------- demo/repex.ipynb | 352 ------------------------- demo/sire_restraints.ipynb | 354 -------------------------- scripts/Atoms_finding_sire_test.ipynb | 206 --------------- scripts/addanchors.py | 148 ----------- scripts/addanchors_exampleinputs.txt | 4 - 8 files changed, 1741 deletions(-) delete mode 100644 demo/bubble_tyk2.ipynb delete mode 100644 demo/first_example.ipynb delete mode 100644 demo/lambda_schedule.ipynb delete mode 100644 demo/repex.ipynb delete mode 100644 demo/sire_restraints.ipynb delete mode 100644 scripts/Atoms_finding_sire_test.ipynb delete mode 100644 scripts/addanchors.py delete mode 100644 scripts/addanchors_exampleinputs.txt diff --git a/demo/bubble_tyk2.ipynb b/demo/bubble_tyk2.ipynb deleted file mode 100644 index e8f4aeb3..00000000 --- a/demo/bubble_tyk2.ipynb +++ /dev/null @@ -1,179 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "d086ec3c-3d0c-45b1-949f-0224e68043fe", - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c2e8df62-7bcd-4028-a97e-51ed67530ce0", - "metadata": {}, - "outputs": [], - "source": [ - "mols = sr.stream.load(\"bound_31_42.bss\")\n", - "mols.add_shared_property(\"space\", mols.property(\"space\"))\n", - "timestep = \"1fs\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "169dbf60-fb80-469f-9859-d36ce6187ab4", - "metadata": {}, - "outputs": [], - "source": [ - "# link reference properties to main properties\n", - "for mol in mols.molecules(\"molecule property is_perturbable\"):\n", - " mols.update(mol.perturbation().link_to_reference().commit())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ccde94f0", - "metadata": {}, - "outputs": [], - "source": [ - "ligand = mols[\"molecule with property is_perturbable\"]\n", - "ligand" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f8dbad46", - "metadata": {}, - "outputs": [], - "source": [ - "ligand_center = ligand.evaluate().center()\n", - "radius = \"15 A\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0e3a9d11", - "metadata": {}, - "outputs": [], - "source": [ - "restraints = sr.restraints.positional(\n", - " mols,\n", - " f\"residues within {radius} of {ligand_center}\",\n", - " position=ligand_center,\n", - " r0=radius,\n", - " k=\"10 kcal mol-1 A-2\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f711a43", - "metadata": {}, - "outputs": [], - "source": [ - "restraints" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "72927a60", - "metadata": {}, - "outputs": [], - "source": [ - "mols = (\n", - " mols.minimisation(\n", - " fixed=f\"not (residues within {radius} of {ligand_center})\",\n", - " restraints=restraints,\n", - " map={\"ignore_perturbations\": True},\n", - " )\n", - " .run()\n", - " .commit()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "648ece01", - "metadata": {}, - "outputs": [], - "source": [ - "d = mols.dynamics(\n", - " timestep=timestep,\n", - " temperature=\"25oC\",\n", - " restraints=restraints,\n", - " fixed=f\"not (residues within {radius} of {ligand_center})\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "460f5a9c", - "metadata": {}, - "outputs": [], - "source": [ - "d.run(\"200ps\", save_frequency=\"1ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c297b9d", - "metadata": {}, - "outputs": [], - "source": [ - "mols = d.commit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a3af4a74", - "metadata": {}, - "outputs": [], - "source": [ - "sr.save(mols.trajectory()[0], \"tyk2_bubble\", format=[\"pdb\"])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c39e1ee9", - "metadata": {}, - "outputs": [], - "source": [ - "sr.save(mols.trajectory(), \"tyk2_bubble\", format=[\"DCD\"])" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/demo/first_example.ipynb b/demo/first_example.ipynb deleted file mode 100644 index 1d1290e6..00000000 --- a/demo/first_example.ipynb +++ /dev/null @@ -1,226 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Perturbable System first example\n", - "\n", - "This notebook will outline the basics of the new sire OpenMM functionality." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import BioSimSpace as BSS\n", - "import sire as sr" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Merged Molecules\n", - "This section will demonstrate the creation and visualisation of perturbations using BioSimSpace and sire, the system in this case will be a simple ethane → methanol transformation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ethane = BSS.Parameters.gaff(\"CC\").getMolecule()\n", - "methanol = BSS.Parameters.gaff(\"CO\").getMolecule()\n", - "mapping = BSS.Align.matchAtoms(ethane, methanol)\n", - "ethane = BSS.Align.rmsdAlign(ethane, methanol, mapping)\n", - "merged = BSS.Align.merge(ethane, methanol, mapping)\n", - "\n", - "solvated = BSS.Solvent.tip3p(molecule=merged, box=3 * [3 * BSS.Units.Length.nanometer])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Convert BioSimSpace to sire\n", - "sire_system = sr.convert.to(solvated, \"sire\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for mol in sire_system.molecules():\n", - " if mol.is_perturbable():\n", - " temp = mol\n", - "\n", - "temp.perturbation().view()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Running simulations of perturbed systems\n", - "\n", - "#### Once a perturbed molecule has been created `sire` can be used directly to run simulations and extract energy information.\n", - "\n", - "Here we will run a single simulation of the above perturbation at a lambda value of 0.5\n", - "By default, lambda behaviour is controlled by a simple morph, the same as `SOMD1`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Perturbable sire systems can be minimised directly at any chosen lambda value, functionality here is a wrapper around openmm minimisation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = sire_system.minimisation(lambda_val=0.5)\n", - "sire_system = m.run().commit()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Production simulations can also be run using sire dynamics - this is a simple wrapper around openMM, it adds convenience such as trajectory saving & automated calculation of energies\n", - "\n", - "Here, the `lambda_values` array is used to specify all lambda values at which the potential is to be calculated." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = sire_system.dynamics(lambda_value=0.5)\n", - "d.run(\"10ps\", energy_frequency=\"0.1ps\", lambda_windows=[0.0, 1.0])\n", - "sire_system = d.commit()\n", - "sire_system.energy_trajectory()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# OpenMM functionality\n", - "\n", - "Alternatively, the perturbable sire system can be converted to openMM, resulting in a `SOMMContext`, a simple wrapper around the `OpenMM::context` class containing information on the perturbation " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm = sr.convert.to(sire_system, \"openmm\")\n", - "omm" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Additional information regarding lambda can be set and called directly with this context" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm.set_lambda(0.5)\n", - "omm.get_lambda()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Simulations can then be run directly using this context, in precisely the same manner as any other openMM context" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm.getIntegrator().step(1000)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm.get_potential_energy()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm.set_lambda(0.0)\n", - "omm.get_potential_energy()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "sireDEV", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/demo/lambda_schedule.ipynb b/demo/lambda_schedule.ipynb deleted file mode 100644 index 1e8a6197..00000000 --- a/demo/lambda_schedule.ipynb +++ /dev/null @@ -1,272 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr\n", - "import BioSimSpace as BSS" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Merged Molecules\n", - "This section will demonstrate the creation and visualisation of perturbations using BioSimSpace and sire, the system in this case will be a simple ethane → methanol transformation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ethane = BSS.Parameters.gaff(\"CC\").getMolecule()\n", - "methanol = BSS.Parameters.gaff(\"CO\").getMolecule()\n", - "mapping = BSS.Align.matchAtoms(ethane, methanol)\n", - "ethane = BSS.Align.rmsdAlign(ethane, methanol, mapping)\n", - "merged = BSS.Align.merge(ethane, methanol, mapping)\n", - "\n", - "solvated = BSS.Solvent.tip3p(molecule=merged, box=3 * [3 * BSS.Units.Length.nanometer])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Extract the sire system\n", - "sire_system = sr.system.System(solvated._sire_object)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for mol in sire_system.molecules():\n", - " if mol.is_perturbable():\n", - " temp = mol\n", - "\n", - "temp.perturbation().view()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Custom lambda scheduling\n", - "This section will demonstrate the creation and implementation of custom lambda scheduling in sire. This will exploit the new functionality of `sr.cas.LambdaSchedule`\n", - "\n", - "First, create an empty lambda schedule" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "l = sr.cas.LambdaSchedule()\n", - "l" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Add a simple morph to the lambda schedule" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "l.add_stage(\"morphing\", (1 - l.lam()) * l.initial() + l.lam() * l.final())\n", - "l" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This lambda schedule can then be converted to a dataframe and visualised" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df = l.get_lever_values(to_pandas=True, initial=0, final=1, num_lambda=10)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df.plot()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "More complex schedules can be created by adding levers to specific properties using add_lever " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Create a new lambda schedule\n", - "l_complex = sr.cas.LambdaSchedule()\n", - "\n", - "# Add multiple stages. The charging stages currently act only to set parameters equal to their initial/final values.\n", - "l_complex.add_stage(\"de-charging\", l_complex.initial())\n", - "l_complex.add_stage(\n", - " \"morphing\",\n", - " (1 - l_complex.lam()) * l_complex.initial() + l_complex.lam() * l_complex.final(),\n", - ")\n", - "l_complex.add_stage(\"re-charging\", l_complex.final())\n", - "\n", - "# By adding levers the de-charging and re-charging stages can be applied to specific properties, in this case charge\n", - "l_complex.add_lever(\"charge\")\n", - "l_complex.set_equation(\n", - " \"de-charging\", \"charge\", (1.0 - 0.8 * l_complex.lam()) * l_complex.final()\n", - ")\n", - "l_complex.set_equation(\n", - " \"re-charging\", \"charge\", (0.2 + 0.8 * l_complex.lam()) * l_complex.final()\n", - ")\n", - "\n", - "# We also need to morph the charges scaled by 0.2 (since we scale down to 0.2)\n", - "l_complex.set_equation(\n", - " \"morphing\",\n", - " \"charge\",\n", - " 0.2 * (l_complex.final()),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df_complex = l_complex.get_lever_values(initial=0.0, final=1, num_lambda=100)\n", - "df_complex.plot()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Lambda schedules can be injected directly in to sire dynamics" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "m = sire_system.minimisation()\n", - "sire_system = m.run().commit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "d = sire_system.dynamics()\n", - "d.set_schedule(l_complex)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "\n", - "for lam in np.arange(0, 1.1, 0.1):\n", - " d.set_lambda(lam)\n", - " print(f\"lambda = {lam}, energy = {d.current_potential_energy()}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Alternatively, the SOMMContext can be extracted and lambda schedules set within it" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "omm = sr.convert.to(\n", - " sire_system, \"openmm\", map={\"cutoff\": sr.u(\"7.5A\"), \"cutoff_type\": \"PME\"}\n", - ")\n", - "omm.set_lambda_schedule(l_complex)\n", - "omm.set_lambda(0.0)\n", - "omm.get_potential_energy()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "for lam in np.arange(0, 1.1, 0.1):\n", - " omm.set_lambda(lam)\n", - " print(f\"Lambda = {lam}, energy = {omm.get_potential_energy()}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "sireDEV", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/demo/repex.ipynb b/demo/repex.ipynb deleted file mode 100644 index d7fdb7b9..00000000 --- a/demo/repex.ipynb +++ /dev/null @@ -1,352 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "510fe067-ffb7-45d1-a18e-8aaf27f8036d", - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr" - ] - }, - { - "cell_type": "markdown", - "id": "83fcc9a0", - "metadata": {}, - "source": [ - "# Replica Exchange\n", - "\n", - "The ease with which multiple simulations can be handled simultaneously allows for a simple implementation of replica exchange." - ] - }, - { - "cell_type": "markdown", - "id": "a2912617-c78d-40c1-b846-c3d4633bd7d9", - "metadata": {}, - "source": [ - "Load an example perturbable system" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "957ecaa8-6885-441a-9c5a-c59671522c9b", - "metadata": {}, - "outputs": [], - "source": [ - "mols = sr.load_test_files(\"merged_molecule.s3\")" - ] - }, - { - "cell_type": "markdown", - "id": "e393e257-a50d-4204-97d2-0ddd9a2d60b4", - "metadata": {}, - "source": [ - "Create two replicas of the system, at two different lambda values" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "75b9681f-59cf-424e-ba00-b7d63415c396", - "metadata": {}, - "outputs": [], - "source": [ - "rep0 = mols.dynamics(timestep=\"4fs\", temperature=\"25oC\", lambda_value=0.0)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e7f24c6f-8c69-475c-bd0b-c576ee8419f5", - "metadata": {}, - "outputs": [], - "source": [ - "rep1 = mols.dynamics(timestep=\"4fs\", temperature=\"25oC\", lambda_value=0.2)" - ] - }, - { - "cell_type": "markdown", - "id": "573869dd", - "metadata": {}, - "source": [ - "### Implementation of a minimal `replica_exchange` function\n", - "\n", - "This function takes in a pair of sire `dynamics` objects and performs a Hamiltonian replica exchange move, returning the two systems as well as a boolean that indicates whether or not the move was accepted" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0304cd61", - "metadata": {}, - "outputs": [], - "source": [ - "def replica_exchange(replica0, replica1):\n", - " # Retrieve the information we need for each replica from the dynamics objects\n", - " lam0 = replica0.get_lambda()\n", - " lam1 = replica1.get_lambda()\n", - "\n", - " ensemble0 = replica0.ensemble()\n", - " ensemble1 = replica1.ensemble()\n", - "\n", - " temperature0 = ensemble0.temperature()\n", - " temperature1 = ensemble1.temperature()\n", - "\n", - " # The lambda_values argument allows us to retrieve the potential energy from both objects at both lambda values\n", - " nrgs0 = replica0.current_potential_energy(lambda_values=[lam0, lam1])\n", - " nrgs1 = replica1.current_potential_energy(lambda_values=[lam0, lam1])\n", - "\n", - " from sire.units import k_boltz\n", - "\n", - " beta0 = 1.0 / (k_boltz * temperature0)\n", - " beta1 = 1.0 / (k_boltz * temperature1)\n", - "\n", - " # Check properties of the ensemble to see if we need to include a pressure term\n", - " if not ensemble0.is_constant_pressure():\n", - " delta = beta1 * (nrgs1[0] - nrgs1[1]) + beta0 * (nrgs0[0] - nrgs0[1])\n", - " else:\n", - " volume0 = replica0.current_space().volume()\n", - " volume1 = replica1.current_space().volume()\n", - "\n", - " pressure0 = ensemble0.pressure()\n", - " pressure1 = ensemble1.pressure()\n", - "\n", - " delta = beta1 * (\n", - " nrgs1[0] - nrgs1[1] + pressure1 * (volume1 - volume0)\n", - " ) + beta0 * (nrgs0[0] - nrgs0[1] + pressure0 * (volume0 - volume1))\n", - "\n", - " from math import exp\n", - " import random\n", - "\n", - " move_passed = delta > 0 or (exp(delta) >= random.random())\n", - "\n", - " if move_passed:\n", - " if lam0 != lam1:\n", - " replica0.set_lambda(lam1)\n", - " replica1.set_lambda(lam0)\n", - " return (replica1, replica0, True)\n", - "\n", - " else:\n", - " return (replica0, replica1, False)" - ] - }, - { - "cell_type": "markdown", - "id": "38ebb8b1-7056-4873-b87a-1a1d8754472c", - "metadata": {}, - "source": [ - "Run dynamics on both replicas. We'll minimise each replica first, to prevent NaN errors. The error catching will mostly catch these and auto-minimise if found (i.e. you could comment out the minimisation lines)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c7f26b29-f270-4b0a-aeba-7200c9439945", - "metadata": {}, - "outputs": [], - "source": [ - "rep0.minimise()\n", - "rep0.run(\"5ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "158c49b5-4a42-4697-8e59-4e682f1905de", - "metadata": {}, - "outputs": [], - "source": [ - "rep1.minimise()\n", - "rep1.run(\"5ps\")" - ] - }, - { - "cell_type": "markdown", - "id": "38d21dfe-212f-480a-9d0e-2a1934fa93af", - "metadata": {}, - "source": [ - "Perform a replica exchange move between these two replicas. If the move passes, then the replicas are swapped (by swapping their lambda values). They are returned from this function in the same lambda order as they went in (i.e. in increasing lambda order)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6abfb021-1fd5-4faa-8b5c-f5631f88931c", - "metadata": {}, - "outputs": [], - "source": [ - "(rep0, rep1, swapped) = replica_exchange(rep0, rep1)" - ] - }, - { - "cell_type": "markdown", - "id": "dc19d9c6-14ed-4807-bc05-741b09219370", - "metadata": {}, - "source": [ - "Was the move successful?" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9ac37cac-a278-431d-a50d-ce0d45f0dd10", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"Swapped?\", swapped)" - ] - }, - { - "cell_type": "markdown", - "id": "cfd648a1-9f90-4a5f-92a6-7453c2a1e6fe", - "metadata": {}, - "source": [ - "Even if they were swapped, the order of lambda is preserved" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12a7e248-881b-4ec4-be65-9c65d25541cc", - "metadata": {}, - "outputs": [], - "source": [ - "print(rep0.get_lambda(), rep1.get_lambda())" - ] - }, - { - "cell_type": "markdown", - "id": "e6ef979f", - "metadata": {}, - "source": [ - "#### This functionality also exists within the current version of sire (the sire version also supports temperature-based repex) and can be accessed with `sire.morph.replica_exchange`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2b8f77f2", - "metadata": {}, - "outputs": [], - "source": [ - "(rep0, rep1, swapped) = sr.morph.replica_exchange(rep0, rep1)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c349f46", - "metadata": {}, - "outputs": [], - "source": [ - "print(\"Swapped?\", swapped)\n", - "print(rep0.get_lambda(), rep1.get_lambda())" - ] - }, - { - "cell_type": "markdown", - "id": "c9e0078a", - "metadata": {}, - "source": [ - "# Non-equilibrium switching\n", - "\n", - "Direct access to the lambda value of dyamics objects allows it to be changed on-the-fly" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1e578418", - "metadata": {}, - "outputs": [], - "source": [ - "d = mols.dynamics(\n", - " timestep=\"4fs\", temperature=\"25oC\", lambda_value=0.0, energy_frequency=sr.u(\"1ps\")\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ef51e597", - "metadata": {}, - "outputs": [], - "source": [ - "d.minimise()\n", - "d.run(\"5ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f43f45d4", - "metadata": {}, - "outputs": [], - "source": [ - "d.get_lambda()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c40db3ed", - "metadata": {}, - "outputs": [], - "source": [ - "d.set_lambda(1.0)\n", - "d.run(\"5ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2b50b17e", - "metadata": {}, - "outputs": [], - "source": [ - "d.get_lambda()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7910c234", - "metadata": {}, - "outputs": [], - "source": [ - "df = d.energy_trajectory(to_pandas=True)\n", - "df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4cb7b999", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/demo/sire_restraints.ipynb b/demo/sire_restraints.ipynb deleted file mode 100644 index e70a164b..00000000 --- a/demo/sire_restraints.ipynb +++ /dev/null @@ -1,354 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "d086ec3c-3d0c-45b1-949f-0224e68043fe", - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr" - ] - }, - { - "cell_type": "markdown", - "id": "4721068a", - "metadata": {}, - "source": [ - "# Positional Restraints\n", - "This section of the notebook will demonstrate new sire positional restraint functionality, building to a system in which all molecules outside a defined 'bubble' are fixed in place " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c2e8df62-7bcd-4028-a97e-51ed67530ce0", - "metadata": {}, - "outputs": [], - "source": [ - "mols = sr.load_test_files(\"ala.top\", \"ala.crd\")\n", - "mols.make_whole()\n", - "mols.view()" - ] - }, - { - "cell_type": "markdown", - "id": "39a6ea9f", - "metadata": {}, - "source": [ - "Use the new sire `restraints` functionality to create a `restraints` object, defining the restraints which are to be applied to the simulated system. In this case a simple positional restraint will be added to the alpha carbon of our alanine dipeptide" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "169dbf60-fb80-469f-9859-d36ce6187ab4", - "metadata": {}, - "outputs": [], - "source": [ - "restraints = sr.restraints.positional(mols, \"resname ALA and not element H\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "526b6399-68a3-4564-9ff7-72eb48748933", - "metadata": {}, - "outputs": [], - "source": [ - "restraints" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0f8ae7c2", - "metadata": {}, - "outputs": [], - "source": [ - "print(mols[0].atoms([8]))" - ] - }, - { - "cell_type": "markdown", - "id": "f1a2165d", - "metadata": {}, - "source": [ - "`Restraint` can be further expanded to set the force constant `k` and the half-harmonic width `r0`, as well as set a restraint position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a0f8acb9", - "metadata": {}, - "outputs": [], - "source": [ - "restraints = sr.restraints.positional(\n", - " mols,\n", - " \"resname ALA and not element H\",\n", - " k=\"100 kcal mol-1 A-2\",\n", - " r0=\"0.0 A\",\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "528b6d5f-05db-40ba-a85d-0f6a8dac8484", - "metadata": {}, - "outputs": [], - "source": [ - "mols = (\n", - " mols.minimisation(\n", - " restraints=restraints,\n", - " )\n", - " .run()\n", - " .commit()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4f3b87c7-641b-48fe-825e-390e5a08a26e", - "metadata": {}, - "outputs": [], - "source": [ - "d = mols.dynamics(\n", - " timestep=\"4fs\",\n", - " temperature=\"25oC\",\n", - " restraints=restraints,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b6df854a-7180-4b1f-88cf-331383747c35", - "metadata": {}, - "outputs": [], - "source": [ - "d.run(\"20ps\", frame_frequency=\"0.5ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "47b25cfd-5282-4427-8588-7b28612d8a62", - "metadata": {}, - "outputs": [], - "source": [ - "mols = d.commit()\n", - "mols.view()" - ] - }, - { - "cell_type": "markdown", - "id": "5e1a166c", - "metadata": {}, - "source": [ - "This functionality can be expanded to, for example, freeze all atoms outside a given distance of the ligand, effectively truncating the simulated region (note that this currently doesn't improve performance, a future update will add the ability to approximate the contributions of the frozen atoms).\n", - "\n", - "This is a two-part process, first we restrain the atoms within the bubble itself in order to prevent them from leaving it, this is achieved with the `restraints.positional` functionality seen above:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a9200af9", - "metadata": {}, - "outputs": [], - "source": [ - "restraints_bubble = sr.restraints.positional(\n", - " mols,\n", - " \"molecules within 7.5 of resname ALA\",\n", - " position=mols[\"resname ALA\"].coordinates(),\n", - " r0=sr.u(\"10 A\"),\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "2a71791c", - "metadata": {}, - "source": [ - "Next, we pass the `fixed` argument in to both minimisation and dynamics (alternatively `fixed` can be specified along with all other simulation options within `map`)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9375540", - "metadata": {}, - "outputs": [], - "source": [ - "mols = (\n", - " mols.minimisation(\n", - " restraints=restraints_bubble, fixed=\"not (molecules within 7.5 of molidx 0)\"\n", - " )\n", - " .run()\n", - " .commit()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "91377b33", - "metadata": {}, - "outputs": [], - "source": [ - "map = {\n", - " \"restraints\": restraints_bubble,\n", - " \"fixed\": \"not (molecules within 7.5 of molidx 0)\",\n", - " \"temperature\": 300 * sr.units.kelvin,\n", - "}\n", - "d = mols.dynamics(map=map)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "650106cd", - "metadata": {}, - "outputs": [], - "source": [ - "d.run(\"20ps\", frame_frequency=\"0.5ps\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cdce4dbd", - "metadata": {}, - "outputs": [], - "source": [ - "mols = d.commit()\n", - "mols.view()" - ] - }, - { - "cell_type": "markdown", - "id": "0e53a900", - "metadata": {}, - "source": [ - "At a lower level, restraints can be passed in the `map` argument of `sire.convert`. This creates an openMM context.\n", - "\n", - "Openmm wants a list of indexes for fixed\n", - "\n", - "The key difference here is the form in which the mask of the `fixed` flag is passed" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "23810ad7", - "metadata": {}, - "outputs": [], - "source": [ - "mask_sire = mols[\"not (molecules within 7.5 of molidx 0)\"].atoms()\n", - "mask_openmm = [i.number().value() for i in mask_sire]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a3c14fc9", - "metadata": {}, - "outputs": [], - "source": [ - "omm = sr.convert.to(\n", - " mols, \"openmm\", map={\"restraints\": restraints, \"fixed\": mask_openmm}\n", - ")\n", - "omm" - ] - }, - { - "cell_type": "markdown", - "id": "b23f2c32", - "metadata": {}, - "source": [ - "# Combining restraints and lambda levers" - ] - }, - { - "cell_type": "markdown", - "id": "175be802", - "metadata": {}, - "source": [ - "Restraints can be perturbed in the same manner as any other potential using `sr.cas.lambdaschedule`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a7276e8b", - "metadata": {}, - "outputs": [], - "source": [ - "restraints = sr.restraints.positional(\n", - " mols,\n", - " \"resname ALA and not element H\",\n", - " k=\"100 kcal mol-1 A-2\",\n", - " r0=\"0.0 A\",\n", - " name=\"positional\",\n", - ")\n", - "dst_rest = sr.restraints.distance(mols, atoms0=0, atoms1=1, name=\"distance\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "37d59f38", - "metadata": {}, - "outputs": [], - "source": [ - "l = sr.cas.LambdaSchedule()\n", - "l.add_stage(\"distance_restraints\", 0)\n", - "l.add_stage(\"positional_restraints\", 1)\n", - "l.set_equation(\"distance_restraints\", \"distance\", l.lam() * l.initial())\n", - "l.set_equation(\"positional_restraints\", \"positional\", l.lam() * l.initial())\n", - "l" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3b098278", - "metadata": {}, - "outputs": [], - "source": [ - "l.get_lever_values(initial=1.0, final=1.0, levers=[\"distance\", \"positional\"]).plot()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8e186d9f", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/scripts/Atoms_finding_sire_test.ipynb b/scripts/Atoms_finding_sire_test.ipynb deleted file mode 100644 index 5fa1f8f0..00000000 --- a/scripts/Atoms_finding_sire_test.ipynb +++ /dev/null @@ -1,206 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A simple jupyter notebook used to find heavy atoms within 12 and 15 angstrom of a ligand. Assumes that files 1a~1b.prm7 and 1a~1b.rst7 are in the same directory." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import sire as sr\n", - "import re" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "ename": "OSError", - "evalue": "Cannot find file '1a~1b.prm7'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mOSError\u001b[0m Traceback (most recent call last)", - "Cell \u001b[0;32mIn[2], line 2\u001b[0m\n\u001b[1;32m 1\u001b[0m root \u001b[39m=\u001b[39m \u001b[39m\"\u001b[39m\u001b[39m1a~1b\u001b[39m\u001b[39m\"\u001b[39m\n\u001b[0;32m----> 2\u001b[0m mols \u001b[39m=\u001b[39m sr\u001b[39m.\u001b[39;49mload(\u001b[39m\"\u001b[39;49m\u001b[39m%s\u001b[39;49;00m\u001b[39m.prm7\u001b[39;49m\u001b[39m\"\u001b[39;49m \u001b[39m%\u001b[39;49m root , \u001b[39m\"\u001b[39;49m\u001b[39m%s\u001b[39;49;00m\u001b[39m.rst7\u001b[39;49m\u001b[39m\"\u001b[39;49m \u001b[39m%\u001b[39;49m root)\n", - "File \u001b[0;32m~/mambaforge/envs/openbiosim/lib/python3.10/site-packages/sire/_load.py:399\u001b[0m, in \u001b[0;36mload\u001b[0;34m(path, show_warnings, silent, directory, gromacs_path, parallel, map, *args, **kwargs)\u001b[0m\n\u001b[1;32m 395\u001b[0m p \u001b[39m=\u001b[39m []\n\u001b[1;32m 397\u001b[0m \u001b[39mfor\u001b[39;00m i \u001b[39min\u001b[39;00m \u001b[39mrange\u001b[39m(\u001b[39m0\u001b[39m, \u001b[39mlen\u001b[39m(paths)):\n\u001b[1;32m 398\u001b[0m \u001b[39m# resolve the paths, downloading as needed\u001b[39;00m\n\u001b[0;32m--> 399\u001b[0m p \u001b[39m+\u001b[39m\u001b[39m=\u001b[39m _resolve_path(paths[i], directory\u001b[39m=\u001b[39;49mdirectory, silent\u001b[39m=\u001b[39;49msilent)\n\u001b[1;32m 401\u001b[0m paths \u001b[39m=\u001b[39m p\n\u001b[1;32m 403\u001b[0m \u001b[39mif\u001b[39;00m \u001b[39mlen\u001b[39m(paths) \u001b[39m==\u001b[39m \u001b[39m0\u001b[39m:\n", - "File \u001b[0;32m~/mambaforge/envs/openbiosim/lib/python3.10/site-packages/sire/_load.py:261\u001b[0m, in \u001b[0;36m_resolve_path\u001b[0;34m(path, directory, silent)\u001b[0m\n\u001b[1;32m 257\u001b[0m paths \u001b[39m+\u001b[39m\u001b[39m=\u001b[39m _resolve_path(match, directory\u001b[39m=\u001b[39mdirectory, silent\u001b[39m=\u001b[39msilent)\n\u001b[1;32m 259\u001b[0m \u001b[39mreturn\u001b[39;00m paths\n\u001b[0;32m--> 261\u001b[0m \u001b[39mraise\u001b[39;00m \u001b[39mIOError\u001b[39;00m(\u001b[39mf\u001b[39m\u001b[39m\"\u001b[39m\u001b[39mCannot find file \u001b[39m\u001b[39m'\u001b[39m\u001b[39m{\u001b[39;00mpath\u001b[39m}\u001b[39;00m\u001b[39m'\u001b[39m\u001b[39m\"\u001b[39m)\n", - "\u001b[0;31mOSError\u001b[0m: Cannot find file '1a~1b.prm7'" - ] - } - ], - "source": [ - "root = \"1a~1b\"\n", - "mols = sr.load(\"%s.prm7\" % root, \"%s.rst7\" % root)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Change \"root\" variable according to your input file names" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sr.search.set_token(\"lnd\", \"resname LIG\")\n", - "# sr.search.set_token(\"lnd\",\"count(atoms) > 1 and not (protein or water)\")\n", - "ligand = mols[\"lnd\"]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Uses a sire search token to define the criteria for the ligand, in this case the ligand is simply anything within a residue with residue name ``LIG``\n", - "\n", - "Can use some alternative search mechanism like ``sr.search.set_token(\"lnd\",\"count(atoms) > 1 and not (protein or water)\")``. This doesn't work in this case because the some parts of the truncated protein are not identified by ``protein``. A possible solution to this could be to instead search for ``not amino acid`` instead, need to check sire search functionality to see exactly how this is done." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ligand.view()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use ``view`` to check that the ligand and only the ligand is captured." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "residues = mols[\n", - " \"((atoms within 15 of lnd) and (not atoms within 12 of lnd)) and protein\"\n", - "]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Finds all atoms between 12 and 15 angstrom from the ligand. ``atoms`` can be swapped with ``residues`` if only complete residues are required. \n", - "This still includes hydrogen atoms - need only heavy atoms." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "residues.view()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "heavy = residues[\"not atomname /H*/\"]" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Remove hydrogen atoms," - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "heavy.view()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "an = heavy.numbers()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "nums = []\n", - "for at in an:\n", - " nums.append(int(re.findall(r\"\\d+\", str(at))[0]))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Write atom numbers to a list in the form required by the addanchors script." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(nums)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "sireDEV", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.10" - }, - "orig_nbformat": 4 - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/scripts/addanchors.py b/scripts/addanchors.py deleted file mode 100644 index d759969f..00000000 --- a/scripts/addanchors.py +++ /dev/null @@ -1,148 +0,0 @@ -import sire as sr -import os -import re -import argparse -import csv - -parser = argparse.ArgumentParser( - prog="Add Anchors", - description="A scipt to generate positional restraints for use in SOMD", -) - -parser.add_argument( - "-i", - "--input", - help="Input files - should contain sire compatible coordinate and topology files.", - nargs=2, - required=True, -) - -parser.add_argument( - "-r", - "--restrained", - help="Optional file - take in a .csv file that contains a list of restrained\ - atoms and use those. Bypasses sire operations used to find atoms. Assumes that the file is a single line containing only the list of atom numbers", - type=str, -) - -parser.add_argument( - "-d", - "--distances", - help="The distances between which to restrain atoms. Default is between 12.0 and 15.0 angstrom.", - nargs=2, - type=float, - default=[12.0, 15.0], -) - -parser.add_argument( - "-o", - "--outname", - help="Name of output file. If not defined the output file will be {name of first input file}_restrained.{filetype}", - type=str, -) - -args = parser.parse_args() - -mols = sr.load(args.input[0], args.input[1]) - -if not args.restrained: - if args.distances[0] >= args.distances[1]: - raise ValueError( - "Restraint distances are in the wrong order, please order -d small big" - ) - # Use sire to find all heavy atoms between 12 and 15 angstrom of the ligand - sr.search.set_token("lnd", "resname LIG") - ligand = mols["lnd"] - residues = mols[ - "((atoms within %s of lnd) and (not atoms within %s of lnd)) and protein" - % (args.distances[1], args.distances[0]) - ] - heavy = residues["not atomname /H*/"] - an = heavy.numbers() - restrained_atoms = [] - for at in an: - restrained_atoms.append(int(re.findall(r"\d+", str(at))[0])) - -else: - with open(args.restrained) as f: - reader = csv.reader(f) - data = list(reader) - restrained_atoms = [int(x) for x in data[0]] - -print("Atomnums for restrained atoms:") -print(restrained_atoms) -# restrained_atoms = [18612, 18613, 18614, 18615, 18616, 18617, 18618] - -n_existing_atoms = mols.num_atoms() -n_existing_residues = mols.num_residues() - -newmol = sr.mol.Molecule("dummies") - -editor = newmol.edit() - -# Create a residue -editor = ( - editor.add(sr.mol.ResName("Re")) - .renumber(sr.mol.ResNum(n_existing_residues + 1)) - .molecule() -) - -for i in range(0, len(restrained_atoms)): - editor = ( - editor.add(sr.mol.AtomName("Re")) - .renumber(sr.mol.AtomNum(n_existing_atoms + i + 1)) - .reparent(sr.mol.ResIdx(0)) - .molecule() - ) - -mol = editor.commit() - -cursor = mol.cursor()["atomname Re"] - -# need to set the properties to the correct type... -cursor[0]["charge"] = 1 * sr.units.mod_electron -cursor[0]["mass"] = 1 * sr.units.g_per_mol - -for i in range(0, len(cursor)): - atom = cursor.atom(i) - restrained_at = mols["atomnum %i" % restrained_atoms[i]] - atom["coordinates"] = restrained_at.property("coordinates") - atom["charge"] = 0 * sr.units.mod_electron - atom["element"] = sr.mol.Element(0) - atom["mass"] = 0 * sr.units.g_per_mol - atom["atomtype"] = "DM" - atom["LJ"] = sr.mm.LJParameter(1 * sr.units.angstrom, 0 * sr.units.kcal_per_mol) - -mol = cursor.molecule().commit() - -mols.add(mol) - -if args.outname: - f = sr.save(mols, args.outname, format=["PDB"]) - f = sr.save(mols, args.outname, format=["PRM7", "RST7"]) - -else: - origname = args.input[0].split(".")[0] - f = sr.save(mols, origname + "_restrained", format=["PDB"]) - f = sr.save(mols, origname + "_restrained", format=["PRM7", "RST7"]) - -# load to check -mols = sr.load(f) - -# list of tuples of atoms to save, will be used to specify restraints -# in somd-freenrg -paired_atoms = {} - -last = mols[-1] -for i in range(0, len(last.atoms())): - atom = last.atom(i) - restrained_at = mols["atomnum %i" % restrained_atoms[i]] - paired_atoms[atom.number().value()] = restrained_at.number().value() - # print(atom, atom.property("charge"), atom.property("LJ"), atom.residue()) - -ofile = open("restraint.cfg", "w") -ofile.write("use restraints = True\n") -ofile.write("restrained atoms = %s\n" % paired_atoms) -ofile.close() -print("Atom pairs {dummy atom: original atom}:") -print(paired_atoms) diff --git a/scripts/addanchors_exampleinputs.txt b/scripts/addanchors_exampleinputs.txt deleted file mode 100644 index fc1d2235..00000000 --- a/scripts/addanchors_exampleinputs.txt +++ /dev/null @@ -1,4 +0,0 @@ -To set up restraints with heavy atoms between 10 and 15 angstroms restrained, with input files called 1a~1b.prm7 and 1a~1b.rst7 output files named example.{ext}: -python addanchors.py -i 1a~1b.rst7 1a~1b.prm7 -d 10 15 -o example -To do the same, but now reading atoms from a file called "restrained.csv" -python addanchors.py -i 1a~1b.rst7 1a~1b.prm7 -r restrained.csv -o example From f8077cd72ead9727b3a6bbb8a1bb4b7c9db3bff5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 12 Mar 2026 14:51:17 +0000 Subject: [PATCH 109/212] Remove redundant comment. [ci skip] --- pixi.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pixi.toml b/pixi.toml index 01e13aa4..09fcceb1 100644 --- a/pixi.toml +++ b/pixi.toml @@ -1,7 +1,6 @@ [workspace] name = "somd2" channels = ["conda-forge", "openbiosim/label/dev"] -# No Windows - depends on loch which requires pycuda/pyopencl platforms = ["linux-64", "osx-arm64"] [dependencies] From 7a39b32b7fda5a369682739a539318dbcf59ef5e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Mar 2026 10:35:33 +0000 Subject: [PATCH 110/212] Make repex velocity randomisation optional. --- src/somd2/config/_config.py | 15 +++++++++++++++ src/somd2/runner/_repex.py | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index a799f63d..21b2c155 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -134,6 +134,7 @@ def __init__( opencl_platform_index=0, oversubscription_factor=1, replica_exchange=False, + randomise_velocities=False, perturbed_system=None, gcmc=False, gcmc_frequency=None, @@ -363,6 +364,9 @@ def __init__( Whether to run replica exchange simulation. Currently this can only be used when GPU resources are available. + randomise_velocities: bool + Whether to randomise velocities at the start of each replica exchange cycle. + perturbed_system: str The path to a stream file containing a Sire system for the equilibrated perturbed end state (lambda = 1). This will be used as the starting conformation all lambda @@ -539,6 +543,7 @@ def __init__( self.opencl_platform_index = opencl_platform_index self.oversubscription_factor = oversubscription_factor self.replica_exchange = replica_exchange + self.randomise_velocities = randomise_velocities self.perturbed_system = perturbed_system self.gcmc = gcmc self.gcmc_frequency = gcmc_frequency @@ -1645,6 +1650,16 @@ def replica_exchange(self, replica_exchange): raise ValueError("'replica_exchange' must be of type 'bool'") self._replica_exchange = replica_exchange + @property + def randomise_velocities(self): + return self._randomise_velocities + + @randomise_velocities.setter + def randomise_velocities(self, randomise_velocities): + if not isinstance(randomise_velocities, bool): + raise ValueError("'randomise_velocities' must be of type 'bool'") + self._randomise_velocities = randomise_velocities + @property def perturbed_system(self): return self._perturbed_system diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index b0d56637..ae9c7eea 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1240,7 +1240,8 @@ def _run_block( _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") # Draw new velocities from the Maxwell-Boltzmann distribution. - dynamics.randomise_velocities() + if self._config.randomise_velocities: + dynamics.randomise_velocities() # Run the dynamics. dynamics.run( From b1894c2a88234a5ad18ebb49f41977388234d89d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 18 Mar 2026 14:31:37 +0000 Subject: [PATCH 111/212] Add initial ring-breaking lambda schedules. --- src/somd2/config/_config.py | 281 +++++++++++++++++++++++++++++++++++- 1 file changed, 274 insertions(+), 7 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 21b2c155..840f87f2 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -68,6 +68,8 @@ class Config: "lambda_schedule": [ "standard_morph", "charge_scaled_morph", + "ring_break_morph", + "reverse_ring_break_morph", ], "log_level": [level.lower() for level in _logger._core.levels], } @@ -651,14 +653,20 @@ def as_dict(self, sire_compatible=False): # Handle the lambda schedule separately so that we can use simplified # keyword options. - if self.lambda_schedule == _LambdaSchedule.standard_morph(): - d["lambda_schedule"] = "standard_morph" - elif self.lambda_schedule == _LambdaSchedule.charge_scaled_morph( - self._charge_scale_factor - ): - d["lambda_schedule"] = "charge_scaled_morph" + + # A keyword exists for this lambda schedule. + if self._lambda_schedule_name is not None: + d["lambda_schedule"] = self._lambda_schedule_name + # Try to match the lambda schedule to a known schedule, if not then convert to hex. else: - d["lambda_schedule"] = self._to_hex(self.lambda_schedule) + if self.lambda_schedule == _LambdaSchedule.standard_morph(): + d["lambda_schedule"] = "standard_morph" + elif self.lambda_schedule == _LambdaSchedule.charge_scaled_morph( + self._charge_scale_factor + ): + d["lambda_schedule"] = "charge_scaled_morph" + else: + d["lambda_schedule"] = self._to_hex(self.lambda_schedule) # Serialise restraints. if self.restraints is not None: @@ -997,11 +1005,268 @@ def lambda_schedule(self, lambda_schedule): lambda_schedule = lambda_schedule.strip().lower() if lambda_schedule == "standard_morph": self._lambda_schedule = _LambdaSchedule.standard_morph() + self._lambda_schedule_name = "standard_morph" elif lambda_schedule == "charge_scaled_morph": self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) + self._lambda_schedule_name = "charge_scaled_morph" + elif lambda_schedule == "ring_break_morph": + self._lambda_schedule = _LambdaSchedule.standard_morph() + self._lambda_schedule.prepend_stage( + "restraints_off", self._lambda_schedule.initial() + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="restraint", + equation=1 - self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="bond_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="bond_length", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="angle_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="angle_size", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="torsion_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="restraints_off", + lever="torsion_phase", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + + self._lambda_schedule.prepend_stage( + "potential_swap", self._lambda_schedule.initial() + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="restraint", + equation=0 + self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="bond_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="bond_length", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="angle_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="angle_size", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="torsion_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="torsion_phase", + equation=self._lambda_schedule.initial(), + ) + + self._lambda_schedule.set_equation( + stage="morph", lever="restraint", equation=0 + ) + + self._lambda_schedule.set_equation( + stage="morph", + lever="bond_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="bond_length", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="angle_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="angle_size", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="torsion_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="torsion_phase", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule_name = "ring_break_morph" + elif lambda_schedule == "reverse_ring_break_morph": + self._lambda_schedule = _LambdaSchedule.standard_morph() + self._lambda_schedule.set_equation( + stage="morph", lever="restraint", equation=0 + ) + + self._lambda_schedule.set_equation( + stage="morph", + lever="bond_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="bond_length", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="angle_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="angle_size", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="torsion_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="morph", + lever="torsion_phase", + equation=self._lambda_schedule.initial(), + ) + + self._lambda_schedule.append_stage( + "bonded_perturb", self._lambda_schedule.final() + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="restraint", + equation=0 + self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="bond_k", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="bond_length", + equation=self._lambda_schedule.initial(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="angle_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="angle_size", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="torsion_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", + lever="torsion_phase", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + + self._lambda_schedule.append_stage( + "potential_swap", self._lambda_schedule.final() + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="restraint", + equation=1 - self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="bond_k", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="bond_length", + equation=(1 - self._lambda_schedule.lam()) + * self._lambda_schedule.initial() + + self._lambda_schedule.lam() * self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="angle_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="angle_size", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="torsion_k", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="torsion_phase", + equation=self._lambda_schedule.final(), + ) + self._lambda_schedule_name = "reverse_ring_break_morph" else: try: self._lambda_schedule = self._from_hex(lambda_schedule) + self._lambda_schedule_name = None except Exception: raise ValueError( "Unable to deserialise 'lambda_schedule'. Ensure that this is a " @@ -1010,8 +1275,10 @@ def lambda_schedule(self, lambda_schedule): ) else: self._lambda_schedule = lambda_schedule + self._lambda_schedule_name = None else: self._lambda_schedule = _LambdaSchedule.standard_morph() + self._lambda_schedule_name = "standard_morph" @property def charge_scale_factor(self): From 1289f27d03795b774a9301160639351ce6ed4bd8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 20 Mar 2026 10:11:23 +0000 Subject: [PATCH 112/212] Remove no-op. [ci skip] --- src/somd2/runner/_repex.py | 83 +++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index ae9c7eea..33ef3390 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1102,49 +1102,48 @@ def run(self): _logger.error("Checkpoint cancelled. Exiting.") _sys.exit(1) - if i < cycles: - # Assemble and energy matrix from the results. - _logger.info("Assembling energy matrix") - energy_matrix = self._assemble_results(results) - - # Mix the replicas. - _logger.info("Mixing replicas") - self._dynamics_cache.set_states( - self._mix_replicas( - self._config.num_lambda, - energy_matrix, - self._dynamics_cache.get_proposed(), - self._dynamics_cache.get_accepted(), - ) + # Assemble an energy matrix from the results. + _logger.info("Assembling energy matrix") + energy_matrix = self._assemble_results(results) + + # Mix the replicas. + _logger.info("Mixing replicas") + self._dynamics_cache.set_states( + self._mix_replicas( + self._config.num_lambda, + energy_matrix, + self._dynamics_cache.get_proposed(), + self._dynamics_cache.get_accepted(), ) - self._dynamics_cache.mix_states() - - # This is a checkpoint cycle. - if is_checkpoint: - # Update the block number. - block += 1 - - # Advance the checkpoint threshold. - next_checkpoint += cycles_per_checkpoint - - # Guard the repex state and transition matrix saving with a file lock. - lock = _FileLock(self._lock_file) - with lock.acquire(timeout=self._config.timeout.to("seconds")): - # Save the transition matrix. - _logger.info("Saving replica exchange transition matrix") - self._save_transition_matrix() - - # Backup the dynamics cache pickle file, if it exists. - if self._repex_state.exists(): - _copyfile( - self._repex_state, - self._repex_state.with_suffix(".pkl.bak"), - ) - - # Pickle the dynamics cache. - _logger.info("Saving replica exchange state") - with open(self._repex_state, "wb") as f: - _pickle.dump(self._dynamics_cache, f) + ) + self._dynamics_cache.mix_states() + + # This is a checkpoint cycle. + if is_checkpoint: + # Update the block number. + block += 1 + + # Advance the checkpoint threshold. + next_checkpoint += cycles_per_checkpoint + + # Guard the repex state and transition matrix saving with a file lock. + lock = _FileLock(self._lock_file) + with lock.acquire(timeout=self._config.timeout.to("seconds")): + # Save the transition matrix. + _logger.info("Saving replica exchange transition matrix") + self._save_transition_matrix() + + # Backup the dynamics cache pickle file, if it exists. + if self._repex_state.exists(): + _copyfile( + self._repex_state, + self._repex_state.with_suffix(".pkl.bak"), + ) + + # Pickle the dynamics cache. + _logger.info("Saving replica exchange state") + with open(self._repex_state, "wb") as f: + _pickle.dump(self._dynamics_cache, f) # Record the end time for the production block. prod_end = time() From cae3bf875b46ee9d50c28c959a49e86c659f5c6e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 20 Mar 2026 10:22:38 +0000 Subject: [PATCH 113/212] Apply saved replica exchange mixing on restart to restore post-mix states. --- src/somd2/runner/_repex.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 33ef3390..64448110 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -812,15 +812,17 @@ def __init__(self, system, config): for i in range(len(self._lambda_values)): dynamics, gcmc_sampler = self._dynamics_cache.get(i) - # Reset the OpenMM state. - dynamics.context().setState(self._dynamics_cache._openmm_states[i]) + # Reset the OpenMM state, applying the last replica exchange + # mixing so the correct post-mix state is restored. + state = self._dynamics_cache._states[i] + dynamics.context().setState(self._dynamics_cache._openmm_states[state]) # Reset the GCMC water state. if gcmc_sampler is not None: gcmc_sampler.push() gcmc_sampler._set_water_state( dynamics.context(), - states=self._dynamics_cache._gcmc_states[i], + states=self._dynamics_cache._gcmc_states[state], force=True, ) gcmc_sampler.pop() From 6f57a18cd2a65863d060406658527f1504c570fe Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 24 Mar 2026 12:13:40 +0000 Subject: [PATCH 114/212] Wrap GCMC push/pop pairs in try/finally to guard against crashes. --- src/somd2/runner/_repex.py | 106 +++++++++++++++++++----------------- src/somd2/runner/_runner.py | 74 ++++++++++++++++--------- 2 files changed, 104 insertions(+), 76 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 64448110..523e7d75 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -532,12 +532,14 @@ def mix_states(self): # Update the water state in the GCMCSampler. self._gcmc_samplers[i].push() - self._gcmc_samplers[i]._set_water_state( - self._dynamics[i].context(), - indices=water_idxs, - states=self._gcmc_states[state][water_idxs], - ) - self._gcmc_samplers[i].pop() + try: + self._gcmc_samplers[i]._set_water_state( + self._dynamics[i].context(), + indices=water_idxs, + states=self._gcmc_states[state][water_idxs], + ) + finally: + self._gcmc_samplers[i].pop() # Update the swap matrix. old_state = self._old_states[i] @@ -820,12 +822,14 @@ def __init__(self, system, config): # Reset the GCMC water state. if gcmc_sampler is not None: gcmc_sampler.push() - gcmc_sampler._set_water_state( - dynamics.context(), - states=self._dynamics_cache._gcmc_states[state], - force=True, - ) - gcmc_sampler.pop() + try: + gcmc_sampler._set_water_state( + dynamics.context(), + states=self._dynamics_cache._gcmc_states[state], + force=True, + ) + finally: + gcmc_sampler.pop() # Conversion factor for reduced potential. kT = (_sr.units.k_boltz * self._config.temperature).to(_sr.units.kcal_per_mol) @@ -1277,13 +1281,13 @@ def _run_block( if is_gcmc: # Push the PyCUDA context on top of the stack. gcmc_sampler.push() - - # Perform the GCMC move. - _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") - gcmc_sampler.move(dynamics.context()) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() + try: + # Perform the GCMC move. + _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") + gcmc_sampler.move(dynamics.context()) + finally: + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() # Save the GCMC state. self._dynamics_cache.save_gcmc_state(index) @@ -1340,15 +1344,15 @@ def _minimise(self, index): if gcmc_sampler is not None: # Push the PyCUDA context on top of the stack. gcmc_sampler.push() - - _logger.info( - f"Pre-equilibrating with GCMC moves at {_lam_sym} = {self._lambda_values[index]:.5f}" - ) - for i in range(100): - gcmc_sampler.move(dynamics.context()) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() + try: + _logger.info( + f"Pre-equilibrating with GCMC moves at {_lam_sym} = {self._lambda_values[index]:.5f}" + ) + for i in range(100): + gcmc_sampler.move(dynamics.context()) + finally: + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() # Minimise. dynamics.minimise(timeout=self._config.timeout) @@ -1433,15 +1437,15 @@ def _equilibrate(self, index): if gcmc_sampler is not None: # Push the PyCUDA context on top of the stack. gcmc_sampler.push() - - _logger.info( - f"Equilibrating with GCMC moves at {_lam_sym} = {self._lambda_values[index]:.5f}" - ) - for i in range(100): - gcmc_sampler.move(dynamics.context()) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() + try: + _logger.info( + f"Equilibrating with GCMC moves at {_lam_sym} = {self._lambda_values[index]:.5f}" + ) + for i in range(100): + gcmc_sampler.move(dynamics.context()) + finally: + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() # Store the current water state. water_state = gcmc_sampler.water_state() @@ -1693,14 +1697,14 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): if gcmc_sampler is not None: # Push the PyCUDA context on top of the stack. gcmc_sampler.push() - - _logger.info( - f"Current number of waters in GCMC volume at {_lam_sym} = {lam:.5f} " - f"is {gcmc_sampler.num_waters()}" - ) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() + try: + _logger.info( + f"Current number of waters in GCMC volume at {_lam_sym} = {lam:.5f} " + f"is {gcmc_sampler.num_waters()}" + ) + finally: + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() if is_final_block: _logger.success(f"{_lam_sym} = {lam:.5f} complete") @@ -1823,12 +1827,12 @@ def _reset_gcmc_sampler(gcmc_sampler, dynamics): # Push the PyCUDA context on top of the stack. gcmc_sampler.push() - - # Set the water state. - gcmc_sampler._set_water_state(dynamics.context(), force=True) - - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() + try: + # Set the water state. + gcmc_sampler._set_water_state(dynamics.context(), force=True) + finally: + # Remove the PyCUDA context from the stack. + gcmc_sampler.pop() # Re-bind the GCMC sampler to the dynamics object. gcmc_sampler.bind_dynamics(dynamics) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index fb8b6253..14ad6d99 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -521,8 +521,12 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Equilibrating with GCMC moves at {_lam_sym} = {lambda_value:.5f}" ) - for i in range(100): - gcmc_sampler.move(dynamics.context()) + gcmc_sampler.push() + try: + for i in range(100): + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() # Run without saving energies or frames. dynamics.run( @@ -610,20 +614,24 @@ def generate_lam_vals(lambda_base, increment=0.001): if self._is_restart: from openmm.unit import angstrom - # First set all waters to non-ghosts. - gcmc_sampler._set_water_state( - dynamics.context(), - states=_np.ones(len(gcmc_sampler._water_indices)), - force=True, - ) + gcmc_sampler.push() + try: + # First set all waters to non-ghosts. + gcmc_sampler._set_water_state( + dynamics.context(), + states=_np.ones(len(gcmc_sampler._water_indices)), + force=True, + ) - # Now set the ghost waters. - gcmc_sampler._set_water_state( - dynamics.context(), - self._restart_ghost_waters[index], - states=_np.zeros(len(gcmc_sampler._water_indices)), - force=True, - ) + # Now set the ghost waters. + gcmc_sampler._set_water_state( + dynamics.context(), + self._restart_ghost_waters[index], + states=_np.zeros(len(gcmc_sampler._water_indices)), + force=True, + ) + finally: + gcmc_sampler.pop() # Finally, reset the context positions to match the restart system. dynamics.context().setPositions( @@ -634,10 +642,14 @@ def generate_lam_vals(lambda_base, increment=0.001): # the water state in the new context to match the equilibrated system. elif is_equilibrated: # Reset the water state. - gcmc_sampler._set_water_state( - dynamics.context(), - force=True, - ) + gcmc_sampler.push() + try: + gcmc_sampler._set_water_state( + dynamics.context(), + force=True, + ) + finally: + gcmc_sampler.pop() # Set the number of neighbours used for the energy calculation. # If not None, then we add one to account for the extra windows @@ -728,7 +740,11 @@ def generate_lam_vals(lambda_base, increment=0.001): _logger.info( f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" ) - gcmc_sampler.move(dynamics.context()) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() else: dynamics.run( @@ -816,10 +832,14 @@ def generate_lam_vals(lambda_base, increment=0.001): # Log the number of waters within the GCMC sampling volume. if gcmc_sampler is not None: - _logger.info( - f"Current number of waters in GCMC volume at {_lam_sym} = {lambda_value:.5f} " - f"is {gcmc_sampler.num_waters()}" - ) + gcmc_sampler.push() + try: + _logger.info( + f"Current number of waters in GCMC volume at {_lam_sym} = {lambda_value:.5f} " + f"is {gcmc_sampler.num_waters()}" + ) + finally: + gcmc_sampler.pop() if is_final_block: _logger.success( @@ -928,7 +948,11 @@ def generate_lam_vals(lambda_base, increment=0.001): _logger.info( f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" ) - gcmc_sampler.move(dynamics.context()) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() # Update the runtime. runtime += self._config.energy_frequency From 8d039b80618659a1b27f571f7b4190cb44e3938f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Mar 2026 16:06:39 +0000 Subject: [PATCH 115/212] Normalise lambda and lambda_values as :.5f strings. --- src/somd2/runner/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 1c650c94..b3510542 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1763,14 +1763,14 @@ def _checkpoint( "attrs": df.attrs, "somd2 version": __version__, "sire version": f"{_sire_version}+{_sire_revisionid}", - "lambda": str(lam), + "lambda": f"{lam:.5f}", "speed": speed, "temperature": str(self._config.temperature.value()), } # Add the lambda gradient if available. if lambda_grad is not None: - metadata["lambda_grad"] = lambda_grad + metadata["lambda_grad"] = [f"{v:.5f}" for v in lambda_grad] if is_final_block: # Save the end-state GCMC topologies for trajectory analysis and visualisation. From 222c0b78b1dd9f390cb3bfbca8615b06ea714b8e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 26 Mar 2026 11:17:27 +0000 Subject: [PATCH 116/212] Initialise manager, lock, and queue lazily. --- src/somd2/runner/_runner.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 14ad6d99..5fa0fc81 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -40,11 +40,20 @@ class Runner(_RunnerBase): Standard simulation runner class. (Uncoupled simulations.) """ - from multiprocessing import Manager + _manager = None - _manager = Manager() - _lock = _manager.Lock() - _queue = _manager.Queue() + @classmethod + def _init_manager(cls): + """ + Initialise the shared-memory Manager the first time a Runner is + constructed in the parent process. Deferred from class definition time + so that importing this module does not fork a manager process before + OpenMM threads have been started. + """ + if cls._manager is None: + from multiprocessing import Manager + + cls._manager = Manager() def __init__(self, system, config): """ @@ -73,6 +82,16 @@ def __init__(self, system, config): # Call the base class constructor. super().__init__(system, config) + # Initialise the shared-memory manager lazily so that importing this + # module does not fork a manager process before OpenMM threads exist. + Runner._init_manager() + + # Create Lock and Queue as instance attributes so that they are + # pickled as manager proxies and shared correctly across all spawned + # worker processes, preventing race conditions on the GPU pool. + self._lock = Runner._manager.Lock() + self._queue = Runner._manager.Queue() + # Store the array of lambda values for energy sampling. if self._config.lambda_energy is not None: self._lambda_energy = self._config.lambda_energy.copy() From de402f5a7fe065e7b177df743468f1621ef58e27 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 27 Mar 2026 09:31:09 +0000 Subject: [PATCH 117/212] Perform GCMC before dynamics so energies correspond to correct state. --- src/somd2/runner/_repex.py | 35 ++++++++-------- src/somd2/runner/_runner.py | 80 +++++++++++++++++++++---------------- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 523e7d75..92f999b0 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1242,6 +1242,22 @@ def _run_block( # Get the dynamics object (and GCMC sampler). dynamics, gcmc_sampler = self._dynamics_cache.get(index) + # Perform the GCMC move before dynamics so that the energies + # computed during dynamics are consistent with the state used + # for replica exchange mixing. + if gcmc_sampler is not None and is_gcmc: + gcmc_sampler.push() + try: + _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() + + # Write ghost residues immediately after the GCMC move so the + # ghost state and frame (saved during dynamics) are consistent. + if write_gcmc_ghosts: + gcmc_sampler.write_ghost_residues() + _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") # Draw new velocities from the Maxwell-Boltzmann distribution. @@ -1272,27 +1288,10 @@ def _run_block( ) if gcmc_sampler is not None: - # Write ghost residues before the GCMC move so the ghost state - # is consistent with the saved frame (which is also captured - # before the GCMC move). - if write_gcmc_ghosts: - gcmc_sampler.write_ghost_residues() - - if is_gcmc: - # Push the PyCUDA context on top of the stack. - gcmc_sampler.push() - try: - # Perform the GCMC move. - _logger.info(f"Performing GCMC move at {_lam_sym} = {lam:.5f}") - gcmc_sampler.move(dynamics.context()) - finally: - # Remove the PyCUDA context from the stack. - gcmc_sampler.pop() - # Save the GCMC state. self._dynamics_cache.save_gcmc_state(index) - # Save the OpenMM state after any GCMC move so the context is consistent. + # Save the OpenMM state. self._dynamics_cache.save_openmm_state(index) # Get the energy at each lambda value. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 5fa0fc81..32905b76 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -722,6 +722,29 @@ def generate_lam_vals(lambda_base, increment=0.001): # Loop until we reach the runtime. while runtime < checkpoint_frequency: + # Perform a GCMC move before dynamics so the ghost + # state is consistent with the energies computed + # during dynamics. + _logger.info( + f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + ) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() + + # Write ghost residues immediately after the GCMC + # move if a frame will be saved in the upcoming + # dynamics block. + if ( + save_frames + and runtime + self._config.energy_frequency + >= next_frame + ): + gcmc_sampler.write_ghost_residues() + next_frame += self._config.frame_frequency + # Run the dynamics in blocks of the GCMC frequency. dynamics.run( self._config.gcmc_frequency, @@ -748,23 +771,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Update the runtime. runtime += self._config.energy_frequency - # If a frame is saved, write the ghost residue indices - # before the GCMC move so the ghost state is consistent - # with the saved frame. - if save_frames and runtime >= next_frame: - gcmc_sampler.write_ghost_residues() - next_frame += self._config.frame_frequency - - # Perform a GCMC move. - _logger.info( - f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" - ) - gcmc_sampler.push() - try: - gcmc_sampler.move(dynamics.context()) - finally: - gcmc_sampler.pop() - else: dynamics.run( checkpoint_frequency, @@ -948,7 +954,29 @@ def generate_lam_vals(lambda_base, increment=0.001): next_frame = self._config.frame_frequency # Loop until we reach the runtime. - while runtime <= time: + while runtime < time: + # Perform a GCMC move before dynamics so the ghost + # state is consistent with the energies computed + # during dynamics. + _logger.info( + f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + ) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() + + # Write ghost residues immediately after the GCMC + # move if a frame will be saved in the upcoming + # dynamics block. + if ( + save_frames + and runtime + self._config.energy_frequency >= next_frame + ): + gcmc_sampler.write_ghost_residues() + next_frame += self._config.frame_frequency + # Run the dynamics in blocks of the GCMC frequency. dynamics.run( self._config.gcmc_frequency, @@ -963,24 +991,8 @@ def generate_lam_vals(lambda_base, increment=0.001): save_crash_report=self._config.save_crash_report, ) - # Perform a GCMC move. - _logger.info( - f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" - ) - gcmc_sampler.push() - try: - gcmc_sampler.move(dynamics.context()) - finally: - gcmc_sampler.pop() - # Update the runtime. runtime += self._config.energy_frequency - - # If a frame is saved, then we need to save current indices - # of the ghost water residues. - if save_frames and runtime >= next_frame: - gcmc_sampler.write_ghost_residues() - next_frame += self._config.frame_frequency else: dynamics.run( time, From 11e20dca702ebfee070d5d135fce74e06142247d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 27 Mar 2026 14:28:16 +0000 Subject: [PATCH 118/212] Expose softcore_form option. --- src/somd2/config/_config.py | 23 +++++++++++++++++++++++ src/somd2/runner/_base.py | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 840f87f2..217cd4e8 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -72,6 +72,7 @@ class Config: "reverse_ring_break_morph", ], "log_level": [level.lower() for level in _logger._core.levels], + "softcore_form": ["zacharias", "taylor"], } # A dictionary of nargs for the various options. @@ -149,6 +150,7 @@ def __init__( gcmc_tolerance=0.0, rest2_scale=1.0, rest2_selection=None, + softcore_form="zacharias", output_directory="output", restart=False, use_backup=False, @@ -432,6 +434,10 @@ def __init__( those atoms will be considered as part of the REST2 region. This allows REST2 to be applied to protein mutations. + softcore_form: str + The soft-core potential form to use for alchemical interactions. This can be + either "zacharias" or "taylor". The default is "zacharias". + output_directory: str Path to a directory to store output files. @@ -560,6 +566,7 @@ def __init__( self.rest2_selection = rest2_selection self.restart = restart self.use_backup = use_backup + self.softcore_form = softcore_form self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file self.save_crash_report = save_crash_report @@ -2181,6 +2188,22 @@ def restart(self, restart): raise ValueError("'restart' must be of type 'bool'") self._restart = restart + @property + def softcore_form(self): + return self._softcore_form + + @softcore_form.setter + def softcore_form(self, softcore_form): + if not isinstance(softcore_form, str): + raise TypeError("'softcore_form' must be of type 'str'") + softcore_form = softcore_form.lower().replace(" ", "") + if softcore_form not in self._choices["softcore_form"]: + raise ValueError( + f"'softcore_form' not recognised. Valid forms are: {', '.join(self._choices['softcore_form'])}" + ) + else: + self._softcore_form = softcore_form + @property def use_backup(self): return self._use_backup diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index b3510542..5fd07998 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -206,6 +206,10 @@ def __init__(self, system, config): self._config.fix_perturbable_zero_sigmas ) + # If specified, use the Taylor soft-core form. + if self._config.softcore_form == "taylor": + self._config._extra_args["use_taylor_softening"] = True + # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: from .._utils._somd1 import make_compatible From 038b550b212a73e169d0a96eddc6b21628f5eb1a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sun, 29 Mar 2026 10:14:56 +0100 Subject: [PATCH 119/212] Expose Taylor softcore options and pass through to Loch. --- src/somd2/config/_config.py | 22 ++++++++++++++++++++++ src/somd2/runner/_base.py | 3 +++ 2 files changed, 25 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 217cd4e8..7082c9bb 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -151,6 +151,7 @@ def __init__( rest2_scale=1.0, rest2_selection=None, softcore_form="zacharias", + taylor_power=1, output_directory="output", restart=False, use_backup=False, @@ -438,6 +439,11 @@ def __init__( The soft-core potential form to use for alchemical interactions. This can be either "zacharias" or "taylor". The default is "zacharias". + taylor_power: int + The power to use for the alpha term in the Taylor soft-core LJ expression, + i.e. sig6 = sigma^6 / (alpha^m * sigma^6 + r^6). Must be between 0 and 4. + The default is 1. Only used when softcore_form is "taylor". + output_directory: str Path to a directory to store output files. @@ -567,6 +573,7 @@ def __init__( self.restart = restart self.use_backup = use_backup self.softcore_form = softcore_form + self.taylor_power = taylor_power self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file self.save_crash_report = save_crash_report @@ -2204,6 +2211,21 @@ def softcore_form(self, softcore_form): else: self._softcore_form = softcore_form + @property + def taylor_power(self): + return self._taylor_power + + @taylor_power.setter + def taylor_power(self, taylor_power): + if not isinstance(taylor_power, int): + try: + taylor_power = int(taylor_power) + except Exception: + raise ValueError("'taylor_power' must be of type 'int'") + if not 0 <= taylor_power <= 4: + raise ValueError("'taylor_power' must be between 0 and 4") + self._taylor_power = taylor_power + @property def use_backup(self): return self._use_backup diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 5fd07998..6982c430 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -209,6 +209,7 @@ def __init__(self, system, config): # If specified, use the Taylor soft-core form. if self._config.softcore_form == "taylor": self._config._extra_args["use_taylor_softening"] = True + self._config._extra_args["taylor_power"] = self._config.taylor_power # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: @@ -769,6 +770,8 @@ def __init__(self, system, config): "radius": str(self._config.gcmc_radius), "reference": self._config.gcmc_selection, "restart": self._is_restart, + "softcore_form": self._config.softcore_form, + "taylor_power": self._config.taylor_power, "standard_volume": str(self._config.gcmc_standard_volume), "tolerance": self._config.gcmc_tolerance, } From 8f7716ec628896009c44f3477d033c31245a0c0c Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Mon, 30 Mar 2026 15:51:05 +0100 Subject: [PATCH 120/212] Replace original ring breaking schedule with DMR approach --- src/somd2/config/_config.py | 42 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 7082c9bb..6ab8f549 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1024,15 +1024,17 @@ def lambda_schedule(self, lambda_schedule): self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) self._lambda_schedule_name = "charge_scaled_morph" elif lambda_schedule == "ring_break_morph": - self._lambda_schedule = _LambdaSchedule.standard_morph() self._lambda_schedule.prepend_stage( "restraints_off", self._lambda_schedule.initial() ) self._lambda_schedule.set_equation( stage="restraints_off", - lever="restraint", + lever="morse_soft", equation=1 - self._lambda_schedule.lam(), ) + self._lambda_schedule.set_equation( + stage="restraints_off", lever="morse_hard", equation=0 + ) self._lambda_schedule.set_equation( stage="restraints_off", lever="bond_k", @@ -1071,13 +1073,17 @@ def lambda_schedule(self, lambda_schedule): * self._lambda_schedule.initial() + self._lambda_schedule.lam() * self._lambda_schedule.final(), ) - self._lambda_schedule.prepend_stage( "potential_swap", self._lambda_schedule.initial() ) self._lambda_schedule.set_equation( stage="potential_swap", - lever="restraint", + lever="morse_hard", + equation=1 - self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="morse_soft", equation=0 + self._lambda_schedule.lam(), ) self._lambda_schedule.set_equation( @@ -1114,11 +1120,12 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.initial(), ) - self._lambda_schedule.set_equation( - stage="morph", lever="restraint", equation=0 + stage="morph", lever="morse_hard", equation=0 + ) + self._lambda_schedule.set_equation( + stage="morph", lever="morse_soft", equation=0 ) - self._lambda_schedule.set_equation( stage="morph", lever="bond_k", @@ -1149,13 +1156,13 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.final(), ) - self._lambda_schedule_name = "ring_break_morph" elif lambda_schedule == "reverse_ring_break_morph": - self._lambda_schedule = _LambdaSchedule.standard_morph() self._lambda_schedule.set_equation( - stage="morph", lever="restraint", equation=0 + stage="morph", lever="morse_hard", equation=0 + ) + self._lambda_schedule.set_equation( + stage="morph", lever="morse_soft", equation=0 ) - self._lambda_schedule.set_equation( stage="morph", lever="bond_k", @@ -1192,9 +1199,12 @@ def lambda_schedule(self, lambda_schedule): ) self._lambda_schedule.set_equation( stage="bonded_perturb", - lever="restraint", + lever="morse_soft", equation=0 + self._lambda_schedule.lam(), ) + self._lambda_schedule.set_equation( + stage="bonded_perturb", lever="morse_hard", equation=0 + ) self._lambda_schedule.set_equation( stage="bonded_perturb", lever="bond_k", @@ -1239,7 +1249,12 @@ def lambda_schedule(self, lambda_schedule): ) self._lambda_schedule.set_equation( stage="potential_swap", - lever="restraint", + lever="morse_hard", + equation=0 + self._lambda_schedule.lam(), + ) + self._lambda_schedule.set_equation( + stage="potential_swap", + lever="morse_soft", equation=1 - self._lambda_schedule.lam(), ) self._lambda_schedule.set_equation( @@ -1276,7 +1291,6 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.final(), ) - self._lambda_schedule_name = "reverse_ring_break_morph" else: try: self._lambda_schedule = self._from_hex(lambda_schedule) From 169bfd82b82725bb9f5ea22c33e2ff3a4cdbb2c7 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Mon, 30 Mar 2026 15:57:55 +0100 Subject: [PATCH 121/212] Fix missing fields --- src/somd2/config/_config.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 6ab8f549..b5cc0c52 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1024,6 +1024,7 @@ def lambda_schedule(self, lambda_schedule): self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) self._lambda_schedule_name = "charge_scaled_morph" elif lambda_schedule == "ring_break_morph": + self._lambda_schedule = _LambdaSchedule.standard_morph() self._lambda_schedule.prepend_stage( "restraints_off", self._lambda_schedule.initial() ) @@ -1032,9 +1033,6 @@ def lambda_schedule(self, lambda_schedule): lever="morse_soft", equation=1 - self._lambda_schedule.lam(), ) - self._lambda_schedule.set_equation( - stage="restraints_off", lever="morse_hard", equation=0 - ) self._lambda_schedule.set_equation( stage="restraints_off", lever="bond_k", @@ -1073,6 +1071,7 @@ def lambda_schedule(self, lambda_schedule): * self._lambda_schedule.initial() + self._lambda_schedule.lam() * self._lambda_schedule.final(), ) + self._lambda_schedule.prepend_stage( "potential_swap", self._lambda_schedule.initial() ) @@ -1120,6 +1119,7 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.initial(), ) + self._lambda_schedule.set_equation( stage="morph", lever="morse_hard", equation=0 ) @@ -1157,6 +1157,7 @@ def lambda_schedule(self, lambda_schedule): equation=self._lambda_schedule.final(), ) elif lambda_schedule == "reverse_ring_break_morph": + self._lambda_schedule = _LambdaSchedule.standard_morph() self._lambda_schedule.set_equation( stage="morph", lever="morse_hard", equation=0 ) @@ -1291,6 +1292,7 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.final(), ) + else: try: self._lambda_schedule = self._from_hex(lambda_schedule) From 8877f23ccea9b9f765af4066210874ebc7dacc25 Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Mon, 30 Mar 2026 15:59:28 +0100 Subject: [PATCH 122/212] Re-add missing schedule names --- src/somd2/config/_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index b5cc0c52..66261681 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1156,6 +1156,7 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.final(), ) + self._lambda_schedule_name = "ring_break_morph" elif lambda_schedule == "reverse_ring_break_morph": self._lambda_schedule = _LambdaSchedule.standard_morph() self._lambda_schedule.set_equation( @@ -1292,7 +1293,7 @@ def lambda_schedule(self, lambda_schedule): lever="torsion_phase", equation=self._lambda_schedule.final(), ) - + self._lambda_schedule_name = "reverse_ring_break_morph" else: try: self._lambda_schedule = self._from_hex(lambda_schedule) From 7f9984e922847bcbb1d228d51fcb4933d030a665 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 30 Mar 2026 16:06:33 +0100 Subject: [PATCH 123/212] Improve memory footprint error message. [ci skip] --- src/somd2/runner/_repex.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 92f999b0..83afa649 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -378,9 +378,13 @@ def _create_dynamics( # If this exceeds the total memory, raise an error. if est_total > total_mem: + baseline = info["before"] + replica_cost = first_cost + marginal_cost * (num_contexts - 1) msg = ( f"Not enough memory on device {device} for all assigned replicas. " - f"Estimated memory usage: {est_total / (1024**3):.2f} GB, " + f"Baseline usage before simulation: {baseline / (1024**3):.2f} GB " + f"Estimated replica memory: {replica_cost / (1024**3):.2f} GB, " + f"Total estimated: {est_total / (1024**3):.2f} GB, " f"Available memory: {total_mem / (1024**3):.2f} GB." ) _logger.error(msg) From 85642be997cea857633f21deca47bf9139e0845f Mon Sep 17 00:00:00 2001 From: Audrius Kalpokas Date: Tue, 31 Mar 2026 09:46:07 +0100 Subject: [PATCH 124/212] Fix missing restraints lever equation from ring_break_morph schedule --- src/somd2/config/_config.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 66261681..77333718 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1033,6 +1033,9 @@ def lambda_schedule(self, lambda_schedule): lever="morse_soft", equation=1 - self._lambda_schedule.lam(), ) + self._lambda_schedule.set_equation( + stage="restraints_off", lever="morse_hard", equation=0 + ) self._lambda_schedule.set_equation( stage="restraints_off", lever="bond_k", From f09fb61e5443353648641dc4c2783e4d8f580745 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 31 Mar 2026 12:31:14 +0100 Subject: [PATCH 125/212] Add support for terminal flip MC. --- src/somd2/_utils/__init__.py | 2 + src/somd2/config/_config.py | 69 ++++ src/somd2/runner/_base.py | 49 +++ src/somd2/runner/_repex.py | 64 +++- src/somd2/runner/_runner.py | 142 ++++++++- src/somd2/runner/_terminal_flip.py | 494 +++++++++++++++++++++++++++++ tests/conftest.py | 35 ++ tests/runner/test_terminal_flip.py | 406 ++++++++++++++++++++++++ 8 files changed, 1257 insertions(+), 4 deletions(-) create mode 100644 src/somd2/runner/_terminal_flip.py create mode 100644 tests/runner/test_terminal_flip.py diff --git a/src/somd2/_utils/__init__.py b/src/somd2/_utils/__init__.py index ec25a367..74897957 100644 --- a/src/somd2/_utils/__init__.py +++ b/src/somd2/_utils/__init__.py @@ -23,8 +23,10 @@ if _platform.system() == "Windows": _lam_sym = "lambda" + _delta_sym = "delta" else: _lam_sym = "λ" + _delta_sym = "ΔE" del _platform diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 77333718..ba1dba26 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -139,6 +139,8 @@ def __init__( replica_exchange=False, randomise_velocities=False, perturbed_system=None, + terminal_flip_frequency=None, + terminal_flip_angle=None, gcmc=False, gcmc_frequency=None, gcmc_selection=None, @@ -377,6 +379,17 @@ def __init__( end state (lambda = 1). This will be used as the starting conformation all lambda windows > 0.5 when performing a replica exchange simulation. + terminal_flip_frequency: str + Frequency at which to attempt terminal ring flip Monte Carlo moves. If None + (the default), no terminal flip moves will be performed. When set, terminal + ring groups in perturbable molecules are detected automatically using Sire's + native connectivity. This must be a multiple of 'energy_frequency'. + + terminal_flip_angle: str + Override the flip angle used for all terminal ring groups, e.g. + ``"180 degrees"``. If None (the default), the angle is determined + automatically for each group from its geometry. + gcmc: bool Whether to perform Grand Canonical Monte Carlo (GCMC) water insertions/deletions. @@ -559,6 +572,8 @@ def __init__( self.replica_exchange = replica_exchange self.randomise_velocities = randomise_velocities self.perturbed_system = perturbed_system + self.terminal_flip_frequency = terminal_flip_frequency + self.terminal_flip_angle = terminal_flip_angle self.gcmc = gcmc self.gcmc_frequency = gcmc_frequency self.gcmc_selection = gcmc_selection @@ -1994,6 +2009,60 @@ def perturbed_system(self, perturbed_system): self._perturbed_system = None self._perturbed_system_file = None + @property + def terminal_flip_frequency(self): + return self._terminal_flip_frequency + + @terminal_flip_frequency.setter + def terminal_flip_frequency(self, terminal_flip_frequency): + if terminal_flip_frequency is not None: + if not isinstance(terminal_flip_frequency, str): + raise TypeError("'terminal_flip_frequency' must be of type 'str'") + + from sire.units import picosecond + + try: + t = _sr.u(terminal_flip_frequency) + except Exception: + raise ValueError( + f"Unable to parse 'terminal_flip_frequency' as a Sire GeneralUnit: " + f"{terminal_flip_frequency}" + ) + + if t.value() != 0 and not t.has_same_units(picosecond): + raise ValueError("'terminal_flip_frequency' units are invalid.") + + self._terminal_flip_frequency = t + else: + self._terminal_flip_frequency = None + + @property + def terminal_flip_angle(self): + return self._terminal_flip_angle + + @terminal_flip_angle.setter + def terminal_flip_angle(self, terminal_flip_angle): + if terminal_flip_angle is not None: + if not isinstance(terminal_flip_angle, str): + raise TypeError("'terminal_flip_angle' must be of type 'str'") + + from sire.units import degrees + + try: + a = _sr.u(terminal_flip_angle) + except Exception: + raise ValueError( + f"Unable to parse 'terminal_flip_angle' as a Sire GeneralUnit: " + f"{terminal_flip_angle}" + ) + + if not a.has_same_units(degrees): + raise ValueError("'terminal_flip_angle' units are invalid.") + + self._terminal_flip_angle = a + else: + self._terminal_flip_angle = None + @property def gcmc(self): return self._gcmc diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 6982c430..18e67407 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -664,6 +664,55 @@ def __init__(self, system, config): # Store the excess chemcical potential value. self._mu_ex = self._config.gcmc_excess_chemical_potential.value() + # Terminal flip specific validation and setup. + if self._config.terminal_flip_frequency is not None: + from math import isclose + + # Make sure the terminal flip frequency is a multiple of the + # energy frequency. + ratio = ( + self._config.terminal_flip_frequency / self._config.energy_frequency + ).value() + + if not isclose(ratio, round(ratio), abs_tol=1e-4): + msg = "'terminal_flip_frequency' must be a multiple of 'energy_frequency'." + _logger.error(msg) + raise ValueError(msg) + + # Auto-detect terminal ring groups using Sire connectivity. + from ._terminal_flip import detect_terminal_groups + + if isinstance(self._system, list): + mols = self._system[0] + else: + mols = self._system + + flip_angle = ( + self._config.terminal_flip_angle.to("degrees").value() + if self._config.terminal_flip_angle is not None + else None + ) + self._terminal_groups = detect_terminal_groups(mols, flip_angle=flip_angle) + + if not self._terminal_groups: + _logger.warning( + "No terminal ring groups detected. Terminal flip moves will not " + "be performed." + ) + else: + _logger.info( + f"Detected {len(self._terminal_groups)} terminal ring group(s) " + f"for terminal flip MC." + ) + for i, (angle, indices) in enumerate(self._terminal_groups): + _logger.info( + f" Group {i}: flip angle = {angle}°, " + f"anchor = {indices[0]}, pivot = {indices[1]}, " + f"{len(indices) - 2} mobile atom(s)" + ) + else: + self._terminal_groups = [] + # Store the initial system time. if isinstance(self._system, list): self._initial_time = [] diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 83afa649..6e60b34e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -854,6 +854,21 @@ def __init__(self, system, config): else: self._start_block = 0 + # Create the terminal flip sampler (if terminal groups were detected). + if self._terminal_groups: + from ._terminal_flip import TerminalFlipSampler + + self._terminal_flip_sampler = TerminalFlipSampler( + self._terminal_groups, + float(self._config.temperature.value()), + ) + _logger.info( + f"Terminal flip sampler ready for replica exchange " + f"({len(self._terminal_groups)} group(s))" + ) + else: + self._terminal_flip_sampler = None + from threading import Lock # Create a lock to guard the dynamics cache. @@ -1001,6 +1016,23 @@ def run(self): else: cycles_per_gcmc = cycles + 1 + # Work out the number of cycles per terminal flip move. + if ( + self._config.terminal_flip_frequency is not None + and self._terminal_flip_sampler is not None + ): + cycles_per_flip = max( + 1, + round( + ( + self._config.terminal_flip_frequency + / self._config.energy_frequency + ).value() + ), + ) + else: + cycles_per_flip = cycles + 1 + # Initialise the threshold for the next checkpoint cycle. This is a float # to handle non-integer ratios between the checkpoint and energy frequencies. next_checkpoint = cycles_per_checkpoint @@ -1028,6 +1060,9 @@ def run(self): # Whether to perform a GCMC move before the dynamics block. is_gcmc = (i + 1) % cycles_per_gcmc == 0 + # Whether to perform a terminal flip move before the dynamics block. + is_terminal_flip = (i + 1) % cycles_per_flip == 0 + # Whether a frame is saved at the end of the cycle. write_gcmc_ghosts = (i + 1) % cycles_per_frame == 0 @@ -1043,6 +1078,7 @@ def run(self): repeat(self._lambda_values), repeat(is_gcmc), repeat(write_gcmc_ghosts), + repeat(is_terminal_flip), ): if not result: _logger.error( @@ -1128,6 +1164,15 @@ def run(self): ) self._dynamics_cache.mix_states() + # Log terminal flip acceptance rate at each cycle. + if self._terminal_flip_sampler is not None: + _logger.info( + f"Terminal flip acceptance rate: " + f"{self._terminal_flip_sampler.acceptance_rate:.3f} " + f"({self._terminal_flip_sampler.num_accepted}/" + f"{self._terminal_flip_sampler.num_attempted})" + ) + # This is a checkpoint cycle. if is_checkpoint: # Update the block number. @@ -1202,6 +1247,7 @@ def _run_block( lambdas, is_gcmc=False, write_gcmc_ghosts=False, + is_terminal_flip=False, ): """ Run a dynamics block for a given replica. @@ -1225,6 +1271,10 @@ def _run_block( Whether to write the indices of GCMC ghost residues to file. + is_terminal_flip: bool + Whether a terminal flip MC move should be performed before the + dynamics block. + Returns ------- @@ -1262,6 +1312,11 @@ def _run_block( if write_gcmc_ghosts: gcmc_sampler.write_ghost_residues() + # Perform a terminal flip move before dynamics if requested. + if self._terminal_flip_sampler is not None and is_terminal_flip: + _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") + self._terminal_flip_sampler.move(dynamics.context()) + _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") # Draw new velocities from the Maxwell-Boltzmann distribution. @@ -1701,9 +1756,16 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Push the PyCUDA context on top of the stack. gcmc_sampler.push() try: + n_moves = gcmc_sampler._num_moves + acc_str = ( + f", acceptance rate = {gcmc_sampler.move_acceptance_probability():.3f}" + f" (ins = {gcmc_sampler.num_insertions()}, del = {gcmc_sampler.num_deletions()})" + if n_moves > 0 + else "" + ) _logger.info( f"Current number of waters in GCMC volume at {_lam_sym} = {lam:.5f} " - f"is {gcmc_sampler.num_waters()}" + f"is {gcmc_sampler.num_waters()}{acc_str}" ) finally: # Remove the PyCUDA context from the stack. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 32905b76..314e36fb 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -465,6 +465,31 @@ def generate_lam_vals(lambda_base, increment=0.001): else: gcmc_sampler = None + # Create the terminal flip sampler (if terminal groups were detected). + if self._terminal_groups: + from ._terminal_flip import TerminalFlipSampler + + terminal_flip_sampler = TerminalFlipSampler( + self._terminal_groups, + float(self._config.temperature.value()), + ) + flip_every = max( + 1, + round( + ( + self._config.terminal_flip_frequency + / self._config.energy_frequency + ).value() + ), + ) + _logger.info( + f"Terminal flip sampler ready at {_lam_sym} = {lambda_value:.5f} " + f"(every {flip_every} energy block(s))" + ) + else: + terminal_flip_sampler = None + flip_every = None + # Minimisation. if self._config.minimise: constraint = self._config.constraint @@ -719,6 +744,7 @@ def generate_lam_vals(lambda_base, increment=0.001): runtime = _sr.u("0ps") save_frames = self._config.frame_frequency > 0 next_frame = self._config.frame_frequency + flip_counter = 0 # Loop until we reach the runtime. while runtime < checkpoint_frequency: @@ -734,6 +760,17 @@ def generate_lam_vals(lambda_base, increment=0.001): finally: gcmc_sampler.pop() + # Perform a terminal flip move at the specified frequency. + if ( + terminal_flip_sampler is not None + and flip_counter % flip_every == 0 + ): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + terminal_flip_sampler.move(dynamics.context()) + # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming # dynamics block. @@ -768,8 +805,41 @@ def generate_lam_vals(lambda_base, increment=0.001): ), ) - # Update the runtime. + # Update the runtime and flip counter. runtime += self._config.energy_frequency + flip_counter += 1 + + elif terminal_flip_sampler is not None: + # Terminal flip without GCMC: perform flip moves at the + # specified frequency then run the full dynamics block. + n_flips = max( + 1, + round( + ( + checkpoint_frequency + / self._config.terminal_flip_frequency + ).value() + ), + ) + for _ in range(n_flips): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + terminal_flip_sampler.move(dynamics.context()) + + dynamics.run( + checkpoint_frequency, + energy_frequency=self._config.energy_frequency, + frame_frequency=self._config.frame_frequency, + lambda_windows=lambda_array, + rest2_scale_factors=rest2_scale_factors, + save_velocities=self._config.save_velocities, + auto_fix_minimise=True, + num_energy_neighbours=num_energy_neighbours, + null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, + ) else: dynamics.run( @@ -859,13 +929,30 @@ def generate_lam_vals(lambda_base, increment=0.001): if gcmc_sampler is not None: gcmc_sampler.push() try: + n_moves = gcmc_sampler._num_moves + acc_str = ( + f", acceptance rate = {gcmc_sampler.move_acceptance_probability():.3f}" + f" (ins = {gcmc_sampler.num_insertions()}, del = {gcmc_sampler.num_deletions()})" + if n_moves > 0 + else "" + ) _logger.info( f"Current number of waters in GCMC volume at {_lam_sym} = {lambda_value:.5f} " - f"is {gcmc_sampler.num_waters()}" + f"is {gcmc_sampler.num_waters()}{acc_str}" ) finally: gcmc_sampler.pop() + # Log terminal flip acceptance rate. + if terminal_flip_sampler is not None: + _logger.info( + f"Terminal flip acceptance rate at " + f"{_lam_sym} = {lambda_value:.5f}: " + f"{terminal_flip_sampler.acceptance_rate:.3f} " + f"({terminal_flip_sampler.num_accepted}/" + f"{terminal_flip_sampler.num_attempted})" + ) + if is_final_block: _logger.success( f"{_lam_sym} = {lambda_value:.5f} complete, speed = {speed:.2f} ns day-1" @@ -880,6 +967,14 @@ def generate_lam_vals(lambda_base, increment=0.001): block += 1 block_start = _timer() try: + # Perform one terminal flip at the start of the remainder block. + if terminal_flip_sampler is not None: + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + terminal_flip_sampler.move(dynamics.context()) + dynamics.run( rem, energy_frequency=self._config.energy_frequency, @@ -952,6 +1047,7 @@ def generate_lam_vals(lambda_base, increment=0.001): runtime = _sr.u("0ps") save_frames = self._config.frame_frequency > 0 next_frame = self._config.frame_frequency + flip_counter = 0 # Loop until we reach the runtime. while runtime < time: @@ -967,6 +1063,17 @@ def generate_lam_vals(lambda_base, increment=0.001): finally: gcmc_sampler.pop() + # Perform a terminal flip move at the specified frequency. + if ( + terminal_flip_sampler is not None + and flip_counter % flip_every == 0 + ): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + terminal_flip_sampler.move(dynamics.context()) + # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming # dynamics block. @@ -991,8 +1098,37 @@ def generate_lam_vals(lambda_base, increment=0.001): save_crash_report=self._config.save_crash_report, ) - # Update the runtime. + # Update the runtime and flip counter. runtime += self._config.energy_frequency + flip_counter += 1 + + elif terminal_flip_sampler is not None: + # Terminal flip without GCMC: perform flip moves at the + # start then run the full dynamics block. + n_flips = max( + 1, + round((time / self._config.terminal_flip_frequency).value()), + ) + for _ in range(n_flips): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + terminal_flip_sampler.move(dynamics.context()) + + dynamics.run( + time, + energy_frequency=self._config.energy_frequency, + frame_frequency=self._config.frame_frequency, + lambda_windows=lambda_array, + rest2_scale_factors=rest2_scale_factors, + save_velocities=self._config.save_velocities, + auto_fix_minimise=True, + num_energy_neighbours=num_energy_neighbours, + null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, + ) + else: dynamics.run( time, diff --git a/src/somd2/runner/_terminal_flip.py b/src/somd2/runner/_terminal_flip.py new file mode 100644 index 00000000..67901c69 --- /dev/null +++ b/src/somd2/runner/_terminal_flip.py @@ -0,0 +1,494 @@ +###################################################################### +# SOMD2: GPU accelerated alchemical free-energy engine. +# +# Copyright: 2023-2026 +# +# Authors: The OpenBioSim Team +# +# SOMD2 is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SOMD2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SOMD2. If not, see . +##################################################################### + +# Adapted from the terminal ring flip MC implemenation in GrandFEP: +# https://github.com/deGrootLab/GrandFEP +# (Released under the MIT License.) + +__all__ = ["TerminalFlipSampler", "detect_terminal_groups"] + +import numpy as _np + +import sire.legacy.Mol as _Mol + +from somd2 import _logger +from somd2._utils import _delta_sym + + +def _auto_flip_angle(mol, anchor_idx, pivot_idx, ring_neighbor_idxs): + """ + Compute the flip angle for a terminal group from the molecular geometry. + + The angle is measured between the two ring neighbours of the pivot, + projected onto the plane perpendicular to the rotation axis (anchor → + pivot). For a planar C₂-symmetric ring this is 180°; for higher-symmetry + rings it will be smaller. + + Parameters + ---------- + + mol : sire.legacy.Mol.Molecule + The perturbable molecule. + + anchor_idx : int + Molecule-local index of the anchor atom. + + pivot_idx : int + Molecule-local index of the pivot atom. + + ring_neighbor_idxs : list of int + Molecule-local indices of the two ring atoms directly bonded to the + pivot (i.e. the ortho atoms for a benzene ring). + + Returns + ------- + + float + Raw angle in degrees between the projected ring-neighbour vectors. + """ + + def _coords(idx): + v = mol.atom(_Mol.AtomIdx(idx)).property("coordinates") + return _np.array([v.x().value(), v.y().value(), v.z().value()]) + + anchor = _coords(anchor_idx) + pivot = _coords(pivot_idx) + n1 = _coords(ring_neighbor_idxs[0]) + n2 = _coords(ring_neighbor_idxs[1]) + + # Unit rotation axis from anchor to pivot. + k = pivot - anchor + k = k / _np.linalg.norm(k) + + # Project each ring-neighbour displacement onto the plane perp to k. + v1 = n1 - pivot + v1_perp = v1 - _np.dot(v1, k) * k + + v2 = n2 - pivot + v2_perp = v2 - _np.dot(v2, k) * k + + # Angle between the two projected vectors. + cos_angle = _np.dot(v1_perp, v2_perp) / ( + _np.linalg.norm(v1_perp) * _np.linalg.norm(v2_perp) + ) + return float(_np.degrees(_np.arccos(_np.clip(cos_angle, -1.0, 1.0)))) + + +def _round_to_symmetry_angle(raw_angle, tolerance=10.0): + """ + Round ``raw_angle`` to the nearest crystallographic symmetry angle + (360°/n for n = 2 … 12). Returns ``None`` if the closest match is more + than ``tolerance`` degrees away, indicating that the ring has no useful + rotational symmetry. + + Parameters + ---------- + + raw_angle : float + Measured angle in degrees. + + tolerance : float + Maximum deviation (degrees) from a symmetry angle. Default is 10.0. + + Returns + ------- + + float or None + The nearest symmetry angle in degrees, or None if none is close enough. + """ + symmetry_angles = [360.0 / n for n in range(2, 13)] + diffs = [abs(raw_angle - a) for a in symmetry_angles] + min_idx = int(_np.argmin(diffs)) + if diffs[min_idx] > tolerance: + return None + return symmetry_angles[min_idx] + + +def detect_terminal_groups(system, flip_angle=None): + """ + Detect terminal ring groups in perturbable molecules using Sire's native + connectivity. + + A terminal ring group is identified by a bond between a non-ring atom + (the anchor) and a ring atom (the pivot), where the ring side of the bond + is connected to the rest of the molecule only through that single bond. + The mobile atoms are all atoms reachable from the pivot when the + anchor-pivot bond is cut. + + Parameters + ---------- + + system : sire system or molecule group + The Sire system containing perturbable molecules. + + flip_angle : float or None + The flip angle in degrees. If None (the default), the angle is + determined automatically from the geometry of each terminal group + by measuring the angle between the two ring neighbours of the pivot + projected perpendicular to the rotation axis, then rounding to the + nearest crystallographic symmetry angle (360°/n for n = 2..12). If + a float is given it overrides the geometric measurement for all + groups. + + Returns + ------- + + list of tuple + Each entry is (angle, [anchor_idx, pivot_idx, mobile_idx_0, ...]) + where all indices are absolute atom indices corresponding to OpenMM + atom ordering. + """ + terminal_groups = [] + + # Get the perturbable molecules. + try: + pert_mols = system.molecules("property is_perturbable") + except Exception: + _logger.warning( + "No perturbable molecules found. Terminal flip detection skipped." + ) + return terminal_groups + + # All atoms in the system, used to obtain absolute (OpenMM) atom indices. + all_atoms = system.atoms() + + for mol in pert_mols: + try: + connectivity = mol.property("connectivity") + except Exception: + _logger.warning(f"Molecule {mol} has no 'connectivity' property. Skipping.") + continue + + # Skip molecules whose connectivity changes between end states (e.g. + # ring-breaking/growing perturbations). Terminal groups detected from + # the lambda=0 connectivity would be invalid at lambda=1. + try: + conn0 = mol.property("connectivity0") + conn1 = mol.property("connectivity1") + if conn0 != conn1: + _logger.warning( + f"Molecule {mol} has different connectivity at lambda=0 and " + "lambda=1 (ring-breaking/growing perturbation). Skipping " + "terminal flip detection for this molecule." + ) + continue + except Exception: + pass + + num_atoms = mol.num_atoms() + seen_bonds = set() + + for i in range(num_atoms): + atom_i_idx = _Mol.AtomIdx(i) + + # Only consider non-ring atoms as anchors. + if connectivity.in_ring(atom_i_idx): + continue + + # Skip dead-end atoms (e.g. hydrogen bonded only to a ring + # carbon): a valid anchor must be part of a chain, so it needs + # at least two connections (one to the pivot, one elsewhere). + if len(connectivity.connections_to(atom_i_idx)) < 2: + continue + + for neighbor_idx in connectivity.connections_to(atom_i_idx): + j = neighbor_idx.value() + + # Only consider ring atoms as pivots. + if not connectivity.in_ring(_Mol.AtomIdx(j)): + continue + + # Avoid processing the same bond twice. + bond_key = (min(i, j), max(i, j)) + if bond_key in seen_bonds: + continue + seen_bonds.add(bond_key) + + # Collect mobile atoms via BFS from the pivot, not crossing + # the anchor. The pivot itself does not move (it is the + # rotation centre), so it is excluded from the mobile list. + mobile = _bfs_mobile(connectivity, i, j, num_atoms) + + if not mobile: + continue + + # Determine the flip angle for this group. + if flip_angle is not None: + group_angle = flip_angle + else: + # Find the two ring neighbours of the pivot (mobile atoms + # directly bonded to the pivot that are in the ring). + mobile_set = set(mobile) + pivot_idx_obj = _Mol.AtomIdx(j) + ring_neighbors = [ + n.value() + for n in connectivity.connections_to(pivot_idx_obj) + if n.value() in mobile_set + and connectivity.in_ring(_Mol.AtomIdx(n.value())) + ] + + if len(ring_neighbors) != 2: + _logger.warning( + f"Expected 2 ring neighbours for pivot atom {j}, " + f"found {len(ring_neighbors)}. Skipping group." + ) + continue + + raw = _auto_flip_angle(mol, i, j, ring_neighbors) + group_angle = _round_to_symmetry_angle(raw) + + if group_angle is None: + _logger.warning( + f"Terminal group at pivot atom {j} has no recognised " + f"rotational symmetry (raw angle = {raw:.1f}°). " + "Skipping group." + ) + continue + + _logger.debug( + f"Terminal group at pivot atom {j}: auto-detected flip " + f"angle = {group_angle}° (raw = {raw:.1f}°)." + ) + + # Map molecule-local indices to absolute system indices. + anchor_abs = all_atoms.find(mol.atom(atom_i_idx)) + pivot_abs = all_atoms.find(mol.atom(_Mol.AtomIdx(j))) + mobile_abs = [all_atoms.find(mol.atom(_Mol.AtomIdx(k))) for k in mobile] + + terminal_groups.append( + (group_angle, [anchor_abs, pivot_abs] + mobile_abs) + ) + + return terminal_groups + + +def _bfs_mobile(connectivity, anchor_idx, pivot_idx, num_atoms): + """ + Breadth-first search from ``pivot_idx``, not crossing ``anchor_idx``. + + Returns a sorted list of atom indices for atoms that will be rotated + (all reachable atoms except the anchor and the pivot itself, since the + pivot is the fixed rotation centre). + + Parameters + ---------- + + connectivity : sire.legacy.Mol.Connectivity + The molecular connectivity object. + + anchor_idx : int + Index of the anchor atom (defines the rotation axis start; fixed). + + pivot_idx : int + Index of the pivot atom (rotation centre; fixed). + + num_atoms : int + Total number of atoms in the molecule. + + Returns + ------- + + list of int + Sorted list of mobile atom indices. + """ + visited = {anchor_idx, pivot_idx} + queue = [pivot_idx] + + while queue: + current = queue.pop(0) + for neighbor in connectivity.connections_to(_Mol.AtomIdx(current)): + n = neighbor.value() + if n not in visited: + visited.add(n) + queue.append(n) + + # Exclude the anchor and pivot; only mobile atoms are rotated. + return sorted(visited - {anchor_idx, pivot_idx}) + + +class TerminalFlipSampler: + """ + Monte Carlo sampler for terminal ring flip moves. + + Each move selects one terminal group at random and attempts to rotate + its mobile atoms by ±``flip_angle`` degrees around the bond axis from + the anchor atom to the pivot atom. The move is accepted or rejected + according to the Metropolis criterion. + + The rotation uses Rodrigues' rotation formula:: + + v_rot = v·cos θ + (k × v)·sin θ + k·(k·v)·(1 − cos θ) + + where ``k`` is the unit vector along the rotation axis (anchor → pivot) + and ``v`` is the displacement of a mobile atom from the pivot. + + The sign of ``flip_angle`` is chosen uniformly at random so that the + proposal is symmetric, satisfying detailed balance for any angle. + """ + + def __init__(self, terminal_groups, temperature): + """ + Parameters + ---------- + + terminal_groups : list of tuple + Each entry is (angle, [anchor_idx, pivot_idx, mobile_idx_0, ...]) + where indices are absolute OpenMM atom indices. + + temperature : float + Simulation temperature in Kelvin. + """ + self._terminal_groups = terminal_groups + + # kBT in kJ/mol (R = 8.314462618e-3 kJ mol-1 K-1). + self._kBT = 8.314462618e-3 * temperature + + self._num_attempted = 0 + self._num_accepted = 0 + + def _rotate(self, context, group_idx, angle): + """ + Rotate the mobile atoms of a terminal group by ``angle`` degrees + around the anchor-to-pivot axis, updating the context in place. + + Parameters + ---------- + + context : openmm.Context + The active OpenMM context. + + group_idx : int + Index into ``self._terminal_groups`` selecting the group to rotate. + + angle : float + Rotation angle in degrees. + """ + from openmm import unit as _omm_unit + + _, atom_indices = self._terminal_groups[group_idx] + + positions = ( + context.getState(getPositions=True) + .getPositions(asNumpy=True) + .value_in_unit(_omm_unit.nanometer) + ) + + theta = _np.deg2rad(angle) + cos_t = _np.cos(theta) + sin_t = _np.sin(theta) + + # Anchor (axis start, fixed) and pivot (rotation centre, fixed). + p0 = positions[atom_indices[0]] + p1 = positions[atom_indices[1]] + + # Unit rotation axis from anchor to pivot. + axis = p1 - p0 + axis = axis / _np.linalg.norm(axis) + + # Rotate mobile atoms using Rodrigues' formula. + new_positions = positions.copy() + for atom_idx in atom_indices[2:]: + v = positions[atom_idx] - p1 + new_positions[atom_idx] = ( + p1 + + v * cos_t + + _np.cross(axis, v) * sin_t + + axis * _np.dot(axis, v) * (1.0 - cos_t) + ) + + context.setPositions(new_positions * _omm_unit.nanometer) + + def move(self, context): + """ + Attempt one terminal flip Monte Carlo move. + + A terminal group is chosen at random. The mobile atoms are rotated + by ±``flip_angle`` around the anchor-to-pivot axis. The move is + accepted with Metropolis probability ``min(1, exp(-ΔE / kBT))``. + + Parameters + ---------- + + context : openmm.Context + The active OpenMM context. + """ + from openmm import unit as _omm_unit + + if not self._terminal_groups: + return + + self._num_attempted += 1 + + # Randomly select one terminal group. + group_idx = _np.random.randint(len(self._terminal_groups)) + angle, _ = self._terminal_groups[group_idx] + + # Retrieve current positions and energy before the move. + state = context.getState(getPositions=True, getEnergy=True) + old_positions = state.getPositions(asNumpy=True).value_in_unit( + _omm_unit.nanometer + ) + e_old = state.getPotentialEnergy().value_in_unit(_omm_unit.kilojoule_per_mole) + + # Random sign gives a symmetric proposal (detailed balance). + signed_angle = float(_np.random.choice([-1, 1])) * angle + self._rotate(context, group_idx, signed_angle) + + # Evaluate the energy of the proposed configuration. + e_new = ( + context.getState(getEnergy=True) + .getPotentialEnergy() + .value_in_unit(_omm_unit.kilojoule_per_mole) + ) + + # Metropolis acceptance criterion. + delta_e = (e_new - e_old) / self._kBT + if delta_e <= 0.0 or _np.random.random() < _np.exp(-delta_e): + self._num_accepted += 1 + _logger.debug( + f"Terminal flip accepted (group {group_idx}, " + f"{_delta_sym} = {e_new - e_old:.2f} kJ/mol, " + f"acc = {min(1.0, _np.exp(-delta_e)):.3f})" + ) + else: + context.setPositions(old_positions * _omm_unit.nanometer) + _logger.debug( + f"Terminal flip rejected (group {group_idx}, " + f"{_delta_sym} = {e_new - e_old:.2f} kJ/mol, " + f"acc = {_np.exp(-delta_e):.3f})" + ) + + @property + def num_attempted(self): + """Total number of terminal flip moves attempted.""" + return self._num_attempted + + @property + def num_accepted(self): + """Total number of terminal flip moves accepted.""" + return self._num_accepted + + @property + def acceptance_rate(self): + """Fraction of attempted moves that were accepted.""" + if self._num_attempted == 0: + return 0.0 + return self._num_accepted / self._num_attempted diff --git a/tests/conftest.py b/tests/conftest.py index 02dbb2a8..de64927d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,45 @@ import os + import pytest import sire as sr has_cuda = True if "CUDA_VISIBLE_DEVICES" in os.environ else False +@pytest.fixture(scope="session") +def diphenylethane_mols(): + """ + Load a merged perturbable system built from 1,2-diphenylethane (reference, + lambda = 0) and 1,2-diphenylethanol (perturbed, lambda = 1). + + SMILES: + reference : c1ccccc1CCc1ccccc1 + perturbed : OC(Cc1ccccc1)c1ccccc1 + + Both phenyl rings are terminal, so two terminal ring groups should be + detected. + """ + mols = sr.load_test_files("12diphenylethane_12diphenylethanol.s3") + return sr.morph.link_to_reference(mols) + + +@pytest.fixture(scope="session") +def phenethyl_mols(): + """ + Load a merged perturbable system built from phenethylamine (reference, + lambda = 0) and 2-phenylethanol (perturbed, lambda = 1). + + SMILES: + reference : NCCc1ccccc1 + perturbed : OCCc1ccccc1 + + The phenyl ring is terminal — attached to the aliphatic chain by a single + exocyclic bond — making it the only detectable terminal ring group. + """ + mols = sr.load_test_files("phenethylamine_2phenylethanol.s3") + return sr.morph.link_to_reference(mols) + + @pytest.fixture(scope="session") def ethane_methanol(): mols = sr.load(sr.expand(sr.tutorial_url, "merged_molecule.s3")) diff --git a/tests/runner/test_terminal_flip.py b/tests/runner/test_terminal_flip.py new file mode 100644 index 00000000..ea6dea9b --- /dev/null +++ b/tests/runner/test_terminal_flip.py @@ -0,0 +1,406 @@ +""" +Tests for terminal ring flip Monte Carlo functionality. + +Two fixtures are used (both defined in conftest.py): + +``phenethyl_mols`` + Merged system from phenethylamine (NCCc1ccccc1) and 2-phenylethanol + (OCCc1ccccc1) via ``sr.load_test_files("phenethylamine_2phenylethanol.s3")``. + Contains one terminal phenyl ring. + +``diphenylethane_mols`` + Merged system from 1,2-diphenylethane (c1ccccc1CCc1ccccc1) and + 1,2-diphenylethanol (OC(Cc1ccccc1)c1ccccc1) via + ``sr.load_test_files("12diphenylethane_12diphenylethanol.s3")``. + Contains two terminal phenyl rings. +""" + +import pytest +import tempfile + +import numpy as np + +from somd2.config import Config +from somd2.runner import Runner +from somd2.runner._terminal_flip import TerminalFlipSampler, detect_terminal_groups + +# --------------------------------------------------------------------------- +# detect_terminal_groups +# --------------------------------------------------------------------------- + + +def test_no_terminal_groups(ethane_methanol): + """ + The ethane → methanol perturbation contains no rings, so no terminal + ring groups should be detected. + """ + groups = detect_terminal_groups(ethane_methanol) + assert groups == [] + + +def test_detect_one_terminal_group(phenethyl_mols): + """ + The phenethyl system has exactly one terminal ring (the phenyl group + attached via the –CH2– chain). H atoms bonded to ring carbons must not + be reported as separate groups. + """ + groups = detect_terminal_groups(phenethyl_mols) + assert len(groups) == 1 + + +def test_terminal_group_flip_angle(phenethyl_mols): + """ + The default flip angle should be 180°. + """ + groups = detect_terminal_groups(phenethyl_mols) + angle, _ = groups[0] + assert angle == pytest.approx(180.0) + + +def test_terminal_group_atom_count(phenethyl_mols): + """ + For a mono-substituted benzene ring: + - 1 anchor atom (aliphatic C adjacent to ring) + - 1 pivot atom (ipso ring C) + - 5 mobile ring carbons + - 5 mobile ring hydrogens + Total indices list length = 12. + """ + groups = detect_terminal_groups(phenethyl_mols) + _, indices = groups[0] + # anchor + pivot + 10 mobile atoms + assert len(indices) == 12 + + +def test_anchor_not_in_mobile(phenethyl_mols): + """ + The anchor index must not appear in the mobile atom list. + """ + groups = detect_terminal_groups(phenethyl_mols) + _, indices = groups[0] + anchor_idx = indices[0] + mobile_indices = indices[2:] + assert anchor_idx not in mobile_indices + + +def test_pivot_not_in_mobile(phenethyl_mols): + """ + The pivot index must not appear in the mobile atom list (the pivot is the + fixed rotation centre). + """ + groups = detect_terminal_groups(phenethyl_mols) + _, indices = groups[0] + pivot_idx = indices[1] + mobile_indices = indices[2:] + assert pivot_idx not in mobile_indices + + +def test_auto_flip_angle_phenethyl(phenethyl_mols): + """ + With no flip_angle override, the angle for a monosubstituted benzene ring + should be auto-detected as 180° (C2 symmetry). + """ + groups = detect_terminal_groups(phenethyl_mols) + angle, _ = groups[0] + assert angle == pytest.approx(180.0) + + +def test_auto_flip_angle_diphenylethane(diphenylethane_mols): + """ + Both terminal phenyl groups in the diphenylethane system should + auto-detect as 180°. + """ + groups = detect_terminal_groups(diphenylethane_mols) + assert len(groups) == 2 + for angle, _ in groups: + assert angle == pytest.approx(180.0) + + +def test_custom_flip_angle(phenethyl_mols): + """ + An explicit flip_angle override should be stored and returned as-is, + bypassing the geometric auto-detection. + """ + groups = detect_terminal_groups(phenethyl_mols, flip_angle=90.0) + angle, _ = groups[0] + assert angle == pytest.approx(90.0) + + +def test_detect_two_terminal_groups(diphenylethane_mols): + """ + 1,2-diphenylethane → 1,2-diphenylethanol has two terminal phenyl rings, + each attached via a non-ring CH2/CH anchor, so exactly two groups should + be detected. + """ + groups = detect_terminal_groups(diphenylethane_mols) + assert len(groups) == 2 + + +def test_multiple_groups_unique_pivots(diphenylethane_mols): + """ + The two terminal groups must have distinct pivot atoms (each ring has its + own ipso carbon). + """ + groups = detect_terminal_groups(diphenylethane_mols) + pivot_indices = [indices[1] for _, indices in groups] + assert len(set(pivot_indices)) == 2 + + +def test_multiple_groups_disjoint_mobile(diphenylethane_mols): + """ + The mobile atom sets of the two terminal groups must be disjoint — each + group owns its own ring atoms. + """ + groups = detect_terminal_groups(diphenylethane_mols) + mobile_0 = set(groups[0][1][2:]) + mobile_1 = set(groups[1][1][2:]) + assert mobile_0.isdisjoint(mobile_1) + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +def test_config_terminal_flip_frequency_none(): + """terminal_flip_frequency defaults to None (disabled).""" + config = Config() + assert config.terminal_flip_frequency is None + + +def test_config_terminal_flip_frequency_valid(): + """A valid time string is parsed to a Sire GeneralUnit.""" + config = Config(terminal_flip_frequency="1 ps") + assert config.terminal_flip_frequency is not None + assert str(config.terminal_flip_frequency).startswith("1") + + +def test_config_terminal_flip_frequency_bad_units(): + """Non-time units should raise ValueError.""" + with pytest.raises(ValueError, match="units are invalid"): + Config(terminal_flip_frequency="5 A") + + +def test_config_terminal_flip_frequency_bad_type(): + """A non-string value should raise TypeError.""" + config = Config() + with pytest.raises(TypeError, match="must be of type 'str'"): + config.terminal_flip_frequency = 5 + + +def test_config_terminal_flip_angle_none(): + """terminal_flip_angle defaults to None (auto-detect).""" + config = Config() + assert config.terminal_flip_angle is None + + +def test_config_terminal_flip_angle_valid(): + """A valid angle string is parsed to a Sire GeneralUnit.""" + config = Config(terminal_flip_angle="180 degrees") + assert config.terminal_flip_angle is not None + + +def test_config_terminal_flip_angle_bad_units(): + """Non-angle units should raise ValueError.""" + with pytest.raises(ValueError, match="units are invalid"): + Config(terminal_flip_angle="5 A") + + +def test_config_terminal_flip_angle_bad_type(): + """A non-string value should raise TypeError.""" + config = Config() + with pytest.raises(TypeError, match="must be of type 'str'"): + config.terminal_flip_angle = 180 + + +# --------------------------------------------------------------------------- +# TerminalFlipSampler +# --------------------------------------------------------------------------- + + +def test_sampler_initial_state(phenethyl_mols): + """ + A freshly constructed sampler should report zero attempts and zero + accepted moves. + """ + groups = detect_terminal_groups(phenethyl_mols) + sampler = TerminalFlipSampler(groups, 300.0) + assert sampler.num_attempted == 0 + assert sampler.num_accepted == 0 + assert sampler.acceptance_rate == 0.0 + + +def test_sampler_move(phenethyl_mols): + """ + After one call to move(), num_attempted should be 1 and the statistics + should be internally consistent. The outcome (accepted or rejected) + depends on the torsional energy around the exocyclic bond and is not + deterministic for an arbitrary starting configuration. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + platform="cpu", + output_directory=tmpdir, + num_lambda=1, + lambda_values=[0.0], + terminal_flip_frequency="4fs", + energy_frequency="4fs", + checkpoint_frequency="4fs", + frame_frequency="4fs", + ) + runner = Runner(phenethyl_mols, config) + + # Create a dynamics object to obtain an OpenMM context. + dynamics_kwargs = runner._dynamics_kwargs.copy() + dynamics = runner._system.dynamics(**dynamics_kwargs) + + groups = detect_terminal_groups(phenethyl_mols) + sampler = TerminalFlipSampler(groups, 300.0) + + sampler.move(dynamics.context()) + + assert sampler.num_attempted == 1 + assert sampler.num_accepted in (0, 1) + assert 0.0 <= sampler.acceptance_rate <= 1.0 + + +def test_rotate(phenethyl_mols): + """ + _rotate() must: + - leave the anchor and pivot atoms stationary, + - move all mobile atoms, + - restore all mobile atom positions after two consecutive 180° flips. + """ + from openmm import unit as omm_unit + + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + platform="cpu", + output_directory=tmpdir, + num_lambda=1, + lambda_values=[0.0], + terminal_flip_frequency="4fs", + energy_frequency="4fs", + checkpoint_frequency="4fs", + frame_frequency="4fs", + ) + runner = Runner(phenethyl_mols, config) + + dynamics_kwargs = runner._dynamics_kwargs.copy() + dynamics = runner._system.dynamics(**dynamics_kwargs) + context = dynamics.context() + + groups = detect_terminal_groups(phenethyl_mols) + sampler = TerminalFlipSampler(groups, 300.0) + + _, indices = groups[0] + anchor_idx = indices[0] + pivot_idx = indices[1] + mobile_indices = indices[2:] + + pos_before = ( + context.getState(getPositions=True) + .getPositions(asNumpy=True) + .value_in_unit(omm_unit.nanometer) + ) + + sampler._rotate(context, 0, 180.0) + + pos_after = ( + context.getState(getPositions=True) + .getPositions(asNumpy=True) + .value_in_unit(omm_unit.nanometer) + ) + + # Anchor and pivot must not move. + np.testing.assert_allclose( + pos_after[anchor_idx], pos_before[anchor_idx], atol=1e-5 + ) + np.testing.assert_allclose( + pos_after[pivot_idx], pos_before[pivot_idx], atol=1e-5 + ) + + # All mobile atoms must have moved. + for idx in mobile_indices: + assert not np.allclose(pos_after[idx], pos_before[idx], atol=1e-5), ( + f"Mobile atom {idx} did not move after 180° rotation" + ) + + # A second 180° flip must restore all mobile atom positions. + sampler._rotate(context, 0, 180.0) + pos_restored = ( + context.getState(getPositions=True) + .getPositions(asNumpy=True) + .value_in_unit(omm_unit.nanometer) + ) + np.testing.assert_allclose( + pos_restored[mobile_indices], pos_before[mobile_indices], atol=1e-5 + ) + + +# --------------------------------------------------------------------------- +# Runner integration +# --------------------------------------------------------------------------- + + +def test_runner_no_terminal_groups(ethane_methanol): + """ + Setting terminal_flip_frequency on a ring-free molecule should succeed + (0 groups detected) and the simulation should complete normally. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + runtime="12fs", + output_directory=tmpdir, + energy_frequency="4fs", + checkpoint_frequency="4fs", + frame_frequency="4fs", + platform="cpu", + max_threads=1, + num_lambda=2, + terminal_flip_frequency="4fs", + ) + runner = Runner(ethane_methanol, config) + assert runner._terminal_groups == [] + runner.run() + + +def test_runner_with_terminal_flip(phenethyl_mols): + """ + With terminal_flip_frequency set and a terminal ring present, the runner + should detect one group and complete the simulation successfully. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + runtime="12fs", + output_directory=tmpdir, + energy_frequency="4fs", + checkpoint_frequency="4fs", + frame_frequency="4fs", + platform="cpu", + max_threads=1, + num_lambda=2, + terminal_flip_frequency="4fs", + ) + runner = Runner(phenethyl_mols, config) + assert len(runner._terminal_groups) == 1 + runner.run() + + +def test_runner_validation_frequency_multiple(ethane_methanol): + """ + terminal_flip_frequency must be a multiple of energy_frequency. + A non-multiple should raise ValueError during runner initialisation. + """ + with tempfile.TemporaryDirectory() as tmpdir: + config = Config( + output_directory=tmpdir, + platform="cpu", + num_lambda=2, + energy_frequency="4fs", + terminal_flip_frequency="3fs", # not a multiple of 4fs + ) + with pytest.raises( + ValueError, match="must be a multiple of 'energy_frequency'" + ): + Runner(ethane_methanol, config) From 1ac5ad08f24bc47f10610f5716d7a9abf829b515 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 31 Mar 2026 14:44:32 +0100 Subject: [PATCH 126/212] Use encoding check for Unicode symbols. --- src/somd2/_utils/__init__.py | 15 +++++++++------ src/somd2/runner/_terminal_flip.py | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/somd2/_utils/__init__.py b/src/somd2/_utils/__init__.py index 74897957..0fd4719a 100644 --- a/src/somd2/_utils/__init__.py +++ b/src/somd2/_utils/__init__.py @@ -19,16 +19,19 @@ # along with SOMD2. If not, see . ##################################################################### -import platform as _platform +import sys as _sys -if _platform.system() == "Windows": - _lam_sym = "lambda" - _delta_sym = "delta" -else: +try: + "λΔ°".encode(_sys.stdout.encoding or "utf-8") _lam_sym = "λ" _delta_sym = "ΔE" + _degree_sym = "°" +except (UnicodeEncodeError, LookupError): + _lam_sym = "lambda" + _delta_sym = "delta" + _degree_sym = "deg" -del _platform +del _sys def _has_ghost(mol, idxs, is_lambda1=False): diff --git a/src/somd2/runner/_terminal_flip.py b/src/somd2/runner/_terminal_flip.py index 67901c69..cede8b54 100644 --- a/src/somd2/runner/_terminal_flip.py +++ b/src/somd2/runner/_terminal_flip.py @@ -30,7 +30,7 @@ import sire.legacy.Mol as _Mol from somd2 import _logger -from somd2._utils import _delta_sym +from somd2._utils import _delta_sym, _degree_sym def _auto_flip_angle(mol, anchor_idx, pivot_idx, ring_neighbor_idxs): @@ -258,14 +258,14 @@ def detect_terminal_groups(system, flip_angle=None): if group_angle is None: _logger.warning( f"Terminal group at pivot atom {j} has no recognised " - f"rotational symmetry (raw angle = {raw:.1f}°). " + f"rotational symmetry (raw angle = {raw:.1f}{_degree_sym}). " "Skipping group." ) continue _logger.debug( f"Terminal group at pivot atom {j}: auto-detected flip " - f"angle = {group_angle}° (raw = {raw:.1f}°)." + f"angle = {group_angle}{_degree_sym} (raw = {raw:.1f}{_degree_sym})." ) # Map molecule-local indices to absolute system indices. From ea0ba1299757c708862519487bb3d461e09e084d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 31 Mar 2026 15:35:11 +0100 Subject: [PATCH 127/212] Optionally randomise velocities after flip. --- README.md | 29 +++++++++++++++++++++++++++++ src/somd2/config/_config.py | 3 ++- src/somd2/runner/_repex.py | 6 +++++- src/somd2/runner/_runner.py | 30 +++++++++++++++++++++++++----- src/somd2/runner/_terminal_flip.py | 11 ++++++++++- 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bd212329..3d63603f 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,35 @@ require a different `nvcc` to that provided by conda, you can set the Depending on your setup, you may also need to install the `cuda-nvvm` package from `conda-forge`. +## Terminal ring flip Monte Carlo + +SOMD2 supports terminal ring flip Monte Carlo (MC) moves to improve sampling +of terminal aromatic rings in perturbable ligands, as described in +[this paper](https://chemrxiv.org/doi/full/10.26434/chemrxiv-2025-2zkx5). +Each move attempts a discrete rotation of a terminal ring around the bond +connecting it to the rest of the molecule, accepted or rejected via the +Metropolis criterion. Terminal ring groups are detected automatically from +the molecular connectivity of perturbable molecules. + +To enable terminal flip MC, set the frequency at which moves are attempted: + +``` +somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" +``` + +The flip angle for each group is determined automatically from the ring +geometry. To override this for all groups: + +``` +somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-angle "180 degrees" +``` + +To see all terminal flip related options, run: + +``` +somd2 --help | grep -A2 ' --terminal-flip' +``` + ## Analysis Simulation output will be written to the directory specified using the diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index ba1dba26..8070d20a 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -372,7 +372,8 @@ def __init__( GPU resources are available. randomise_velocities: bool - Whether to randomise velocities at the start of each replica exchange cycle. + Whether to randomise velocities at the start of each replica exchange cycle + or following a terminal flip Monte Carlo move. perturbed_system: str The path to a stream file containing a Sire system for the equilibrated perturbed diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 6e60b34e..4ed30f1f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1315,7 +1315,11 @@ def _run_block( # Perform a terminal flip move before dynamics if requested. if self._terminal_flip_sampler is not None and is_terminal_flip: _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") - self._terminal_flip_sampler.move(dynamics.context()) + if ( + self._terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 314e36fb..df2082ea 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -769,7 +769,11 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - terminal_flip_sampler.move(dynamics.context()) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming @@ -826,7 +830,11 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - terminal_flip_sampler.move(dynamics.context()) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() dynamics.run( checkpoint_frequency, @@ -973,7 +981,11 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - terminal_flip_sampler.move(dynamics.context()) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() dynamics.run( rem, @@ -1072,7 +1084,11 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - terminal_flip_sampler.move(dynamics.context()) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming @@ -1114,7 +1130,11 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - terminal_flip_sampler.move(dynamics.context()) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() dynamics.run( time, diff --git a/src/somd2/runner/_terminal_flip.py b/src/somd2/runner/_terminal_flip.py index cede8b54..001ce094 100644 --- a/src/somd2/runner/_terminal_flip.py +++ b/src/somd2/runner/_terminal_flip.py @@ -429,11 +429,18 @@ def move(self, context): context : openmm.Context The active OpenMM context. + + Returns + ------- + + bool + True if the move was accepted, False otherwise. Returns False + immediately if there are no terminal groups. """ from openmm import unit as _omm_unit if not self._terminal_groups: - return + return False self._num_attempted += 1 @@ -468,6 +475,7 @@ def move(self, context): f"{_delta_sym} = {e_new - e_old:.2f} kJ/mol, " f"acc = {min(1.0, _np.exp(-delta_e)):.3f})" ) + return True else: context.setPositions(old_positions * _omm_unit.nanometer) _logger.debug( @@ -475,6 +483,7 @@ def move(self, context): f"{_delta_sym} = {e_new - e_old:.2f} kJ/mol, " f"acc = {_np.exp(-delta_e):.3f})" ) + return False @property def num_attempted(self): From 5b9c319300f03b26ecba0b2e78d4bd596b1211a5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 31 Mar 2026 15:41:23 +0100 Subject: [PATCH 128/212] Remove redundant section. --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 3d63603f..3c0c8ef4 100644 --- a/README.md +++ b/README.md @@ -242,12 +242,6 @@ geometry. To override this for all groups: somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-angle "180 degrees" ``` -To see all terminal flip related options, run: - -``` -somd2 --help | grep -A2 ' --terminal-flip' -``` - ## Analysis Simulation output will be written to the directory specified using the From 70af0fd52f48d414e7ad626f76f35dec280e8237 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 09:18:22 +0100 Subject: [PATCH 129/212] Avoid duplicate velocity randomisation. --- src/somd2/runner/_repex.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 4ed30f1f..6e60b34e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1315,11 +1315,7 @@ def _run_block( # Perform a terminal flip move before dynamics if requested. if self._terminal_flip_sampler is not None and is_terminal_flip: _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") - if ( - self._terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() + self._terminal_flip_sampler.move(dynamics.context()) _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") From c5839c9343fe01ada10ae0ab9a66862268fb0248 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 09:24:19 +0100 Subject: [PATCH 130/212] Refactor into an internal _samplers sub-package. --- src/somd2/runner/_base.py | 2 +- src/somd2/runner/_repex.py | 2 +- src/somd2/runner/_runner.py | 2 +- src/somd2/runner/_samplers/__init__.py | 23 +++++++++++++++++++ .../runner/{ => _samplers}/_terminal_flip.py | 0 tests/runner/test_terminal_flip.py | 2 +- 6 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 src/somd2/runner/_samplers/__init__.py rename src/somd2/runner/{ => _samplers}/_terminal_flip.py (100%) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 18e67407..81ae61a5 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -680,7 +680,7 @@ def __init__(self, system, config): raise ValueError(msg) # Auto-detect terminal ring groups using Sire connectivity. - from ._terminal_flip import detect_terminal_groups + from ._samplers import detect_terminal_groups if isinstance(self._system, list): mols = self._system[0] diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 6e60b34e..a015190d 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -856,7 +856,7 @@ def __init__(self, system, config): # Create the terminal flip sampler (if terminal groups were detected). if self._terminal_groups: - from ._terminal_flip import TerminalFlipSampler + from ._samplers import TerminalFlipSampler self._terminal_flip_sampler = TerminalFlipSampler( self._terminal_groups, diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index df2082ea..fe34a011 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -467,7 +467,7 @@ def generate_lam_vals(lambda_base, increment=0.001): # Create the terminal flip sampler (if terminal groups were detected). if self._terminal_groups: - from ._terminal_flip import TerminalFlipSampler + from ._samplers import TerminalFlipSampler terminal_flip_sampler = TerminalFlipSampler( self._terminal_groups, diff --git a/src/somd2/runner/_samplers/__init__.py b/src/somd2/runner/_samplers/__init__.py new file mode 100644 index 00000000..5397014f --- /dev/null +++ b/src/somd2/runner/_samplers/__init__.py @@ -0,0 +1,23 @@ +###################################################################### +# SOMD2: GPU accelerated alchemical free-energy engine. +# +# Copyright: 2023-2026 +# +# Authors: The OpenBioSim Team +# +# SOMD2 is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SOMD2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SOMD2. If not, see . +##################################################################### + +from ._terminal_flip import TerminalFlipSampler as TerminalFlipSampler +from ._terminal_flip import detect_terminal_groups as detect_terminal_groups diff --git a/src/somd2/runner/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py similarity index 100% rename from src/somd2/runner/_terminal_flip.py rename to src/somd2/runner/_samplers/_terminal_flip.py diff --git a/tests/runner/test_terminal_flip.py b/tests/runner/test_terminal_flip.py index ea6dea9b..79011214 100644 --- a/tests/runner/test_terminal_flip.py +++ b/tests/runner/test_terminal_flip.py @@ -22,7 +22,7 @@ from somd2.config import Config from somd2.runner import Runner -from somd2.runner._terminal_flip import TerminalFlipSampler, detect_terminal_groups +from somd2.runner._samplers import TerminalFlipSampler, detect_terminal_groups # --------------------------------------------------------------------------- # detect_terminal_groups From 0a366f2036f196e7095eaf5253ec428cc190ff72 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 09:49:23 +0100 Subject: [PATCH 131/212] Sample all discrete flip states, not just nearest neighbours. --- src/somd2/runner/_samplers/_terminal_flip.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index 001ce094..9bfbcf5a 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -455,9 +455,12 @@ def move(self, context): ) e_old = state.getPotentialEnergy().value_in_unit(_omm_unit.kilojoule_per_mole) - # Random sign gives a symmetric proposal (detailed balance). - signed_angle = float(_np.random.choice([-1, 1])) * angle - self._rotate(context, group_idx, signed_angle) + # Pick uniformly from the n-1 non-current states, where n = 360 / angle. + # For 180° (n=2) this is equivalent to a random sign; for higher + # symmetry orders it correctly samples any non-current state in one move. + n = round(360.0 / angle) + step = int(_np.random.randint(1, n)) + self._rotate(context, group_idx, step * angle) # Evaluate the energy of the proposed configuration. e_new = ( From 7f3e67f173607b43a4defb173cedc20625359f4d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 09:53:36 +0100 Subject: [PATCH 132/212] Add reference to original paper. --- src/somd2/runner/_samplers/_terminal_flip.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index 9bfbcf5a..e5f226a7 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -22,6 +22,9 @@ # Adapted from the terminal ring flip MC implemenation in GrandFEP: # https://github.com/deGrootLab/GrandFEP # (Released under the MIT License.) +# +# Original method: Wang et al., ChemRxiv, 2025. +# https://doi.org/10.26434/chemrxiv-2025-2zkx5 __all__ = ["TerminalFlipSampler", "detect_terminal_groups"] From d457482bfde129a895362cb02cd4b19add569db6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 10:27:52 +0100 Subject: [PATCH 133/212] Allow functions to be used standalone. --- src/somd2/runner/_samplers/_terminal_flip.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index e5f226a7..3bb97e29 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -173,7 +173,11 @@ def detect_terminal_groups(system, flip_angle=None): # All atoms in the system, used to obtain absolute (OpenMM) atom indices. all_atoms = system.atoms() + import sire.morph as _morph + for mol in pert_mols: + mol = _morph.link_to_reference(mol) + try: connectivity = mol.property("connectivity") except Exception: From d1a0e9e41d6069c8aa3372d2fd16f538600b328e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 11:39:32 +0100 Subject: [PATCH 134/212] Simplify logger message. --- src/somd2/runner/_repex.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index a015190d..c400733e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -863,8 +863,7 @@ def __init__(self, system, config): float(self._config.temperature.value()), ) _logger.info( - f"Terminal flip sampler ready for replica exchange " - f"({len(self._terminal_groups)} group(s))" + f"Terminal flip sampler ready ({len(self._terminal_groups)} group(s))" ) else: self._terminal_flip_sampler = None From 3745398ea4a3e707abb7d2ff37e8d7f8433c15eb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 11:39:54 +0100 Subject: [PATCH 135/212] Fall back to hybridisation check when geometric check fails. --- src/somd2/runner/_samplers/_terminal_flip.py | 40 +++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index 3bb97e29..f184cd92 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -202,6 +202,7 @@ def detect_terminal_groups(system, flip_angle=None): num_atoms = mol.num_atoms() seen_bonds = set() + rdmol = None # lazily initialised if geometric detection fails for i in range(num_atoms): atom_i_idx = _Mol.AtomIdx(i) @@ -263,12 +264,39 @@ def detect_terminal_groups(system, flip_angle=None): group_angle = _round_to_symmetry_angle(raw) if group_angle is None: - _logger.warning( - f"Terminal group at pivot atom {j} has no recognised " - f"rotational symmetry (raw angle = {raw:.1f}{_degree_sym}). " - "Skipping group." - ) - continue + # Geometric detection failed; fall back to hybridization. + try: + if rdmol is None: + from sire.convert import to_rdkit as _to_rdkit + from rdkit.Chem import HybridizationType as _HybType + + rdmol = _to_rdkit(mol) + hyb = rdmol.GetAtomWithIdx(j).GetHybridization() + if hyb == _HybType.SP2: + group_angle = 180.0 + elif hyb == _HybType.SP3: + group_angle = 120.0 + else: + _logger.warning( + f"Terminal group at pivot atom {j}: geometric " + f"detection gave unrecognised angle " + f"({raw:.1f}{_degree_sym}) and hybridization " + f"({hyb}) has no defined flip angle. Skipping." + ) + continue + _logger.warning( + f"Terminal group at pivot atom {j}: geometric " + f"detection gave unrecognised angle " + f"({raw:.1f}{_degree_sym}), using hybridization-based " + f"angle {group_angle}{_degree_sym} (pivot is {hyb.name})." + ) + except Exception as e: + _logger.warning( + f"Terminal group at pivot atom {j} has no recognised " + f"rotational symmetry (raw angle = {raw:.1f}{_degree_sym}) " + f"and hybridization fallback failed: {e}. Skipping." + ) + continue _logger.debug( f"Terminal group at pivot atom {j}: auto-detected flip " From 88a76f13907fb1b2d98390bd7edaf8ce37c59d51 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 11:49:38 +0100 Subject: [PATCH 136/212] Use a unique TerminalFlipSampler per replica. --- src/somd2/runner/_repex.py | 41 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index c400733e..b8a8392e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -854,19 +854,22 @@ def __init__(self, system, config): else: self._start_block = 0 - # Create the terminal flip sampler (if terminal groups were detected). + # Create a terminal flip sampler per replica (if terminal groups were detected). if self._terminal_groups: from ._samplers import TerminalFlipSampler - self._terminal_flip_sampler = TerminalFlipSampler( - self._terminal_groups, - float(self._config.temperature.value()), - ) + self._terminal_flip_samplers = [ + TerminalFlipSampler( + self._terminal_groups, + float(self._config.temperature.value()), + ) + for _ in self._lambda_values + ] _logger.info( - f"Terminal flip sampler ready ({len(self._terminal_groups)} group(s))" + f"Terminal flip samplers ready ({len(self._terminal_groups)} group(s))" ) else: - self._terminal_flip_sampler = None + self._terminal_flip_samplers = None from threading import Lock @@ -1018,7 +1021,7 @@ def run(self): # Work out the number of cycles per terminal flip move. if ( self._config.terminal_flip_frequency is not None - and self._terminal_flip_sampler is not None + and self._terminal_flip_samplers is not None ): cycles_per_flip = max( 1, @@ -1163,15 +1166,6 @@ def run(self): ) self._dynamics_cache.mix_states() - # Log terminal flip acceptance rate at each cycle. - if self._terminal_flip_sampler is not None: - _logger.info( - f"Terminal flip acceptance rate: " - f"{self._terminal_flip_sampler.acceptance_rate:.3f} " - f"({self._terminal_flip_sampler.num_accepted}/" - f"{self._terminal_flip_sampler.num_attempted})" - ) - # This is a checkpoint cycle. if is_checkpoint: # Update the block number. @@ -1312,9 +1306,9 @@ def _run_block( gcmc_sampler.write_ghost_residues() # Perform a terminal flip move before dynamics if requested. - if self._terminal_flip_sampler is not None and is_terminal_flip: + if self._terminal_flip_samplers is not None and is_terminal_flip: _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") - self._terminal_flip_sampler.move(dynamics.context()) + self._terminal_flip_samplers[index].move(dynamics.context()) _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") @@ -1770,6 +1764,15 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Remove the PyCUDA context from the stack. gcmc_sampler.pop() + # Log terminal flip acceptance rate for this replica. + if self._terminal_flip_samplers is not None: + sampler = self._terminal_flip_samplers[index] + _logger.info( + f"Terminal flip acceptance rate at {_lam_sym} = {lam:.5f}: " + f"{sampler.acceptance_rate:.3f} " + f"({sampler.num_accepted}/{sampler.num_attempted})" + ) + if is_final_block: _logger.success(f"{_lam_sym} = {lam:.5f} complete") From 795f3773a9dbb3adb32d3221acc6884933640384 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 12:03:38 +0100 Subject: [PATCH 137/212] Add serialisation for sampler statistics. --- src/somd2/runner/_base.py | 1 + src/somd2/runner/_repex.py | 42 ++++++++++- src/somd2/runner/_runner.py | 76 ++++++++++++++++++++ src/somd2/runner/_samplers/_terminal_flip.py | 16 +++++ 4 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 81ae61a5..2341336e 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1197,6 +1197,7 @@ def increment_filename(base_filename, suffix): output_directory / f"energy_components_{lam}.txt" ) filenames["gcmc_ghosts"] = str(output_directory / f"gcmc_ghosts_{lam}.txt") + filenames["sampler_stats"] = str(output_directory / f"sampler_stats_{lam}.pkl") if restart: filenames["config"] = str( output_directory / increment_filename("config", "yaml") diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index b8a8392e..931d26bb 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -106,6 +106,8 @@ def __init__( self._openmm_states = [None] * len(lambdas) self._gcmc_samplers = [None] * len(lambdas) self._gcmc_states = [None] * len(lambdas) + self._gcmc_stats = [None] * len(lambdas) + self._terminal_flip_stats = [[0, 0]] * len(lambdas) self._num_proposed = _np.matrix(_np.zeros((len(lambdas), len(lambdas)))) self._num_accepted = _np.matrix(_np.zeros((len(lambdas), len(lambdas)))) self._num_swaps = _np.matrix(_np.zeros((len(lambdas), len(lambdas)))) @@ -130,6 +132,14 @@ def __setstate__(self, state): for key, value in state.items(): setattr(self, key, value) + # Provide defaults for attributes added after the initial release, + # so that old checkpoint files can still be loaded. + n = len(self._lambdas) + if not hasattr(self, "_gcmc_stats"): + self._gcmc_stats = [None] * n + if not hasattr(self, "_terminal_flip_stats"): + self._terminal_flip_stats = [[0, 0]] * n + def __getstate__(self): """ Get the state of the object. @@ -145,6 +155,8 @@ def __getstate__(self): # Don't pickle the GCMC samplers since they need to be recreated. "_gcmc_samplers": len(self._gcmc_samplers) * [None], "_gcmc_states": self._gcmc_states, + "_gcmc_stats": self._gcmc_stats, + "_terminal_flip_stats": self._terminal_flip_stats, "_num_proposed": self._num_proposed, "_num_accepted": self._num_accepted, "_num_swaps": self._num_swaps, @@ -823,7 +835,7 @@ def __init__(self, system, config): state = self._dynamics_cache._states[i] dynamics.context().setState(self._dynamics_cache._openmm_states[state]) - # Reset the GCMC water state. + # Reset the GCMC water state and restore statistics. if gcmc_sampler is not None: gcmc_sampler.push() try: @@ -834,6 +846,13 @@ def __init__(self, system, config): ) finally: gcmc_sampler.pop() + if self._dynamics_cache._gcmc_stats[i] is not None: + gcmc_sampler.restore_stats(self._dynamics_cache._gcmc_stats[i]) + + # Restore terminal flip sampler statistics. + if self._terminal_flip_samplers is not None: + attempted, accepted = self._dynamics_cache._terminal_flip_stats[i] + self._terminal_flip_samplers[i].reset(attempted, accepted) # Conversion factor for reduced potential. kT = (_sr.units.k_boltz * self._config.temperature).to(_sr.units.kcal_per_mol) @@ -1190,6 +1209,7 @@ def run(self): # Pickle the dynamics cache. _logger.info("Saving replica exchange state") + self._save_sampler_stats() with open(self._repex_state, "wb") as f: _pickle.dump(self._dynamics_cache, f) @@ -1211,6 +1231,11 @@ def run(self): # Pickle final state of the dynamics cache. _logger.info("Saving final replica exchange state") + if self._terminal_flip_samplers is not None: + self._dynamics_cache._terminal_flip_stats = [ + [s.num_attempted, s.num_accepted] + for s in self._terminal_flip_samplers + ] with open(self._repex_state, "wb") as f: _pickle.dump(self._dynamics_cache, f) @@ -1842,6 +1867,21 @@ def _mix_replicas(num_replicas, energy_matrix, proposed, accepted): return states + def _save_sampler_stats(self): + """ + Save GCMC and terminal flip sampler statistics to the dynamics cache + prior to pickling. + """ + for i in range(len(self._lambda_values)): + _, gcmc_sampler = self._dynamics_cache.get(i) + if gcmc_sampler is not None: + self._dynamics_cache._gcmc_stats[i] = gcmc_sampler.get_stats() + + if self._terminal_flip_samplers is not None: + self._dynamics_cache._terminal_flip_stats = [ + [s.num_attempted, s.num_accepted] for s in self._terminal_flip_samplers + ] + def _save_transition_matrix(self): """ Internal method to save the replica exchange transition matrix. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index fe34a011..54345e43 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -695,6 +695,16 @@ def generate_lam_vals(lambda_base, increment=0.001): finally: gcmc_sampler.pop() + # Restore sampler statistics from a previous run. + if self._is_restart: + stats = self._load_sampler_stats(index) + if stats is not None: + if gcmc_sampler is not None and "gcmc" in stats: + gcmc_sampler.restore_stats(stats["gcmc"]) + if terminal_flip_sampler is not None and "terminal_flip" in stats: + attempted, accepted = stats["terminal_flip"] + terminal_flip_sampler.reset(attempted, accepted) + # Set the number of neighbours used for the energy calculation. # If not None, then we add one to account for the extra windows # used for finite-difference gradient analysis. @@ -924,6 +934,11 @@ def generate_lam_vals(lambda_base, increment=0.001): if error is not None: raise error + # Save sampler statistics alongside the checkpoint. + self._save_sampler_stats( + index, gcmc_sampler, terminal_flip_sampler + ) + # Delete all trajectory frames from the Sire system within the # dynamics object. dynamics._d._sire_mols.delete_all_frames() @@ -1213,12 +1228,73 @@ def generate_lam_vals(lambda_base, increment=0.001): _logger.error(msg) raise RuntimeError(msg) + # Save sampler statistics alongside the final checkpoint. + self._save_sampler_stats(index, gcmc_sampler, terminal_flip_sampler) + _logger.success( f"{_lam_sym} = {lambda_value:.5f} complete, speed = {speed:.2f} ns day-1" ) return time + def _save_sampler_stats(self, index, gcmc_sampler, terminal_flip_sampler): + """ + Save GCMC and terminal flip sampler statistics to a pickle file. + + Parameters + ---------- + + index : int + The index of the lambda value. + + gcmc_sampler : GCMCSampler or None + The GCMC sampler for this replica. + + terminal_flip_sampler : TerminalFlipSampler or None + The terminal flip sampler for this replica. + """ + import pickle as _pickle + + stats = {} + if gcmc_sampler is not None: + stats["gcmc"] = gcmc_sampler.get_stats() + if terminal_flip_sampler is not None: + stats["terminal_flip"] = [ + terminal_flip_sampler.num_attempted, + terminal_flip_sampler.num_accepted, + ] + with open(self._filenames[index]["sampler_stats"], "wb") as f: + _pickle.dump(stats, f) + + def _load_sampler_stats(self, index): + """ + Load sampler statistics from a pickle file. + + Parameters + ---------- + + index : int + The index of the lambda value. + + Returns + ------- + + dict or None + The sampler statistics, or None if the file does not exist. + """ + import pickle as _pickle + from pathlib import Path as _Path + + path = _Path(self._filenames[index]["sampler_stats"]) + if not path.exists(): + return None + try: + with open(path, "rb") as f: + return _pickle.load(f) + except Exception as e: + _logger.warning(f"Could not load sampler stats for index {index}: {e}") + return None + def _minimisation( self, system, diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index f184cd92..9f3a6d69 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -539,3 +539,19 @@ def acceptance_rate(self): if self._num_attempted == 0: return 0.0 return self._num_accepted / self._num_attempted + + def reset(self, num_attempted=0, num_accepted=0): + """ + Reset the move counters. + + Parameters + ---------- + + num_attempted : int + Value to restore ``num_attempted`` to. Defaults to 0. + + num_accepted : int + Value to restore ``num_accepted`` to. Defaults to 0. + """ + self._num_attempted = num_attempted + self._num_accepted = num_accepted From 3f281a7d712f7bcabd38a3b5e8b41ddadbb8e584 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 1 Apr 2026 12:40:09 +0100 Subject: [PATCH 138/212] Add option to limit size of ring flip region. --- src/somd2/config/_config.py | 27 ++++++++++++++++++++ src/somd2/runner/_base.py | 6 ++++- src/somd2/runner/_samplers/_terminal_flip.py | 15 ++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 8070d20a..41bb8d47 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -141,6 +141,7 @@ def __init__( perturbed_system=None, terminal_flip_frequency=None, terminal_flip_angle=None, + terminal_flip_max_mobile_atoms=None, gcmc=False, gcmc_frequency=None, gcmc_selection=None, @@ -391,6 +392,11 @@ def __init__( ``"180 degrees"``. If None (the default), the angle is determined automatically for each group from its geometry. + terminal_flip_max_mobile_atoms: int or None + Maximum number of mobile atoms allowed in a terminal ring group. + Groups with more mobile atoms than this threshold are skipped during + detection. Defaults to None (no limit). + gcmc: bool Whether to perform Grand Canonical Monte Carlo (GCMC) water insertions/deletions. @@ -575,6 +581,7 @@ def __init__( self.perturbed_system = perturbed_system self.terminal_flip_frequency = terminal_flip_frequency self.terminal_flip_angle = terminal_flip_angle + self.terminal_flip_max_mobile_atoms = terminal_flip_max_mobile_atoms self.gcmc = gcmc self.gcmc_frequency = gcmc_frequency self.gcmc_selection = gcmc_selection @@ -2064,6 +2071,26 @@ def terminal_flip_angle(self, terminal_flip_angle): else: self._terminal_flip_angle = None + @property + def terminal_flip_max_mobile_atoms(self): + return self._terminal_flip_max_mobile_atoms + + @terminal_flip_max_mobile_atoms.setter + def terminal_flip_max_mobile_atoms(self, terminal_flip_max_mobile_atoms): + if terminal_flip_max_mobile_atoms is not None: + if not isinstance(terminal_flip_max_mobile_atoms, int): + try: + terminal_flip_max_mobile_atoms = int(terminal_flip_max_mobile_atoms) + except: + raise ValueError( + "'terminal_flip_max_mobile_atoms' must be of type 'int'" + ) + if terminal_flip_max_mobile_atoms < 1: + raise ValueError( + "'terminal_flip_max_mobile_atoms' must be greater than 0" + ) + self._terminal_flip_max_mobile_atoms = terminal_flip_max_mobile_atoms + @property def gcmc(self): return self._gcmc diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 2341336e..00d4aa76 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -692,7 +692,11 @@ def __init__(self, system, config): if self._config.terminal_flip_angle is not None else None ) - self._terminal_groups = detect_terminal_groups(mols, flip_angle=flip_angle) + self._terminal_groups = detect_terminal_groups( + mols, + flip_angle=flip_angle, + max_mobile_atoms=self._config.terminal_flip_max_mobile_atoms, + ) if not self._terminal_groups: _logger.warning( diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index 9f3a6d69..971a85ac 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -125,7 +125,7 @@ def _round_to_symmetry_angle(raw_angle, tolerance=10.0): return symmetry_angles[min_idx] -def detect_terminal_groups(system, flip_angle=None): +def detect_terminal_groups(system, flip_angle=None, max_mobile_atoms=None): """ Detect terminal ring groups in perturbable molecules using Sire's native connectivity. @@ -151,6 +151,11 @@ def detect_terminal_groups(system, flip_angle=None): a float is given it overrides the geometric measurement for all groups. + max_mobile_atoms : int or None + Maximum number of mobile atoms allowed in a terminal ring group. + Groups with more mobile atoms than this threshold are skipped. + Defaults to None (no limit). + Returns ------- @@ -238,6 +243,14 @@ def detect_terminal_groups(system, flip_angle=None): if not mobile: continue + # Skip groups with too many mobile atoms. + if max_mobile_atoms is not None and len(mobile) > max_mobile_atoms: + _logger.warning( + f"Terminal group at pivot atom {j} has {len(mobile)} mobile " + f"atoms (max_mobile_atoms={max_mobile_atoms}). Skipping group." + ) + continue + # Determine the flip angle for this group. if flip_angle is not None: group_angle = flip_angle From 140d57c9539f1b94f4ac10531c438a9e11f8a616 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 9 Apr 2026 12:48:51 +0100 Subject: [PATCH 139/212] Don't serialise helper attributes. --- src/somd2/config/_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 41bb8d47..3ab44495 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -688,6 +688,11 @@ def as_dict(self, sire_compatible=False): if value is None and sire_compatible: d[attr_l] = False + # Don't include lambda_schedule_name or perturbed_system_file in the dictionary, + # since these are just helper attributes. + d.pop("_lambda_schedule_name", None) + d.pop("_perturbed_system_file", None) + # Handle the lambda schedule separately so that we can use simplified # keyword options. @@ -716,7 +721,6 @@ def as_dict(self, sire_compatible=False): and self._perturbed_system_file is not None ): d["perturbed_system"] = str(self._perturbed_system_file) - d.pop("perturbed_system_file", None) return d From 570429b23b3740e9d547078d1607e62f3e9aa3cc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 9 Apr 2026 12:52:15 +0100 Subject: [PATCH 140/212] Fix comparison of restraints. --- src/somd2/runner/_base.py | 77 ++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 00d4aa76..dcfd78c1 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1397,7 +1397,6 @@ def _compare_configs(config1, config2): "frame_frequency", "save_velocities", "perturbed_system", - "perturbed_system_file", "platform", "max_threads", "max_gpus", @@ -1434,51 +1433,61 @@ def _compare_configs(config1, config2): # Standard schedules are stored as strings, so we can compare these directly. if v1 == v2: continue - else: - try: - v1 = _Config._from_hex(v1) - except Exception as e: + try: + v1 = _Config._from_hex(v1) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config1: {str(e)}" + ) + try: + v2 = _Config._from_hex(v2) + except Exception as e: + raise ValueError( + f"Unable to deserialise lambda schedule from config2: {str(e)}" + ) + if v1 != v2: + raise ValueError( + f"{key} has changed since the last run. This is not " + "allowed when using the restart option." + ) + continue + + # Restraints are stored as a list of hexadecimal strings of serialised objects. + # We need to deserialise them before comparison. + elif key == "restraints": + if v1 and v2: + if len(v1) != len(v2): raise ValueError( - f"Unable to deserialise lambda schedule from config1: {str(e)}" + f"Number of restraints has changed since the last run " + f"({len(v1)} vs {len(v2)}). This is not allowed when " + "using the restart option." ) + # Deserialise all restraints from both configs. try: - v2 = _Config._from_hex(v2) + deserialized_v1 = [_Config._from_hex(r) for r in v1] except Exception as e: raise ValueError( - f"Unable to deserialise lambda schedule from config2: {str(e)}" + f"Unable to deserialise restraint from config1: {str(e)}" ) - if v1 != v2: + try: + deserialized_v2 = [_Config._from_hex(r) for r in v2] + except Exception as e: raise ValueError( - f"{key} has changed since the last run. This is not " - "allowed when using the restart option." + f"Unable to deserialise restraint from config2: {str(e)}" ) - else: - continue - - # Restraints are stored as a list of hexadecimal strings of serialised objects. - # We need to deserialise them before comparison. - elif key == "restraints": - if v1 and v2: - for r1, r2 in zip(v1, v2): - try: - r1 = _Config._from_hex(r1) - except Exception as e: - raise ValueError( - f"Unable to deserialise restraint from config1: {str(e)}" - ) - try: - r2 = _Config._from_hex(r2) - except Exception as e: - raise ValueError( - f"Unable to deserialise restraint from config2: {str(e)}" - ) - if r1 != r2: + # Match each restraint in v1 against v2, regardless of order. + unmatched = list(deserialized_v2) + for r1 in deserialized_v1: + for i, r2 in enumerate(unmatched): + if r1 == r2: + unmatched.pop(i) + break + else: raise ValueError( f"{key} has changed since the last run. This is not " "allowed when using the restart option." ) - else: - continue + continue # Convert GeneralUnits to strings for comparison. if isinstance(v1, _GeneralUnit): From b1dc8645214e93e8038256972b1986a83e806c85 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 9 Apr 2026 13:14:02 +0100 Subject: [PATCH 141/212] Add section on copying intermediate files. [ci skip] --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 3c0c8ef4..ee7fd99d 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,38 @@ geometry. To override this for all groups: somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-angle "180 degrees" ``` +## Copying output files during a simulation + +When `SOMD2` writes checkpoint files it acquires an exclusive +[file lock](https://filelock.readthedocs.io) on `somd2.lock` inside the output +directory. This guarantees that checkpoint files are always in a consistent +state on disk. + +If you want to copy the output directory while a simulation is running (for +example, to create a backup or to inspect intermediate results), acquire the +same lock first so that you do not copy files mid-write. On Linux/macOS this +can be done with the `flock` command: + +```bash +flock /path/to/output/somd2.lock cp -r /path/to/output /destination +``` + +Or from Python using the [filelock](https://pypi.org/project/filelock/) package +(which `somd2` already depends on): + +```python +from filelock import FileLock + +with FileLock("/path/to/output/somd2.lock"): + # copy files here + ... +``` + +> [!NOTE] +> The `--timeout` option (default: `300 s`) controls how long `SOMD2` will +> wait to re-acquire the lock after your copy completes. If you hold the lock +> for longer than this, the simulation will raise a `Timeout` error. + ## Analysis Simulation output will be written to the directory specified using the From e1cb792dd6a6c9ffc1e6ff4eb3faa15c421a5423 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 9 Apr 2026 14:09:00 +0100 Subject: [PATCH 142/212] Fix link. [ci skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ee7fd99d..8fc73d6f 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-an ## Copying output files during a simulation When `SOMD2` writes checkpoint files it acquires an exclusive -[file lock](https://filelock.readthedocs.io) on `somd2.lock` inside the output +[file lock](https://py-filelock.readthedocs.io) on `somd2.lock` inside the output directory. This guarantees that checkpoint files are always in a consistent state on disk. From f6e0d6a3544b1a6f9197a3a1b9688c1f304b21ff Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 10 Apr 2026 10:55:07 +0100 Subject: [PATCH 143/212] Seed perturbed coords from a system clone, not raw arrays. --- src/somd2/runner/_base.py | 43 +++++++++++++------------ src/somd2/runner/_repex.py | 64 +++++++++----------------------------- 2 files changed, 35 insertions(+), 72 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index dcfd78c1..f4c2ed79 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -94,28 +94,6 @@ def __init__(self, system, config): _logger.error(msg) raise ValueError(msg) - # Make sure the coordinates property is linked. - perturbed_system = _sr.morph.link_to_perturbed( - self._config.perturbed_system - ) - - # Store the positions. - self._perturbed_positions = _sr.io.get_coords_array(perturbed_system) - - # Store the box vectors. - cell = self._config.perturbed_system.space().box_matrix() - c0 = cell.column0() - c1 = cell.column1() - c2 = cell.column2() - self._perturbed_box = ( - (c0.x().value(), c0.y().value(), c0.z().value()), - (c1.x().value(), c1.y().value(), c1.z().value()), - (c2.x().value(), c2.y().value(), c2.z().value()), - ) - else: - self._perturbed_positions = None - self._perturbed_box = None - # Log the versions of somd2 and sire. from somd2 import ( __version__, @@ -523,6 +501,27 @@ def __init__(self, system, config): # Store the current system as a reference. self._reference_system = self._system.clone() + # Create a clone of the fully-prepared reference system with the + # perturbed end-state coordinates and periodic space. This is done + # after all system preparation so that the clone inherits the same + # topology and properties. It is used to seed starting coordinates + # for lambda > 0.5 replicas. + if self._config.replica_exchange and self._config.perturbed_system is not None: + from sire.legacy.IO import setCoordinates as _setCoordinates + + pert_coords = _sr.io.get_coords_array( + _sr.morph.link_to_perturbed(self._config.perturbed_system) + ) + self._perturbed_system = _sr.system.System( + _setCoordinates(self._system._system, pert_coords.tolist()) + ) + self._perturbed_system.set_space(self._config.perturbed_system.space()) + + # Link properties to the lambda = 0 end state. + self._perturbed_system = _sr.morph.link_to_reference(self._perturbed_system) + else: + self._perturbed_system = None + # Check for a valid restart. if self._config.restart: if self._config.use_backup: diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 931d26bb..b019030e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -52,8 +52,7 @@ def __init__( dynamics_kwargs, gcmc_kwargs=None, output_directory=None, - perturbed_positions=None, - perturbed_box=None, + perturbed_system=None, ): """ Constructor. @@ -82,13 +81,9 @@ def __init__( output_directory: pathlib.Path The directory for simulation output. - perturbed_positions: numpy.ndarray - The positions for the perturbed state. If None, then the perturbed state - is not used. - - perturbed_box: numpy.ndarray - The box vectors for the perturbed state. If None, then the perturbed state - is not used. + perturbed_system: :class: `System ` + The perturbed end-state system used to seed starting coordinates for + lambda > 0.5 replicas. If None, the perturbed state is not used. """ # Warn if the number of replicas is not a multiple of the number of GPUs. @@ -121,8 +116,7 @@ def __init__( dynamics_kwargs, gcmc_kwargs=gcmc_kwargs, output_directory=output_directory, - perturbed_positions=perturbed_positions, - perturbed_box=perturbed_box, + perturbed_system=perturbed_system, ) def __setstate__(self, state): @@ -173,8 +167,7 @@ def _create_dynamics( dynamics_kwargs, gcmc_kwargs=None, output_directory=None, - perturbed_positions=None, - perturbed_box=None, + perturbed_system=None, ): """ Create the dynamics objects. @@ -203,13 +196,9 @@ def _create_dynamics( output_directory: pathlib.Path The directory for simulation output. - perturbed_positions: numpy.ndarray - The positions for the perturbed state. If None, then the perturbed state - is not used. - - perturbed_box: numpy.ndarray - The box vectors for the perturbed state. If None, then the perturbed state - is not used. + perturbed_system: :class: `System ` + The perturbed end-state system used to seed starting coordinates for + lambda > 0.5 replicas. If None, the perturbed state is not used. """ from math import floor @@ -256,7 +245,10 @@ def _create_dynamics( # This is a restart, get the system for this replica. if isinstance(system, list): mols = system[i] - # This is a new simulation. + # This is a new simulation. For lambda > 0.5, use the perturbed + # system to seed the starting coordinates and periodic space. + elif perturbed_system is not None and lam > 0.5: + mols = perturbed_system else: mols = system @@ -314,33 +306,6 @@ def _create_dynamics( _logger.error(msg) raise RuntimeError(msg) from e - # Update the box vectors and positions if the perturbed state is used. - if ( - perturbed_positions is not None - and perturbed_box is not None - and lam > 0.5 - ): - from openmm.unit import angstrom - - # Get the positions from the context. - positions = ( - dynamics.context() - .getState(getPositions=True) - .getPositions(asNumpy=True) - ) / angstrom - - # The positions array also contains the ghost water atoms that - # were added during the GCMC setup. We need to make sure that - # we copy these over to the perturbed positions array. - diff = len(positions) - len(perturbed_positions) - if diff != 0: - perturbed_positions = _np.concatenate( - [perturbed_positions, positions[-diff:]] - ) - - dynamics.context().setPeriodicBoxVectors(*perturbed_box * angstrom) - dynamics.context().setPositions(perturbed_positions * angstrom) - # Bind the GCMC sampler to the dynamics object. This allows the # dynamics object to reset the water state in its internal OpenMM # context following a crash recovery. @@ -782,8 +747,7 @@ def __init__(self, system, config): self._num_gpus, dynamics_kwargs, gcmc_kwargs=self._gcmc_kwargs, - perturbed_positions=self._perturbed_positions, - perturbed_box=self._perturbed_box, + perturbed_system=self._perturbed_system, output_directory=self._config.output_directory, ) else: From 492b2a5b7db4942abf5fa7d7caeead4bd7614d71 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 10:57:33 +0100 Subject: [PATCH 144/212] Set dynamics _pre_run_state for crash recovery from mix_states and MC moves. --- src/somd2/runner/_repex.py | 21 +++++++++++++++- src/somd2/runner/_runner.py | 48 +++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index b019030e..e8c5a3d0 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -526,6 +526,10 @@ def mix_states(self): old_state = self._old_states[i] self._num_swaps[old_state, state] += 1 + # Snapshot the pre-run state for crash recovery. + for i, state in enumerate(self._states): + self._dynamics[i]._d._pre_run_state = self._openmm_states[state] + # Store the current states. self._old_states = self._states.copy() @@ -1278,6 +1282,10 @@ def _run_block( # Get the dynamics object (and GCMC sampler). dynamics, gcmc_sampler = self._dynamics_cache.get(index) + # Track whether any MC move changed the context positions so we + # can update _pre_run_state once at the end. + needs_pre_run_snapshot = False + # Perform the GCMC move before dynamics so that the energies # computed during dynamics are consistent with the state used # for replica exchange mixing. @@ -1289,6 +1297,8 @@ def _run_block( finally: gcmc_sampler.pop() + needs_pre_run_snapshot = True + # Write ghost residues immediately after the GCMC move so the # ghost state and frame (saved during dynamics) are consistent. if write_gcmc_ghosts: @@ -1297,7 +1307,16 @@ def _run_block( # Perform a terminal flip move before dynamics if requested. if self._terminal_flip_samplers is not None and is_terminal_flip: _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") - self._terminal_flip_samplers[index].move(dynamics.context()) + if self._terminal_flip_samplers[index].move(dynamics.context()): + needs_pre_run_snapshot = True + + # Snapshot the context state for crash recovery if any MC move + # changed positions. This overwrites the snapshot set in + # mix_states() so _rebuild_and_minimise() has a consistent state. + if needs_pre_run_snapshot: + dynamics._d._pre_run_state = dynamics.context().getState( + getPositions=True, getVelocities=True + ) _logger.info(f"Running dynamics at {_lam_sym} = {lam:.5f}") diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 54345e43..b7356089 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -770,6 +770,9 @@ def generate_lam_vals(lambda_base, increment=0.001): finally: gcmc_sampler.pop() + # GCMC always changes positions. + needs_pre_run_snapshot = True + # Perform a terminal flip move at the specified frequency. if ( terminal_flip_sampler is not None @@ -779,11 +782,23 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() + flip_accepted = terminal_flip_sampler.move( + dynamics.context() + ) + if flip_accepted: + needs_pre_run_snapshot = True + if self._config.randomise_velocities: + dynamics.randomise_velocities() + + # Snapshot the context state once for crash recovery + # if any MC move changed positions. + if needs_pre_run_snapshot: + dynamics._d._pre_run_state = ( + dynamics.context().getState( + getPositions=True, getVelocities=True + ) + ) + needs_pre_run_snapshot = False # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming @@ -1090,6 +1105,9 @@ def generate_lam_vals(lambda_base, increment=0.001): finally: gcmc_sampler.pop() + # GCMC always changes positions. + needs_pre_run_snapshot = True + # Perform a terminal flip move at the specified frequency. if ( terminal_flip_sampler is not None @@ -1099,11 +1117,21 @@ def generate_lam_vals(lambda_base, increment=0.001): f"Performing terminal flip move at " f"{_lam_sym} = {lambda_value:.5f}" ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() + flip_accepted = terminal_flip_sampler.move( + dynamics.context() + ) + if flip_accepted: + needs_pre_run_snapshot = True + if self._config.randomise_velocities: + dynamics.randomise_velocities() + + # Snapshot the context state once for crash recovery + # if any MC move changed positions. + if needs_pre_run_snapshot: + dynamics._d._pre_run_state = dynamics.context().getState( + getPositions=True, getVelocities=True + ) + needs_pre_run_snapshot = False # Write ghost residues immediately after the GCMC # move if a frame will be saved in the upcoming From 597994653c5f9a9bc5ae3d3c55ad340cfe83a66e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 11:52:53 +0100 Subject: [PATCH 145/212] Expose auto_fix_minimise dynamics run option. --- src/somd2/config/_config.py | 16 ++++++++++++++++ src/somd2/runner/_repex.py | 4 ++-- src/somd2/runner/_runner.py | 16 ++++++++-------- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 3ab44495..e2969fa8 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -162,6 +162,7 @@ def __init__( overwrite=False, somd1_compatibility=False, pert_file=None, + auto_fix_minimise=True, save_crash_report=False, save_energy_components=False, page_size=None, @@ -496,6 +497,10 @@ def __init__( The path to a SOMD1 perturbation file to apply to the reference system. When set, this will automatically set 'somd1_compatibility' to True. + auto_fix_minimise: bool + Whether to attempt to automatically recover from simulation instabilities + by minimising and restarting. Defaults to True. + save_crash_report: bool Whether to save a crash report if the simulation crashes. @@ -599,6 +604,7 @@ def __init__( self.taylor_power = taylor_power self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file + self.auto_fix_minimise = auto_fix_minimise self.save_crash_report = save_crash_report self.save_energy_components = save_energy_components self.timeout = timeout @@ -2383,6 +2389,16 @@ def pert_file(self, pert_file): self._pert_file = pert_file + @property + def auto_fix_minimise(self): + return self._auto_fix_minimise + + @auto_fix_minimise.setter + def auto_fix_minimise(self, auto_fix_minimise): + if not isinstance(auto_fix_minimise, bool): + raise ValueError("'auto_fix_minimise' must be of type 'bool'") + self._auto_fix_minimise = auto_fix_minimise + @property def save_crash_report(self): return self._save_crash_report diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index e8c5a3d0..be189ea1 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1332,7 +1332,7 @@ def _run_block( lambda_windows=lambdas, rest2_scale_factors=self._rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=self._config.num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -1563,7 +1563,7 @@ def _equilibrate(self, index): energy_frequency=0, frame_frequency=0, save_velocities=False, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, save_crash_report=self._config.save_crash_report, ) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index b7356089..cc3a8c43 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -578,7 +578,7 @@ def generate_lam_vals(lambda_base, increment=0.001): energy_frequency=0, frame_frequency=0, save_velocities=False, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, save_crash_report=self._config.save_crash_report, ) @@ -819,7 +819,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -868,7 +868,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -882,7 +882,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -1024,7 +1024,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -1151,7 +1151,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -1186,7 +1186,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, @@ -1200,7 +1200,7 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_windows=lambda_array, rest2_scale_factors=rest2_scale_factors, save_velocities=self._config.save_velocities, - auto_fix_minimise=True, + auto_fix_minimise=self._config.auto_fix_minimise, num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, From 3243d7b4764bdc74deb4d5828e5fb8237758aa35 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 12:28:31 +0100 Subject: [PATCH 146/212] Only set pre-run state when auto_fix_minimise=True. --- src/somd2/runner/_repex.py | 24 +++++++++++++++--------- src/somd2/runner/_runner.py | 5 +++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index be189ea1..4c6aa2e9 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -526,10 +526,6 @@ def mix_states(self): old_state = self._old_states[i] self._num_swaps[old_state, state] += 1 - # Snapshot the pre-run state for crash recovery. - for i, state in enumerate(self._states): - self._dynamics[i]._d._pre_run_state = self._openmm_states[state] - # Store the current states. self._old_states = self._states.copy() @@ -1153,6 +1149,13 @@ def run(self): ) self._dynamics_cache.mix_states() + # Snapshot the pre-run state for crash recovery. + if self._config.auto_fix_minimise: + for i, state in enumerate(self._dynamics_cache.get_states()): + self._dynamics_cache._dynamics[ + i + ]._d._pre_run_state = self._dynamics_cache._openmm_states[state] + # This is a checkpoint cycle. if is_checkpoint: # Update the block number. @@ -1283,8 +1286,10 @@ def _run_block( dynamics, gcmc_sampler = self._dynamics_cache.get(index) # Track whether any MC move changed the context positions so we - # can update _pre_run_state once at the end. + # can update _pre_run_state once at the end. Only needed when + # crash recovery is enabled. needs_pre_run_snapshot = False + auto_fix_minimise = self._config.auto_fix_minimise # Perform the GCMC move before dynamics so that the energies # computed during dynamics are consistent with the state used @@ -1297,7 +1302,8 @@ def _run_block( finally: gcmc_sampler.pop() - needs_pre_run_snapshot = True + if auto_fix_minimise: + needs_pre_run_snapshot = True # Write ghost residues immediately after the GCMC move so the # ghost state and frame (saved during dynamics) are consistent. @@ -1308,11 +1314,11 @@ def _run_block( if self._terminal_flip_samplers is not None and is_terminal_flip: _logger.info(f"Performing terminal flip move at {_lam_sym} = {lam:.5f}") if self._terminal_flip_samplers[index].move(dynamics.context()): - needs_pre_run_snapshot = True + if auto_fix_minimise: + needs_pre_run_snapshot = True # Snapshot the context state for crash recovery if any MC move - # changed positions. This overwrites the snapshot set in - # mix_states() so _rebuild_and_minimise() has a consistent state. + # changed positions. if needs_pre_run_snapshot: dynamics._d._pre_run_state = dynamics.context().getState( getPositions=True, getVelocities=True diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index cc3a8c43..188f8ce2 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -771,7 +771,7 @@ def generate_lam_vals(lambda_base, increment=0.001): gcmc_sampler.pop() # GCMC always changes positions. - needs_pre_run_snapshot = True + needs_pre_run_snapshot = self._config.auto_fix_minimise # Perform a terminal flip move at the specified frequency. if ( @@ -786,7 +786,8 @@ def generate_lam_vals(lambda_base, increment=0.001): dynamics.context() ) if flip_accepted: - needs_pre_run_snapshot = True + if self._config.auto_fix_minimise: + needs_pre_run_snapshot = True if self._config.randomise_velocities: dynamics.randomise_velocities() From 727269cf56b070cc54a9f937f2d6f95963b8ace0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 14:03:39 +0100 Subject: [PATCH 147/212] Add support for post-equilibration checkpoint. --- src/somd2/runner/_base.py | 61 +++++++++++++++++++++---------------- src/somd2/runner/_repex.py | 46 +++++++++++++++++++++++++--- src/somd2/runner/_runner.py | 25 +++++++++++++++ 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index f4c2ed79..3e05798b 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1820,26 +1820,33 @@ def _checkpoint( # Get the lambda value. lam = self._lambda_values[index] + # -1 is the sentinel for a post-equilibration checkpoint. No + # energies are collected during equilibration, so skip all + # parquet-related work in this case. + is_post_equilibration = block == -1 + # Get the energy trajectory. - df = system.energy_trajectory(to_alchemlyb=True, energy_unit="kT") + if not is_post_equilibration: + df = system.energy_trajectory(to_alchemlyb=True, energy_unit="kT") # Set the lambda values at which energies were sampled. if lambda_energy is None: lambda_energy = self._lambda_values # Create the metadata. - metadata = { - "attrs": df.attrs, - "somd2 version": __version__, - "sire version": f"{_sire_version}+{_sire_revisionid}", - "lambda": f"{lam:.5f}", - "speed": speed, - "temperature": str(self._config.temperature.value()), - } - - # Add the lambda gradient if available. - if lambda_grad is not None: - metadata["lambda_grad"] = [f"{v:.5f}" for v in lambda_grad] + if not is_post_equilibration: + metadata = { + "attrs": df.attrs, + "somd2 version": __version__, + "sire version": f"{_sire_version}+{_sire_revisionid}", + "lambda": f"{lam:.5f}", + "speed": speed, + "temperature": str(self._config.temperature.value()), + } + + # Add the lambda gradient if available. + if lambda_grad is not None: + metadata["lambda_grad"] = [f"{v:.5f}" for v in lambda_grad] if is_final_block: # Save the end-state GCMC topologies for trajectory analysis and visualisation. @@ -1930,7 +1937,7 @@ def _checkpoint( else: # Update the starting block if necessary. - if block == 0: + if block <= 0: block = self._start_block # Save the current trajectory chunk to file. @@ -1958,18 +1965,20 @@ def _checkpoint( # Stream the checkpoint to file. _sr.stream.save(system, self._filenames[index]["checkpoint"]) - # Create the parquet file name. - filename = self._filenames[index]["energy_traj"] - - # Create the parquet file. - if block == self._start_block: - _dataframe_to_parquet(df, metadata=metadata, filename=filename) - # Append to the parquet file. - else: - _parquet_append( - filename, - df.iloc[-self._energy_per_block :], - ) + # Skip parquet creation for post-equilibration checkpoints. + if not is_post_equilibration: + # Create the parquet file name. + filename = self._filenames[index]["energy_traj"] + + # Create the parquet file. + if block == self._start_block: + _dataframe_to_parquet(df, metadata=metadata, filename=filename) + # Append to the parquet file. + else: + _parquet_append( + filename, + df.iloc[-self._energy_per_block :], + ) except Exception as e: return index, e diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 4c6aa2e9..19e474de 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -979,6 +979,39 @@ def run(self): _logger.error("Equilibration cancelled. Exiting.") _sys.exit(1) + # Write a checkpoint immediately after equilibration so that a restart + # after an early production crash doesn't need to re-equilibrate. + if self._is_equilibration and not self._is_restart: + lock = _FileLock(self._lock_file) + with lock.acquire(timeout=self._config.timeout.to("seconds")): + for j in range(num_checkpoint_batches): + replicas = replica_list[ + j * num_checkpoint_workers : (j + 1) * num_checkpoint_workers + ] + with ThreadPoolExecutor( + max_workers=num_checkpoint_workers + ) as executor: + try: + for index, error in executor.map( + self._checkpoint, + replicas, + repeat(self._lambda_values), + repeat(-1), + repeat(cycles), + ): + if error is not None: + msg = ( + f"Post-equilibration checkpoint failed for {_lam_sym} = " + f"{self._lambda_values[index]:.5f}:\n{error}" + ) + _logger.error(msg) + raise error + except KeyboardInterrupt: + _logger.error( + "Post-equilibration checkpoint cancelled. Exiting." + ) + _sys.exit(1) + # Current block number. block = self._start_block @@ -1753,10 +1786,15 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # dynamics object. dynamics._d._sire_mols.delete_all_frames() - _logger.info( - f"Finished block {block + 1} of {self._start_block + num_blocks} " - f"for {_lam_sym} = {lam:.5f}" - ) + if block == -1: + _logger.info( + f"Writing post-equilibration checkpoint for {_lam_sym} = {lam:.5f}" + ) + else: + _logger.info( + f"Finished block {block + 1} of {self._start_block + num_blocks} " + f"for {_lam_sym} = {lam:.5f}" + ) # Log the number of waters within the GCMC sampling volume. if gcmc_sampler is not None: diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 188f8ce2..8dabc360 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -719,6 +719,31 @@ def generate_lam_vals(lambda_base, increment=0.001): # Store the checkpoint time in nanoseconds. checkpoint_interval = checkpoint_frequency.to("ns") + # Write a checkpoint immediately after equilibration so that a restart + # after an early production crash doesn't need to re-equilibrate. + if is_equilibrated: + lock = _FileLock(self._lock_file) + with lock.acquire(timeout=self._config.timeout.to("seconds")): + _, error = self._checkpoint( + system, + index, + block=-1, + speed=0.0, + lambda_energy=lambda_energy, + lambda_grad=lambda_grad, + ) + if error is not None: + msg = ( + f"Post-equilibration checkpoint failed for {_lam_sym} = " + f"{lambda_value:.5f}:\n{error}" + ) + _logger.error(msg) + raise error + _logger.info( + f"Writing post-equilibration checkpoint " + f"for {_lam_sym} = {lambda_value:.5f}" + ) + # Store the start time. start = _timer() From 306102cd8f04e5435461ab311366577e40223360 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 16:28:09 +0100 Subject: [PATCH 148/212] Update dynamics_kwargs to use equilibration_timestep. --- src/somd2/runner/_repex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 19e474de..3be4ff8b 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1583,6 +1583,7 @@ def _equilibrate(self, index): dynamics_kwargs["device"] = device dynamics_kwargs["lambda_value"] = self._lambda_values[index] dynamics_kwargs["rest2_scale"] = self._rest2_scale_factors[index] + dynamics_kwargs["timestep"] = self._config._equilibration_timestep dynamics_kwargs["constraint"] = constraint dynamics_kwargs["perturbable_constraint"] = perturbable_constraint From 9d591487d40760b8e35b6fc6f3145721b5f32498 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 26 Mar 2026 10:51:36 +0000 Subject: [PATCH 149/212] Context reconstruction no longer needed for energy decomposition. --- src/somd2/runner/_base.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 3e05798b..c8525f17 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -2038,30 +2038,21 @@ def _save_energy_components(self, index, context): The current OpenMM context. """ - from copy import deepcopy import openmm - # Get the current context and system. - system = deepcopy(context.getSystem()) - - # Add each force to a unique group. - for i, f in enumerate(system.getForces()): - f.setForceGroup(i) - - # Create a new context. - new_context = openmm.Context(system, deepcopy(context.getIntegrator())) - new_context.setPositions(context.getState(getPositions=True).getPositions()) - header = f"{'# Sample':>10}" record = f"{self._nrg_sample:>10}" - # Process the records. - for i, f in enumerate(system.getForces()): - state = new_context.getState(getEnergy=True, groups={i}) - name = f.getName() + # Use the named force groups already assigned by sire_to_openmm_system; + # no deepcopy or new context is needed. + for name, grp in context._force_group_map.items(): + state = context.getState(getEnergy=True, groups=(1 << grp)) + nrg = state.getPotentialEnergy().value_in_unit( + openmm.unit.kilocalories_per_mole + ) name_len = len(name) - header += f"{f.getName():>{name_len + 2}}" - record += f"{state.getPotentialEnergy().value_in_unit(openmm.unit.kilocalories_per_mole):>{name_len + 2}.2f}" + header += f"{name:>{name_len + 2}}" + record += f"{nrg:>{name_len + 2}.2f}" # Write to file. if self._nrg_sample == 0: From b9ad5f01415b5559c229fa6974d0446f703a4bbb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 13 Apr 2026 14:53:49 +0100 Subject: [PATCH 150/212] Write CSV of energy components during checkpoint. --- src/somd2/config/_config.py | 3 +-- src/somd2/runner/_base.py | 53 +++++++++++++++++++------------------ src/somd2/runner/_repex.py | 16 +++++------ src/somd2/runner/_runner.py | 36 +++++++++---------------- 4 files changed, 46 insertions(+), 62 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index e2969fa8..40c2d46d 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -164,7 +164,7 @@ def __init__( pert_file=None, auto_fix_minimise=True, save_crash_report=False, - save_energy_components=False, + save_energy_components=True, page_size=None, timeout="300 s", ): @@ -506,7 +506,6 @@ def __init__( save_energy_components: bool Whether to save the energy contribution for each force when checkpointing. - This is useful when debugging crashes. page_size: int The page size for trajectory handling in megabytes. If None, then Sire diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index c8525f17..297b4fee 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -555,9 +555,6 @@ def __init__(self, system, config): self._config.checkpoint_frequency / self._config.energy_frequency ) - # Zero the energy sample. - self._nrg_sample = 0 - # GCMC specific validation. if self._config.gcmc: if self._config.platform not in ["cuda", "opencl"]: @@ -1197,7 +1194,7 @@ def increment_filename(base_filename, suffix): filenames["trajectory"] = str(output_directory / f"traj_{lam}.dcd") filenames["trajectory_chunk"] = str(output_directory / f"traj_{lam}_") filenames["energy_components"] = str( - output_directory / f"energy_components_{lam}.txt" + output_directory / f"energy_components_{lam}.csv" ) filenames["gcmc_ghosts"] = str(output_directory / f"gcmc_ghosts_{lam}.txt") filenames["sampler_stats"] = str(output_directory / f"sampler_stats_{lam}.pkl") @@ -2024,9 +2021,10 @@ def _backup_checkpoint(self, index): return index, None - def _save_energy_components(self, index, context): + def _save_energy_components(self, index, context, time_ns): """ - Internal function to save the energy components for each force group to file. + Internal function to save the energy components for each force group to a + CSV file. Parameters ---------- @@ -2036,35 +2034,38 @@ def _save_energy_components(self, index, context): context : openmm.Context The current OpenMM context. + + time_ns : float + The current simulation time in nanoseconds. """ + import csv as _csv import openmm - header = f"{'# Sample':>10}" - record = f"{self._nrg_sample:>10}" + filepath = self._filenames[index]["energy_components"] + file_exists = _Path(filepath).exists() - # Use the named force groups already assigned by sire_to_openmm_system; - # no deepcopy or new context is needed. - for name, grp in context._force_group_map.items(): + # Use the named force groups already assigned by sire_to_openmm_system, + # sorted alphabetically for a consistent column order across runs. + energies = {} + for name, grp in sorted(context._force_group_map.items()): state = context.getState(getEnergy=True, groups=(1 << grp)) - nrg = state.getPotentialEnergy().value_in_unit( + energies[name] = state.getPotentialEnergy().value_in_unit( openmm.unit.kilocalories_per_mole ) - name_len = len(name) - header += f"{name:>{name_len + 2}}" - record += f"{nrg:>{name_len + 2}.2f}" - - # Write to file. - if self._nrg_sample == 0: - with open(self._filenames[index]["energy_components"], "w") as f: - f.write(header + "\n") - f.write(record + "\n") - else: - with open(self._filenames[index]["energy_components"], "a") as f: - f.write(record + "\n") - # Increment the sample number. - self._nrg_sample += 1 + columns = ["time"] + list(energies.keys()) + row = {"time": round(time_ns, 6)} | { + name: round(nrg, 4) for name, nrg in energies.items() + } + + with open(filepath, "a", newline="") as f: + writer = _csv.DictWriter(f, fieldnames=columns) + if not file_exists: + # Write a comment line with units before the header. + f.write("# time: ns, energy: kcal/mol\n") + writer.writeheader() + writer.writerow(row) def _restore_backup_files(self): """ diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 3be4ff8b..58999d4c 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1397,11 +1397,6 @@ def _run_block( energies = dynamics._current_energy_array() except Exception as e: - try: - # Save the energy components for debugging purposes. - self._save_energy_components(index, dynamics.context()) - except: - pass return False, index, e # Return the index and the energies. @@ -1647,11 +1642,6 @@ def _equilibrate(self, index): ) except Exception as e: - try: - # Save the energy components for debugging purposes. - self._save_energy_components(index, dynamics.context()) - except: - pass return False, index, e return True, index, None @@ -1767,6 +1757,12 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Commit the current system. system = dynamics.commit() + # Save the energy contribution for each force. + if self._config.save_energy_components: + self._save_energy_components( + index, dynamics.context(), system.time().to("ns") + ) + # If performing GCMC, then we need to flag the ghost waters. if gcmc_sampler is not None: system = gcmc_sampler._flag_ghost_waters(system) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 8dabc360..7e5f6b73 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -592,10 +592,6 @@ def generate_lam_vals(lambda_base, increment=0.001): system.set_time(_sr.u("0ps")) except Exception as e: - try: - self._save_energy_components(index, dynamics.context()) - except: - pass raise RuntimeError(f"Equilibration failed: {e}") # Work out the lambda values for finite-difference gradient analysis. @@ -914,23 +910,21 @@ def generate_lam_vals(lambda_base, increment=0.001): save_crash_report=self._config.save_crash_report, ) except Exception as e: - try: - self._save_energy_components(index, dynamics.context()) - except: - pass raise RuntimeError( f"Dynamics block {block + 1} for {_lam_sym} = {lambda_value:.5f} failed: {e}" ) # Checkpoint. try: - # Save the energy contribution for each force. - if self._config.save_energy_components: - self._save_energy_components(index, dynamics.context()) - # Commit the current system. system = dynamics.commit() + # Save the energy contribution for each force. + if self._config.save_energy_components: + self._save_energy_components( + index, dynamics.context(), system.time().to("ns") + ) + # If performing GCMC, then we need to flag the ghost waters. if gcmc_sampler is not None: system = gcmc_sampler._flag_ghost_waters(system) @@ -1056,13 +1050,15 @@ def generate_lam_vals(lambda_base, increment=0.001): save_crash_report=self._config.save_crash_report, ) - # Save the energy contribution for each force. - if self._config.save_energy_components: - self._save_energy_components(index, dynamics.context()) - # Commit the current system. system = dynamics.commit() + # Save the energy contribution for each force. + if self._config.save_energy_components: + self._save_energy_components( + index, dynamics.context(), system.time().to("ns") + ) + # Record the end time. block_end = _timer() @@ -1101,10 +1097,6 @@ def generate_lam_vals(lambda_base, increment=0.001): f"{_lam_sym} = {lambda_value:.5f} complete, speed = {speed:.2f} ns day-1" ) except Exception as e: - try: - self._save_energy_components(index, dynamics.context()) - except: - pass raise RuntimeError( f"Final dynamics block for {lam_sym} = {lambda_value:.5f} failed: {e}" ) @@ -1232,10 +1224,6 @@ def generate_lam_vals(lambda_base, increment=0.001): save_crash_report=self._config.save_crash_report, ) except Exception as e: - try: - self._save_energy_components(index, dynamics.context()) - except: - pass raise RuntimeError( f"Dynamics for {_lam_sym} = {lambda_value:.5f} failed: {e}" ) From 8b1e152c5454d1a770a4e67d43a76f24dd41d23d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 14 Apr 2026 16:02:41 +0100 Subject: [PATCH 151/212] Add option to save OpenMM system to XML file. --- src/somd2/config/_config.py | 17 +++++++++++++++++ src/somd2/runner/_base.py | 1 + src/somd2/runner/_repex.py | 24 ++++++++++++++++++++++++ src/somd2/runner/_runner.py | 5 +++++ 4 files changed, 47 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 40c2d46d..6c68e542 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -165,6 +165,7 @@ def __init__( auto_fix_minimise=True, save_crash_report=False, save_energy_components=True, + save_xml=False, page_size=None, timeout="300 s", ): @@ -507,6 +508,11 @@ def __init__( save_energy_components: bool Whether to save the energy contribution for each force when checkpointing. + save_xml: bool + Whether to write an XML file for the OpenMM system to the output + directory on startup. This can be useful for debugging or for + use with other tools that can read OpenMM XML files. + page_size: int The page size for trajectory handling in megabytes. If None, then Sire will automatically set the page size. @@ -606,6 +612,7 @@ def __init__( self.auto_fix_minimise = auto_fix_minimise self.save_crash_report = save_crash_report self.save_energy_components = save_energy_components + self.save_xml = save_xml self.timeout = timeout self.num_energy_neighbours = num_energy_neighbours self.null_energy = null_energy @@ -2418,6 +2425,16 @@ def save_energy_components(self, save_energy_components): raise ValueError("'save_energy_components' must be of type 'bool'") self._save_energy_components = save_energy_components + @property + def save_xml(self): + return self._save_xml + + @save_xml.setter + def save_xml(self, save_xml): + if not isinstance(save_xml, bool): + raise ValueError("'save_xml' must be of type 'bool'") + self._save_xml = save_xml + @property def page_size(self): return self._page_size diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 297b4fee..e717a29d 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1198,6 +1198,7 @@ def increment_filename(base_filename, suffix): ) filenames["gcmc_ghosts"] = str(output_directory / f"gcmc_ghosts_{lam}.txt") filenames["sampler_stats"] = str(output_directory / f"sampler_stats_{lam}.pkl") + filenames["xml"] = str(output_directory / f"system_{lam}.xml") if restart: filenames["config"] = str( output_directory / increment_filename("config", "yaml") diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 58999d4c..09fc46a3 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -53,6 +53,7 @@ def __init__( gcmc_kwargs=None, output_directory=None, perturbed_system=None, + xml_filenames=None, ): """ Constructor. @@ -84,6 +85,10 @@ def __init__( perturbed_system: :class: `System ` The perturbed end-state system used to seed starting coordinates for lambda > 0.5 replicas. If None, the perturbed state is not used. + + xml_filenames: list of str + A list of file paths for the OpenMM XML output, one per replica. + If None, XML files are not written. """ # Warn if the number of replicas is not a multiple of the number of GPUs. @@ -117,6 +122,7 @@ def __init__( gcmc_kwargs=gcmc_kwargs, output_directory=output_directory, perturbed_system=perturbed_system, + xml_filenames=xml_filenames, ) def __setstate__(self, state): @@ -168,6 +174,7 @@ def _create_dynamics( gcmc_kwargs=None, output_directory=None, perturbed_system=None, + xml_filenames=None, ): """ Create the dynamics objects. @@ -199,6 +206,10 @@ def _create_dynamics( perturbed_system: :class: `System ` The perturbed end-state system used to seed starting coordinates for lambda > 0.5 replicas. If None, the perturbed state is not used. + + xml_filenames: list of str + A list of file paths for the OpenMM XML output, one per replica. + If None, XML files are not written. """ from math import floor @@ -315,6 +326,13 @@ def _create_dynamics( # Append the dynamics object. self._dynamics.append(dynamics) + # Write the OpenMM XML file to the output directory. + if xml_filenames is not None: + _logger.info( + f"Writing OpenMM XML for lambda {lam:.5f} on device {device}" + ) + dynamics.to_xml(xml_filenames[i]) + # Track memory footprint for this device. info = device_mem[device] info["count"] += 1 @@ -740,6 +758,11 @@ def __init__(self, system, config): # Create the dynamics cache. if not self._is_restart: + xml_filenames = ( + [self._filenames[i]["xml"] for i in range(len(self._lambda_values))] + if self._config.save_xml + else None + ) self._dynamics_cache = DynamicsCache( self._system, self._lambda_values, @@ -749,6 +772,7 @@ def __init__(self, system, config): gcmc_kwargs=self._gcmc_kwargs, perturbed_system=self._perturbed_system, output_directory=self._config.output_directory, + xml_filenames=xml_filenames, ) else: _logger.debug("Restarting from file") diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 7e5f6b73..ccd1073b 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -641,6 +641,11 @@ def generate_lam_vals(lambda_base, increment=0.001): # Create the dynamics object. dynamics = system.dynamics(**dynamics_kwargs) + # Write the OpenMM XML file to the output directory. + if self._config.save_xml and not is_restart: + _logger.info(f"Writing OpenMM XML for {_lam_sym} = {lambda_value:.5f}") + dynamics.to_xml(self._filenames[index]["xml"]) + # Reset the GCMC sampler. This resets the sampling statistics and clears # the associated OpenMM forces. if gcmc_sampler is not None: From a61191165bde7c84bfd2851ae667ebf35dfdfe44 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 15 Apr 2026 09:02:16 +0100 Subject: [PATCH 152/212] Improve energy components debugging option. --- README.md | 19 ++++++ src/somd2/config/_config.py | 5 +- src/somd2/runner/_base.py | 69 +++++++++++++++++----- src/somd2/runner/_repex.py | 26 ++++++-- src/somd2/runner/_runner.py | 114 +++++++++++++++++++++++++++++++++--- 5 files changed, 204 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 8fc73d6f..d61601e3 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,25 @@ geometry. To override this for all groups: somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-angle "180 degrees" ``` +## Debugging with energy components + +To help diagnose simulation instabilities, `SOMD2` can record the potential +energy contribution from each OpenMM force group at every `energy-frequency` +interval. This is enabled with the `--save-energy-components` flag: + +``` +somd2 perturbable_system.bss --save-energy-components +``` + +One Parquet file per λ window is written to the output directory, named +`energy_components_.parquet`. Times are in nanoseconds and energies in +kcal/mol; both are stored as schema metadata in the file. + +> [!NOTE] +> Energy components are written more frequently than checkpoint files and are +> not guarded by the file lock, so they may lead the checkpoint files by up +> to one `checkpoint-frequency` interval when copying output mid-simulation. + ## Copying output files during a simulation When `SOMD2` writes checkpoint files it acquires an exclusive diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 6c68e542..1ba21b5f 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -506,7 +506,10 @@ def __init__( Whether to save a crash report if the simulation crashes. save_energy_components: bool - Whether to save the energy contribution for each force when checkpointing. + Whether to save per-force-group energy contributions to a Parquet file + in the output directory. Energies are recorded at every 'energy_frequency' + interval, or 'gcmc_frequency' when running with GCMC. Intended for + debugging purposes. save_xml: bool Whether to write an XML file for the OpenMM system to the output diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index e717a29d..664138dc 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -498,6 +498,10 @@ def __init__(self, system, config): # Check the output directories and create names of output files. self._filenames = self._prepare_output() + # Per-window cache of the last saved energy-components time (ns), + # used to skip duplicate rows on restart. + self._last_ec_time = {} + # Store the current system as a reference. self._reference_system = self._system.clone() @@ -1194,7 +1198,7 @@ def increment_filename(base_filename, suffix): filenames["trajectory"] = str(output_directory / f"traj_{lam}.dcd") filenames["trajectory_chunk"] = str(output_directory / f"traj_{lam}_") filenames["energy_components"] = str( - output_directory / f"energy_components_{lam}.csv" + output_directory / f"energy_components_{lam}.parquet" ) filenames["gcmc_ghosts"] = str(output_directory / f"gcmc_ghosts_{lam}.txt") filenames["sampler_stats"] = str(output_directory / f"sampler_stats_{lam}.pkl") @@ -2020,12 +2024,23 @@ def _backup_checkpoint(self, index): except Exception as e: return index, e + try: + # Backup the existing energy components file, if it exists. + path = _Path(self._filenames[index]["energy_components"]) + if path.exists() and path.stat().st_size > 0: + _copyfile( + self._filenames[index]["energy_components"], + str(self._filenames[index]["energy_components"]) + ".bak", + ) + except Exception as e: + return index, e + return index, None def _save_energy_components(self, index, context, time_ns): """ Internal function to save the energy components for each force group to a - CSV file. + Parquet file. Parameters ---------- @@ -2040,11 +2055,28 @@ def _save_energy_components(self, index, context, time_ns): The current simulation time in nanoseconds. """ - import csv as _csv + import json as _json import openmm + import pandas as _pd + import pyarrow as _pa + import pyarrow.parquet as _pq_local filepath = self._filenames[index]["energy_components"] - file_exists = _Path(filepath).exists() + + # Lazy-initialise the last saved time for restart deduplication. + # On the first call for this window, read the existing file (if any) + # to find the maximum time already written. + if index not in self._last_ec_time: + path = _Path(filepath) + if path.exists() and path.stat().st_size > 0: + existing = _pq_local.read_table(filepath).to_pandas() + self._last_ec_time[index] = float(existing["time"].max()) + else: + self._last_ec_time[index] = -1.0 + + # Skip rows that have already been written (restart deduplication). + if time_ns <= self._last_ec_time[index]: + return # Use the named force groups already assigned by sire_to_openmm_system, # sorted alphabetically for a consistent column order across runs. @@ -2055,18 +2087,25 @@ def _save_energy_components(self, index, context, time_ns): openmm.unit.kilocalories_per_mole ) - columns = ["time"] + list(energies.keys()) - row = {"time": round(time_ns, 6)} | { - name: round(nrg, 4) for name, nrg in energies.items() - } + row = {"time": round(time_ns, 6)} | energies + df = _pd.DataFrame([row]) + + path = _Path(filepath) + if path.exists() and path.stat().st_size > 0: + _parquet_append(filepath, df) + else: + # First write: embed units as schema metadata under the "somd2" key, + # consistent with how the energy trajectory parquet files are written. + table = _pa.Table.from_pandas(df) + meta = _json.dumps( + {"time_units": "ns", "energy_units": "kcal/mol"} + ).encode() + table = table.replace_schema_metadata( + {b"somd2": meta, **table.schema.metadata} + ) + _pq_local.write_table(table, filepath) - with open(filepath, "a", newline="") as f: - writer = _csv.DictWriter(f, fieldnames=columns) - if not file_exists: - # Write a comment line with units before the header. - f.write("# time: ns, energy: kcal/mol\n") - writer.writeheader() - writer.writerow(row) + self._last_ec_time[index] = time_ns def _restore_backup_files(self): """ diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 09fc46a3..3950a7c4 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1108,6 +1108,16 @@ def run(self): # Whether a frame is saved at the end of the cycle. write_gcmc_ghosts = (i + 1) % cycles_per_frame == 0 + # Current simulation time in ns for energy components saving. + time_ns = ( + ( + self._start_block * checkpoint_frequency + + (i + 1) * self._config.energy_frequency + ).to("ns") + if self._config.save_energy_components + else None + ) + # Run a dynamics block for each replica, making sure only each GPU is only # oversubscribed by a factor of self._config.oversubscription_factor. for j in range(num_batches): @@ -1121,6 +1131,7 @@ def run(self): repeat(is_gcmc), repeat(write_gcmc_ghosts), repeat(is_terminal_flip), + repeat(time_ns), ): if not result: _logger.error( @@ -1294,6 +1305,7 @@ def _run_block( is_gcmc=False, write_gcmc_ghosts=False, is_terminal_flip=False, + time_ns=None, ): """ Run a dynamics block for a given replica. @@ -1321,6 +1333,10 @@ def _run_block( Whether a terminal flip MC move should be performed before the dynamics block. + time_ns: float or None + The current simulation time in nanoseconds, used when saving energy + components. If None, energy components are not saved. + Returns ------- @@ -1417,6 +1433,10 @@ def _run_block( # Save the OpenMM state. self._dynamics_cache.save_openmm_state(index) + # Save the energy contribution for each force. + if self._config.save_energy_components and time_ns is not None: + self._save_energy_components(index, dynamics.context(), time_ns) + # Get the energy at each lambda value. energies = dynamics._current_energy_array() @@ -1781,12 +1801,6 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Commit the current system. system = dynamics.commit() - # Save the energy contribution for each force. - if self._config.save_energy_components: - self._save_energy_components( - index, dynamics.context(), system.time().to("ns") - ) - # If performing GCMC, then we need to flag the ghost waters. if gcmc_sampler is not None: system = gcmc_sampler._flag_ghost_waters(system) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index ccd1073b..542444ca 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -781,6 +781,9 @@ def generate_lam_vals(lambda_base, increment=0.001): save_frames = self._config.frame_frequency > 0 next_frame = self._config.frame_frequency flip_counter = 0 + # Track elapsed simulation time separately for energy components, + # since dynamics blocks increment by gcmc_frequency not energy_frequency. + ec_elapsed = _sr.u("0ps") # Loop until we reach the runtime. while runtime < checkpoint_frequency: @@ -863,6 +866,56 @@ def generate_lam_vals(lambda_base, increment=0.001): # Update the runtime and flip counter. runtime += self._config.energy_frequency + ec_elapsed += self._config.gcmc_frequency + flip_counter += 1 + + # Save the energy contribution for each force. + if self._config.save_energy_components: + self._save_energy_components( + index, + dynamics.context(), + (block * checkpoint_frequency + ec_elapsed).to( + "ns" + ), + ) + + elif self._config.save_energy_components: + # Sub-block loop to save energy components at energy_frequency + # intervals, with optional terminal flip moves. + runtime = _sr.u("0ps") + flip_counter = 0 + while runtime < checkpoint_frequency: + if ( + terminal_flip_sampler is not None + and flip_counter % flip_every == 0 + ): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() + dynamics.run( + self._config.energy_frequency, + energy_frequency=self._config.energy_frequency, + frame_frequency=self._config.frame_frequency, + lambda_windows=lambda_array, + rest2_scale_factors=rest2_scale_factors, + save_velocities=self._config.save_velocities, + auto_fix_minimise=self._config.auto_fix_minimise, + num_energy_neighbours=num_energy_neighbours, + null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, + ) + runtime += self._config.energy_frequency + self._save_energy_components( + index, + dynamics.context(), + (block * checkpoint_frequency + runtime).to("ns"), + ) flip_counter += 1 elif terminal_flip_sampler is not None: @@ -924,12 +977,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Commit the current system. system = dynamics.commit() - # Save the energy contribution for each force. - if self._config.save_energy_components: - self._save_energy_components( - index, dynamics.context(), system.time().to("ns") - ) - # If performing GCMC, then we need to flag the ghost waters. if gcmc_sampler is not None: system = gcmc_sampler._flag_ghost_waters(system) @@ -1103,7 +1150,7 @@ def generate_lam_vals(lambda_base, increment=0.001): ) except Exception as e: raise RuntimeError( - f"Final dynamics block for {lam_sym} = {lambda_value:.5f} failed: {e}" + f"Final dynamics block for {_lam_sym} = {lambda_value:.5f} failed: {e}" ) else: try: @@ -1113,6 +1160,9 @@ def generate_lam_vals(lambda_base, increment=0.001): save_frames = self._config.frame_frequency > 0 next_frame = self._config.frame_frequency flip_counter = 0 + # Track elapsed simulation time separately for energy components, + # since dynamics blocks increment by gcmc_frequency not energy_frequency. + ec_elapsed = _sr.u("0ps") # Loop until we reach the runtime. while runtime < time: @@ -1182,6 +1232,56 @@ def generate_lam_vals(lambda_base, increment=0.001): # Update the runtime and flip counter. runtime += self._config.energy_frequency + ec_elapsed += self._config.gcmc_frequency + flip_counter += 1 + + # Save the energy contribution for each force. + if self._config.save_energy_components: + self._save_energy_components( + index, + dynamics.context(), + (self._config.runtime - time + ec_elapsed).to("ns"), + ) + + elif self._config.save_energy_components: + # Sub-block loop to save energy components at energy_frequency + # intervals, with optional terminal flip moves. + runtime = _sr.u("0ps") + flip_counter = 0 + # Elapsed time before this run (0 for fresh, restart time for restart). + time_base = self._config.runtime - time + while runtime < time: + if ( + terminal_flip_sampler is not None + and flip_counter % flip_every == 0 + ): + _logger.info( + f"Performing terminal flip move at " + f"{_lam_sym} = {lambda_value:.5f}" + ) + if ( + terminal_flip_sampler.move(dynamics.context()) + and self._config.randomise_velocities + ): + dynamics.randomise_velocities() + dynamics.run( + self._config.energy_frequency, + energy_frequency=self._config.energy_frequency, + frame_frequency=self._config.frame_frequency, + lambda_windows=lambda_array, + rest2_scale_factors=rest2_scale_factors, + save_velocities=self._config.save_velocities, + auto_fix_minimise=self._config.auto_fix_minimise, + num_energy_neighbours=num_energy_neighbours, + null_energy=self._config.null_energy, + save_crash_report=self._config.save_crash_report, + ) + runtime += self._config.energy_frequency + self._save_energy_components( + index, + dynamics.context(), + (time_base + runtime).to("ns"), + ) flip_counter += 1 elif terminal_flip_sampler is not None: From 8e238660987b9c879a2b3b01a6a3ebbaba2afc4c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 15 Apr 2026 09:42:18 +0100 Subject: [PATCH 153/212] Consolidate conditional branches to remove code duplication. --- README.md | 12 +- src/somd2/config/_config.py | 5 +- src/somd2/runner/_runner.py | 411 +++++++++++++++--------------------- 3 files changed, 180 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index d61601e3..6ec3b904 100644 --- a/README.md +++ b/README.md @@ -245,8 +245,8 @@ somd2 perturbable_system.bss --terminal-flip-frequency "1 ps" --terminal-flip-an ## Debugging with energy components To help diagnose simulation instabilities, `SOMD2` can record the potential -energy contribution from each OpenMM force group at every `energy-frequency` -interval. This is enabled with the `--save-energy-components` flag: +energy contribution from each OpenMM force group. This is enabled with the +`--save-energy-components` flag: ``` somd2 perturbable_system.bss --save-energy-components @@ -256,6 +256,14 @@ One Parquet file per λ window is written to the output directory, named `energy_components_.parquet`. Times are in nanoseconds and energies in kcal/mol; both are stored as schema metadata in the file. +The recording interval depends on the runner and active samplers: + +- **Replica exchange**: always `energy-frequency` +- **Standard runner, no MC**: `energy-frequency` +- **Standard runner, with MC**: the shortest active MC frequency, i.e. + `gcmc-frequency`, `terminal-flip-frequency`, or the smaller of the two + when both are active + > [!NOTE] > Energy components are written more frequently than checkpoint files and are > not guarded by the file lock, so they may lead the checkpoint files by up diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 1ba21b5f..2a9a461b 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -508,8 +508,9 @@ def __init__( save_energy_components: bool Whether to save per-force-group energy contributions to a Parquet file in the output directory. Energies are recorded at every 'energy_frequency' - interval, or 'gcmc_frequency' when running with GCMC. Intended for - debugging purposes. + interval. When not running replica exchange, the interval is instead the + shortest active MC frequency when running with GCMC or terminal flip moves. + Intended for debugging purposes. save_xml: bool Whether to write an XML file for the OpenMM system to the output diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 542444ca..7479c598 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -473,22 +473,12 @@ def generate_lam_vals(lambda_base, increment=0.001): self._terminal_groups, float(self._config.temperature.value()), ) - flip_every = max( - 1, - round( - ( - self._config.terminal_flip_frequency - / self._config.energy_frequency - ).value() - ), - ) _logger.info( f"Terminal flip sampler ready at {_lam_sym} = {lambda_value:.5f} " - f"(every {flip_every} energy block(s))" + f"(every {self._config.terminal_flip_frequency})" ) else: terminal_flip_sampler = None - flip_every = None # Minimisation. if self._config.minimise: @@ -772,40 +762,87 @@ def generate_lam_vals(lambda_base, increment=0.001): # Run the dynamics. try: - # GCMC specific handling. Note that the frame and checkpoint - # frequencies are multiples of the energy frequency so we can - # run in energy frequency blocks with no remainder. - if self._config.gcmc: - # Initialise the run time and time at which the next frame is saved. + # Run in sub-blocks when any MC sampler is active or energy + # components are being saved; otherwise run the full block. + needs_subblock = ( + gcmc_sampler is not None + or terminal_flip_sampler is not None + or self._config.save_energy_components + ) + if needs_subblock: runtime = _sr.u("0ps") - save_frames = self._config.frame_frequency > 0 - next_frame = self._config.frame_frequency - flip_counter = 0 - # Track elapsed simulation time separately for energy components, - # since dynamics blocks increment by gcmc_frequency not energy_frequency. ec_elapsed = _sr.u("0ps") - - # Loop until we reach the runtime. - while runtime < checkpoint_frequency: - # Perform a GCMC move before dynamics so the ghost - # state is consistent with the energies computed - # during dynamics. - _logger.info( - f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + flip_counter = 0 + save_frames = ( + gcmc_sampler is not None + and self._config.frame_frequency > 0 + ) + next_frame = ( + self._config.frame_frequency if save_frames else None + ) + # Sub-block size: shortest active MC frequency, or + # energy_frequency when only saving energy components. + if ( + gcmc_sampler is not None + and terminal_flip_sampler is not None + ): + block_size = min( + self._config.gcmc_frequency, + self._config.terminal_flip_frequency, ) - gcmc_sampler.push() - try: - gcmc_sampler.move(dynamics.context()) - finally: - gcmc_sampler.pop() + elif gcmc_sampler is not None: + block_size = self._config.gcmc_frequency + elif terminal_flip_sampler is not None: + block_size = self._config.terminal_flip_frequency + else: + block_size = self._config.energy_frequency + # How often to attempt each MC move (in sub-block units). + gcmc_every = ( + max( + 1, + round( + (self._config.gcmc_frequency / block_size).value() + ), + ) + if gcmc_sampler is not None + else None + ) + mc_flip_every = ( + max( + 1, + round( + ( + self._config.terminal_flip_frequency + / block_size + ).value() + ), + ) + if terminal_flip_sampler is not None + else None + ) - # GCMC always changes positions. - needs_pre_run_snapshot = self._config.auto_fix_minimise + while runtime < checkpoint_frequency: + needs_pre_run_snapshot = False - # Perform a terminal flip move at the specified frequency. + # GCMC move. + if ( + gcmc_sampler is not None + and flip_counter % gcmc_every == 0 + ): + _logger.info( + f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + ) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() + needs_pre_run_snapshot = self._config.auto_fix_minimise + + # Terminal flip move. if ( terminal_flip_sampler is not None - and flip_counter % flip_every == 0 + and flip_counter % mc_flip_every == 0 ): _logger.info( f"Performing terminal flip move at " @@ -820,30 +857,24 @@ def generate_lam_vals(lambda_base, increment=0.001): if self._config.randomise_velocities: dynamics.randomise_velocities() - # Snapshot the context state once for crash recovery - # if any MC move changed positions. + # Snapshot the context state for crash recovery if + # any MC move changed positions. if needs_pre_run_snapshot: dynamics._d._pre_run_state = ( dynamics.context().getState( getPositions=True, getVelocities=True ) ) - needs_pre_run_snapshot = False - # Write ghost residues immediately after the GCMC - # move if a frame will be saved in the upcoming - # dynamics block. - if ( - save_frames - and runtime + self._config.energy_frequency - >= next_frame - ): + # Write ghost residues immediately before the dynamics + # block if a frame will be saved within it. + if save_frames and runtime + block_size >= next_frame: gcmc_sampler.write_ghost_residues() next_frame += self._config.frame_frequency - # Run the dynamics in blocks of the GCMC frequency. + # Run the dynamics block. dynamics.run( - self._config.gcmc_frequency, + block_size, energy_frequency=self._config.energy_frequency, frame_frequency=self._config.frame_frequency, lambda_windows=lambda_array, @@ -853,7 +884,6 @@ def generate_lam_vals(lambda_base, increment=0.001): num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, - # GCMC specific options. excess_chemical_potential=( self._mu_ex if gcmc_sampler is not None else None ), @@ -864,12 +894,11 @@ def generate_lam_vals(lambda_base, increment=0.001): ), ) - # Update the runtime and flip counter. - runtime += self._config.energy_frequency - ec_elapsed += self._config.gcmc_frequency + runtime += block_size + ec_elapsed += block_size flip_counter += 1 - # Save the energy contribution for each force. + # Save energy components. if self._config.save_energy_components: self._save_energy_components( index, @@ -879,81 +908,6 @@ def generate_lam_vals(lambda_base, increment=0.001): ), ) - elif self._config.save_energy_components: - # Sub-block loop to save energy components at energy_frequency - # intervals, with optional terminal flip moves. - runtime = _sr.u("0ps") - flip_counter = 0 - while runtime < checkpoint_frequency: - if ( - terminal_flip_sampler is not None - and flip_counter % flip_every == 0 - ): - _logger.info( - f"Performing terminal flip move at " - f"{_lam_sym} = {lambda_value:.5f}" - ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() - dynamics.run( - self._config.energy_frequency, - energy_frequency=self._config.energy_frequency, - frame_frequency=self._config.frame_frequency, - lambda_windows=lambda_array, - rest2_scale_factors=rest2_scale_factors, - save_velocities=self._config.save_velocities, - auto_fix_minimise=self._config.auto_fix_minimise, - num_energy_neighbours=num_energy_neighbours, - null_energy=self._config.null_energy, - save_crash_report=self._config.save_crash_report, - ) - runtime += self._config.energy_frequency - self._save_energy_components( - index, - dynamics.context(), - (block * checkpoint_frequency + runtime).to("ns"), - ) - flip_counter += 1 - - elif terminal_flip_sampler is not None: - # Terminal flip without GCMC: perform flip moves at the - # specified frequency then run the full dynamics block. - n_flips = max( - 1, - round( - ( - checkpoint_frequency - / self._config.terminal_flip_frequency - ).value() - ), - ) - for _ in range(n_flips): - _logger.info( - f"Performing terminal flip move at " - f"{_lam_sym} = {lambda_value:.5f}" - ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() - - dynamics.run( - checkpoint_frequency, - energy_frequency=self._config.energy_frequency, - frame_frequency=self._config.frame_frequency, - lambda_windows=lambda_array, - rest2_scale_factors=rest2_scale_factors, - save_velocities=self._config.save_velocities, - auto_fix_minimise=self._config.auto_fix_minimise, - num_energy_neighbours=num_energy_neighbours, - null_energy=self._config.null_energy, - save_crash_report=self._config.save_crash_report, - ) - else: dynamics.run( checkpoint_frequency, @@ -1154,37 +1108,75 @@ def generate_lam_vals(lambda_base, increment=0.001): ) else: try: - if gcmc_sampler is not None: - # Initialise the run time and time at which the next frame is saved. + # Run in sub-blocks when any MC sampler is active or energy + # components are being saved; otherwise run a single block. + needs_subblock = ( + gcmc_sampler is not None + or terminal_flip_sampler is not None + or self._config.save_energy_components + ) + if needs_subblock: runtime = _sr.u("0ps") - save_frames = self._config.frame_frequency > 0 - next_frame = self._config.frame_frequency - flip_counter = 0 - # Track elapsed simulation time separately for energy components, - # since dynamics blocks increment by gcmc_frequency not energy_frequency. ec_elapsed = _sr.u("0ps") + flip_counter = 0 + save_frames = ( + gcmc_sampler is not None and self._config.frame_frequency > 0 + ) + next_frame = self._config.frame_frequency if save_frames else None + # Sub-block size: shortest active MC frequency, or + # energy_frequency when only saving energy components. + if gcmc_sampler is not None and terminal_flip_sampler is not None: + block_size = min( + self._config.gcmc_frequency, + self._config.terminal_flip_frequency, + ) + elif gcmc_sampler is not None: + block_size = self._config.gcmc_frequency + elif terminal_flip_sampler is not None: + block_size = self._config.terminal_flip_frequency + else: + block_size = self._config.energy_frequency + # How often to attempt each MC move (in sub-block units). + gcmc_every = ( + max( + 1, round((self._config.gcmc_frequency / block_size).value()) + ) + if gcmc_sampler is not None + else None + ) + mc_flip_every = ( + max( + 1, + round( + ( + self._config.terminal_flip_frequency / block_size + ).value() + ), + ) + if terminal_flip_sampler is not None + else None + ) + time_base = self._config.runtime - time - # Loop until we reach the runtime. while runtime < time: - # Perform a GCMC move before dynamics so the ghost - # state is consistent with the energies computed - # during dynamics. - _logger.info( - f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" - ) - gcmc_sampler.push() - try: - gcmc_sampler.move(dynamics.context()) - finally: - gcmc_sampler.pop() + needs_pre_run_snapshot = False - # GCMC always changes positions. - needs_pre_run_snapshot = True + # GCMC move. + if gcmc_sampler is not None and flip_counter % gcmc_every == 0: + _logger.info( + f"Performing GCMC move at {_lam_sym} = {lambda_value:.5f}" + ) + gcmc_sampler.push() + try: + gcmc_sampler.move(dynamics.context()) + finally: + gcmc_sampler.pop() + needs_pre_run_snapshot = self._config.auto_fix_minimise - # Perform a terminal flip move at the specified frequency. + # Terminal flip move. if ( terminal_flip_sampler is not None - and flip_counter % flip_every == 0 + and flip_counter % mc_flip_every == 0 ): _logger.info( f"Performing terminal flip move at " @@ -1194,31 +1186,27 @@ def generate_lam_vals(lambda_base, increment=0.001): dynamics.context() ) if flip_accepted: - needs_pre_run_snapshot = True + if self._config.auto_fix_minimise: + needs_pre_run_snapshot = True if self._config.randomise_velocities: dynamics.randomise_velocities() - # Snapshot the context state once for crash recovery - # if any MC move changed positions. + # Snapshot the context state for crash recovery if + # any MC move changed positions. if needs_pre_run_snapshot: dynamics._d._pre_run_state = dynamics.context().getState( getPositions=True, getVelocities=True ) - needs_pre_run_snapshot = False - # Write ghost residues immediately after the GCMC - # move if a frame will be saved in the upcoming - # dynamics block. - if ( - save_frames - and runtime + self._config.energy_frequency >= next_frame - ): + # Write ghost residues immediately before the dynamics + # block if a frame will be saved within it. + if save_frames and runtime + block_size >= next_frame: gcmc_sampler.write_ghost_residues() next_frame += self._config.frame_frequency - # Run the dynamics in blocks of the GCMC frequency. + # Run the dynamics block. dynamics.run( - self._config.gcmc_frequency, + block_size, energy_frequency=self._config.energy_frequency, frame_frequency=self._config.frame_frequency, lambda_windows=lambda_array, @@ -1228,93 +1216,28 @@ def generate_lam_vals(lambda_base, increment=0.001): num_energy_neighbours=num_energy_neighbours, null_energy=self._config.null_energy, save_crash_report=self._config.save_crash_report, + excess_chemical_potential=( + self._mu_ex if gcmc_sampler is not None else None + ), + num_waters=( + _np.sum(gcmc_sampler.water_state()) + if gcmc_sampler is not None + else None + ), ) - # Update the runtime and flip counter. - runtime += self._config.energy_frequency - ec_elapsed += self._config.gcmc_frequency + runtime += block_size + ec_elapsed += block_size flip_counter += 1 - # Save the energy contribution for each force. + # Save energy components. if self._config.save_energy_components: self._save_energy_components( index, dynamics.context(), - (self._config.runtime - time + ec_elapsed).to("ns"), + (time_base + ec_elapsed).to("ns"), ) - elif self._config.save_energy_components: - # Sub-block loop to save energy components at energy_frequency - # intervals, with optional terminal flip moves. - runtime = _sr.u("0ps") - flip_counter = 0 - # Elapsed time before this run (0 for fresh, restart time for restart). - time_base = self._config.runtime - time - while runtime < time: - if ( - terminal_flip_sampler is not None - and flip_counter % flip_every == 0 - ): - _logger.info( - f"Performing terminal flip move at " - f"{_lam_sym} = {lambda_value:.5f}" - ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() - dynamics.run( - self._config.energy_frequency, - energy_frequency=self._config.energy_frequency, - frame_frequency=self._config.frame_frequency, - lambda_windows=lambda_array, - rest2_scale_factors=rest2_scale_factors, - save_velocities=self._config.save_velocities, - auto_fix_minimise=self._config.auto_fix_minimise, - num_energy_neighbours=num_energy_neighbours, - null_energy=self._config.null_energy, - save_crash_report=self._config.save_crash_report, - ) - runtime += self._config.energy_frequency - self._save_energy_components( - index, - dynamics.context(), - (time_base + runtime).to("ns"), - ) - flip_counter += 1 - - elif terminal_flip_sampler is not None: - # Terminal flip without GCMC: perform flip moves at the - # start then run the full dynamics block. - n_flips = max( - 1, - round((time / self._config.terminal_flip_frequency).value()), - ) - for _ in range(n_flips): - _logger.info( - f"Performing terminal flip move at " - f"{_lam_sym} = {lambda_value:.5f}" - ) - if ( - terminal_flip_sampler.move(dynamics.context()) - and self._config.randomise_velocities - ): - dynamics.randomise_velocities() - - dynamics.run( - time, - energy_frequency=self._config.energy_frequency, - frame_frequency=self._config.frame_frequency, - lambda_windows=lambda_array, - rest2_scale_factors=rest2_scale_factors, - save_velocities=self._config.save_velocities, - auto_fix_minimise=self._config.auto_fix_minimise, - num_energy_neighbours=num_energy_neighbours, - null_energy=self._config.null_energy, - save_crash_report=self._config.save_crash_report, - ) - else: dynamics.run( time, From b09fe7f761567936849f4ceb7bef031e019c57cb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 21 Apr 2026 10:35:34 +0100 Subject: [PATCH 154/212] Fix AttributeError on repex restart by deferring terminal flip stats restore --- src/somd2/runner/_repex.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 3950a7c4..60e9b63f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -837,11 +837,6 @@ def __init__(self, system, config): if self._dynamics_cache._gcmc_stats[i] is not None: gcmc_sampler.restore_stats(self._dynamics_cache._gcmc_stats[i]) - # Restore terminal flip sampler statistics. - if self._terminal_flip_samplers is not None: - attempted, accepted = self._dynamics_cache._terminal_flip_stats[i] - self._terminal_flip_samplers[i].reset(attempted, accepted) - # Conversion factor for reduced potential. kT = (_sr.units.k_boltz * self._config.temperature).to(_sr.units.kcal_per_mol) self._beta = 1.0 / kT @@ -878,6 +873,13 @@ def __init__(self, system, config): else: self._terminal_flip_samplers = None + # Restore terminal flip sampler statistics from checkpoint (deferred + # until here so that _terminal_flip_samplers is always initialised first). + if self._is_restart and self._terminal_flip_samplers is not None: + for i in range(len(self._lambda_values)): + attempted, accepted = self._dynamics_cache._terminal_flip_stats[i] + self._terminal_flip_samplers[i].reset(attempted, accepted) + from threading import Lock # Create a lock to guard the dynamics cache. From 05bd793ae3ebc9f0b11a7704cec167b285375d81 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 21 Apr 2026 12:03:19 +0100 Subject: [PATCH 155/212] Add regression test to verify HMR mass consistency for forward/reverse perts. --- tests/conftest.py | 27 ++ tests/runner/inputs/backward.pert | 764 ++++++++++++++++++++++++++++++ tests/runner/inputs/forward.pert | 764 ++++++++++++++++++++++++++++++ tests/runner/test_hmr.py | 50 ++ 4 files changed, 1605 insertions(+) create mode 100644 tests/runner/inputs/backward.pert create mode 100644 tests/runner/inputs/forward.pert diff --git a/tests/conftest.py b/tests/conftest.py index de64927d..af1565b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import pytest import sire as sr @@ -59,3 +60,29 @@ def ethane_methanol_ions(): mols = sr.load(sr.expand(sr.tutorial_url, "merged_molecule_ions.s3")) mols = sr.morph.link_to_reference(mols) return mols + + +@pytest.fixture(scope="session") +def pert_fwd_mols(): + """ + Load the forward perturbation system from AMBER files hosted on the sire + test server and apply the local forward pert file. + """ + from somd2._utils._somd1 import apply_pert + + mols = sr.load_test_files("somd1_forward.prm7", "somd1_forward.rst7") + pert_file = str(Path(__file__).parent / "runner" / "inputs" / "forward.pert") + return apply_pert(mols, pert_file) + + +@pytest.fixture(scope="session") +def pert_rev_mols(): + """ + Load the reverse perturbation system from AMBER files hosted on the sire + test server and apply the local backward pert file. + """ + from somd2._utils._somd1 import apply_pert + + mols = sr.load_test_files("somd1_backward.prm7", "somd1_backward.rst7") + pert_file = str(Path(__file__).parent / "runner" / "inputs" / "backward.pert") + return apply_pert(mols, pert_file) diff --git a/tests/runner/inputs/backward.pert b/tests/runner/inputs/backward.pert new file mode 100644 index 00000000..fc9c68fa --- /dev/null +++ b/tests/runner/inputs/backward.pert @@ -0,0 +1,764 @@ +version 1 +molecule LIG + atom + name C + initial_type C1 + final_type C1 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge 0.13695 + final_charge 0.15665 + endatom + atom + name C1CB + initial_type C5 + final_type C3 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.05488 + final_charge 0.03744 + endatom + atom + name C6B9 + initial_type C7 + final_type C5 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.00427 + final_charge -0.03795 + endatom + atom + name C9SX + initial_type C16 + final_type C14 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.11834 + final_charge -0.11662 + endatom + atom + name CC38 + initial_type C14 + final_type C12 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.04315 + final_charge 0.03754 + endatom + atom + name CFNO + initial_type C6 + final_type C4 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.36103 + final_charge -0.33381 + endatom + atom + name CFYG + initial_type C12 + final_type C10 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.15102 + final_charge 0.14521 + endatom + atom + name CG8F + initial_type C4 + final_type C2 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.19576 + final_charge -0.13592 + endatom + atom + name CHBR + initial_type C2 + final_type C16 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge 0.37902 + final_charge -0.12559 + endatom + atom + name CHYF + initial_type C15 + final_type C13 + initial_LJ 3.39967 0.21000 + final_LJ 3.39967 0.21000 + initial_charge 0.23216 + final_charge 0.22972 + endatom + atom + name CM80 + initial_type C8 + final_type C6 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.17624 + final_charge -0.16460 + endatom + atom + name CNVG + initial_type C11 + final_type C9 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.17763 + final_charge -0.17388 + endatom + atom + name CO2R + initial_type C9 + final_type C7 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.21908 + final_charge 0.17414 + endatom + atom + name CPOI + initial_type C3 + final_type C15 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge 0.22272 + final_charge 0.22186 + endatom + atom + name CVRJ + initial_type C10 + final_type C8 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.13107 + final_charge 0.12274 + endatom + atom + name CWWQ + initial_type C13 + final_type C11 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.14493 + final_charge -0.14827 + endatom + atom + name DU01 + initial_type du + final_type H5 + initial_LJ 0.00000 0.00000 + final_LJ 2.58323 0.01641 + initial_charge 0.00000 + final_charge 0.04771 + endatom + atom + name DU02 + initial_type du + final_type H7 + initial_LJ 0.00000 0.00000 + final_LJ 2.64454 0.01578 + initial_charge 0.00000 + final_charge 0.05601 + endatom + atom + name F + initial_type F1 + final_type F1 + initial_LJ 3.11815 0.06100 + final_LJ 3.11815 0.06100 + initial_charge -0.12847 + final_charge -0.13081 + endatom + atom + name FKXW + initial_type F4 + final_type H2 + initial_LJ 3.11815 0.06100 + final_LJ 2.64454 0.01578 + initial_charge -0.21530 + final_charge 0.05601 + endatom + atom + name FOGY + initial_type F2 + final_type H6 + initial_LJ 3.11815 0.06100 + final_LJ 2.64454 0.01578 + initial_charge -0.21530 + final_charge 0.05601 + endatom + atom + name FR3X + initial_type F3 + final_type F2 + initial_LJ 3.11815 0.06100 + final_LJ 3.11815 0.06100 + initial_charge -0.20706 + final_charge -0.22639 + endatom + atom + name H + initial_type H1 + final_type H1 + initial_LJ 1.10343 0.01409 + final_LJ 1.10343 0.01409 + initial_charge 0.45185 + final_charge 0.45014 + endatom + atom + name HCUZ + initial_type H6 + final_type H11 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.17099 + final_charge 0.16814 + endatom + atom + name HK3Y + initial_type H3 + final_type H8 + initial_LJ 1.10343 0.01409 + final_LJ 1.10343 0.01409 + initial_charge 0.45185 + final_charge 0.45014 + endatom + atom + name HK8P + initial_type H2 + final_type H3 + initial_LJ 0.53454 0.00001 + final_LJ 0.53454 0.00001 + initial_charge 0.42180 + final_charge 0.41410 + endatom + atom + name HQIP + initial_type H10 + final_type H14 + initial_LJ 2.58323 0.01641 + final_LJ 2.58323 0.01641 + initial_charge 0.08414 + final_charge 0.05920 + endatom + atom + name HR9O + initial_type H4 + final_type H9 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.14114 + final_charge 0.13043 + endatom + atom + name HREN + initial_type H7 + final_type H12 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.17060 + final_charge 0.16842 + endatom + atom + name HUDO + initial_type H5 + final_type H10 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.16343 + final_charge 0.15833 + endatom + atom + name HUN5 + initial_type H8 + final_type H13 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.16785 + final_charge 0.16457 + endatom + atom + name HZ3J + initial_type H9 + final_type H4 + initial_LJ 2.58323 0.01641 + final_LJ 2.58323 0.01641 + initial_charge 0.09026 + final_charge 0.04771 + endatom + atom + name N + initial_type N1 + final_type N2 + initial_LJ 3.20688 0.16769 + final_LJ 3.20688 0.16769 + initial_charge -0.33919 + final_charge -0.34137 + endatom + atom + name NNRE + initial_type N2 + final_type N1 + initial_LJ 3.20688 0.16769 + final_LJ 3.20688 0.16769 + initial_charge -1.02643 + final_charge -1.02860 + endatom + atom + name O + initial_type O1 + final_type O4 + initial_LJ 2.99716 0.20947 + final_LJ 2.99716 0.20947 + initial_charge -0.56256 + final_charge -0.58176 + endatom + atom + name OAK1 + initial_type O2 + final_type O1 + initial_LJ 3.02511 0.16847 + final_LJ 3.02511 0.16847 + initial_charge -0.25503 + final_charge -0.24315 + endatom + atom + name OMEC + initial_type O3 + final_type O2 + initial_LJ 3.03981 0.21021 + final_LJ 3.03981 0.21021 + initial_charge -0.64869 + final_charge -0.65063 + endatom + atom + name OOSF + initial_type O4 + final_type O3 + initial_LJ 3.03981 0.21021 + final_LJ 3.03981 0.21021 + initial_charge -0.64869 + final_charge -0.65063 + endatom + atom + name S + initial_type S1 + final_type S1 + initial_LJ 3.56359 0.25000 + final_LJ 3.56359 0.25000 + initial_charge 1.54093 + final_charge 1.53779 + endatom + bond + atom0 C + atom1 CHBR + initial_force 215.23769 + initial_equil 1.53368 + final_force 0.00000 + final_equil 1.53368 + endbond + bond + atom0 C + atom1 DU01 + initial_force 357.85825 + initial_equil 1.09398 + final_force 357.85825 + final_equil 1.09398 + endbond + bond + atom0 CHBR + atom1 DU02 + initial_force 357.85825 + initial_equil 1.09398 + final_force 357.85825 + final_equil 1.09398 + endbond + bond + atom0 CHBR + atom1 FKXW + initial_force 287.85918 + initial_equil 1.35926 + final_force 357.85825 + final_equil 1.09398 + endbond + bond + atom0 CHBR + atom1 FOGY + initial_force 287.85918 + initial_equil 1.35926 + final_force 357.85825 + final_equil 1.09398 + endbond + angle + atom0 CHBR + atom1 C + atom2 C1CB + initial_force 90.87409 + initial_equil 1.88728 + final_force 0.00000 + final_equil 1.88728 + endangle + angle + atom0 CHBR + atom1 C + atom2 HZ3J + initial_force 66.83561 + initial_equil 1.91883 + final_force 0.00000 + final_equil 1.91883 + endangle + angle + atom0 O + atom1 C + atom2 CHBR + initial_force 66.83561 + initial_equil 1.91883 + final_force 0.00000 + final_equil 1.91883 + endangle + angle + atom0 C + atom1 CHBR + atom2 CPOI + initial_force 90.87409 + initial_equil 1.88728 + final_force 0.00000 + final_equil 1.88728 + endangle + angle + atom0 C + atom1 CHBR + atom2 FKXW + initial_force 66.83561 + initial_equil 1.91883 + final_force 0.00000 + final_equil 1.91883 + endangle + angle + atom0 C + atom1 CHBR + atom2 FOGY + initial_force 66.83561 + initial_equil 1.91883 + final_force 0.00000 + final_equil 1.91883 + endangle + angle + atom0 C1CB + atom1 C + atom2 DU01 + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 HZ3J + atom1 C + atom2 DU01 + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 O + atom1 C + atom2 DU01 + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 C + atom1 C1CB + atom2 CFNO + initial_force 76.84599 + initial_equil 2.26374 + final_force 84.46100 + final_equil 2.09590 + endangle + angle + atom0 C + atom1 C1CB + atom2 CG8F + initial_force 90.87409 + initial_equil 1.88728 + final_force 84.46100 + final_equil 2.09590 + endangle + angle + atom0 CPOI + atom1 CG8F + atom2 C1CB + initial_force 90.87409 + initial_equil 1.88728 + final_force 84.46100 + final_equil 2.09590 + endangle + angle + atom0 CPOI + atom1 CG8F + atom2 CO2R + initial_force 76.84599 + initial_equil 2.26374 + final_force 84.46100 + final_equil 2.09590 + endangle + angle + atom0 CPOI + atom1 CHBR + atom2 DU02 + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 FKXW + atom1 CHBR + atom2 DU02 + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 FOGY + atom1 CHBR + atom2 DU02 + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 FOGY + atom1 CHBR + atom2 FKXW + initial_force 66.83561 + initial_equil 1.91883 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 CHBR + atom1 CPOI + atom2 CG8F + initial_force 90.87409 + initial_equil 1.88728 + final_force 66.83561 + final_equil 1.91883 + endangle + dihedral + atom0 CHBR + atom1 C + atom2 C1CB + atom3 CFNO + initial_form 0.2173 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CHBR + atom1 C + atom2 C1CB + atom3 CG8F + initial_form 0.2173 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 C1CB + atom1 C + atom2 CHBR + atom3 FKXW + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 C1CB + atom1 C + atom2 CHBR + atom3 FOGY + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 O + atom1 C + atom2 CHBR + atom3 CPOI + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 O + atom1 C + atom2 CHBR + atom3 FKXW + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 O + atom1 C + atom2 CHBR + atom3 FOGY + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CHBR + atom1 C + atom2 O + atom3 HK8P + initial_form 0.0713 1.0 -0.000000 0.3984 3.0 -0.000000 + final_form 0.0000 1.0 -0.000000 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CPOI + atom1 CHBR + atom2 C + atom3 C1CB + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CPOI + atom1 CHBR + atom2 C + atom3 HZ3J + initial_form 0.0887 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 FKXW + atom1 CHBR + atom2 C + atom3 HZ3J + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + enddihedral + dihedral + atom0 FOGY + atom1 CHBR + atom2 C + atom3 HZ3J + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 CHBR + atom2 CPOI + atom3 CG8F + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 CHBR + atom2 CPOI + atom3 FR3X + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 CHBR + atom2 CPOI + atom3 HQIP + initial_form 0.0887 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 CFNO + atom2 CG8F + atom3 C1CB + initial_form 0.0000 2.0 3.141593 + final_form 1.7667 2.0 3.141593 + enddihedral + dihedral + atom0 CFNO + atom1 C1CB + atom2 C + atom3 DU01 + initial_form 0.0000 3.0 -0.000000 + final_form 0.2173 3.0 -0.000000 + enddihedral + dihedral + atom0 CG8F + atom1 C1CB + atom2 C + atom3 DU01 + initial_form 0.0000 3.0 -0.000000 + final_form 0.2173 3.0 -0.000000 + enddihedral + dihedral + atom0 FKXW + atom1 CHBR + atom2 CPOI + atom3 HQIP + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form 0.2390 3.0 -0.000000 + enddihedral + dihedral + atom0 FOGY + atom1 CHBR + atom2 CPOI + atom3 FR3X + initial_form -0.1944 1.0 3.141593 0.0721 3.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 FOGY + atom1 CHBR + atom2 CPOI + atom3 HQIP + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form 0.2390 3.0 -0.000000 + enddihedral + dihedral + atom0 CG8F + atom1 CPOI + atom2 CHBR + atom3 DU02 + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 FR3X + atom1 CPOI + atom2 CHBR + atom3 DU02 + initial_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 FR3X + atom1 CPOI + atom2 CHBR + atom3 FKXW + initial_form -0.1944 1.0 3.141593 0.0721 3.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 HQIP + atom1 CPOI + atom2 CHBR + atom3 DU02 + initial_form 0.0000 3.0 -0.000000 + final_form 0.2390 3.0 -0.000000 + enddihedral + dihedral + atom0 HK8P + atom1 O + atom2 C + atom3 DU01 + initial_form 0.0000 3.0 -0.000000 + final_form 0.3884 3.0 -0.000000 + enddihedral +endmolecule diff --git a/tests/runner/inputs/forward.pert b/tests/runner/inputs/forward.pert new file mode 100644 index 00000000..6017b587 --- /dev/null +++ b/tests/runner/inputs/forward.pert @@ -0,0 +1,764 @@ +version 1 +molecule LIG + atom + name C + initial_type C1 + final_type C1 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge 0.15665 + final_charge 0.13695 + endatom + atom + name C1CB + initial_type C5 + final_type C7 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.03795 + final_charge -0.00427 + endatom + atom + name C6B9 + initial_type C7 + final_type C9 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.17414 + final_charge 0.21908 + endatom + atom + name CAK1 + initial_type C10 + final_type C12 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.14521 + final_charge 0.15102 + endatom + atom + name CFNO + initial_type C6 + final_type C8 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.16460 + final_charge -0.17624 + endatom + atom + name CFYG + initial_type C13 + final_type C15 + initial_LJ 3.39967 0.21000 + final_LJ 3.39967 0.21000 + initial_charge 0.22972 + final_charge 0.23216 + endatom + atom + name CG8F + initial_type C4 + final_type C6 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.33381 + final_charge -0.36103 + endatom + atom + name CHBR + initial_type C2 + final_type C4 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.13592 + final_charge -0.19576 + endatom + atom + name CHYF + initial_type C15 + final_type C3 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge 0.22186 + final_charge 0.22272 + endatom + atom + name CM80 + initial_type C8 + final_type C10 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.12274 + final_charge 0.13107 + endatom + atom + name CNVG + initial_type C12 + final_type C14 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.03754 + final_charge 0.04315 + endatom + atom + name CO2R + initial_type C9 + final_type C11 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.17388 + final_charge -0.17763 + endatom + atom + name COGY + initial_type C16 + final_type C2 + initial_LJ 3.37953 0.10884 + final_LJ 3.37953 0.10884 + initial_charge -0.12559 + final_charge 0.37902 + endatom + atom + name CPOI + initial_type C3 + final_type C5 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge 0.03744 + final_charge 0.05488 + endatom + atom + name CVRJ + initial_type C11 + final_type C13 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.14827 + final_charge -0.14493 + endatom + atom + name CWWQ + initial_type C14 + final_type C16 + initial_LJ 3.48065 0.08688 + final_LJ 3.48065 0.08688 + initial_charge -0.11662 + final_charge -0.11834 + endatom + atom + name F + initial_type F1 + final_type F1 + initial_LJ 3.11815 0.06100 + final_LJ 3.11815 0.06100 + initial_charge -0.13081 + final_charge -0.12847 + endatom + atom + name FOSF + initial_type F2 + final_type F3 + initial_LJ 3.11815 0.06100 + final_LJ 3.11815 0.06100 + initial_charge -0.22639 + final_charge -0.20706 + endatom + atom + name H + initial_type H1 + final_type H1 + initial_LJ 1.10343 0.01409 + final_LJ 1.10343 0.01409 + initial_charge 0.45014 + final_charge 0.45185 + endatom + atom + name H1ZX + initial_type H14 + final_type H10 + initial_LJ 2.58323 0.01641 + final_LJ 2.58323 0.01641 + initial_charge 0.05920 + final_charge 0.08414 + endatom + atom + name H98Q + initial_type H13 + final_type H8 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.16457 + final_charge 0.16785 + endatom + atom + name HCUZ + initial_type H8 + final_type H3 + initial_LJ 1.10343 0.01409 + final_LJ 1.10343 0.01409 + initial_charge 0.45014 + final_charge 0.45185 + endatom + atom + name HK3Y + initial_type H5 + final_type du + initial_LJ 2.58323 0.01641 + final_LJ 0.00000 0.00000 + initial_charge 0.04771 + final_charge 0.00000 + endatom + atom + name HK8P + initial_type H4 + final_type H9 + initial_LJ 2.58323 0.01641 + final_LJ 2.58323 0.01641 + initial_charge 0.04771 + final_charge 0.09026 + endatom + atom + name HKXW + initial_type H2 + final_type F4 + initial_LJ 2.64454 0.01578 + final_LJ 3.11815 0.06100 + initial_charge 0.05601 + final_charge -0.21530 + endatom + atom + name HNRE + initial_type H3 + final_type H2 + initial_LJ 0.53454 0.00001 + final_LJ 0.53454 0.00001 + initial_charge 0.41410 + final_charge 0.42180 + endatom + atom + name HQIP + initial_type H12 + final_type H7 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.16842 + final_charge 0.17060 + endatom + atom + name HR9O + initial_type H6 + final_type F2 + initial_LJ 2.64454 0.01578 + final_LJ 3.11815 0.06100 + initial_charge 0.05601 + final_charge -0.21530 + endatom + atom + name HREN + initial_type H9 + final_type H4 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.13043 + final_charge 0.14114 + endatom + atom + name HUDO + initial_type H7 + final_type du + initial_LJ 2.64454 0.01578 + final_LJ 0.00000 0.00000 + initial_charge 0.05601 + final_charge 0.00000 + endatom + atom + name HUN5 + initial_type H10 + final_type H5 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.15833 + final_charge 0.16343 + endatom + atom + name HZ3J + initial_type H11 + final_type H6 + initial_LJ 2.57258 0.01561 + final_LJ 2.57258 0.01561 + initial_charge 0.16814 + final_charge 0.17099 + endatom + atom + name N + initial_type N1 + final_type N2 + initial_LJ 3.20688 0.16769 + final_LJ 3.20688 0.16769 + initial_charge -1.02860 + final_charge -1.02643 + endatom + atom + name NR3X + initial_type N2 + final_type N1 + initial_LJ 3.20688 0.16769 + final_LJ 3.20688 0.16769 + initial_charge -0.34137 + final_charge -0.33919 + endatom + atom + name O + initial_type O1 + final_type O2 + initial_LJ 3.02511 0.16847 + final_LJ 3.02511 0.16847 + initial_charge -0.24315 + final_charge -0.25503 + endatom + atom + name O9SX + initial_type O3 + final_type O4 + initial_LJ 3.03981 0.21021 + final_LJ 3.03981 0.21021 + initial_charge -0.65063 + final_charge -0.64869 + endatom + atom + name OC38 + initial_type O2 + final_type O3 + initial_LJ 3.03981 0.21021 + final_LJ 3.03981 0.21021 + initial_charge -0.65063 + final_charge -0.64869 + endatom + atom + name OMEC + initial_type O4 + final_type O1 + initial_LJ 2.99716 0.20947 + final_LJ 2.99716 0.20947 + initial_charge -0.58176 + final_charge -0.56256 + endatom + atom + name S + initial_type S1 + final_type S1 + initial_LJ 3.56359 0.25000 + final_LJ 3.56359 0.25000 + initial_charge 1.53779 + final_charge 1.54093 + endatom + bond + atom0 C + atom1 COGY + initial_force 0.00000 + initial_equil 1.53368 + final_force 215.23769 + final_equil 1.53368 + endbond + bond + atom0 C + atom1 HK3Y + initial_force 357.85825 + initial_equil 1.09398 + final_force 357.85825 + final_equil 1.09398 + endbond + bond + atom0 COGY + atom1 HKXW + initial_force 357.85825 + initial_equil 1.09398 + final_force 287.85918 + final_equil 1.35926 + endbond + bond + atom0 COGY + atom1 HR9O + initial_force 357.85825 + initial_equil 1.09398 + final_force 287.85918 + final_equil 1.35926 + endbond + bond + atom0 COGY + atom1 HUDO + initial_force 357.85825 + initial_equil 1.09398 + final_force 357.85825 + final_equil 1.09398 + endbond + angle + atom0 COGY + atom1 C + atom2 HK8P + initial_force 0.00000 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 CPOI + atom1 C + atom2 COGY + initial_force 0.00000 + initial_equil 1.88728 + final_force 90.87409 + final_equil 1.88728 + endangle + angle + atom0 OMEC + atom1 C + atom2 COGY + initial_force 0.00000 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 C + atom1 COGY + atom2 CHYF + initial_force 0.00000 + initial_equil 1.88728 + final_force 90.87409 + final_equil 1.88728 + endangle + angle + atom0 C + atom1 COGY + atom2 HKXW + initial_force 0.00000 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 C + atom1 COGY + atom2 HR9O + initial_force 0.00000 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 CPOI + atom1 C + atom2 HK3Y + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 HK8P + atom1 C + atom2 HK3Y + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 OMEC + atom1 C + atom2 HK3Y + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 C6B9 + atom1 CHBR + atom2 CHYF + initial_force 84.46100 + initial_equil 2.09590 + final_force 76.84599 + final_equil 2.26374 + endangle + angle + atom0 CPOI + atom1 CHBR + atom2 CHYF + initial_force 84.46100 + initial_equil 2.09590 + final_force 90.87409 + final_equil 1.88728 + endangle + angle + atom0 CHBR + atom1 CHYF + atom2 COGY + initial_force 66.83561 + initial_equil 1.91883 + final_force 90.87409 + final_equil 1.88728 + endangle + angle + atom0 CHYF + atom1 COGY + atom2 HUDO + initial_force 66.83561 + initial_equil 1.91883 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 HKXW + atom1 COGY + atom2 HR9O + initial_force 36.66571 + initial_equil 1.89088 + final_force 66.83561 + final_equil 1.91883 + endangle + angle + atom0 HKXW + atom1 COGY + atom2 HUDO + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 HR9O + atom1 COGY + atom2 HUDO + initial_force 36.66571 + initial_equil 1.89088 + final_force 36.66571 + final_equil 1.89088 + endangle + angle + atom0 C + atom1 CPOI + atom2 CG8F + initial_force 84.46100 + initial_equil 2.09590 + final_force 76.84599 + final_equil 2.26374 + endangle + angle + atom0 C + atom1 CPOI + atom2 CHBR + initial_force 84.46100 + initial_equil 2.09590 + final_force 90.87409 + final_equil 1.88728 + endangle + dihedral + atom0 C + atom1 CG8F + atom2 CHBR + atom3 CPOI + initial_form 1.7667 2.0 3.141593 + final_form 0.0000 2.0 3.141593 + enddihedral + dihedral + atom0 CPOI + atom1 C + atom2 COGY + atom3 CHYF + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 CPOI + atom1 C + atom2 COGY + atom3 HKXW + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 CPOI + atom1 C + atom2 COGY + atom3 HR9O + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 HK8P + atom1 C + atom2 COGY + atom3 HR9O + initial_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 OMEC + atom1 C + atom2 COGY + atom3 HKXW + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 OMEC + atom1 C + atom2 COGY + atom3 HR9O + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 COGY + atom1 C + atom2 OMEC + atom3 HNRE + initial_form 0.0000 1.0 -0.000000 0.0000 3.0 -0.000000 + final_form 0.0713 1.0 -0.000000 0.3984 3.0 -0.000000 + enddihedral + dihedral + atom0 CHYF + atom1 COGY + atom2 C + atom3 HK8P + initial_form 0.0000 3.0 -0.000000 + final_form 0.0887 3.0 -0.000000 + enddihedral + dihedral + atom0 CHYF + atom1 COGY + atom2 C + atom3 OMEC + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 HKXW + atom1 COGY + atom2 C + atom3 HK8P + initial_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 COGY + atom2 CHYF + atom3 CHBR + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 COGY + atom2 CHYF + atom3 FOSF + initial_form 0.0000 3.0 -0.000000 + final_form 0.1467 3.0 -0.000000 + enddihedral + dihedral + atom0 C + atom1 COGY + atom2 CHYF + atom3 H1ZX + initial_form 0.0000 3.0 -0.000000 + final_form 0.0887 3.0 -0.000000 + enddihedral + dihedral + atom0 CG8F + atom1 CPOI + atom2 C + atom3 COGY + initial_form 0.0000 3.0 -0.000000 + final_form 0.2173 3.0 -0.000000 + enddihedral + dihedral + atom0 CHBR + atom1 CPOI + atom2 C + atom3 COGY + initial_form 0.0000 3.0 -0.000000 + final_form 0.2173 3.0 -0.000000 + enddihedral + dihedral + atom0 CHBR + atom1 CHYF + atom2 COGY + atom3 HUDO + initial_form 0.1467 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 FOSF + atom1 CHYF + atom2 COGY + atom3 HKXW + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form -0.1944 1.0 3.141593 0.0721 3.0 -0.000000 + enddihedral + dihedral + atom0 FOSF + atom1 CHYF + atom2 COGY + atom3 HR9O + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form -0.1944 1.0 3.141593 0.0721 3.0 -0.000000 + enddihedral + dihedral + atom0 FOSF + atom1 CHYF + atom2 COGY + atom3 HUDO + initial_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + final_form 0.0000 3.0 -0.000000 0.0000 1.0 -0.000000 + enddihedral + dihedral + atom0 HKXW + atom1 COGY + atom2 CHYF + atom3 H1ZX + initial_form 0.2390 3.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 HR9O + atom1 COGY + atom2 CHYF + atom3 H1ZX + initial_form 0.2390 3.0 -0.000000 + final_form 0.0939 3.0 -0.000000 0.4539 1.0 -0.000000 + enddihedral + dihedral + atom0 HUDO + atom1 COGY + atom2 CHYF + atom3 H1ZX + initial_form 0.2390 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CG8F + atom1 CPOI + atom2 C + atom3 HK3Y + initial_form 0.2173 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 CHBR + atom1 CPOI + atom2 C + atom3 HK3Y + initial_form 0.2173 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral + dihedral + atom0 HNRE + atom1 OMEC + atom2 C + atom3 HK3Y + initial_form 0.3884 3.0 -0.000000 + final_form 0.0000 3.0 -0.000000 + enddihedral +endmolecule diff --git a/tests/runner/test_hmr.py b/tests/runner/test_hmr.py index 998e91f4..9f7efa64 100644 --- a/tests/runner/test_hmr.py +++ b/tests/runner/test_hmr.py @@ -1,8 +1,58 @@ import math +from collections import defaultdict + +import sire as sr from somd2.runner import Runner +def _masses_by_element(system): + """ + Return atom masses for the perturbable ligand at both end states, grouped + and sorted by element symbol. Dummy atoms (element Xx) are excluded. + """ + result = {} + for label, link_fn in ( + ("lam0", sr.morph.link_to_reference), + ("lam1", sr.morph.link_to_perturbed), + ): + linked = link_fn(system) + mol = next(m for m in linked["not water"].molecules() if m.num_atoms() > 1) + by_elem = defaultdict(list) + for atom in mol.atoms(): + elem = atom.element().symbol() + if elem != "Xx": + by_elem[elem].append(round(atom.mass().value(), 4)) + result[label] = {k: sorted(v) for k, v in by_elem.items()} + return result + + +def test_hmr_pertfile(pert_fwd_mols, pert_rev_mols): + """ + Verify HMR gives consistent masses for forward and reverse perturbations. + + Ligand A is the reference (lambda=0) in the forward perturbation and the + perturbed state (lambda=1) in the reverse perturbation. After HMR, the + same physical atoms must carry the same masses in both input paths. + Likewise for Ligand B. + """ + fwd = Runner._repartition_h_mass(pert_fwd_mols, 1.5) + rev = Runner._repartition_h_mass(pert_rev_mols, 1.5) + + fwd_masses = _masses_by_element(fwd) + rev_masses = _masses_by_element(rev) + + # Ligand A: forward lambda=0 must match reverse lambda=1 + assert fwd_masses["lam0"] == rev_masses["lam1"], ( + "Ligand A masses differ between forward λ=0 and reverse λ=1 after HMR" + ) + + # Ligand B: forward lambda=1 must match reverse lambda=0 + assert fwd_masses["lam1"] == rev_masses["lam0"], ( + "Ligand B masses differ between forward λ=1 and reverse λ=0 after HMR" + ) + + def test_hmr(ethane_methanol, ethane_methanol_hmr): """Ensure that we can handle systems that have already been repartioned.""" From ef750576b60693bf508698e16c6261e604f99834 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 21 Apr 2026 16:25:12 +0100 Subject: [PATCH 156/212] Use _lam_sym for test_hmr_pertfile assertion messages. [ci skip] --- tests/runner/test_hmr.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/runner/test_hmr.py b/tests/runner/test_hmr.py index 9f7efa64..4d8a55dd 100644 --- a/tests/runner/test_hmr.py +++ b/tests/runner/test_hmr.py @@ -3,6 +3,7 @@ import sire as sr +from somd2._utils import _lam_sym from somd2.runner import Runner @@ -44,12 +45,12 @@ def test_hmr_pertfile(pert_fwd_mols, pert_rev_mols): # Ligand A: forward lambda=0 must match reverse lambda=1 assert fwd_masses["lam0"] == rev_masses["lam1"], ( - "Ligand A masses differ between forward λ=0 and reverse λ=1 after HMR" + f"Ligand A masses differ between forward {_lam_sym}=0 and reverse {_lam_sym}=1 after HMR" ) # Ligand B: forward lambda=1 must match reverse lambda=0 assert fwd_masses["lam1"] == rev_masses["lam0"], ( - "Ligand B masses differ between forward λ=1 and reverse λ=0 after HMR" + f"Ligand B masses differ between forward {_lam_sym}=1 and reverse {_lam_sym}=0 after HMR" ) From 968668b931136c76cef885ea94b46bfd0b1f79b9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 22 Apr 2026 09:56:40 +0100 Subject: [PATCH 157/212] Add note regarding limitation of pertfile input. [ci skip] --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 6ec3b904..5393bf10 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,13 @@ If you want to load an existing system from a perturbation file and use the new `somd2` [ghost atom bonded-term modifications](https://github.com/OpenBioSim/ghostly), then simply omit the `--somd1-compatibility` option. +> [!NOTE] +> Using a ``pertfile`` as input is only supported when end states have the same +> connectivity, i.e. it can't be used for ring-breaking perturbations, or +> when non-bonded scale factors differ between the end states. For these cases, +> you will need to generate a new perturbation stream file using `prepareFEP.py` +> with the `--somd2` option as described above. + ## GPU oversubscription If you have an NVIDIA GPU that supports the multi-process service (MPS), you can From ed8272883532b4f2ead330e57f9efbb4c4d985f0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 22 Apr 2026 15:51:09 +0100 Subject: [PATCH 158/212] Ensure GCMC water count is up-to-date before resetting sampler. --- src/somd2/runner/_repex.py | 7 +++++++ src/somd2/runner/_runner.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 60e9b63f..c37145c5 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1992,6 +1992,13 @@ def _reset_gcmc_sampler(gcmc_sampler, dynamics): dynamics: sire.mol.Dynamics The dynamics object associated with the GCMC sampler. """ + # Ensure the water count is up to date before resetting. If the last + # move was a bulk sampling move, _is_bulk is True and num_waters() + # needs the stored context to recompute _N. Calling it here, while + # the context is still available, clears _is_bulk so that reset() + # can safely null the context. + gcmc_sampler.num_waters() + # Reset the GCMC sampler. This resets the sampling statistics and # clears the associated OpenMM forces. gcmc_sampler.reset() diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 7479c598..683e6b99 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -639,6 +639,12 @@ def generate_lam_vals(lambda_base, increment=0.001): # Reset the GCMC sampler. This resets the sampling statistics and clears # the associated OpenMM forces. if gcmc_sampler is not None: + # Ensure the water count is up to date before resetting. If the + # last move was a bulk sampling move, _is_bulk is True and + # num_waters() needs the stored context to recompute _N. Calling + # it here, while the context is still available, clears _is_bulk + # so that reset() can safely null the context. + gcmc_sampler.num_waters() gcmc_sampler.reset() # Bind the GCMC sampler to the dynamics object. From 31495c3b406f0d74d4e07d4ddf5fa71a41f67893 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 23 Apr 2026 12:17:46 +0100 Subject: [PATCH 159/212] Fix removal of helper attributes. --- src/somd2/config/_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 2a9a461b..fce678e6 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -706,8 +706,8 @@ def as_dict(self, sire_compatible=False): # Don't include lambda_schedule_name or perturbed_system_file in the dictionary, # since these are just helper attributes. - d.pop("_lambda_schedule_name", None) - d.pop("_perturbed_system_file", None) + d.pop("lambda_schedule_name", None) + d.pop("perturbed_system_file", None) # Handle the lambda schedule separately so that we can use simplified # keyword options. From 7779db2ebab2708c14a3a8ebb183a4e841831113 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 09:37:02 +0100 Subject: [PATCH 160/212] Calculate number of GCMC waters while context is alive. --- src/somd2/_utils/_somd1.py | 91 ++++++++++++++++++++++++++++++++++++- src/somd2/runner/_base.py | 8 ++++ src/somd2/runner/_repex.py | 11 ++--- src/somd2/runner/_runner.py | 13 +++--- 4 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index d25b795b..002d1cbe 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -19,7 +19,12 @@ # along with SOMD2. If not, see . ##################################################################### -__all__ = ["apply_pert", "make_compatible", "reconstruct_system"] +__all__ = [ + "apply_pert", + "make_compatible", + "reconstruct_intrascale", + "reconstruct_system", +] from sire.system import System as _System from sire.legacy.System import System as _LegacySystem @@ -611,6 +616,90 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): return system +def reconstruct_intrascale(system): + """ + Reconstruct end-state connectivity and intrascale matrices for perturbable + molecules from their bonded terms. This is required when a perturbation + file is used with AMBER topology/coordinate input, since the pertfile + assumes unchanged connectivity, which does not hold for ring-breaking or + ring-size-changing perturbations. + + Parameters + ---------- + + system : sire.system.System, sire.legacy.System.System + The system containing the perturbable molecules. + + Returns + ------- + + system : sire.system.System + The updated system with corrected connectivity0, connectivity1, + intrascale0, and intrascale1 properties on each perturbable molecule. + """ + + import sire.legacy.CAS as _SireCAS + + if not isinstance(system, (_System, _LegacySystem)): + raise TypeError( + "'system' must of type 'sire.system.System' or 'sire.legacy.System.System'" + ) + + if isinstance(system, _LegacySystem): + system = _System(system) + + system = system.clone() + + try: + pert_mols = system.molecules("property is_perturbable") + except KeyError: + raise KeyError("No perturbable molecules in the system") + + r = _SireCAS.Symbol("r") + + for mol in pert_mols: + info = mol.info() + + # Build connectivity from bond0 potentials, skipping zero-k bonds. + conn0 = _SireMol.Connectivity(info).edit() + for bond in mol.property("bond0").potentials(): + if _SireMM.AmberBond(bond.function(), r).k() != 0.0: + conn0.connect(bond.atom0(), bond.atom1()) + conn0 = conn0.commit() + + # Build connectivity from bond1 potentials, skipping zero-k bonds. + conn1 = _SireMol.Connectivity(info).edit() + for bond in mol.property("bond1").potentials(): + if _SireMM.AmberBond(bond.function(), r).k() != 0.0: + conn1.connect(bond.atom0(), bond.atom1()) + conn1 = conn1.commit() + + # Get the 1-4 scale factors from the lambda=0 forcefield. + ff = mol.property("forcefield0") + sf14 = _SireMM.CLJScaleFactor( + ff.electrostatic14_scale_factor(), ff.vdw14_scale_factor() + ) + + # Build intrascale matrices from the per-state connectivity. + intra0 = _SireMM.CLJNBPairs(conn0, sf14) + intra1 = _SireMM.CLJNBPairs(conn1, sf14) + + edit_mol = mol.edit() + + if conn0 == conn1: + edit_mol = edit_mol.set_property("connectivity", conn0).molecule() + else: + edit_mol = edit_mol.set_property("connectivity0", conn0).molecule() + edit_mol = edit_mol.set_property("connectivity1", conn1).molecule() + + edit_mol = edit_mol.set_property("intrascale0", intra0).molecule() + edit_mol = edit_mol.set_property("intrascale1", intra1).molecule() + + system.update(edit_mol.commit()) + + return system + + def reconstruct_system(system): """ Reconstruct a perturbable system to its original state, i.e. extract the diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 664138dc..a6c637c0 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -147,6 +147,14 @@ def __init__(self, system, config): _logger.error(msg) raise IOError(msg) + # Reconstruct end-state connectivity and intrascale matrices from + # the bonded terms. The lambda=0 reference topology is used as the + # starting point and the pertfile does not express changes in + # connectivity or intrascale directly. + from .._utils._somd1 import reconstruct_intrascale + + self._system = reconstruct_intrascale(self._system) + # If we're not using SOMD1 compatibility, then reconstruct the original # perturbable system. We only need to do this if applying modifications # to ghost atom bonded terms. diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index c37145c5..1b76cb53 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1657,6 +1657,10 @@ def _equilibrate(self, index): else: system.set_time(_sr.u("0ps")) + # Resolve the water count while the context is still alive. + if gcmc_sampler is not None: + gcmc_sampler.num_waters() + # Delete the dynamics object. self._dynamics_cache.delete(index) @@ -1992,13 +1996,6 @@ def _reset_gcmc_sampler(gcmc_sampler, dynamics): dynamics: sire.mol.Dynamics The dynamics object associated with the GCMC sampler. """ - # Ensure the water count is up to date before resetting. If the last - # move was a bulk sampling move, _is_bulk is True and num_waters() - # needs the stored context to recompute _N. Calling it here, while - # the context is still available, clears _is_bulk so that reset() - # can safely null the context. - gcmc_sampler.num_waters() - # Reset the GCMC sampler. This resets the sampling statistics and # clears the associated OpenMM forces. gcmc_sampler.reset() diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 683e6b99..06068e3f 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -628,6 +628,13 @@ def generate_lam_vals(lambda_base, increment=0.001): } ) + # Resolve the water count while the old context is still alive. If the + # last equilibration move was a bulk sampling move, _is_bulk is True + # and num_waters() needs the stored context to recompute _N. Creating + # a new dynamics object below destroys that context. + if gcmc_sampler is not None: + gcmc_sampler.num_waters() + # Create the dynamics object. dynamics = system.dynamics(**dynamics_kwargs) @@ -639,12 +646,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Reset the GCMC sampler. This resets the sampling statistics and clears # the associated OpenMM forces. if gcmc_sampler is not None: - # Ensure the water count is up to date before resetting. If the - # last move was a bulk sampling move, _is_bulk is True and - # num_waters() needs the stored context to recompute _N. Calling - # it here, while the context is still available, clears _is_bulk - # so that reset() can safely null the context. - gcmc_sampler.num_waters() gcmc_sampler.reset() # Bind the GCMC sampler to the dynamics object. From 746e20f4077f7631d1c46fb82d28d912dd770ba4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 09:41:23 +0100 Subject: [PATCH 161/212] Add unit test for connectivity reconstruction. --- tests/_utils/test_somd1.py | 25 +++++++++++++++++++++++++ tests/conftest.py | 4 ++-- tests/{runner => }/inputs/backward.pert | 0 tests/{runner => }/inputs/forward.pert | 0 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/_utils/test_somd1.py rename tests/{runner => }/inputs/backward.pert (100%) rename tests/{runner => }/inputs/forward.pert (100%) diff --git a/tests/_utils/test_somd1.py b/tests/_utils/test_somd1.py new file mode 100644 index 00000000..37543855 --- /dev/null +++ b/tests/_utils/test_somd1.py @@ -0,0 +1,25 @@ +import sire.legacy.Mol as _SireMol + + +def test_reconstruct_intrascale(pert_fwd_mols): + """ + Verify that reconstruct_intrascale correctly rebuilds end-state connectivity + and intrascale matrices from bond potentials. + + The forward perturbation has two hydrogen atoms that are real at lambda=0 + and become ghost atoms (du) at lambda=1. Their bonds therefore have a + non-zero force constant at lambda=0 but zero at lambda=1, so the + reconstructed connectivity objects must differ between the two end states. + """ + from somd2._utils._somd1 import reconstruct_intrascale + + system = reconstruct_intrascale(pert_fwd_mols) + + mol = system.molecules("property is_perturbable")[0] + + conn0 = mol.property("connectivity0") + conn1 = mol.property("connectivity1") + + assert isinstance(conn0, _SireMol.Connectivity) + assert isinstance(conn1, _SireMol.Connectivity) + assert conn0 != conn1 diff --git a/tests/conftest.py b/tests/conftest.py index af1565b9..db0a3484 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,7 +71,7 @@ def pert_fwd_mols(): from somd2._utils._somd1 import apply_pert mols = sr.load_test_files("somd1_forward.prm7", "somd1_forward.rst7") - pert_file = str(Path(__file__).parent / "runner" / "inputs" / "forward.pert") + pert_file = str(Path(__file__).parent / "inputs" / "forward.pert") return apply_pert(mols, pert_file) @@ -84,5 +84,5 @@ def pert_rev_mols(): from somd2._utils._somd1 import apply_pert mols = sr.load_test_files("somd1_backward.prm7", "somd1_backward.rst7") - pert_file = str(Path(__file__).parent / "runner" / "inputs" / "backward.pert") + pert_file = str(Path(__file__).parent / "inputs" / "backward.pert") return apply_pert(mols, pert_file) diff --git a/tests/runner/inputs/backward.pert b/tests/inputs/backward.pert similarity index 100% rename from tests/runner/inputs/backward.pert rename to tests/inputs/backward.pert diff --git a/tests/runner/inputs/forward.pert b/tests/inputs/forward.pert similarity index 100% rename from tests/runner/inputs/forward.pert rename to tests/inputs/forward.pert From a3b1c1bf83d5a27ba66ea2797b4e32cf00cb3c48 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 09:46:43 +0100 Subject: [PATCH 162/212] Update README. --- README.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.md b/README.md index 5393bf10..6ec3b904 100644 --- a/README.md +++ b/README.md @@ -388,13 +388,6 @@ If you want to load an existing system from a perturbation file and use the new `somd2` [ghost atom bonded-term modifications](https://github.com/OpenBioSim/ghostly), then simply omit the `--somd1-compatibility` option. -> [!NOTE] -> Using a ``pertfile`` as input is only supported when end states have the same -> connectivity, i.e. it can't be used for ring-breaking perturbations, or -> when non-bonded scale factors differ between the end states. For these cases, -> you will need to generate a new perturbation stream file using `prepareFEP.py` -> with the `--somd2` option as described above. - ## GPU oversubscription If you have an NVIDIA GPU that supports the multi-process service (MPS), you can From 9f84543be2436545d426446bcb352fe76331e21c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 10:01:23 +0100 Subject: [PATCH 163/212] Make sure test works in both directions. [ci skip] --- tests/_utils/test_somd1.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/_utils/test_somd1.py b/tests/_utils/test_somd1.py index 37543855..6cd0f759 100644 --- a/tests/_utils/test_somd1.py +++ b/tests/_utils/test_somd1.py @@ -1,19 +1,26 @@ +import pytest import sire.legacy.Mol as _SireMol -def test_reconstruct_intrascale(pert_fwd_mols): +@pytest.fixture +def mols(request): + return request.getfixturevalue(request.param) + + +@pytest.mark.parametrize("mols", ["pert_fwd_mols", "pert_rev_mols"], indirect=True) +def test_reconstruct_intrascale(mols): """ Verify that reconstruct_intrascale correctly rebuilds end-state connectivity and intrascale matrices from bond potentials. The forward perturbation has two hydrogen atoms that are real at lambda=0 - and become ghost atoms (du) at lambda=1. Their bonds therefore have a - non-zero force constant at lambda=0 but zero at lambda=1, so the - reconstructed connectivity objects must differ between the two end states. + and become ghost atoms (du) at lambda=1; the reverse perturbation is the + mirror image. In both cases the reconstructed connectivity objects must + differ between the two end states. """ from somd2._utils._somd1 import reconstruct_intrascale - system = reconstruct_intrascale(pert_fwd_mols) + system = reconstruct_intrascale(mols) mol = system.molecules("property is_perturbable")[0] From ad9ae3f2a1ec1d46f4b928a14d90af522091a567 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 10:04:04 +0100 Subject: [PATCH 164/212] Re-link properties. --- src/somd2/_utils/_somd1.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index 002d1cbe..eddd8728 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -697,7 +697,9 @@ def reconstruct_intrascale(system): system.update(edit_mol.commit()) - return system + from sire import morph as _morph + + return _morph.link_to_reference(system) def reconstruct_system(system): From c781bac3ee71a5a84cb9ae4a4e01fa5b4093cf13 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 16:07:17 +0100 Subject: [PATCH 165/212] Store GCMC water count prior to any context deletion. --- src/somd2/runner/_repex.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 1b76cb53..19fdae40 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1510,6 +1510,10 @@ def _minimise(self, index): # Commit the current system. system = dynamics.commit() + # Resolve the water count while the context is still alive. + if gcmc_sampler is not None: + gcmc_sampler.num_waters() + # Delete the dynamics object. self._dynamics_cache.delete(index) @@ -1611,6 +1615,10 @@ def _equilibrate(self, index): # Commit the current system. system = dynamics.commit() + # Resolve the water count while the context is still alive. + if gcmc_sampler is not None: + gcmc_sampler.num_waters() + # Delete the current dynamics object. self._dynamics_cache.delete(index) From 1baa28b4f44387f9d6ad1907bc19e0a2fbf55baf Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 18:35:21 +0100 Subject: [PATCH 166/212] Pass context when computing post-equilibration GCMC water count. --- src/somd2/runner/_repex.py | 28 ++++++++-------------------- src/somd2/runner/_runner.py | 8 +------- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 19fdae40..c4e1fabe 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1481,7 +1481,6 @@ def _minimise(self, index): dynamics, gcmc_sampler = self._dynamics_cache.get(index) if gcmc_sampler is not None: - # Push the PyCUDA context on top of the stack. gcmc_sampler.push() try: _logger.info( @@ -1490,7 +1489,6 @@ def _minimise(self, index): for i in range(100): gcmc_sampler.move(dynamics.context()) finally: - # Remove the PyCUDA context from the stack. gcmc_sampler.pop() # Minimise. @@ -1510,10 +1508,6 @@ def _minimise(self, index): # Commit the current system. system = dynamics.commit() - # Resolve the water count while the context is still alive. - if gcmc_sampler is not None: - gcmc_sampler.num_waters() - # Delete the dynamics object. self._dynamics_cache.delete(index) @@ -1578,7 +1572,6 @@ def _equilibrate(self, index): dynamics, gcmc_sampler = self._dynamics_cache.get(index) if gcmc_sampler is not None: - # Push the PyCUDA context on top of the stack. gcmc_sampler.push() try: _logger.info( @@ -1587,7 +1580,6 @@ def _equilibrate(self, index): for i in range(100): gcmc_sampler.move(dynamics.context()) finally: - # Remove the PyCUDA context from the stack. gcmc_sampler.pop() # Store the current water state. @@ -1615,10 +1607,6 @@ def _equilibrate(self, index): # Commit the current system. system = dynamics.commit() - # Resolve the water count while the context is still alive. - if gcmc_sampler is not None: - gcmc_sampler.num_waters() - # Delete the current dynamics object. self._dynamics_cache.delete(index) @@ -1665,10 +1653,6 @@ def _equilibrate(self, index): else: system.set_time(_sr.u("0ps")) - # Resolve the water count while the context is still alive. - if gcmc_sampler is not None: - gcmc_sampler.num_waters() - # Delete the dynamics object. self._dynamics_cache.delete(index) @@ -1692,6 +1676,14 @@ def _equilibrate(self, index): if gcmc_sampler is not None: self._reset_gcmc_sampler(gcmc_sampler, dynamics) + # Compute the current number of waters in the GCMC sampling + # volume after equilibration. + gcmc_sampler.push() + try: + gcmc_sampler.num_waters(context=dynamics.context()) + finally: + gcmc_sampler.pop() + # Set the new dynamics object. self._dynamics_cache.set(index, dynamics) @@ -1847,7 +1839,6 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Log the number of waters within the GCMC sampling volume. if gcmc_sampler is not None: - # Push the PyCUDA context on top of the stack. gcmc_sampler.push() try: n_moves = gcmc_sampler._num_moves @@ -1862,7 +1853,6 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): f"is {gcmc_sampler.num_waters()}{acc_str}" ) finally: - # Remove the PyCUDA context from the stack. gcmc_sampler.pop() # Log terminal flip acceptance rate for this replica. @@ -2008,13 +1998,11 @@ def _reset_gcmc_sampler(gcmc_sampler, dynamics): # clears the associated OpenMM forces. gcmc_sampler.reset() - # Push the PyCUDA context on top of the stack. gcmc_sampler.push() try: # Set the water state. gcmc_sampler._set_water_state(dynamics.context(), force=True) finally: - # Remove the PyCUDA context from the stack. gcmc_sampler.pop() # Re-bind the GCMC sampler to the dynamics object. diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 06068e3f..c7532dbe 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -628,13 +628,6 @@ def generate_lam_vals(lambda_base, increment=0.001): } ) - # Resolve the water count while the old context is still alive. If the - # last equilibration move was a bulk sampling move, _is_bulk is True - # and num_waters() needs the stored context to recompute _N. Creating - # a new dynamics object below destroys that context. - if gcmc_sampler is not None: - gcmc_sampler.num_waters() - # Create the dynamics object. dynamics = system.dynamics(**dynamics_kwargs) @@ -690,6 +683,7 @@ def generate_lam_vals(lambda_base, increment=0.001): dynamics.context(), force=True, ) + gcmc_sampler.num_waters(context=dynamics.context()) finally: gcmc_sampler.pop() From 1c9d77315a7261a94c30ff76ac50d95b2986517f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 24 Apr 2026 20:03:00 +0100 Subject: [PATCH 167/212] Write XML at earliest opportunity. --- src/somd2/runner/_runner.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index c7532dbe..574eb9b5 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -546,6 +546,13 @@ def generate_lam_vals(lambda_base, increment=0.001): # Create the dynamics object. dynamics = system.dynamics(**dynamics_kwargs) + # Write the OpenMM XML file to the output directory. + if self._config.save_xml and not is_restart: + _logger.info( + f"Writing OpenMM XML for {_lam_sym} = {lambda_value:.5f}" + ) + dynamics.to_xml(self._filenames[index]["xml"]) + # Equilibrate with GCMC moves. if gcmc_sampler is not None: # Bind the GCMC sampler to the dynamics object. @@ -631,8 +638,9 @@ def generate_lam_vals(lambda_base, increment=0.001): # Create the dynamics object. dynamics = system.dynamics(**dynamics_kwargs) - # Write the OpenMM XML file to the output directory. - if self._config.save_xml and not is_restart: + # Write the OpenMM XML file to the output directory (only if not already + # written during equilibration). + if self._config.save_xml and not is_restart and not is_equilibrated: _logger.info(f"Writing OpenMM XML for {_lam_sym} = {lambda_value:.5f}") dynamics.to_xml(self._filenames[index]["xml"]) From 61901fc6430646eb452df1129aefa41462e69d04 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 29 Apr 2026 14:45:54 +0100 Subject: [PATCH 168/212] Add support for long-range LJ dispersion correction. --- src/somd2/config/_config.py | 18 ++++++++++++++++++ src/somd2/runner/_base.py | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index fce678e6..7dae0aa8 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -151,6 +151,7 @@ def __init__( gcmc_radius="4 A", gcmc_bulk_sampling_probability=0.1, gcmc_tolerance=0.0, + use_dispersion_correction=False, rest2_scale=1.0, rest2_selection=None, softcore_form="zacharias", @@ -438,6 +439,12 @@ def __init__( of acceptance for a move. This can be used to exclude low probability candidates that can cause instabilities or crashes for the MD engine. + use_dispersion_correction: bool + Whether to use the long-range dispersion correction for LJ interactions. + When True, the correction is evaluated analytically via a CustomVolumeForce + and cached per lambda state, avoiding expensive recomputation on every + lambda change. Default False. + rest2_scale: float, list(float) The scaling factor for Replica Exchange with Solute Tempering (REST) simulations. This is the factor by which the temperature of the solute is scaled with respect to @@ -605,6 +612,7 @@ def __init__( self.gcmc_radius = gcmc_radius self.gcmc_bulk_sampling_probability = gcmc_bulk_sampling_probability self.gcmc_tolerance = gcmc_tolerance + self.use_dispersion_correction = use_dispersion_correction self.rest2_scale = rest2_scale self.rest2_selection = rest2_selection self.restart = restart @@ -2286,6 +2294,16 @@ def gcmc_tolerance(self, gcmc_tolerance): raise ValueError("'gcmc_tolerance' must be greater than or equal to 0.0") self._gcmc_tolerance = gcmc_tolerance + @property + def use_dispersion_correction(self): + return self._use_dispersion_correction + + @use_dispersion_correction.setter + def use_dispersion_correction(self, use_dispersion_correction): + if not isinstance(use_dispersion_correction, bool): + raise TypeError("'use_dispersion_correction' must be of type 'bool'") + self._use_dispersion_correction = use_dispersion_correction + @property def rest2_scale(self): return self._rest2_scale diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index a6c637c0..c3748b42 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -192,6 +192,16 @@ def __init__(self, system, config): self._config.fix_perturbable_zero_sigmas ) + # Long-range dispersion correction. + self._config._extra_args["use_dispersion_correction"] = ( + self._config.use_dispersion_correction + ) + + # GCMC LRC map options. + if self._config.gcmc and self._config.use_dispersion_correction: + self._config._extra_args["use_gcmc_lrc"] = True + self._config._extra_args["num_gcmc_waters"] = self._config.gcmc_num_waters + # If specified, use the Taylor soft-core form. if self._config.softcore_form == "taylor": self._config._extra_args["use_taylor_softening"] = True From d3061d3e8b71334763fc51ef9f9ec578833a9809 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 30 Apr 2026 09:21:36 +0100 Subject: [PATCH 169/212] Warn when use_dispersion_correction=True and there is no space. --- src/somd2/runner/_base.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index c3748b42..571707ea 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -301,6 +301,13 @@ def __init__(self, system, config): except: self._has_water = False + # Warn if dispersion correction is requested but can't be applied. + if self._config.use_dispersion_correction and not self._has_space: + _logger.warning( + "'use_dispersion_correction=True' has no effect: the system " + "has no periodic space. The option will be ignored." + ) + # Check the end state constraints. self._check_end_state_constraints() From 3c75e5793341ef8305c00574d1b096333ffb04c8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 30 Apr 2026 10:08:45 +0100 Subject: [PATCH 170/212] Add Beutler soft-core form for ABFE. --- src/somd2/_utils/_schedules.py | 261 ++++++++++++++++++++++++++ src/somd2/config/_config.py | 332 +++++---------------------------- src/somd2/runner/_base.py | 12 +- 3 files changed, 315 insertions(+), 290 deletions(-) create mode 100644 src/somd2/_utils/_schedules.py diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py new file mode 100644 index 00000000..9a22ce52 --- /dev/null +++ b/src/somd2/_utils/_schedules.py @@ -0,0 +1,261 @@ +###################################################################### +# SOMD2: GPU accelerated alchemical free-energy engine. +# +# Copyright: 2023-2026 +# +# Authors: The OpenBioSim Team +# +# SOMD2 is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SOMD2 is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SOMD2. If not, see . +##################################################################### + +__all__ = [ + "annihilate", + "decouple", + "ring_break_morph", + "reverse_ring_break_morph", +] + + +def annihilate(): + """ + Build the ABFE lambda schedule using decharge → annihilate with constant epsilon. + + Annihilation removes ALL non-bonded interactions (including intramolecular LJ + between non-bonded pairs), matching the GROMACS ABFE protocol. Epsilon is held + constant at its real-atom value throughout the annihilate stage so that the + (1-alpha) prefactor for the Buetler soft-core provides the sole LJ decay pathway, + giving true single (1-lambda) scaling consistent with GROMACS Beutler. + + Returns + ------- + + schedule : sire.legacy.CAS.LambdaSchedule + The lambda schedule. + """ + from sire.cas import LambdaSchedule as _LambdaSchedule + + # Start with the standard decouple schedule and modify the stages and + # equations as needed. This will be folded into Sire in future, but + # we will use this approach for prototyping. + s = _LambdaSchedule.standard_decouple() + + s.remove_stage("decouple") + + s.add_stage("decharge", equation=s.initial()) + s.set_equation( + stage="decharge", + lever="charge", + equation=s.lam() * s.final() + s.initial() * (1 - s.lam()), + ) + s.set_equation(stage="decharge", force="restraint", equation=s.lam() * s.final()) + + s.add_stage( + "annihilate", + equation=(-s.lam() + 1) * s.initial() + s.lam() * s.final(), + ) + s.set_equation(stage="annihilate", lever="charge", equation=s.final()) + s.set_equation(stage="annihilate", force="restraint", equation=s.final()) + s.set_equation(stage="annihilate", lever="epsilon", equation=s.initial()) + + return s + + +def decouple(): + """ + Build the ABFE lambda schedule using decharge → decouple with constant epsilon. + + Decoupling removes only INTERMOLECULAR non-bonded interactions; intramolecular + terms are preserved via kappa=0 on ghost/ghost and ghost-14 forces. Epsilon is + held constant throughout the decouple stage (see annihilate for rationale). + + Returns + ------- + + schedule : sire.legacy.CAS.LambdaSchedule + The lambda schedule. + """ + from sire.cas import LambdaSchedule as _LambdaSchedule + + # Start with the standard decouple schedule and modify the stages and + # equations as needed. This will be folded into Sire in future, but + # we will use this approach for prototyping. + s = _LambdaSchedule.standard_decouple() + + s.set_equation(stage="decouple", lever="restraint", equation=s.final()) + s.set_equation(stage="decouple", lever="kappa", force="ghost/ghost", equation=0) + s.set_equation(stage="decouple", lever="kappa", force="ghost-14", equation=0) + s.set_equation(stage="decouple", lever="charge", equation=s.final()) + s.set_equation(stage="decouple", lever="epsilon", equation=s.initial()) + + s.prepend_stage("decharge", s.initial()) + s.set_equation( + stage="decharge", + lever="charge", + equation=s.lam() * s.final() + s.initial() * (1 - s.lam()), + ) + s.set_equation(stage="decharge", force="ghost/ghost", equation=s.initial()) + s.set_equation(stage="decharge", force="ghost-14", equation=s.initial()) + s.set_equation( + stage="decharge", lever="kappa", force="ghost/ghost", equation=-s.lam() + 1 + ) + s.set_equation( + stage="decharge", lever="kappa", force="ghost-14", equation=-s.lam() + 1 + ) + s.set_equation(stage="decharge", lever="restraint", equation=s.initial() * s.lam()) + + return s + + +def ring_break_morph(): + """ + Build a lambda schedule for ring-breaking perturbations. + + Three stages: potential_swap → restraints_off → morph. + + Returns + ------- + + schedule : sire.legacy.CAS.LambdaSchedule + The lambda schedule. + """ + from sire.cas import LambdaSchedule as _LambdaSchedule + + s = _LambdaSchedule.standard_morph() + + s.prepend_stage("restraints_off", s.initial()) + s.set_equation(stage="restraints_off", lever="morse_soft", equation=1 - s.lam()) + s.set_equation(stage="restraints_off", lever="morse_hard", equation=0) + s.set_equation(stage="restraints_off", lever="bond_k", equation=s.final()) + s.set_equation(stage="restraints_off", lever="bond_length", equation=s.final()) + s.set_equation( + stage="restraints_off", + lever="angle_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="restraints_off", + lever="angle_size", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="restraints_off", + lever="torsion_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="restraints_off", + lever="torsion_phase", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + + s.prepend_stage("potential_swap", s.initial()) + s.set_equation(stage="potential_swap", lever="morse_hard", equation=1 - s.lam()) + s.set_equation(stage="potential_swap", lever="morse_soft", equation=0 + s.lam()) + s.set_equation( + stage="potential_swap", + lever="bond_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="potential_swap", + lever="bond_length", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation(stage="potential_swap", lever="angle_k", equation=s.initial()) + s.set_equation(stage="potential_swap", lever="angle_size", equation=s.initial()) + s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.initial()) + s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.initial()) + + s.set_equation(stage="morph", lever="morse_hard", equation=0) + s.set_equation(stage="morph", lever="morse_soft", equation=0) + s.set_equation(stage="morph", lever="bond_k", equation=s.final()) + s.set_equation(stage="morph", lever="bond_length", equation=s.final()) + s.set_equation(stage="morph", lever="angle_k", equation=s.final()) + s.set_equation(stage="morph", lever="angle_size", equation=s.final()) + s.set_equation(stage="morph", lever="torsion_k", equation=s.final()) + s.set_equation(stage="morph", lever="torsion_phase", equation=s.final()) + + return s + + +def reverse_ring_break_morph(): + """ + Build a lambda schedule for reverse ring-breaking perturbations. + + Three stages: morph → bonded_perturb → potential_swap. + + Returns + ------- + + schedule : sire.legacy.CAS.LambdaSchedule + The lambda schedule. + """ + from sire.cas import LambdaSchedule as _LambdaSchedule + + s = _LambdaSchedule.standard_morph() + + s.set_equation(stage="morph", lever="morse_hard", equation=0) + s.set_equation(stage="morph", lever="morse_soft", equation=0) + s.set_equation(stage="morph", lever="bond_k", equation=s.initial()) + s.set_equation(stage="morph", lever="bond_length", equation=s.initial()) + s.set_equation(stage="morph", lever="angle_k", equation=s.initial()) + s.set_equation(stage="morph", lever="angle_size", equation=s.initial()) + s.set_equation(stage="morph", lever="torsion_k", equation=s.initial()) + s.set_equation(stage="morph", lever="torsion_phase", equation=s.initial()) + + s.append_stage("bonded_perturb", s.final()) + s.set_equation(stage="bonded_perturb", lever="morse_soft", equation=0 + s.lam()) + s.set_equation(stage="bonded_perturb", lever="morse_hard", equation=0) + s.set_equation(stage="bonded_perturb", lever="bond_k", equation=s.initial()) + s.set_equation(stage="bonded_perturb", lever="bond_length", equation=s.initial()) + s.set_equation( + stage="bonded_perturb", + lever="angle_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="bonded_perturb", + lever="angle_size", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="bonded_perturb", + lever="torsion_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="bonded_perturb", + lever="torsion_phase", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + + s.append_stage("potential_swap", s.final()) + s.set_equation(stage="potential_swap", lever="morse_hard", equation=0 + s.lam()) + s.set_equation(stage="potential_swap", lever="morse_soft", equation=1 - s.lam()) + s.set_equation( + stage="potential_swap", + lever="bond_k", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation( + stage="potential_swap", + lever="bond_length", + equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), + ) + s.set_equation(stage="potential_swap", lever="angle_k", equation=s.final()) + s.set_equation(stage="potential_swap", lever="angle_size", equation=s.final()) + s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.final()) + s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.final()) + + return s diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 7dae0aa8..6d206155 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -70,9 +70,11 @@ class Config: "charge_scaled_morph", "ring_break_morph", "reverse_ring_break_morph", + "annihilate", + "decouple", ], "log_level": [level.lower() for level in _logger._core.levels], - "softcore_form": ["zacharias", "taylor"], + "softcore_form": ["zacharias", "taylor", "beutler"], } # A dictionary of nargs for the various options. @@ -103,7 +105,6 @@ def __init__( lambda_schedule="standard_morph", charge_scale_factor=0.2, swap_end_states=False, - coulomb_power=0.0, shift_coulomb="1 A", shift_delta="1.5 A", restraints=None, @@ -156,6 +157,7 @@ def __init__( rest2_selection=None, softcore_form="zacharias", taylor_power=1, + beutler_alpha=0.5, output_directory="output", restart=False, use_backup=False, @@ -234,10 +236,6 @@ def __init__( swap_end_states: bool Whether to swap the end states of the alchemical system. - coulomb_power : float - Power to use for the soft-core Coulomb interaction. This is used - to soften the electrostatic interaction. - shift_coulomb : str The soft-core shift-coulomb parameter. This is used to soften the Coulomb interaction. @@ -465,14 +463,20 @@ def __init__( be applied to protein mutations. softcore_form: str - The soft-core potential form to use for alchemical interactions. This can be - either "zacharias" or "taylor". The default is "zacharias". + The soft-core potential form to use for alchemical interactions. Valid + options are "zacharias" (default), "taylor", and "beutler". The Beutler + form is recommended for ABFE calculations. taylor_power: int The power to use for the alpha term in the Taylor soft-core LJ expression, i.e. sig6 = sigma^6 / (alpha^m * sigma^6 + r^6). Must be between 0 and 4. The default is 1. Only used when softcore_form is "taylor". + beutler_alpha: float + The dimensionless scale factor for the r^6 shift in the Beutler soft-core + form. Must be >= 0. The default is 0.5. Only used when softcore_form is + "beutler". + output_directory: str Path to a directory to store output files. @@ -566,7 +570,6 @@ def __init__( self.lambda_schedule = lambda_schedule self.charge_scale_factor = charge_scale_factor self.swap_end_states = swap_end_states - self.coulomb_power = coulomb_power self.shift_coulomb = shift_coulomb self.shift_delta = shift_delta self.restraints = restraints @@ -619,6 +622,7 @@ def __init__( self.use_backup = use_backup self.softcore_form = softcore_form self.taylor_power = taylor_power + self.beutler_alpha = beutler_alpha self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file self.auto_fix_minimise = auto_fix_minimise @@ -1075,279 +1079,29 @@ def lambda_schedule(self, lambda_schedule): self._lambda_schedule = _LambdaSchedule.charge_scaled_morph(0.2) self._lambda_schedule_name = "charge_scaled_morph" elif lambda_schedule == "ring_break_morph": - self._lambda_schedule = _LambdaSchedule.standard_morph() - self._lambda_schedule.prepend_stage( - "restraints_off", self._lambda_schedule.initial() - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="morse_soft", - equation=1 - self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", lever="morse_hard", equation=0 - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="bond_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="bond_length", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="angle_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="angle_size", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="torsion_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="restraints_off", - lever="torsion_phase", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - - self._lambda_schedule.prepend_stage( - "potential_swap", self._lambda_schedule.initial() - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="morse_hard", - equation=1 - self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="morse_soft", - equation=0 + self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="bond_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="bond_length", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="angle_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="angle_size", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="torsion_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="torsion_phase", - equation=self._lambda_schedule.initial(), + from .._utils._schedules import ( + ring_break_morph as _ring_break_morph, ) - self._lambda_schedule.set_equation( - stage="morph", lever="morse_hard", equation=0 - ) - self._lambda_schedule.set_equation( - stage="morph", lever="morse_soft", equation=0 - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="bond_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="bond_length", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="angle_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="angle_size", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="torsion_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="torsion_phase", - equation=self._lambda_schedule.final(), - ) + self._lambda_schedule = _ring_break_morph() self._lambda_schedule_name = "ring_break_morph" elif lambda_schedule == "reverse_ring_break_morph": - self._lambda_schedule = _LambdaSchedule.standard_morph() - self._lambda_schedule.set_equation( - stage="morph", lever="morse_hard", equation=0 - ) - self._lambda_schedule.set_equation( - stage="morph", lever="morse_soft", equation=0 - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="bond_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="bond_length", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="angle_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="angle_size", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="torsion_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="morph", - lever="torsion_phase", - equation=self._lambda_schedule.initial(), + from .._utils._schedules import ( + reverse_ring_break_morph as _reverse_ring_break_morph, ) - self._lambda_schedule.append_stage( - "bonded_perturb", self._lambda_schedule.final() - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="morse_soft", - equation=0 + self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", lever="morse_hard", equation=0 - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="bond_k", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="bond_length", - equation=self._lambda_schedule.initial(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="angle_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="angle_size", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="torsion_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="bonded_perturb", - lever="torsion_phase", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - - self._lambda_schedule.append_stage( - "potential_swap", self._lambda_schedule.final() - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="morse_hard", - equation=0 + self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="morse_soft", - equation=1 - self._lambda_schedule.lam(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="bond_k", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="bond_length", - equation=(1 - self._lambda_schedule.lam()) - * self._lambda_schedule.initial() - + self._lambda_schedule.lam() * self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="angle_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="angle_size", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="torsion_k", - equation=self._lambda_schedule.final(), - ) - self._lambda_schedule.set_equation( - stage="potential_swap", - lever="torsion_phase", - equation=self._lambda_schedule.final(), - ) + self._lambda_schedule = _reverse_ring_break_morph() self._lambda_schedule_name = "reverse_ring_break_morph" + elif lambda_schedule == "annihilate": + from .._utils._schedules import annihilate as _annihilate + + self._lambda_schedule = _annihilate() + self._lambda_schedule_name = "annihilate" + elif lambda_schedule == "decouple": + from .._utils._schedules import decouple as _decouple + + self._lambda_schedule = _decouple() + self._lambda_schedule_name = "decouple" else: try: self._lambda_schedule = self._from_hex(lambda_schedule) @@ -1393,19 +1147,6 @@ def swap_end_states(self, swap_end_states): raise ValueError("'swap_end_states' must be of type 'bool'") self._swap_end_states = swap_end_states - @property - def coulomb_power(self): - return self._coulomb_power - - @coulomb_power.setter - def coulomb_power(self, coulomb_power): - if not isinstance(coulomb_power, float): - try: - coulomb_power = float(coulomb_power) - except Exception: - raise ValueError("'coulomb_power' must be a of type 'float'") - self._coulomb_power = coulomb_power - @property def shift_coulomb(self): return self._shift_coulomb @@ -2381,6 +2122,21 @@ def taylor_power(self, taylor_power): raise ValueError("'taylor_power' must be between 0 and 4") self._taylor_power = taylor_power + @property + def beutler_alpha(self): + return self._beutler_alpha + + @beutler_alpha.setter + def beutler_alpha(self, beutler_alpha): + if not isinstance(beutler_alpha, float): + try: + beutler_alpha = float(beutler_alpha) + except Exception: + raise ValueError("'beutler_alpha' must be of type 'float'") + if beutler_alpha < 0.0: + raise ValueError("'beutler_alpha' must be >= 0") + self._beutler_alpha = beutler_alpha + @property def use_backup(self): return self._use_backup diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 571707ea..07437567 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -202,10 +202,19 @@ def __init__(self, system, config): self._config._extra_args["use_gcmc_lrc"] = True self._config._extra_args["num_gcmc_waters"] = self._config.gcmc_num_waters - # If specified, use the Taylor soft-core form. + # Set the soft-core form. if self._config.softcore_form == "taylor": self._config._extra_args["use_taylor_softening"] = True self._config._extra_args["taylor_power"] = self._config.taylor_power + elif self._config.softcore_form == "beutler": + schedule_name = self._config._lambda_schedule_name + if schedule_name not in (None, "annihilate", "decouple"): + raise ValueError( + "The Beutler soft-core form is only supported with the 'annihilate' " + "or 'decouple' lambda schedules, or a custom schedule." + ) + self._config._extra_args["use_beutler_softening"] = True + self._config._extra_args["beutler_alpha"] = self._config.beutler_alpha # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: @@ -803,7 +812,6 @@ def __init__(self, system, config): # Common kwargs shared by both dynamics and GCMC sampling. self._common_kwargs = { - "coulomb_power": self._config.coulomb_power, "cutoff": self._config.cutoff, "cutoff_type": self._config.cutoff_type, "platform": self._config.platform, From 4c51884b8612e4d472cbb22e0e424760586eeb02 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 30 Apr 2026 12:08:54 +0100 Subject: [PATCH 171/212] Add warning for non-ring-breaking connectivity changes. --- src/somd2/runner/_base.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 07437567..14ff5458 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -178,6 +178,40 @@ def __init__(self, system, config): # Link properties to the lambda = 0 end state. self._system = _sr.morph.link_to_reference(self._system) + # Whether this is a ring-breaking schedule. + if ( + self._config._lambda_schedule_name is not None + and "ring_breaking" in self._config._lambda_schedule_name + ): + self._is_ring_breaking = True + else: + self._is_ring_breaking = False + + # Check to see if the end-state connectivities are the same. + if not self._is_ring_breaking: + for mol in self._system["property is_perturbable"].molecules(): + has_end_state_connectivity = False + try: + # The molecule will have two connectivity properties if + # the merge detected a change in connectivity. + c0 = mol.property("connectivity0") + c1 = mol.property("connectivity1") + has_end_state_connectivity = True + except: + # No connectivity change detected. + has_end_state_connectivity = False + pass + + # Check the connectivities regardless. + if has_end_state_connectivity: + if c0 != c1: + msg = ( + "End-state connectivities are different. If this is a ring-breaking " + "perturbation, please set 'lambda_schedule_name' to 'ring_breaking'." + ) + _logger.warning(msg) + break + # Set the default configuration options. # Restrict the atomic properties used to define light atoms when From 82f3b952948a1c47a41788ead6455b08b77677b9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 30 Apr 2026 14:48:04 +0100 Subject: [PATCH 172/212] Remove redundant option. --- src/somd2/runner/_base.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 14ff5458..9bce2b8e 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -325,9 +325,7 @@ def __init__(self, system, config): # Angle optimisation can sometimes fail. except Exception as e1: try: - self._system, self._modifications = modify( - self._system, optimise_angles=False - ) + self._system, self._modifications = modify(self._system) except Exception as e2: msg = f"Unable to apply modifications to ghost atom bonded terms: {e1}; {e2}" _logger.error(msg) From 9dad004c2e43f2ae0fce22f1ec57d30d50d51381 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 1 May 2026 11:34:11 +0100 Subject: [PATCH 173/212] Add support for LRC scaling. --- src/somd2/_utils/_schedules.py | 54 ++++++++++++++++++++++++++-------- src/somd2/config/_config.py | 8 ++--- src/somd2/runner/_base.py | 11 +++++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 9a22ce52..0ba08019 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -27,15 +27,22 @@ ] -def annihilate(): +def annihilate(fix_epsilon=True): """ - Build the ABFE lambda schedule using decharge → annihilate with constant epsilon. + Build the ABFE lambda schedule using decharge → annihilate. Annihilation removes ALL non-bonded interactions (including intramolecular LJ - between non-bonded pairs), matching the GROMACS ABFE protocol. Epsilon is held - constant at its real-atom value throughout the annihilate stage so that the - (1-alpha) prefactor for the Buetler soft-core provides the sole LJ decay pathway, - giving true single (1-lambda) scaling consistent with GROMACS Beutler. + between non-bonded pairs). + + Parameters + ---------- + fix_epsilon : bool, optional + If True (default), epsilon is held constant at its real-atom value + throughout the annihilate stage so that the (1-alpha) prefactor of the + Beutler soft-core provides the sole LJ decay pathway. The ghost-LRC + force is then explicitly scaled to zero over the stage to compensate. + If False, epsilon is scaled normally from initial to final and the LRC + follows naturally. Returns ------- @@ -66,18 +73,33 @@ def annihilate(): ) s.set_equation(stage="annihilate", lever="charge", equation=s.final()) s.set_equation(stage="annihilate", force="restraint", equation=s.final()) - s.set_equation(stage="annihilate", lever="epsilon", equation=s.initial()) + + if fix_epsilon: + s.set_equation(stage="annihilate", lever="epsilon", equation=s.initial()) + s.set_equation( + stage="annihilate", + force="ghost-lrc", + lever="lrc_scale", + equation=1 - s.lam(), + ) return s -def decouple(): +def decouple(fix_epsilon=True): """ - Build the ABFE lambda schedule using decharge → decouple with constant epsilon. + Build the ABFE lambda schedule using decharge → decouple. Decoupling removes only INTERMOLECULAR non-bonded interactions; intramolecular - terms are preserved via kappa=0 on ghost/ghost and ghost-14 forces. Epsilon is - held constant throughout the decouple stage (see annihilate for rationale). + terms are preserved via kappa=0 on ghost/ghost and ghost-14 forces. + + Parameters + ---------- + fix_epsilon : bool, optional + If True (default), epsilon is held constant at its real-atom value + throughout the decouple stage (see annihilate for rationale). The + ghost-LRC force is then explicitly scaled to zero over the stage. + If False, epsilon is scaled normally and the LRC follows naturally. Returns ------- @@ -96,7 +118,15 @@ def decouple(): s.set_equation(stage="decouple", lever="kappa", force="ghost/ghost", equation=0) s.set_equation(stage="decouple", lever="kappa", force="ghost-14", equation=0) s.set_equation(stage="decouple", lever="charge", equation=s.final()) - s.set_equation(stage="decouple", lever="epsilon", equation=s.initial()) + + if fix_epsilon: + s.set_equation(stage="decouple", lever="epsilon", equation=s.initial()) + s.set_equation( + stage="decouple", + force="ghost-lrc", + lever="lrc_scale", + equation=1 - s.lam(), + ) s.prepend_stage("decharge", s.initial()) s.set_equation( diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 6d206155..9a605b7c 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -1093,14 +1093,10 @@ def lambda_schedule(self, lambda_schedule): self._lambda_schedule = _reverse_ring_break_morph() self._lambda_schedule_name = "reverse_ring_break_morph" elif lambda_schedule == "annihilate": - from .._utils._schedules import annihilate as _annihilate - - self._lambda_schedule = _annihilate() + self._lambda_schedule = None self._lambda_schedule_name = "annihilate" elif lambda_schedule == "decouple": - from .._utils._schedules import decouple as _decouple - - self._lambda_schedule = _decouple() + self._lambda_schedule = None self._lambda_schedule_name = "decouple" else: try: diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 9bce2b8e..8029f71c 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -250,6 +250,17 @@ def __init__(self, system, config): self._config._extra_args["use_beutler_softening"] = True self._config._extra_args["beutler_alpha"] = self._config.beutler_alpha + # Build deferred schedules now that the softcore form is known. + fix_epsilon = self._config.softcore_form == "beutler" + if self._config._lambda_schedule_name == "annihilate": + from .._utils._schedules import annihilate as _annihilate + + self._config._lambda_schedule = _annihilate(fix_epsilon=fix_epsilon) + elif self._config._lambda_schedule_name == "decouple": + from .._utils._schedules import decouple as _decouple + + self._config._lambda_schedule = _decouple(fix_epsilon=fix_epsilon) + # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: from .._utils._somd1 import make_compatible From e4de0a9699ac2458724a962fb5679094c0b728cb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 5 May 2026 09:42:29 +0100 Subject: [PATCH 174/212] Disable LRC for vacuum simulations. --- src/somd2/runner/_base.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 8029f71c..885cedd6 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -212,6 +212,23 @@ def __init__(self, system, config): _logger.warning(msg) break + # Check for a periodic space. + self._has_space = self._check_space() + + # Check for water. + try: + # The search will fail if there are no water molecules. + water = self._system["water"].molecules() + self._has_water = True + except: + self._has_water = False + + # Warn if dispersion correction is requested but can't be applied. + if self._config.use_dispersion_correction and not self._has_water: + msg = "Cannot use dispersion correction for vacuum simulations. Disabling!" + _logger.warning(msg) + self._config.use_dispersion_correction = False + # Set the default configuration options. # Restrict the atomic properties used to define light atoms when @@ -342,24 +359,6 @@ def __init__(self, system, config): _logger.error(msg) raise RuntimeError(msg) - # Check for a periodic space. - self._has_space = self._check_space() - - # Check for water. - try: - # The search will fail if there are no water molecules. - water = self._system["water"].molecules() - self._has_water = True - except: - self._has_water = False - - # Warn if dispersion correction is requested but can't be applied. - if self._config.use_dispersion_correction and not self._has_space: - _logger.warning( - "'use_dispersion_correction=True' has no effect: the system " - "has no periodic space. The option will be ignored." - ) - # Check the end state constraints. self._check_end_state_constraints() From d111e032b83f655618b366c746a41ade46b73430 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 5 May 2026 09:42:58 +0100 Subject: [PATCH 175/212] Add functionality for skipping/warning deprecated options. --- src/somd2/io/_io.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/somd2/io/_io.py b/src/somd2/io/_io.py index e845e6ab..3ccb9eb2 100644 --- a/src/somd2/io/_io.py +++ b/src/somd2/io/_io.py @@ -34,8 +34,15 @@ import pyarrow as _pa import pyarrow.parquet as _pq import pandas as _pd +import warnings as _warnings import yaml as _yaml +# Options that have been removed from the config. Any of these found in a YAML +# config file will be silently dropped after emitting a deprecation warning. +_REMOVED_OPTIONS = { + "coulomb_power": "'coulomb_power' has been removed and will be ignored.", +} + def dataframe_to_parquet(df, metadata, filepath=None, filename=None): """ @@ -142,6 +149,11 @@ def yaml_to_dict(path): except Exception as e: raise ValueError(f"Could not load YAML file: {e}") + for key, msg in _REMOVED_OPTIONS.items(): + if key in d: + _warnings.warn(msg) + d.pop(key) + return d From 3fecef7458c811bee8d9582a257c743dc1aebc4a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 5 May 2026 09:48:39 +0100 Subject: [PATCH 176/212] Remove coulomb_power option from unit test. --- tests/runner/test_restart.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/runner/test_restart.py b/tests/runner/test_restart.py index bfb5fd0a..6c1e2f6a 100644 --- a/tests/runner/test_restart.py +++ b/tests/runner/test_restart.py @@ -123,13 +123,6 @@ def test_restart(mols, request): with pytest.raises(ValueError): runner_constraints = Runner(mols, Config(**config_diffconstraint)) - config_diffcoulombpower = config_new.copy() - config_diffcoulombpower["runtime"] = "36fs" - config_diffcoulombpower["coulomb_power"] = 0.5 - - with pytest.raises(ValueError): - runner_coulombpower = Runner(mols, Config(**config_diffcoulombpower)) - config_diffcutofftype = config_new.copy() config_diffcutofftype["runtime"] = "36fs" config_diffcutofftype["cutoff_type"] = "rf" From 55971646f547524b60a1421713dac2a4deb51107 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 5 May 2026 11:36:32 +0100 Subject: [PATCH 177/212] Fix schedule name check. --- src/somd2/runner/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 885cedd6..c69c9ecd 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -181,7 +181,7 @@ def __init__(self, system, config): # Whether this is a ring-breaking schedule. if ( self._config._lambda_schedule_name is not None - and "ring_breaking" in self._config._lambda_schedule_name + and "ring_break" in self._config._lambda_schedule_name ): self._is_ring_breaking = True else: From db7bdeefb13202bfee5bc1bc34df1537f17a76d4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 09:13:26 +0100 Subject: [PATCH 178/212] Reverse LambdaSchedule when swap_end_states=True. [ref #78] --- src/somd2/runner/_base.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index c69c9ecd..d78dbdf6 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -906,6 +906,17 @@ def __init__(self, system, config): else: self._gcmc_kwargs = None + # Reverse the lambda schedule when swapping end states so that the + # schedule progresses from the perturbed end state to the reference. + if self._config.swap_end_states: + self._dynamics_kwargs["schedule"] = self._dynamics_kwargs[ + "schedule" + ].reverse() + if self._gcmc_kwargs is not None: + self._gcmc_kwargs["lambda_schedule"] = self._gcmc_kwargs[ + "lambda_schedule" + ].reverse() + # Limit the number of CPU threads available to Sire when running in parallel. if self._is_gpu: # First get the total number of threads that are available to Sire. From 5d410f480ee918fb6c195650f3105d40d7aebded Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 7 May 2026 15:10:06 +0100 Subject: [PATCH 179/212] Let loch handle it's own schedule reversal. --- src/somd2/runner/_base.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index d78dbdf6..9e9f2c61 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -908,14 +908,11 @@ def __init__(self, system, config): # Reverse the lambda schedule when swapping end states so that the # schedule progresses from the perturbed end state to the reference. + # (The GCMC schedule is reversed inside loch itself.) if self._config.swap_end_states: self._dynamics_kwargs["schedule"] = self._dynamics_kwargs[ "schedule" ].reverse() - if self._gcmc_kwargs is not None: - self._gcmc_kwargs["lambda_schedule"] = self._gcmc_kwargs[ - "lambda_schedule" - ].reverse() # Limit the number of CPU threads available to Sire when running in parallel. if self._is_gpu: From 44bd395ea50dcb9d81c3c5c561d3ce2cab6debf1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 8 May 2026 10:27:34 +0100 Subject: [PATCH 180/212] Add ring-break and ring-make lever equations to ring-break schedules. --- src/somd2/_utils/_schedules.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 0ba08019..04d42f22 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -216,6 +216,22 @@ def ring_break_morph(): s.set_equation(stage="morph", lever="torsion_k", equation=s.final()) s.set_equation(stage="morph", lever="torsion_phase", equation=s.final()) + # Ring-breaking bonds: softcore interaction grows from zero as the ring + # opens during the morph stage (alpha: 1→0, kappa: 0→1). + # Ring-making bonds: softcore interaction shrinks to zero as the ring + # closes during the morph stage (alpha: 0→1, kappa: 1→0). + # potential_swap and restraints_off stages use default (s.initial()): + # ring-break alpha=1.0/kappa=0.0 (no interaction, ring still bonded) + # ring-make alpha=0.0/kappa=1.0 (full interaction, ring not yet formed) + s.set_equation( + stage="morph", force="ring-break", lever="alpha", equation=1 - s.lam() + ) + s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=s.lam()) + s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=s.lam()) + s.set_equation( + stage="morph", force="ring-make", lever="kappa", equation=1 - s.lam() + ) + return s @@ -288,4 +304,29 @@ def reverse_ring_break_morph(): s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.final()) s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.final()) + # morph stage (first): nonbonded-only changes; ring bonds still intact/absent. + # ring-break: alpha=1.0/kappa=0.0 throughout (no interaction, ring bonded at λ=0) + # ring-make: alpha=0.0/kappa=1.0 throughout (full interaction, ring absent at λ=0) + # bonded_perturb stage (second): ring bonds established/dissolved via Morse. + # ring-make softcore turns off as ring forms (alpha: 0→1, kappa: 1→0) + # ring-break softcore turns on as ring opens (alpha: 1→0, kappa: 0→1) + # potential_swap stage (last): Morse→harmonic swap; ring fully transitioned. + # defaults (s.final()) give ring-break alpha=0/kappa=1, ring-make alpha=1/kappa=0. + s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=1) + s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) + s.set_equation( + stage="bonded_perturb", force="ring-break", lever="alpha", equation=1 - s.lam() + ) + s.set_equation( + stage="bonded_perturb", force="ring-break", lever="kappa", equation=s.lam() + ) + s.set_equation( + stage="bonded_perturb", force="ring-make", lever="alpha", equation=s.lam() + ) + s.set_equation( + stage="bonded_perturb", force="ring-make", lever="kappa", equation=1 - s.lam() + ) + return s From 963eacb53bccddbfd6361c8a03c83da3dc0e01c5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 8 May 2026 12:17:34 +0100 Subject: [PATCH 181/212] Add ring_open/ring_close stages to ring-break schedules. --- src/somd2/_utils/_schedules.py | 116 +++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 04d42f22..bae6a68c 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -151,7 +151,7 @@ def ring_break_morph(): """ Build a lambda schedule for ring-breaking perturbations. - Three stages: potential_swap → restraints_off → morph. + Four stages: potential_swap → restraints_off → ring_open → morph. Returns ------- @@ -163,6 +163,33 @@ def ring_break_morph(): s = _LambdaSchedule.standard_morph() + # ring_open: Morse is already off; ring-break nonbonded interaction ramps + # on (alpha: 1→0, kappa: 0→1) while non-bonded terms stay at initial and + # bonded terms remain at final. The softcore interaction gently pushes the + # atoms into the open-chain geometry before the full nonbonded morph begins, + # improving HREX overlap at the ring-break boundary. + s.prepend_stage("ring_open", s.initial()) + s.set_equation(stage="ring_open", lever="morse_hard", equation=0) + s.set_equation(stage="ring_open", lever="morse_soft", equation=0) + s.set_equation(stage="ring_open", lever="bond_k", equation=s.final()) + s.set_equation(stage="ring_open", lever="bond_length", equation=s.final()) + s.set_equation(stage="ring_open", lever="angle_k", equation=s.final()) + s.set_equation(stage="ring_open", lever="angle_size", equation=s.final()) + s.set_equation(stage="ring_open", lever="torsion_k", equation=s.final()) + s.set_equation(stage="ring_open", lever="torsion_phase", equation=s.final()) + s.set_equation( + stage="ring_open", force="ring-break", lever="alpha", equation=1 - s.lam() + ) + s.set_equation( + stage="ring_open", force="ring-break", lever="kappa", equation=s.lam() + ) + s.set_equation( + stage="ring_open", force="ring-make", lever="alpha", equation=s.lam() + ) + s.set_equation( + stage="ring_open", force="ring-make", lever="kappa", equation=1 - s.lam() + ) + s.prepend_stage("restraints_off", s.initial()) s.set_equation(stage="restraints_off", lever="morse_soft", equation=1 - s.lam()) s.set_equation(stage="restraints_off", lever="morse_hard", equation=0) @@ -207,6 +234,8 @@ def ring_break_morph(): s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.initial()) s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.initial()) + # morph: standard nonbonded morphing. Ring-break is fixed at fully open + # (kappa=1, alpha=0) since geometry has already relaxed in ring_open. s.set_equation(stage="morph", lever="morse_hard", equation=0) s.set_equation(stage="morph", lever="morse_soft", equation=0) s.set_equation(stage="morph", lever="bond_k", equation=s.final()) @@ -215,22 +244,10 @@ def ring_break_morph(): s.set_equation(stage="morph", lever="angle_size", equation=s.final()) s.set_equation(stage="morph", lever="torsion_k", equation=s.final()) s.set_equation(stage="morph", lever="torsion_phase", equation=s.final()) - - # Ring-breaking bonds: softcore interaction grows from zero as the ring - # opens during the morph stage (alpha: 1→0, kappa: 0→1). - # Ring-making bonds: softcore interaction shrinks to zero as the ring - # closes during the morph stage (alpha: 0→1, kappa: 1→0). - # potential_swap and restraints_off stages use default (s.initial()): - # ring-break alpha=1.0/kappa=0.0 (no interaction, ring still bonded) - # ring-make alpha=0.0/kappa=1.0 (full interaction, ring not yet formed) - s.set_equation( - stage="morph", force="ring-break", lever="alpha", equation=1 - s.lam() - ) - s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=s.lam()) - s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=s.lam()) - s.set_equation( - stage="morph", force="ring-make", lever="kappa", equation=1 - s.lam() - ) + s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=0) + s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=1) + s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=1) + s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=0) return s @@ -239,7 +256,7 @@ def reverse_ring_break_morph(): """ Build a lambda schedule for reverse ring-breaking perturbations. - Three stages: morph → bonded_perturb → potential_swap. + Four stages: morph → ring_close → bonded_perturb → potential_swap. Returns ------- @@ -251,6 +268,8 @@ def reverse_ring_break_morph(): s = _LambdaSchedule.standard_morph() + # morph: standard nonbonded morphing. Ring-break fixed at fully open + # (kappa=1, alpha=0); ring-make fixed at full interaction (kappa=1, alpha=0). s.set_equation(stage="morph", lever="morse_hard", equation=0) s.set_equation(stage="morph", lever="morse_soft", equation=0) s.set_equation(stage="morph", lever="bond_k", equation=s.initial()) @@ -259,7 +278,35 @@ def reverse_ring_break_morph(): s.set_equation(stage="morph", lever="angle_size", equation=s.initial()) s.set_equation(stage="morph", lever="torsion_k", equation=s.initial()) s.set_equation(stage="morph", lever="torsion_phase", equation=s.initial()) + s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=1) + s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) + + # ring_close: non-bonded terms fixed at final; ring-make interaction ramps + # off (alpha: 0→1, kappa: 1→0) to allow atoms to relax into ring geometry + # before Morse is applied. Symmetric counterpart to ring_open. + s.append_stage("ring_close", s.final()) + s.set_equation(stage="ring_close", lever="morse_hard", equation=0) + s.set_equation(stage="ring_close", lever="morse_soft", equation=0) + s.set_equation(stage="ring_close", lever="bond_k", equation=s.initial()) + s.set_equation(stage="ring_close", lever="bond_length", equation=s.initial()) + s.set_equation(stage="ring_close", lever="angle_k", equation=s.initial()) + s.set_equation(stage="ring_close", lever="angle_size", equation=s.initial()) + s.set_equation(stage="ring_close", lever="torsion_k", equation=s.initial()) + s.set_equation(stage="ring_close", lever="torsion_phase", equation=s.initial()) + s.set_equation(stage="ring_close", force="ring-break", lever="alpha", equation=1) + s.set_equation(stage="ring_close", force="ring-break", lever="kappa", equation=0) + s.set_equation( + stage="ring_close", force="ring-make", lever="alpha", equation=s.lam() + ) + s.set_equation( + stage="ring_close", force="ring-make", lever="kappa", equation=1 - s.lam() + ) + # bonded_perturb: Morse soft ramps on; ring-make already off from ring_close. + # Ring-break softcore turns on as any ring-break bond opens (alpha: 1→0, + # kappa: 0→1); ring-make stays off (kappa=0, alpha=1). s.append_stage("bonded_perturb", s.final()) s.set_equation(stage="bonded_perturb", lever="morse_soft", equation=0 + s.lam()) s.set_equation(stage="bonded_perturb", lever="morse_hard", equation=0) @@ -285,6 +332,14 @@ def reverse_ring_break_morph(): lever="torsion_phase", equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), ) + s.set_equation( + stage="bonded_perturb", force="ring-break", lever="alpha", equation=1 - s.lam() + ) + s.set_equation( + stage="bonded_perturb", force="ring-break", lever="kappa", equation=s.lam() + ) + s.set_equation(stage="bonded_perturb", force="ring-make", lever="alpha", equation=1) + s.set_equation(stage="bonded_perturb", force="ring-make", lever="kappa", equation=0) s.append_stage("potential_swap", s.final()) s.set_equation(stage="potential_swap", lever="morse_hard", equation=0 + s.lam()) @@ -304,29 +359,4 @@ def reverse_ring_break_morph(): s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.final()) s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.final()) - # morph stage (first): nonbonded-only changes; ring bonds still intact/absent. - # ring-break: alpha=1.0/kappa=0.0 throughout (no interaction, ring bonded at λ=0) - # ring-make: alpha=0.0/kappa=1.0 throughout (full interaction, ring absent at λ=0) - # bonded_perturb stage (second): ring bonds established/dissolved via Morse. - # ring-make softcore turns off as ring forms (alpha: 0→1, kappa: 1→0) - # ring-break softcore turns on as ring opens (alpha: 1→0, kappa: 0→1) - # potential_swap stage (last): Morse→harmonic swap; ring fully transitioned. - # defaults (s.final()) give ring-break alpha=0/kappa=1, ring-make alpha=1/kappa=0. - s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=1) - s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=0) - s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) - s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) - s.set_equation( - stage="bonded_perturb", force="ring-break", lever="alpha", equation=1 - s.lam() - ) - s.set_equation( - stage="bonded_perturb", force="ring-break", lever="kappa", equation=s.lam() - ) - s.set_equation( - stage="bonded_perturb", force="ring-make", lever="alpha", equation=s.lam() - ) - s.set_equation( - stage="bonded_perturb", force="ring-make", lever="kappa", equation=1 - s.lam() - ) - return s From 18b66e189a67ab229b457828f7b7351069b6dea2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 8 May 2026 13:36:06 +0100 Subject: [PATCH 182/212] Try weighting ring-breaking schedule stages. --- src/somd2/_utils/_schedules.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index bae6a68c..5207a84a 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -162,13 +162,14 @@ def ring_break_morph(): from sire.cas import LambdaSchedule as _LambdaSchedule s = _LambdaSchedule.standard_morph() + s.set_stage_weight("morph", 2) # ring_open: Morse is already off; ring-break nonbonded interaction ramps # on (alpha: 1→0, kappa: 0→1) while non-bonded terms stay at initial and # bonded terms remain at final. The softcore interaction gently pushes the # atoms into the open-chain geometry before the full nonbonded morph begins, # improving HREX overlap at the ring-break boundary. - s.prepend_stage("ring_open", s.initial()) + s.prepend_stage("ring_open", s.initial(), weight=1) s.set_equation(stage="ring_open", lever="morse_hard", equation=0) s.set_equation(stage="ring_open", lever="morse_soft", equation=0) s.set_equation(stage="ring_open", lever="bond_k", equation=s.final()) @@ -190,7 +191,7 @@ def ring_break_morph(): stage="ring_open", force="ring-make", lever="kappa", equation=1 - s.lam() ) - s.prepend_stage("restraints_off", s.initial()) + s.prepend_stage("restraints_off", s.initial(), weight=1) s.set_equation(stage="restraints_off", lever="morse_soft", equation=1 - s.lam()) s.set_equation(stage="restraints_off", lever="morse_hard", equation=0) s.set_equation(stage="restraints_off", lever="bond_k", equation=s.final()) @@ -216,7 +217,7 @@ def ring_break_morph(): equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), ) - s.prepend_stage("potential_swap", s.initial()) + s.prepend_stage("potential_swap", s.initial(), weight=2) s.set_equation(stage="potential_swap", lever="morse_hard", equation=1 - s.lam()) s.set_equation(stage="potential_swap", lever="morse_soft", equation=0 + s.lam()) s.set_equation( @@ -267,6 +268,7 @@ def reverse_ring_break_morph(): from sire.cas import LambdaSchedule as _LambdaSchedule s = _LambdaSchedule.standard_morph() + s.set_stage_weight("morph", 2) # morph: standard nonbonded morphing. Ring-break fixed at fully open # (kappa=1, alpha=0); ring-make fixed at full interaction (kappa=1, alpha=0). @@ -286,7 +288,7 @@ def reverse_ring_break_morph(): # ring_close: non-bonded terms fixed at final; ring-make interaction ramps # off (alpha: 0→1, kappa: 1→0) to allow atoms to relax into ring geometry # before Morse is applied. Symmetric counterpart to ring_open. - s.append_stage("ring_close", s.final()) + s.append_stage("ring_close", s.final(), weight=1) s.set_equation(stage="ring_close", lever="morse_hard", equation=0) s.set_equation(stage="ring_close", lever="morse_soft", equation=0) s.set_equation(stage="ring_close", lever="bond_k", equation=s.initial()) @@ -307,7 +309,7 @@ def reverse_ring_break_morph(): # bonded_perturb: Morse soft ramps on; ring-make already off from ring_close. # Ring-break softcore turns on as any ring-break bond opens (alpha: 1→0, # kappa: 0→1); ring-make stays off (kappa=0, alpha=1). - s.append_stage("bonded_perturb", s.final()) + s.append_stage("bonded_perturb", s.final(), weight=1) s.set_equation(stage="bonded_perturb", lever="morse_soft", equation=0 + s.lam()) s.set_equation(stage="bonded_perturb", lever="morse_hard", equation=0) s.set_equation(stage="bonded_perturb", lever="bond_k", equation=s.initial()) @@ -341,7 +343,7 @@ def reverse_ring_break_morph(): s.set_equation(stage="bonded_perturb", force="ring-make", lever="alpha", equation=1) s.set_equation(stage="bonded_perturb", force="ring-make", lever="kappa", equation=0) - s.append_stage("potential_swap", s.final()) + s.append_stage("potential_swap", s.final(), weight=2) s.set_equation(stage="potential_swap", lever="morse_hard", equation=0 + s.lam()) s.set_equation(stage="potential_swap", lever="morse_soft", equation=1 - s.lam()) s.set_equation( From 78a311de2bf4df50d8a5e4d995dce12d4e1b549e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 12 May 2026 16:09:22 +0100 Subject: [PATCH 183/212] Add unit test for ring-break/make forces. --- tests/conftest.py | 12 ++ tests/schedules/test_ring_break.py | 232 +++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 tests/schedules/test_ring_break.py diff --git a/tests/conftest.py b/tests/conftest.py index db0a3484..a4ee48c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -86,3 +86,15 @@ def pert_rev_mols(): mols = sr.load_test_files("somd1_backward.prm7", "somd1_backward.rst7") pert_file = str(Path(__file__).parent / "inputs" / "backward.pert") return apply_pert(mols, pert_file) + + +@pytest.fixture(scope="session") +def syk_ring_break_mols(): + """ + Load the SYK 5035→5033 ring-breaking perturbation system. + + Reference state (λ=0): SYK-5035 with an intact ring containing a + breaking bond. Perturbed state (λ=1): SYK-5033, the open-chain analogue. + """ + mols = sr.load_test_files("syk_5035_5033.s3") + return sr.morph.link_to_reference(mols) diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py new file mode 100644 index 00000000..6f589f22 --- /dev/null +++ b/tests/schedules/test_ring_break.py @@ -0,0 +1,232 @@ +""" +Tests for ring-break / ring-make CustomBondForce setup and schedule behaviour. + +Checks: + - ``ring-break`` force is registered (not ``ring-make``) for the forward + direction (swap_end_states=False, ring_break_morph schedule). + - ``ring-make`` force is registered (not ``ring-break``) for the reverse + direction (swap_end_states=True, reverse_ring_break_morph schedule). + - Ring-break energy is near-zero when kappa=0 (potential_swap and + restraints_off stages) and clearly non-zero when kappa=1 (morph stage). + - Ring-make energy follows the symmetric pattern under reverse_ring_break_morph: + non-zero at λ=0 (morph, kappa=1) and near-zero at λ=1 (potential_swap, kappa=0). + +Stage boundaries for ring_break_morph (weights 2:1:1:2, total 6): + potential_swap λ ∈ [0, 1/3) ring-break kappa = 0 + restraints_off λ ∈ [1/3, 1/2) ring-break kappa = 0 + ring_open λ ∈ [1/2, 2/3) ring-break kappa ramps 0 → 1 + morph λ ∈ [2/3, 1] ring-break kappa = 1 (fixed) + +Stage boundaries for reverse_ring_break_morph (weights 2:1:1:2, total 6): + morph λ ∈ [0, 1/3) ring-make kappa = 1 (fixed) + ring_close λ ∈ [1/3, 1/2) ring-make kappa ramps 1 → 0 + bonded_perturb λ ∈ [1/2, 2/3) ring-make kappa = 0 + potential_swap λ ∈ [2/3, 1] ring-make kappa = 0 +""" + +import pytest +import sire as sr + +pytestmark = pytest.mark.skipif( + "openmm" not in sr.convert.supported_formats(), + reason="openmm support is not available", +) + +# Energy threshold (kcal/mol) separating "inactive" from "active" force states. +# Chosen conservatively: sp_scan shows kappa=0 energies up to ~0.07 kcal/mol +# and kappa=1 energies of at least ~0.19 kcal/mol at the ring_open/morph boundary. +_INACTIVE_THRESHOLD = 0.1 # abs(E) must be below this when kappa=0 +_ACTIVE_THRESHOLD = 0.1 # abs(E) must be above this when kappa=1 + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _build_dynamics(mols, schedule, swap_end_states): + """ + Construct a dynamics context for the ring-break system with Morse restraints. + + Mirrors the production setup in somd2_api_runner_dmr.py / sp_scan.py so + that the force groups match what a real simulation would create. + Uses reaction-field (rf) rather than PME since the test system is a + vacuum/non-periodic input with no periodic box vectors. + """ + from somd2._utils._somd1 import make_compatible + + mols = mols.clone() + + hard_restraints, mols = sr.restraints.morse_potential( + mols, + de="150 kcal/mol", + auto_parametrise=True, + direct_morse_replacement=True, + name="morse_hard", + ) + soft_restraints, _ = sr.restraints.morse_potential( + mols, + atoms0=hard_restraints[0].atom0(), + atoms1=hard_restraints[0].atom1(), + r0=hard_restraints[0].r0(), + k="125 kcal mol-1 A-2", + auto_parametrise=False, + de="50 kcal mol-1", + name="morse_soft", + ) + mols = make_compatible(mols) + + return mols.dynamics( + constraint="h_bonds", + perturbable_constraint="h_bonds_not_heavy_perturbed", + cutoff="10A", + cutoff_type="rf", + dynamic_constraints=True, + include_constrained_energies=False, + swap_end_states=swap_end_states, + map={ + "ghosts_are_light": True, + "check_for_h_by_max_mass": True, + "check_for_h_by_mass": False, + "check_for_h_by_element": False, + "check_for_h_by_ambertype": False, + "fix_perturbable_zero_sigmas": True, + "lambda_schedule": schedule, + "restraints": [hard_restraints, soft_restraints], + }, + ) + + +def _force_energy_kcal(d, lam, force_name): + """Return the energy (kcal/mol) for *force_name* at *lam*.""" + import openmm + + context = d.context() + d.set_lambda(lam, update_constraints=True) + grp = context._force_group_map[force_name] + state = context.getState(getEnergy=True, groups=(1 << grp)) + return state.getPotentialEnergy().value_in_unit(openmm.unit.kilocalories_per_mole) + + +# ── fixtures ───────────────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def forward_dynamics(syk_ring_break_mols): + """Forward ring-breaking dynamics: swap_end_states=False, ring_break_morph.""" + from somd2._utils._schedules import ring_break_morph + + return _build_dynamics( + syk_ring_break_mols, ring_break_morph(), swap_end_states=False + ) + + +@pytest.fixture(scope="module") +def reverse_dynamics(syk_ring_break_mols): + """Reverse ring-making dynamics: swap_end_states=True, reverse_ring_break_morph.""" + from somd2._utils._schedules import reverse_ring_break_morph + + return _build_dynamics( + syk_ring_break_mols, reverse_ring_break_morph(), swap_end_states=True + ) + + +# ── force-presence tests ────────────────────────────────────────────────────── + + +def test_forward_has_ring_break_not_ring_make(forward_dynamics): + """ + Forward direction: ring-break CustomBondForce is registered; ring-make is absent. + """ + fmap = forward_dynamics.context()._force_group_map + assert "ring-break" in fmap, "ring-break force group missing for forward direction" + assert "ring-make" not in fmap, ( + "ring-make force group should not exist for forward direction" + ) + + +def test_reverse_has_ring_make_not_ring_break(reverse_dynamics): + """ + Reverse direction (swap_end_states=True): ring-make CustomBondForce is + registered; ring-break is absent. + """ + fmap = reverse_dynamics.context()._force_group_map + assert "ring-make" in fmap, "ring-make force group missing for reverse direction" + assert "ring-break" not in fmap, ( + "ring-break force group should not exist for reverse direction" + ) + + +# ── forward schedule energy tests ───────────────────────────────────────────── + +# ring_break_morph: kappa=0 throughout potential_swap (λ<1/3) and +# restraints_off (λ<1/2); kappa ramps 0→1 through ring_open (1/2→2/3); +# kappa=1 fixed throughout morph (λ>2/3). + + +@pytest.mark.parametrize("lam", [0.0, 1 / 3, 0.5]) +def test_ring_break_inactive_before_ring_open(forward_dynamics, lam): + """ + Ring-break energy is near-zero (kappa=0) in potential_swap and + restraints_off stages and at the start of ring_open. + """ + e = _force_energy_kcal(forward_dynamics, lam, "ring-break") + assert abs(e) < _INACTIVE_THRESHOLD, ( + f"ring-break energy {e:.4f} kcal/mol at λ={lam:.4f} exceeds inactive " + f"threshold {_INACTIVE_THRESHOLD} kcal/mol (kappa should be 0)" + ) + + +@pytest.mark.parametrize("lam", [2 / 3, 1.0]) +def test_ring_break_active_after_ring_open(forward_dynamics, lam): + """ + Ring-break energy is clearly non-zero (kappa=1) at the end of ring_open + and throughout morph. + """ + e = _force_energy_kcal(forward_dynamics, lam, "ring-break") + assert abs(e) > _ACTIVE_THRESHOLD, ( + f"ring-break energy {e:.4f} kcal/mol at λ={lam:.4f} is below active " + f"threshold {_ACTIVE_THRESHOLD} kcal/mol (kappa should be 1)" + ) + + +def test_ring_break_energy_increases_with_kappa(forward_dynamics): + """ + Energy at kappa=1 (λ=1, morph) substantially exceeds energy at kappa=0 (λ=0). + """ + e0 = _force_energy_kcal(forward_dynamics, 0.0, "ring-break") + e1 = _force_energy_kcal(forward_dynamics, 1.0, "ring-break") + assert abs(e1) > abs(e0) + 0.5, ( + f"ring-break energy at λ=1 ({e1:.4f} kcal/mol) should be much larger " + f"than at λ=0 ({e0:.4f} kcal/mol)" + ) + + +# ── reverse schedule energy tests ───────────────────────────────────────────── + +# reverse_ring_break_morph: ring-make kappa=1 fixed in morph (λ<1/3); +# kappa ramps 1→0 in ring_close (1/3→1/2); kappa=0 in bonded_perturb and +# potential_swap (λ>1/2). + + +def test_ring_make_active_at_lambda_zero(reverse_dynamics): + """ + Ring-make energy is non-zero at λ=0: the morph stage fixes kappa=1 + so the ring-make interaction is fully on from the start. + """ + e = _force_energy_kcal(reverse_dynamics, 0.0, "ring-make") + assert abs(e) > _ACTIVE_THRESHOLD, ( + f"ring-make energy {e:.4f} kcal/mol at λ=0 is below active threshold " + f"{_ACTIVE_THRESHOLD} kcal/mol (kappa should be 1 in morph stage)" + ) + + +@pytest.mark.parametrize("lam", [2 / 3, 1.0]) +def test_ring_make_inactive_after_ring_close(reverse_dynamics, lam): + """ + Ring-make energy is near-zero (kappa=0) in bonded_perturb and + potential_swap stages of reverse_ring_break_morph. + """ + e = _force_energy_kcal(reverse_dynamics, lam, "ring-make") + assert abs(e) < _INACTIVE_THRESHOLD, ( + f"ring-make energy {e:.4f} kcal/mol at λ={lam:.4f} exceeds inactive " + f"threshold {_INACTIVE_THRESHOLD} kcal/mol (kappa should be 0)" + ) From a98642be2cf222d2b3506c934dc2ffb08bc67ed4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 15 May 2026 11:14:59 +0100 Subject: [PATCH 184/212] Use .reverse() to avoid maintaining two schedules. --- src/somd2/_utils/_schedules.py | 140 +++++++++------------------------ 1 file changed, 37 insertions(+), 103 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 5207a84a..b646b3ba 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -153,6 +153,13 @@ def ring_break_morph(): Four stages: potential_swap → restraints_off → ring_open → morph. + The ring-break softcore kappa ramps 0→1 through ring_open and is fixed at 1 + in morph. The ring-make equations mirror ring-break so that + ``ring_break_morph().reverse()`` is the correct schedule for the ring-making + direction (used by :func:`reverse_ring_break_morph`). Because ring_break_morph + is only used for ring-breaking perturbations (no ring-make force present), the + ring-make equations have no effect on forward simulations. + Returns ------- @@ -169,6 +176,10 @@ def ring_break_morph(): # bonded terms remain at final. The softcore interaction gently pushes the # atoms into the open-chain geometry before the full nonbonded morph begins, # improving HREX overlap at the ring-break boundary. + # + # ring-make equations mirror ring-break so the reversed schedule is correct: + # after .reverse(), ring-make kappa ramps 1→0 through this stage, matching + # what ring-break does here in the forward direction. s.prepend_stage("ring_open", s.initial(), weight=1) s.set_equation(stage="ring_open", lever="morse_hard", equation=0) s.set_equation(stage="ring_open", lever="morse_soft", equation=0) @@ -184,11 +195,12 @@ def ring_break_morph(): s.set_equation( stage="ring_open", force="ring-break", lever="kappa", equation=s.lam() ) + # ring-make mirrors ring-break so reversed schedule ramps ring-make 1→0 here. s.set_equation( - stage="ring_open", force="ring-make", lever="alpha", equation=s.lam() + stage="ring_open", force="ring-make", lever="alpha", equation=1 - s.lam() ) s.set_equation( - stage="ring_open", force="ring-make", lever="kappa", equation=1 - s.lam() + stage="ring_open", force="ring-make", lever="kappa", equation=s.lam() ) s.prepend_stage("restraints_off", s.initial(), weight=1) @@ -237,6 +249,8 @@ def ring_break_morph(): # morph: standard nonbonded morphing. Ring-break is fixed at fully open # (kappa=1, alpha=0) since geometry has already relaxed in ring_open. + # ring-make mirrors ring-break: kappa=1, alpha=0 so that .reverse() gives + # kappa=1 at lam=0 of the reversed morph stage (ring-making direction start). s.set_equation(stage="morph", lever="morse_hard", equation=0) s.set_equation(stage="morph", lever="morse_soft", equation=0) s.set_equation(stage="morph", lever="bond_k", equation=s.final()) @@ -247,17 +261,33 @@ def ring_break_morph(): s.set_equation(stage="morph", lever="torsion_phase", equation=s.final()) s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=0) s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=1) - s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=1) - s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) + s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) return s def reverse_ring_break_morph(): """ - Build a lambda schedule for reverse ring-breaking perturbations. + Build a lambda schedule for ring-making perturbations (reverse ring-break). + + Returns ``ring_break_morph().reverse()``: four stages in reversed order + (morph → ring_open → restraints_off → potential_swap) with all equations + reflected about λ=½ and initial/final end-states swapped. - Four stages: morph → ring_close → bonded_perturb → potential_swap. + This schedule is correct for two equivalent use-cases: + + 1. A ring-making perturbation run with ``swap_end_states=False``: the + ring-make softcore force (kappa=1 at λ=0, ramping to 0) is controlled + directly by the ring-make lever equations. + 2. A ring-breaking perturbation run with ``swap_end_states=True`` (the + runner reverses the schedule automatically, yielding the same effective + schedule): the ring-make softcore — which now controls the original + ring-breaking bond after the end-state swap — is handled identically. + + The energy symmetry invariant holds for both cases: + ``E_ring_make_reverse(λ) == E_ring_break_forward(1-λ)`` at any fixed + geometry. Returns ------- @@ -265,100 +295,4 @@ def reverse_ring_break_morph(): schedule : sire.legacy.CAS.LambdaSchedule The lambda schedule. """ - from sire.cas import LambdaSchedule as _LambdaSchedule - - s = _LambdaSchedule.standard_morph() - s.set_stage_weight("morph", 2) - - # morph: standard nonbonded morphing. Ring-break fixed at fully open - # (kappa=1, alpha=0); ring-make fixed at full interaction (kappa=1, alpha=0). - s.set_equation(stage="morph", lever="morse_hard", equation=0) - s.set_equation(stage="morph", lever="morse_soft", equation=0) - s.set_equation(stage="morph", lever="bond_k", equation=s.initial()) - s.set_equation(stage="morph", lever="bond_length", equation=s.initial()) - s.set_equation(stage="morph", lever="angle_k", equation=s.initial()) - s.set_equation(stage="morph", lever="angle_size", equation=s.initial()) - s.set_equation(stage="morph", lever="torsion_k", equation=s.initial()) - s.set_equation(stage="morph", lever="torsion_phase", equation=s.initial()) - s.set_equation(stage="morph", force="ring-break", lever="alpha", equation=1) - s.set_equation(stage="morph", force="ring-break", lever="kappa", equation=0) - s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) - s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) - - # ring_close: non-bonded terms fixed at final; ring-make interaction ramps - # off (alpha: 0→1, kappa: 1→0) to allow atoms to relax into ring geometry - # before Morse is applied. Symmetric counterpart to ring_open. - s.append_stage("ring_close", s.final(), weight=1) - s.set_equation(stage="ring_close", lever="morse_hard", equation=0) - s.set_equation(stage="ring_close", lever="morse_soft", equation=0) - s.set_equation(stage="ring_close", lever="bond_k", equation=s.initial()) - s.set_equation(stage="ring_close", lever="bond_length", equation=s.initial()) - s.set_equation(stage="ring_close", lever="angle_k", equation=s.initial()) - s.set_equation(stage="ring_close", lever="angle_size", equation=s.initial()) - s.set_equation(stage="ring_close", lever="torsion_k", equation=s.initial()) - s.set_equation(stage="ring_close", lever="torsion_phase", equation=s.initial()) - s.set_equation(stage="ring_close", force="ring-break", lever="alpha", equation=1) - s.set_equation(stage="ring_close", force="ring-break", lever="kappa", equation=0) - s.set_equation( - stage="ring_close", force="ring-make", lever="alpha", equation=s.lam() - ) - s.set_equation( - stage="ring_close", force="ring-make", lever="kappa", equation=1 - s.lam() - ) - - # bonded_perturb: Morse soft ramps on; ring-make already off from ring_close. - # Ring-break softcore turns on as any ring-break bond opens (alpha: 1→0, - # kappa: 0→1); ring-make stays off (kappa=0, alpha=1). - s.append_stage("bonded_perturb", s.final(), weight=1) - s.set_equation(stage="bonded_perturb", lever="morse_soft", equation=0 + s.lam()) - s.set_equation(stage="bonded_perturb", lever="morse_hard", equation=0) - s.set_equation(stage="bonded_perturb", lever="bond_k", equation=s.initial()) - s.set_equation(stage="bonded_perturb", lever="bond_length", equation=s.initial()) - s.set_equation( - stage="bonded_perturb", - lever="angle_k", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation( - stage="bonded_perturb", - lever="angle_size", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation( - stage="bonded_perturb", - lever="torsion_k", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation( - stage="bonded_perturb", - lever="torsion_phase", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation( - stage="bonded_perturb", force="ring-break", lever="alpha", equation=1 - s.lam() - ) - s.set_equation( - stage="bonded_perturb", force="ring-break", lever="kappa", equation=s.lam() - ) - s.set_equation(stage="bonded_perturb", force="ring-make", lever="alpha", equation=1) - s.set_equation(stage="bonded_perturb", force="ring-make", lever="kappa", equation=0) - - s.append_stage("potential_swap", s.final(), weight=2) - s.set_equation(stage="potential_swap", lever="morse_hard", equation=0 + s.lam()) - s.set_equation(stage="potential_swap", lever="morse_soft", equation=1 - s.lam()) - s.set_equation( - stage="potential_swap", - lever="bond_k", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation( - stage="potential_swap", - lever="bond_length", - equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), - ) - s.set_equation(stage="potential_swap", lever="angle_k", equation=s.final()) - s.set_equation(stage="potential_swap", lever="angle_size", equation=s.final()) - s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.final()) - s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.final()) - - return s + return ring_break_morph().reverse() From be3824ada1341df8d89f2d016af42a53b0171ca0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 15 May 2026 11:15:43 +0100 Subject: [PATCH 185/212] Update ring-break schedule tests following softcore update. --- tests/schedules/test_ring_break.py | 310 +++++++++++++++++++++-------- 1 file changed, 229 insertions(+), 81 deletions(-) diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py index 6f589f22..223b31e1 100644 --- a/tests/schedules/test_ring_break.py +++ b/tests/schedules/test_ring_break.py @@ -1,29 +1,3 @@ -""" -Tests for ring-break / ring-make CustomBondForce setup and schedule behaviour. - -Checks: - - ``ring-break`` force is registered (not ``ring-make``) for the forward - direction (swap_end_states=False, ring_break_morph schedule). - - ``ring-make`` force is registered (not ``ring-break``) for the reverse - direction (swap_end_states=True, reverse_ring_break_morph schedule). - - Ring-break energy is near-zero when kappa=0 (potential_swap and - restraints_off stages) and clearly non-zero when kappa=1 (morph stage). - - Ring-make energy follows the symmetric pattern under reverse_ring_break_morph: - non-zero at λ=0 (morph, kappa=1) and near-zero at λ=1 (potential_swap, kappa=0). - -Stage boundaries for ring_break_morph (weights 2:1:1:2, total 6): - potential_swap λ ∈ [0, 1/3) ring-break kappa = 0 - restraints_off λ ∈ [1/3, 1/2) ring-break kappa = 0 - ring_open λ ∈ [1/2, 2/3) ring-break kappa ramps 0 → 1 - morph λ ∈ [2/3, 1] ring-break kappa = 1 (fixed) - -Stage boundaries for reverse_ring_break_morph (weights 2:1:1:2, total 6): - morph λ ∈ [0, 1/3) ring-make kappa = 1 (fixed) - ring_close λ ∈ [1/3, 1/2) ring-make kappa ramps 1 → 0 - bonded_perturb λ ∈ [1/2, 2/3) ring-make kappa = 0 - potential_swap λ ∈ [2/3, 1] ring-make kappa = 0 -""" - import pytest import sire as sr @@ -32,24 +6,18 @@ reason="openmm support is not available", ) -# Energy threshold (kcal/mol) separating "inactive" from "active" force states. -# Chosen conservatively: sp_scan shows kappa=0 energies up to ~0.07 kcal/mol -# and kappa=1 energies of at least ~0.19 kcal/mol at the ring_open/morph boundary. -_INACTIVE_THRESHOLD = 0.1 # abs(E) must be below this when kappa=0 -_ACTIVE_THRESHOLD = 0.1 # abs(E) must be above this when kappa=1 - +# Energy threshold (kcal/mol) for the "active" state: kappa=1 should give +# clearly non-zero CustomBondForce energy. +_ACTIVE_THRESHOLD = 0.1 -# ── helpers ────────────────────────────────────────────────────────────────── +# Minimum energy delta (kcal/mol) expected when kappa switches from 0 to 1 +# at fixed geometry (within ring_open, where all bonded params are at s.final()). +_KAPPA_DELTA_THRESHOLD = 0.2 def _build_dynamics(mols, schedule, swap_end_states): """ Construct a dynamics context for the ring-break system with Morse restraints. - - Mirrors the production setup in somd2_api_runner_dmr.py / sp_scan.py so - that the force groups match what a real simulation would create. - Uses reaction-field (rf) rather than PME since the test system is a - vacuum/non-periodic input with no periodic box vectors. """ from somd2._utils._somd1 import make_compatible @@ -106,9 +74,6 @@ def _force_energy_kcal(d, lam, force_name): return state.getPotentialEnergy().value_in_unit(openmm.unit.kilocalories_per_mole) -# ── fixtures ───────────────────────────────────────────────────────────────── - - @pytest.fixture(scope="module") def forward_dynamics(syk_ring_break_mols): """Forward ring-breaking dynamics: swap_end_states=False, ring_break_morph.""" @@ -121,7 +86,13 @@ def forward_dynamics(syk_ring_break_mols): @pytest.fixture(scope="module") def reverse_dynamics(syk_ring_break_mols): - """Reverse ring-making dynamics: swap_end_states=True, reverse_ring_break_morph.""" + """ + Reverse ring-making dynamics: swap_end_states=True, reverse_ring_break_morph. + + reverse_ring_break_morph() == ring_break_morph().reverse(), so this fixture + also implicitly tests the reversed schedule path used by the runner when a + ring-breaking perturbation is run with swap_end_states=True. + """ from somd2._utils._schedules import reverse_ring_break_morph return _build_dynamics( @@ -155,56 +126,166 @@ def test_reverse_has_ring_make_not_ring_break(reverse_dynamics): ) -# ── forward schedule energy tests ───────────────────────────────────────────── +# ── schedule kappa/alpha tests ──────────────────────────────────────────────── +# +# These tests verify kappa and alpha values by calling schedule.morph() directly, +# using the same initial/final values that lambdalever passes in production. +# They are completely independent of Sire's energy formula and will continue to +# work correctly regardless of changes to the softcore implementation. + +# ring_break_morph kappa/alpha points: +# λ=0.00 potential_swap start kappa=0, alpha=1 +# λ=0.15 potential_swap mid kappa=0, alpha=1 +# λ=1/3 restraints_off start kappa=0, alpha=1 +# λ=0.45 restraints_off mid kappa=0, alpha=1 +# λ=0.50 ring_open start kappa=0, alpha=1 (within-stage lam=0) +# λ=0.55 ring_open mid kappa=0.3, alpha=0.7 +# λ=0.60 ring_open mid kappa=0.6, alpha=0.4 +# λ=2/3 morph start kappa=1, alpha=0 +# λ=0.85 morph mid kappa=1, alpha=0 +# λ=1.00 morph end kappa=1, alpha=0 +_FWD_KAPPA_ALPHA = [ + (0.00, 0.0, 1.0), + (0.15, 0.0, 1.0), + (1 / 3, 0.0, 1.0), + (0.45, 0.0, 1.0), + (0.50, 0.0, 1.0), + (0.55, 0.3, 0.7), + (0.60, 0.6, 0.4), + (2 / 3, 1.0, 0.0), + (0.85, 1.0, 0.0), + (1.00, 1.0, 0.0), +] + +# reverse_ring_break_morph ring-make kappa/alpha points (mirror of forward): +# λ=0.00 morph start kappa=1, alpha=0 +# λ=0.15 morph mid kappa=1, alpha=0 +# λ=1/3 ring_open start kappa=1, alpha=0 (within-stage lam=0) +# λ=0.45 ring_open mid kappa=0.3, alpha=0.7 (within-stage lam=0.7) +# λ=0.50 restraints_off start kappa=0, alpha=1 +# λ=0.60 restraints_off mid kappa=0, alpha=1 +# λ=2/3 potential_swap start kappa=0, alpha=1 +# λ=0.85 potential_swap mid kappa=0, alpha=1 +# λ=1.00 potential_swap end kappa=0, alpha=1 +_REV_KAPPA_ALPHA = [ + (0.00, 1.0, 0.0), + (0.15, 1.0, 0.0), + (1 / 3, 1.0, 0.0), + (0.45, 0.3, 0.7), + (0.50, 0.0, 1.0), + (0.60, 0.0, 1.0), + (2 / 3, 0.0, 1.0), + (0.85, 0.0, 1.0), + (1.00, 0.0, 1.0), +] + + +@pytest.mark.parametrize("lam,expected_kappa,expected_alpha", _FWD_KAPPA_ALPHA) +def test_ring_break_morph_schedule(lam, expected_kappa, expected_alpha): + """ + ring_break_morph() produces the correct ring-break kappa and alpha at each λ. -# ring_break_morph: kappa=0 throughout potential_swap (λ<1/3) and -# restraints_off (λ<1/2); kappa ramps 0→1 through ring_open (1/2→2/3); -# kappa=1 fixed throughout morph (λ>2/3). + Uses lambdalever's initial/final values (kappa: 0→1, alpha: 1→0) to ensure + the test matches production behaviour exactly. + """ + from somd2._utils._schedules import ring_break_morph + + s = ring_break_morph() + kappa = s.morph("ring-break", "kappa", 0.0, 1.0, lam) + alpha = s.morph("ring-break", "alpha", 1.0, 0.0, lam) + assert abs(kappa - expected_kappa) < 1e-10, ( + f"ring-break kappa={kappa:.8f} at λ={lam:.4f}, expected {expected_kappa}" + ) + assert abs(alpha - expected_alpha) < 1e-10, ( + f"ring-break alpha={alpha:.8f} at λ={lam:.4f}, expected {expected_alpha}" + ) -@pytest.mark.parametrize("lam", [0.0, 1 / 3, 0.5]) -def test_ring_break_inactive_before_ring_open(forward_dynamics, lam): +@pytest.mark.parametrize("lam,expected_kappa,expected_alpha", _REV_KAPPA_ALPHA) +def test_reverse_ring_break_morph_schedule(lam, expected_kappa, expected_alpha): """ - Ring-break energy is near-zero (kappa=0) in potential_swap and - restraints_off stages and at the start of ring_open. + reverse_ring_break_morph() produces the correct ring-make kappa and alpha at each λ. + + Uses lambdalever's initial/final values (kappa: 1→0, alpha: 0→1) to ensure + the test matches production behaviour exactly. """ - e = _force_energy_kcal(forward_dynamics, lam, "ring-break") - assert abs(e) < _INACTIVE_THRESHOLD, ( - f"ring-break energy {e:.4f} kcal/mol at λ={lam:.4f} exceeds inactive " - f"threshold {_INACTIVE_THRESHOLD} kcal/mol (kappa should be 0)" + from somd2._utils._schedules import reverse_ring_break_morph + + s = reverse_ring_break_morph() + kappa = s.morph("ring-make", "kappa", 1.0, 0.0, lam) + alpha = s.morph("ring-make", "alpha", 0.0, 1.0, lam) + assert abs(kappa - expected_kappa) < 1e-10, ( + f"ring-make kappa={kappa:.8f} at λ={lam:.4f}, expected {expected_kappa}" + ) + assert abs(alpha - expected_alpha) < 1e-10, ( + f"ring-make alpha={alpha:.8f} at λ={lam:.4f}, expected {expected_alpha}" ) -@pytest.mark.parametrize("lam", [2 / 3, 1.0]) -def test_ring_break_active_after_ring_open(forward_dynamics, lam): +# ── energy delta test ───────────────────────────────────────────────────────── +# +# Within the ring_open stage (λ ∈ [1/2, 2/3)) all bonded parameters are fixed +# at s.final() throughout; only kappa and alpha change. Heavy-atom bonds are +# not in the h_bonds constraint set, so update_constraints=True does not move +# the ring-break atoms between the two set_lambda calls. Both measurements +# therefore use identical atom positions, making the energy delta a clean +# measure of the kappa switch rather than a geometry change. +# +# With Sire's current softcore formula the CustomBondForce energy at kappa=0 is +# the negative of the hard-hard correction (not near zero), but the DELTA +# between kappa=0 and kappa=1 at fixed geometry equals the net softcore +# correction and is a meaningful, formula-independent observable. + + +def test_ring_break_softcore_delta(forward_dynamics): """ - Ring-break energy is clearly non-zero (kappa=1) at the end of ring_open - and throughout morph. + The ring-break CustomBondForce energy changes substantially between the start + (kappa=0, α=1) and end (kappa=1, α=0) of the ring_open stage. + + Both measurements use the same atom geometry because ring_open fixes all + bonded parameters at s.final() and heavy-atom bonds are unconstrained. """ - e = _force_energy_kcal(forward_dynamics, lam, "ring-break") - assert abs(e) > _ACTIVE_THRESHOLD, ( - f"ring-break energy {e:.4f} kcal/mol at λ={lam:.4f} is below active " - f"threshold {_ACTIVE_THRESHOLD} kcal/mol (kappa should be 1)" + e_off = _force_energy_kcal(forward_dynamics, 0.5, "ring-break") # kappa=0 + e_on = _force_energy_kcal(forward_dynamics, 2 / 3, "ring-break") # kappa=1 + assert abs(e_on - e_off) > _KAPPA_DELTA_THRESHOLD, ( + f"ring-break energy delta between kappa=0 (λ=0.5, E={e_off:.4f} kcal/mol) " + f"and kappa=1 (λ=2/3, E={e_on:.4f} kcal/mol) is only " + f"{abs(e_on - e_off):.4f} kcal/mol (expected > {_KAPPA_DELTA_THRESHOLD})" ) -def test_ring_break_energy_increases_with_kappa(forward_dynamics): +def test_ring_make_softcore_delta(reverse_dynamics): """ - Energy at kappa=1 (λ=1, morph) substantially exceeds energy at kappa=0 (λ=0). + The ring-make CustomBondForce energy changes substantially between the start + (kappa=1, α=0) and end (kappa=0, α=1) of the reversed ring_open stage. + + Symmetric counterpart of test_ring_break_softcore_delta for the reverse schedule. + Reversed ring_open spans λ ∈ [1/3, 1/2); measurements at λ=1/3 (kappa=1) and + λ=1/2 (kappa=0) share the same atom geometry for the same reason. """ - e0 = _force_energy_kcal(forward_dynamics, 0.0, "ring-break") - e1 = _force_energy_kcal(forward_dynamics, 1.0, "ring-break") - assert abs(e1) > abs(e0) + 0.5, ( - f"ring-break energy at λ=1 ({e1:.4f} kcal/mol) should be much larger " - f"than at λ=0 ({e0:.4f} kcal/mol)" + e_on = _force_energy_kcal(reverse_dynamics, 1 / 3, "ring-make") # kappa=1 + e_off = _force_energy_kcal(reverse_dynamics, 0.5, "ring-make") # kappa=0 + assert abs(e_on - e_off) > _KAPPA_DELTA_THRESHOLD, ( + f"ring-make energy delta between kappa=1 (λ=1/3, E={e_on:.4f} kcal/mol) " + f"and kappa=0 (λ=1/2, E={e_off:.4f} kcal/mol) is only " + f"{abs(e_on - e_off):.4f} kcal/mol (expected > {_KAPPA_DELTA_THRESHOLD})" ) -# ── reverse schedule energy tests ───────────────────────────────────────────── +# ── energy magnitude tests ──────────────────────────────────────────────────── + -# reverse_ring_break_morph: ring-make kappa=1 fixed in morph (λ<1/3); -# kappa ramps 1→0 in ring_close (1/3→1/2); kappa=0 in bonded_perturb and -# potential_swap (λ>1/2). +@pytest.mark.parametrize("lam", [2 / 3, 1.0]) +def test_ring_break_active_after_ring_open(forward_dynamics, lam): + """ + Ring-break energy is clearly non-zero (kappa=1) at the end of ring_open + and throughout morph. + """ + e = _force_energy_kcal(forward_dynamics, lam, "ring-break") + assert abs(e) > _ACTIVE_THRESHOLD, ( + f"ring-break energy {e:.4f} kcal/mol at λ={lam:.4f} is below active " + f"threshold {_ACTIVE_THRESHOLD} kcal/mol (kappa should be 1)" + ) def test_ring_make_active_at_lambda_zero(reverse_dynamics): @@ -219,14 +300,81 @@ def test_ring_make_active_at_lambda_zero(reverse_dynamics): ) -@pytest.mark.parametrize("lam", [2 / 3, 1.0]) -def test_ring_make_inactive_after_ring_close(reverse_dynamics, lam): +def test_ring_make_inactive_at_lambda_one(reverse_dynamics): + """ + Ring-make energy is near-zero at λ=1 (potential_swap end, kappa=0). + + At λ=1 the system is at the ring-open end state; the hard-hard correction + term in the CustomBondForce is small because the pair is at nonbonded + separation, so the absolute energy remains below the active threshold. + """ + e = _force_energy_kcal(reverse_dynamics, 1.0, "ring-make") + assert abs(e) < _ACTIVE_THRESHOLD, ( + f"ring-make energy {e:.4f} kcal/mol at λ=1 exceeds threshold " + f"{_ACTIVE_THRESHOLD} kcal/mol (kappa should be 0)" + ) + + +# ── energy symmetry tests ───────────────────────────────────────────────────── +# +# The invariant ring_break_morph().reverse() == reverse_ring_break_morph() means +# that the softcore kappa/alpha values at (forward, λ) and (reverse, 1-λ) are +# equal. Both forces act on the same bond (the original ring_breaking_bond, +# which swap_end_states=True maps to ring_making_pairs), so the energies must +# also match. The hard-hard correction appears identically on both sides and +# cancels in the comparison, making this test robust to formula changes. +# +# Test points span zero and non-zero energy regions: +# λ=0.0 → forward kappa=0, reverse at 1-λ=1.0 kappa=0 (both ≈0) +# λ=0.55 → forward ring_open (kappa=0.3), reverse ring_open at 0.45 (kappa=0.3) +# λ=2/3 → forward morph start (kappa=1), reverse ring_open start at 1/3 (kappa=1) +# λ=0.85 → forward morph (kappa=1), reverse reversed-morph at 0.15 (kappa=1) +# λ=1.0 → forward morph end (kappa=1), reverse at 0.0 reversed-morph (kappa=1) + + +@pytest.mark.parametrize("lam", [0.0, 0.55, 2 / 3, 0.85, 1.0]) +def test_energy_symmetry_forward_reverse(forward_dynamics, reverse_dynamics, lam): """ - Ring-make energy is near-zero (kappa=0) in bonded_perturb and - potential_swap stages of reverse_ring_break_morph. + Single-point energy symmetry: E_ring_break_forward(λ) == E_ring_make_reverse(1-λ). + + Verifies that reverse_ring_break_morph() == ring_break_morph().reverse() and + that the mirrored kappa/alpha produce identical corrections on the same bond. """ - e = _force_energy_kcal(reverse_dynamics, lam, "ring-make") - assert abs(e) < _INACTIVE_THRESHOLD, ( - f"ring-make energy {e:.4f} kcal/mol at λ={lam:.4f} exceeds inactive " - f"threshold {_INACTIVE_THRESHOLD} kcal/mol (kappa should be 0)" + e_fwd = _force_energy_kcal(forward_dynamics, lam, "ring-break") + e_rev = _force_energy_kcal(reverse_dynamics, 1.0 - lam, "ring-make") + assert abs(e_fwd - e_rev) < 1e-4, ( + f"Energy symmetry broken at λ={lam:.4f}: " + f"ring-break forward = {e_fwd:.6f} kcal/mol, " + f"ring-make reverse(1-λ={1 - lam:.4f}) = {e_rev:.6f} kcal/mol, " + f"difference = {abs(e_fwd - e_rev):.2e} kcal/mol" ) + + +def test_schedule_symmetry(): + """ + reverse_ring_break_morph() must equal ring_break_morph().reverse(). + + Checks that the simplified implementation produces identical schedules by + comparing kappa values at a dense grid of lambda points using the default + initial/final values that lambdalever passes for ring-break kappa. + """ + from somd2._utils._schedules import ring_break_morph, reverse_ring_break_morph + + fwd = ring_break_morph() + rev = reverse_ring_break_morph() + rev_via_reverse = fwd.reverse() + + test_lambdas = [i / 20 for i in range(21)] + for lam in test_lambdas: + for force, lever, init, fin in [ + ("ring-break", "kappa", 0.0, 1.0), + ("ring-break", "alpha", 1.0, 0.0), + ("ring-make", "kappa", 1.0, 0.0), + ("ring-make", "alpha", 0.0, 1.0), + ]: + v_rev = rev.morph(force, lever, init, fin, lam) + v_rev2 = rev_via_reverse.morph(force, lever, init, fin, lam) + assert abs(v_rev - v_rev2) < 1e-12, ( + f"Mismatch for {force}/{lever} at λ={lam:.2f}: " + f"reverse_ring_break_morph={v_rev}, ring_break_morph().reverse()={v_rev2}" + ) From 7183ef9b40a0c83f0fb21c942f0b3cb4d51ccf0f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 15 May 2026 14:35:13 +0100 Subject: [PATCH 186/212] Update schedule and tests for decoupled ring-break softcore. --- src/somd2/_utils/_schedules.py | 29 ++++++++++++++++ tests/schedules/test_ring_break.py | 54 ------------------------------ 2 files changed, 29 insertions(+), 54 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index b646b3ba..1c4e48d0 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -264,6 +264,35 @@ def ring_break_morph(): s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) + # coul_kappa decouples Coulomb onset from LJ onset: zero throughout the + # bonded stages so the CLJ exception carries no charge while atoms are at + # covalent distances, then ramps 0→1 during morph only (where the LJ + # softcore has already separated the atoms). ring-make mirrors ring-break + # so that .reverse() gives the correct reversed schedule (coul_kappa ramps + # 1→0 through the reversed morph stage for the ring-making direction). + s.set_equation( + stage="potential_swap", force="ring-break", lever="coul_kappa", equation=0 + ) + s.set_equation( + stage="restraints_off", force="ring-break", lever="coul_kappa", equation=0 + ) + s.set_equation( + stage="ring_open", force="ring-break", lever="coul_kappa", equation=0 + ) + s.set_equation( + stage="morph", force="ring-break", lever="coul_kappa", equation=s.lam() + ) + s.set_equation( + stage="potential_swap", force="ring-make", lever="coul_kappa", equation=0 + ) + s.set_equation( + stage="restraints_off", force="ring-make", lever="coul_kappa", equation=0 + ) + s.set_equation(stage="ring_open", force="ring-make", lever="coul_kappa", equation=0) + s.set_equation( + stage="morph", force="ring-make", lever="coul_kappa", equation=s.lam() + ) + return s diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py index 223b31e1..ce74757f 100644 --- a/tests/schedules/test_ring_break.py +++ b/tests/schedules/test_ring_break.py @@ -10,10 +10,6 @@ # clearly non-zero CustomBondForce energy. _ACTIVE_THRESHOLD = 0.1 -# Minimum energy delta (kcal/mol) expected when kappa switches from 0 to 1 -# at fixed geometry (within ring_open, where all bonded params are at s.final()). -_KAPPA_DELTA_THRESHOLD = 0.2 - def _build_dynamics(mols, schedule, swap_end_states): """ @@ -222,56 +218,6 @@ def test_reverse_ring_break_morph_schedule(lam, expected_kappa, expected_alpha): ) -# ── energy delta test ───────────────────────────────────────────────────────── -# -# Within the ring_open stage (λ ∈ [1/2, 2/3)) all bonded parameters are fixed -# at s.final() throughout; only kappa and alpha change. Heavy-atom bonds are -# not in the h_bonds constraint set, so update_constraints=True does not move -# the ring-break atoms between the two set_lambda calls. Both measurements -# therefore use identical atom positions, making the energy delta a clean -# measure of the kappa switch rather than a geometry change. -# -# With Sire's current softcore formula the CustomBondForce energy at kappa=0 is -# the negative of the hard-hard correction (not near zero), but the DELTA -# between kappa=0 and kappa=1 at fixed geometry equals the net softcore -# correction and is a meaningful, formula-independent observable. - - -def test_ring_break_softcore_delta(forward_dynamics): - """ - The ring-break CustomBondForce energy changes substantially between the start - (kappa=0, α=1) and end (kappa=1, α=0) of the ring_open stage. - - Both measurements use the same atom geometry because ring_open fixes all - bonded parameters at s.final() and heavy-atom bonds are unconstrained. - """ - e_off = _force_energy_kcal(forward_dynamics, 0.5, "ring-break") # kappa=0 - e_on = _force_energy_kcal(forward_dynamics, 2 / 3, "ring-break") # kappa=1 - assert abs(e_on - e_off) > _KAPPA_DELTA_THRESHOLD, ( - f"ring-break energy delta between kappa=0 (λ=0.5, E={e_off:.4f} kcal/mol) " - f"and kappa=1 (λ=2/3, E={e_on:.4f} kcal/mol) is only " - f"{abs(e_on - e_off):.4f} kcal/mol (expected > {_KAPPA_DELTA_THRESHOLD})" - ) - - -def test_ring_make_softcore_delta(reverse_dynamics): - """ - The ring-make CustomBondForce energy changes substantially between the start - (kappa=1, α=0) and end (kappa=0, α=1) of the reversed ring_open stage. - - Symmetric counterpart of test_ring_break_softcore_delta for the reverse schedule. - Reversed ring_open spans λ ∈ [1/3, 1/2); measurements at λ=1/3 (kappa=1) and - λ=1/2 (kappa=0) share the same atom geometry for the same reason. - """ - e_on = _force_energy_kcal(reverse_dynamics, 1 / 3, "ring-make") # kappa=1 - e_off = _force_energy_kcal(reverse_dynamics, 0.5, "ring-make") # kappa=0 - assert abs(e_on - e_off) > _KAPPA_DELTA_THRESHOLD, ( - f"ring-make energy delta between kappa=1 (λ=1/3, E={e_on:.4f} kcal/mol) " - f"and kappa=0 (λ=1/2, E={e_off:.4f} kcal/mol) is only " - f"{abs(e_on - e_off):.4f} kcal/mol (expected > {_KAPPA_DELTA_THRESHOLD})" - ) - - # ── energy magnitude tests ──────────────────────────────────────────────────── From 765a80af49330c872cad2e520538cd60be91f6e2 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 15 May 2026 15:10:00 +0100 Subject: [PATCH 187/212] Add new coul_kappa lever to tests. --- tests/schedules/test_ring_break.py | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py index ce74757f..044136b3 100644 --- a/tests/schedules/test_ring_break.py +++ b/tests/schedules/test_ring_break.py @@ -175,6 +175,41 @@ def test_reverse_has_ring_make_not_ring_break(reverse_dynamics): (1.00, 0.0, 1.0), ] +# ring_break_morph coul_kappa points (initial=0, final=1): +# λ=0.00–2/3 all pre-morph stages coul_kappa=0 +# λ=2/3 morph start coul_kappa=0 (within-stage lam=0) +# λ=0.85 morph mid coul_kappa=0.55 ((0.85-2/3)*3) +# λ=1.00 morph end coul_kappa=1.0 +_FWD_COUL_KAPPA = [ + (0.00, 0.0), + (0.15, 0.0), + (1 / 3, 0.0), + (0.45, 0.0), + (0.50, 0.0), + (0.55, 0.0), + (0.60, 0.0), + (2 / 3, 0.0), + (0.85, 0.55), + (1.00, 1.0), +] + +# reverse_ring_break_morph ring-make coul_kappa points (initial=1, final=0): +# λ=0.00 morph start (reversed) coul_kappa=1.0 +# λ=0.15 morph mid coul_kappa=0.55 (1 - 0.15*3) +# λ=1/3 morph end / ring_open coul_kappa=0.0 +# λ=0.45–1.0 ring_open/restraints_off/potential_swap coul_kappa=0 +_REV_COUL_KAPPA = [ + (0.00, 1.0), + (0.15, 0.55), + (1 / 3, 0.0), + (0.45, 0.0), + (0.50, 0.0), + (0.60, 0.0), + (2 / 3, 0.0), + (0.85, 0.0), + (1.00, 0.0), +] + @pytest.mark.parametrize("lam,expected_kappa,expected_alpha", _FWD_KAPPA_ALPHA) def test_ring_break_morph_schedule(lam, expected_kappa, expected_alpha): @@ -218,6 +253,42 @@ def test_reverse_ring_break_morph_schedule(lam, expected_kappa, expected_alpha): ) +@pytest.mark.parametrize("lam,expected_coul_kappa", _FWD_COUL_KAPPA) +def test_ring_break_morph_coul_kappa(lam, expected_coul_kappa): + """ + ring_break_morph() pins coul_kappa=0 through all pre-morph stages and ramps + it 0→1 during morph only, so Coulomb only activates once atoms are separated. + + Uses lambdalever's initial/final values (coul_kappa: 0→1). + """ + from somd2._utils._schedules import ring_break_morph + + s = ring_break_morph() + coul_kappa = s.morph("ring-break", "coul_kappa", 0.0, 1.0, lam) + assert abs(coul_kappa - expected_coul_kappa) < 1e-10, ( + f"ring-break coul_kappa={coul_kappa:.8f} at λ={lam:.4f}, " + f"expected {expected_coul_kappa}" + ) + + +@pytest.mark.parametrize("lam,expected_coul_kappa", _REV_COUL_KAPPA) +def test_reverse_ring_break_morph_coul_kappa(lam, expected_coul_kappa): + """ + reverse_ring_break_morph() ramps ring-make coul_kappa 1→0 through the + reversed morph stage and pins it to 0 in all subsequent stages. + + Uses lambdalever's initial/final values (coul_kappa: 1→0). + """ + from somd2._utils._schedules import reverse_ring_break_morph + + s = reverse_ring_break_morph() + coul_kappa = s.morph("ring-make", "coul_kappa", 1.0, 0.0, lam) + assert abs(coul_kappa - expected_coul_kappa) < 1e-10, ( + f"ring-make coul_kappa={coul_kappa:.8f} at λ={lam:.4f}, " + f"expected {expected_coul_kappa}" + ) + + # ── energy magnitude tests ──────────────────────────────────────────────────── @@ -315,8 +386,10 @@ def test_schedule_symmetry(): for force, lever, init, fin in [ ("ring-break", "kappa", 0.0, 1.0), ("ring-break", "alpha", 1.0, 0.0), + ("ring-break", "coul_kappa", 0.0, 1.0), ("ring-make", "kappa", 1.0, 0.0), ("ring-make", "alpha", 0.0, 1.0), + ("ring-make", "coul_kappa", 1.0, 0.0), ]: v_rev = rev.morph(force, lever, init, fin, lam) v_rev2 = rev_via_reverse.morph(force, lever, init, fin, lam) From 8e48925825d5167d27c15862bbc0ebbef5a7b36e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 18 May 2026 10:29:49 +0100 Subject: [PATCH 188/212] Add back missing force contribution test. --- tests/schedules/test_ring_break.py | 57 +++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py index 044136b3..bf6cac55 100644 --- a/tests/schedules/test_ring_break.py +++ b/tests/schedules/test_ring_break.py @@ -43,6 +43,7 @@ def _build_dynamics(mols, schedule, swap_end_states): perturbable_constraint="h_bonds_not_heavy_perturbed", cutoff="10A", cutoff_type="rf", + schedule=schedule, dynamic_constraints=True, include_constrained_energies=False, swap_end_states=swap_end_states, @@ -53,7 +54,6 @@ def _build_dynamics(mols, schedule, swap_end_states): "check_for_h_by_element": False, "check_for_h_by_ambertype": False, "fix_perturbable_zero_sigmas": True, - "lambda_schedule": schedule, "restraints": [hard_restraints, soft_restraints], }, ) @@ -397,3 +397,58 @@ def test_schedule_symmetry(): f"Mismatch for {force}/{lever} at λ={lam:.2f}: " f"reverse_ring_break_morph={v_rev}, ring_break_morph().reverse()={v_rev2}" ) + + +def test_force_contribution(forward_dynamics, syk_ring_break_mols): + """ + Verify the softcore CustomBondForce contribution at each end state. + + At λ=0 the softcore is fully off (α=1, LJ=0; coul_kappa=0, Coulomb=0) so + the total energy should match a system without the force to within numerical + precision. At λ=1 the exclusion has morphed away, so without the force the + ring-break pair sees a large LJ repulsion at bonded distance; with the force + the softcore smooths this repulsion, giving a substantially lower energy. + """ + + from somd2._utils._schedules import ring_break_morph + + # Delete the ring_breaking_bonds property from the perturbable molecule + # so that the soft-core ring-breaking force isn't created. + mols = syk_ring_break_mols.clone() + mol = mols["perturbable"].molecules()[0] + cursor = mol.cursor() + del [cursor["ring_breaking_bonds"]] + mols.update(cursor.commit()) + + # Build a dynamics object with the same schedule but without the ring-breaking force. + d = _build_dynamics(mols, ring_break_morph(), swap_end_states=False) + + # Get the λ=0 energies. At this end state the softcore is fully off (α=1 + # gives LJ=0; coul_kappa=0 gives Coulomb=0), so the force contributes + # nothing and both systems should agree to within numerical precision. + d.set_lambda(0.0, update_constraints=True) + forward_dynamics.set_lambda(0.0, update_constraints=True) + nrg_no_force = d.current_potential_energy().value() + nrg_with_force = forward_dynamics.current_potential_energy().value() + + assert abs(nrg_no_force - nrg_with_force) < 0.05, ( + f"Energy mismatch at λ=0: energy with force = {nrg_with_force:.6f} kcal/mol, " + f"energy without force = {nrg_no_force:.6f} kcal/mol, " + f"difference = {abs(nrg_with_force - nrg_no_force):.2e} kcal/mol" + ) + + # Get the λ=1 energies. At this end state the exclusion between the + # ring-break atoms has morphed away, so without the force they interact via + # the regular NonbondedForce with their perturbed LJ parameters (large sigma) + # at bonded distance, giving enormous repulsion. With the softcore force the + # repulsion is smoothed, giving a substantially lower energy. + d.set_lambda(1.0, update_constraints=True) + forward_dynamics.set_lambda(1.0, update_constraints=True) + nrg_no_force = d.current_potential_energy().value() + nrg_with_force = forward_dynamics.current_potential_energy().value() + + assert nrg_with_force < nrg_no_force, ( + f"Energy with softcore force ({nrg_with_force:.6f} kcal/mol) should be lower " + f"than without ({nrg_no_force:.6f} kcal/mol) at λ=1 with ring-closed geometry: " + f"the softcore should smooth the large LJ repulsion when the exclusion morphs away" + ) From 5dfbc1c6f0c83825f21c94f88a5e4a30051fa017 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 18 May 2026 15:15:39 +0100 Subject: [PATCH 189/212] Fix make_compatible dropping unique non-ghost terms for ring-breaking systems --- src/somd2/_utils/_somd1.py | 64 +++++++++++++++++++++++++++++++ tests/_utils/test_somd1.py | 77 ++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 15 ++++++++ 3 files changed, 156 insertions(+) diff --git a/src/somd2/_utils/_somd1.py b/src/somd2/_utils/_somd1.py index eddd8728..826c6b25 100644 --- a/src/somd2/_utils/_somd1.py +++ b/src/somd2/_utils/_somd1.py @@ -264,6 +264,19 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): new_bonds0.set(idx0, idx1, p0.function()) new_bonds1.set(idx0, idx1, p1.function()) + # Pass through unique terms that have no ghost in the state they exist in. + for b_idx in bonds0_unique_idx.values(): + p = bonds0[b_idx] + a0, a1 = p.atom0(), p.atom1() + if not _has_ghost(mol, [a0, a1]): + new_bonds0.set(a0, a1, p.function()) + + for b_idx in bonds1_unique_idx.values(): + p = bonds1[b_idx] + a0, a1 = p.atom0(), p.atom1() + if not _has_ghost(mol, [a0, a1], True): + new_bonds1.set(a0, a1, p.function()) + # Set the new bonded terms. edit_mol = edit_mol.set_property("bond0", new_bonds0).molecule() edit_mol = edit_mol.set_property("bond1", new_bonds1).molecule() @@ -366,6 +379,19 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): new_angles0.set(idx0, idx1, idx2, p0.function()) new_angles1.set(idx0, idx1, idx2, p1.function()) + # Pass through unique terms that have no ghost in the state they exist in. + for a_idx in angles0_unique_idx.values(): + p = angles0[a_idx] + a0, a1, a2 = p.atom0(), p.atom1(), p.atom2() + if not _has_ghost(mol, [a0, a1, a2]): + new_angles0.set(a0, a1, a2, p.function()) + + for a_idx in angles1_unique_idx.values(): + p = angles1[a_idx] + a0, a1, a2 = p.atom0(), p.atom1(), p.atom2() + if not _has_ghost(mol, [a0, a1, a2], True): + new_angles1.set(a0, a1, a2, p.function()) + # Set the new angle terms. edit_mol = edit_mol.set_property("angle0", new_angles0).molecule() edit_mol = edit_mol.set_property("angle1", new_angles1).molecule() @@ -479,6 +505,25 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): new_dihedrals0.set(idx0, idx1, idx2, idx3, p0.function()) new_dihedrals1.set(idx0, idx1, idx2, idx3, p1.function()) + # Pass through unique terms that have no ghost in the state they exist in. + for d_idx in dihedrals0_unique_idx.values(): + p = dihedrals0[d_idx] + a0 = info.atom_idx(p.atom0()) + a1 = info.atom_idx(p.atom1()) + a2 = info.atom_idx(p.atom2()) + a3 = info.atom_idx(p.atom3()) + if not _has_ghost(mol, [a0, a1, a2, a3]): + new_dihedrals0.set(a0, a1, a2, a3, p.function()) + + for d_idx in dihedrals1_unique_idx.values(): + p = dihedrals1[d_idx] + a0 = info.atom_idx(p.atom0()) + a1 = info.atom_idx(p.atom1()) + a2 = info.atom_idx(p.atom2()) + a3 = info.atom_idx(p.atom3()) + if not _has_ghost(mol, [a0, a1, a2, a3], True): + new_dihedrals1.set(a0, a1, a2, a3, p.function()) + # Set the new dihedral terms. edit_mol = edit_mol.set_property("dihedral0", new_dihedrals0).molecule() edit_mol = edit_mol.set_property("dihedral1", new_dihedrals1).molecule() @@ -605,6 +650,25 @@ def make_compatible(system, fix_perturbable_zero_sigmas=False): new_impropers0.set(idx0, idx1, idx2, idx3, p0.function()) new_impropers1.set(idx0, idx1, idx2, idx3, p1.function()) + # Pass through unique terms that have no ghost in the state they exist in. + for i_idx in impropers0_unique_idx.values(): + p = impropers0[i_idx] + a0 = info.atom_idx(p.atom0()) + a1 = info.atom_idx(p.atom1()) + a2 = info.atom_idx(p.atom2()) + a3 = info.atom_idx(p.atom3()) + if not _has_ghost(mol, [a0, a1, a2, a3]): + new_impropers0.set(a0, a1, a2, a3, p.function()) + + for i_idx in impropers1_unique_idx.values(): + p = impropers1[i_idx] + a0 = info.atom_idx(p.atom0()) + a1 = info.atom_idx(p.atom1()) + a2 = info.atom_idx(p.atom2()) + a3 = info.atom_idx(p.atom3()) + if not _has_ghost(mol, [a0, a1, a2, a3], True): + new_impropers1.set(a0, a1, a2, a3, p.function()) + # Set the new improper terms. edit_mol = edit_mol.set_property("improper0", new_impropers0).molecule() edit_mol = edit_mol.set_property("improper1", new_impropers1).molecule() diff --git a/tests/_utils/test_somd1.py b/tests/_utils/test_somd1.py index 6cd0f759..740cb41f 100644 --- a/tests/_utils/test_somd1.py +++ b/tests/_utils/test_somd1.py @@ -2,11 +2,88 @@ import sire.legacy.Mol as _SireMol +def _unique_nonghost_terms(mol, term_type, n_atoms, final=False): + """ + Return the set of atom-index tuples for terms in `term_type{0 or 1}` that + are absent from the other end state and involve no ghost atoms in the state + they exist in. + """ + from somd2._utils import _has_ghost + + suffix_own = "1" if final else "0" + suffix_other = "0" if final else "1" + + info = mol.info() + + def potentials(suffix): + return mol.property(f"{term_type}{suffix}").potentials() + + def key(p): + return tuple( + info.atom_idx(getattr(p, f"atom{k}")()).value() for k in range(n_atoms) + ) + + own_keys = {key(p): p for p in potentials(suffix_own)} + other_keys = {key(p) for p in potentials(suffix_other)} + # also consider reversed keys for symmetric terms + other_keys |= {k[::-1] for k in other_keys} + + unique = {} + for k, p in own_keys.items(): + if k not in other_keys: + atoms = [info.atom_idx(getattr(p, f"atom{i}")()) for i in range(n_atoms)] + if not _has_ghost(mol, atoms, final): + unique[k] = p.function() + return unique + + @pytest.fixture def mols(request): return request.getfixturevalue(request.param) +def test_make_compatible_ring_break(ring_break_mols): + """ + Verify that make_compatible preserves non-ghost bonded terms that are + unique to one end state, rather than silently dropping them. + + The 6YNGD→intgd perturbation breaks an N-C ring bond. The cross-bond + angles, dihedrals, and impropers that span this bond exist only in + state0 (the ring is intact there) and involve no ghost atoms, so they + must survive make_compatible unchanged. + """ + from somd2._utils._somd1 import make_compatible + + mol_before = ring_break_mols.molecules("property is_perturbable")[0] + + # Collect unique non-ghost terms in state0 before the call. + before = { + term: _unique_nonghost_terms(mol_before, term, n) + for term, n in [("angle", 3), ("dihedral", 4), ("improper", 4)] + } + + # Require that there are actually unique non-ghost terms to test against. + assert any(before[t] for t in before), ( + "No unique non-ghost terms found in state0 — test input may be wrong" + ) + + system_after = make_compatible(ring_break_mols) + mol_after = system_after.molecules("property is_perturbable")[0] + + info = mol_after.info() + + for term, n in [("angle", 3), ("dihedral", 4), ("improper", 4)]: + after_keys = { + tuple(info.atom_idx(getattr(p, f"atom{k}")()).value() for k in range(n)) + for p in mol_after.property(f"{term}0").potentials() + } + for atom_key in before[term]: + assert atom_key in after_keys or atom_key[::-1] in after_keys, ( + f"Unique non-ghost {term}0 term {atom_key} was incorrectly " + f"removed by make_compatible" + ) + + @pytest.mark.parametrize("mols", ["pert_fwd_mols", "pert_rev_mols"], indirect=True) def test_reconstruct_intrascale(mols): """ diff --git a/tests/conftest.py b/tests/conftest.py index a4ee48c8..93f8e0ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,3 +98,18 @@ def syk_ring_break_mols(): """ mols = sr.load_test_files("syk_5035_5033.s3") return sr.morph.link_to_reference(mols) + + +@pytest.fixture(scope="session") +def ring_break_mols(): + """ + Load the 6YNGD→intgd ring-breaking perturbation system. + + Reference state (λ=0): 6YNGD ligand with an intact N-C ring bond. + Perturbed state (λ=1): open-chain analogue (intgd) where that bond + is absent. The cross-bond angles, dihedrals, and impropers spanning + the breaking bond are non-ghost unique-to-state0 terms and must be + preserved by make_compatible. + """ + mols = sr.load_test_files("6yngd_to_intgd.s3") + return sr.morph.link_to_reference(mols) From 14e522fbd1fc8ac11998f408142c0457895ed6dd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 25 May 2026 10:18:05 +0100 Subject: [PATCH 190/212] Make filelock dependency explicit. [ci skip] --- pixi.toml | 1 + recipes/somd2/recipe.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/pixi.toml b/pixi.toml index 09fcceb1..f3890e85 100644 --- a/pixi.toml +++ b/pixi.toml @@ -6,6 +6,7 @@ platforms = ["linux-64", "osx-arm64"] [dependencies] python = ">=3.10" biosimspace = "*" +filelock = "*" ghostly = "*" loch = "*" loguru = "*" diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index 02170745..9fd813b5 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -20,6 +20,7 @@ requirements: - versioningit run: - biosimspace + - filelock - ghostly - loch - loguru From 90aee7cc401203a5b1c96077ee13da1ffda2ec04 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 26 May 2026 22:04:53 +0100 Subject: [PATCH 191/212] Add support for GCMC in osmotic ensemble. --- src/somd2/runner/_base.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 9e9f2c61..13a670ed 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -647,11 +647,6 @@ def __init__(self, system, config): _logger.error(msg) raise ValueError(msg) - if self._config.pressure != None: - msg = "GCMC simulations must be run in the NVT ensemble." - _logger.error(msg) - raise ValueError(msg) - if isinstance(self._system, list): mols = self._system[0] else: @@ -894,6 +889,7 @@ def __init__(self, system, config): "lambda_schedule": self._config.lambda_schedule, "no_logger": True, "num_ghost_waters": self._config.gcmc_num_waters, + "pressure": self._config.pressure, "overwrite": self._config.overwrite, "radius": str(self._config.gcmc_radius), "reference": self._config.gcmc_selection, From e96316aa75a6c636697e21246505e58d3dc17800 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 20:51:15 +0100 Subject: [PATCH 192/212] Handle restarts from crashes during final checkpoint. --- src/somd2/runner/_repex.py | 80 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index c4e1fabe..91cb1b53 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -716,6 +716,11 @@ def __init__(self, system, config): # Store the name of the replica exchange swap acceptance matrix. self._repex_matrix = self._config.output_directory / "repex_matrix.txt" + # Sentinel file written only after a fully successful run (dynamics + + # trajectory consolidation + backup cleanup). Used to distinguish + # "truly complete" from "complete dynamics but killed during cleanup". + self._done_file = self._config.output_directory / "simulation.done" + # Flag that we haven't equilibrated. self._is_equilibration = False @@ -756,6 +761,11 @@ def __init__(self, system, config): } ) + # On a fresh (non-restart) run, remove any leftover sentinel so that + # a repeated run with --overwrite doesn't immediately exit as complete. + if not self._is_restart and self._done_file.exists(): + self._done_file.unlink() + # Create the dynamics cache. if not self._is_restart: xml_filenames = ( @@ -777,10 +787,33 @@ def __init__(self, system, config): else: _logger.debug("Restarting from file") - # Check to see if the simulation is already complete. time = self._system[0].time() + + # Check to see if the simulation is already complete. + if self._done_file.exists(): + # The runtime may have been extended beyond the previous run. + # If so, clear the sentinel and continue. + if time < self._config.runtime - self._config.timestep: + _logger.info( + "Runtime has been extended. Clearing completion sentinel." + ) + self._done_file.unlink() + else: + _logger.success("Simulation already complete. Exiting.") + _sys.exit(0) + if time > self._config.runtime - self._config.timestep: - _logger.success("Simulation already complete. Exiting.") + # Dynamics finished but the process was killed before cleanup + # completed (e.g. during DCD consolidation or backup removal). + # Consolidate any remaining trajectory chunks and tidy up. + _logger.warning( + "Simulation dynamics are complete but post-run cleanup was " + "not finished. Completing cleanup now." + ) + self._consolidate_trajectories() + self._cleanup() + self._done_file.touch() + _logger.success("Cleanup complete. Exiting.") _sys.exit(0) else: _logger.info( @@ -1300,6 +1333,10 @@ def run(self): # Delete all backup files from the working directory. self._cleanup() + # Write the sentinel file to signal that the run completed fully, + # including trajectory consolidation and cleanup. + self._done_file.touch() + def _run_block( self, index, @@ -1872,6 +1909,45 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): except Exception as e: return index, e + def _consolidate_trajectories(self): + """ + Consolidate any remaining trajectory chunk files into the final DCD. + + Called when a restart detects that dynamics completed but the process + was killed before post-run cleanup finished. Safe to call when some + replicas are already fully consolidated (no chunks left) — those are + skipped automatically. + """ + from glob import glob as _glob_local + from pathlib import Path as _Path_local + from shutil import copyfile as _copyfile_local + + if not self._config.save_trajectories: + return + + for i in range(len(self._lambda_values)): + traj_filename = self._filenames[i]["trajectory"] + chunk_pattern = f"{self._filenames[i]['trajectory_chunk']}*" + traj_chunks = sorted(_glob_local(chunk_pattern)) + + # On a restart, prepend an existing final DCD as .prev so frames + # from a previous (possibly partial) consolidation are preserved. + path = _Path_local(traj_filename) + if path.exists() and path.stat().st_size > 0: + prev = f"{traj_filename}.prev" + _copyfile_local(traj_filename, prev) + traj_chunks = [prev] + traj_chunks + + if not traj_chunks: + continue + + topology0 = self._filenames["topology0"] + mols = _sr.load([topology0] + traj_chunks) + _sr.save(mols.trajectory(), traj_filename, format=["DCD"]) + + for chunk in traj_chunks: + _Path_local(chunk).unlink() + @staticmethod @_njit def _mix_replicas(num_replicas, energy_matrix, proposed, accepted): From e9f1cc3106eef47033d79de6b7b524f72b65c464 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 20:21:45 +0100 Subject: [PATCH 193/212] Remove redundant old_states attribute. --- src/somd2/runner/_repex.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 91cb1b53..1d7caf46 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -102,7 +102,6 @@ def __init__( self._lambdas = lambdas self._rest2_scale_factors = rest2_scale_factors self._states = _np.array(range(len(lambdas))) - self._old_states = _np.array(range(len(lambdas))) self._openmm_states = [None] * len(lambdas) self._gcmc_samplers = [None] * len(lambdas) self._gcmc_states = [None] * len(lambdas) @@ -150,7 +149,6 @@ def __getstate__(self): "_lambdas": self._lambdas, "_rest2_scale_factors": self._rest2_scale_factors, "_states": self._states, - "_old_states": self._old_states, "_openmm_states": self._openmm_states, # Don't pickle the GCMC samplers since they need to be recreated. "_gcmc_samplers": len(self._gcmc_samplers) * [None], @@ -511,9 +509,14 @@ def set_states(self, states): """ self._states = states - def mix_states(self): + def mix_states(self, old_states): """ Mix the states of the dynamics objects. + + Parameters + ---------- + old_states : numpy.ndarray + The state indices from before the last replica mix. """ # Mix the states. for i, state in enumerate(self._states): @@ -541,11 +544,7 @@ def mix_states(self): self._gcmc_samplers[i].pop() # Update the swap matrix. - old_state = self._old_states[i] - self._num_swaps[old_state, state] += 1 - - # Store the current states. - self._old_states = self._states.copy() + self._num_swaps[old_states[i], state] += 1 def get_proposed(self): """ @@ -1242,6 +1241,7 @@ def run(self): # Mix the replicas. _logger.info("Mixing replicas") + old_states = self._dynamics_cache.get_states() self._dynamics_cache.set_states( self._mix_replicas( self._config.num_lambda, @@ -1250,7 +1250,7 @@ def run(self): self._dynamics_cache.get_accepted(), ) ) - self._dynamics_cache.mix_states() + self._dynamics_cache.mix_states(old_states) # Snapshot the pre-run state for crash recovery. if self._config.auto_fix_minimise: From 7e5b1b6acf48d3007a1e4220966eb10f447fcf31 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 21:12:40 +0100 Subject: [PATCH 194/212] Serialise OpenMM state as NumPy arrays, not XML. --- src/somd2/runner/_repex.py | 60 ++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index c4e1fabe..3039e090 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -468,8 +468,40 @@ def save_openmm_state(self, index): .getState(getPositions=True, getVelocities=True) ) - # Store the state. - self._openmm_states[index] = state + # Store positions, velocities, and box vectors as compact numpy arrays + # rather than the OpenMM State object, which serialises to XML when + # pickled and is orders of magnitude larger. + self._openmm_states[index] = { + "positions": state.getPositions(asNumpy=True), + "velocities": state.getVelocities(asNumpy=True), + "box": state.getPeriodicBoxVectors(asNumpy=True), + } + + @staticmethod + def _apply_openmm_state(context, state): + """ + Apply a saved OpenMM state to a context. + + Parameters + ---------- + + context: openmm.Context + The OpenMM context to update. + + state: dict or openmm.State + The state to apply. Dicts (new format) contain "positions", + "velocities", and "box" numpy arrays. A bare openmm.State is + accepted for backwards compatibility with old checkpoint files. + """ + if isinstance(state, dict): + context.setPositions(state["positions"]) + context.setVelocities(state["velocities"]) + if state["box"] is not None: + context.setPeriodicBoxVectors(*state["box"]) + else: + # Legacy openmm.State from checkpoint files written before this + # format change. + context.setState(state) def save_gcmc_state(self, index): """ @@ -520,7 +552,9 @@ def mix_states(self): # The state has changed. if i != state: _logger.debug(f"Replica {i} seeded from state {state}") - self._dynamics[i].context().setState(self._openmm_states[state]) + self._apply_openmm_state( + self._dynamics[i].context(), self._openmm_states[state] + ) # Swap the water state in the GCMCSamplers. if self._gcmc_samplers[i] is not None: @@ -821,7 +855,9 @@ def __init__(self, system, config): # Reset the OpenMM state, applying the last replica exchange # mixing so the correct post-mix state is restored. state = self._dynamics_cache._states[i] - dynamics.context().setState(self._dynamics_cache._openmm_states[state]) + DynamicsCache._apply_openmm_state( + dynamics.context(), self._dynamics_cache._openmm_states[state] + ) # Reset the GCMC water state and restore statistics. if gcmc_sampler is not None: @@ -1222,9 +1258,11 @@ def run(self): # Snapshot the pre-run state for crash recovery. if self._config.auto_fix_minimise: for i, state in enumerate(self._dynamics_cache.get_states()): - self._dynamics_cache._dynamics[ - i - ]._d._pre_run_state = self._dynamics_cache._openmm_states[state] + self._dynamics_cache._dynamics[i]._d._pre_run_state = ( + self._dynamics_cache._dynamics[i] + .context() + .getState(getPositions=True, getVelocities=True) + ) # This is a checkpoint cycle. if is_checkpoint: @@ -1734,14 +1772,18 @@ def _compute_energies(self, index): # Loop over the states. for i in range(self._config.num_lambda): # Set the state. - dynamics.context().setState(self._dynamics_cache._openmm_states[i]) + DynamicsCache._apply_openmm_state( + dynamics.context(), self._dynamics_cache._openmm_states[i] + ) dynamics._d._clear_state() # Compute and store the energy for this state. energies[i] = dynamics.current_potential_energy().value() # Reset the state. - dynamics.context().setState(self._dynamics_cache._openmm_states[index]) + DynamicsCache._apply_openmm_state( + dynamics.context(), self._dynamics_cache._openmm_states[index] + ) return index, energies From 2a7897012f085a25c9b889b7db52b6d042238746 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 22:10:35 +0100 Subject: [PATCH 195/212] Remove per-replica stream files from repex restarts. --- src/somd2/runner/_base.py | 117 ++++++++++++++-------- src/somd2/runner/_repex.py | 87 ++++++++++++++--- src/somd2/runner/_runner.py | 184 +++++++++++++++++++++++++++++------ tests/runner/test_restart.py | 37 ++++--- 4 files changed, 320 insertions(+), 105 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 13a670ed..c1ccdb7a 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -1273,6 +1273,7 @@ def increment_filename(base_filename, suffix): lam = f"{lambda_value:.5f}" filenames = {} filenames["checkpoint"] = str(output_directory / f"checkpoint_{lam}.s3") + filenames["checkpoint_state"] = str(output_directory / f"checkpoint_{lam}.npz") filenames["energy_traj"] = str(output_directory / f"energy_traj_{lam}.parquet") filenames["trajectory"] = str(output_directory / f"traj_{lam}.dcd") filenames["trajectory_chunk"] = str(output_directory / f"traj_{lam}_") @@ -1628,18 +1629,22 @@ def get_last_config(output_directory): f"No config files found in {self._config.output_directory}, " "attempting to retrieve config from lambda = 0 checkpoint file." ) - try: - system_temp = _sr.stream.load( - str(self._config.output_directory / "checkpoint_0.00000.s3") - ) - except: - expdir = self._config.output_directory / "checkpoint_0.00000.s3" - _logger.error(f"Unable to load checkpoint file from {expdir}.") - raise + s3_path = self._config.output_directory / "checkpoint_0.00000.s3" + if s3_path.exists(): + try: + system_temp = _sr.stream.load(str(s3_path)) + except: + _logger.error(f"Unable to load checkpoint file from {s3_path}.") + raise + else: + self._last_config = dict(system_temp.property("config")) + config = self._config.as_dict(sire_compatible=True) + del system_temp else: - self._last_config = dict(system_temp.property("config")) - config = self._config.as_dict(sire_compatible=True) - del system_temp + raise OSError( + f"No config file found in {self._config.output_directory}. " + "Cannot validate restart config without a config.yaml file." + ) self._compare_configs(self._last_config, config) @@ -1854,6 +1859,8 @@ def _checkpoint( lambda_energy=None, lambda_grad=None, is_final_block=False, + context=None, + gcmc_sampler=None, ): """ Save a checkpoint file. @@ -1997,24 +2004,18 @@ def _checkpoint( for chunk in traj_chunks: _Path(chunk).unlink() - # Add config and lambda value to the system properties. - system.set_property( - "config", self._config.as_dict(sire_compatible=True) + # Write the checkpoint system to file. + self._write_checkpoint_system( + system, index, context=context, gcmc_sampler=gcmc_sampler ) - system.set_property("lambda", lam) - - # Delete all frames from the system. - system.delete_all_frames() - - # Stream the final system to file. - _sr.stream.save(system, self._filenames[index]["checkpoint"]) - # Create the final parquet file. - _dataframe_to_parquet( - df, - metadata=metadata, - filename=self._filenames[index]["energy_traj"], - ) + # Append the final block's energy data. If no parquet exists + # yet (e.g. checkpoint_frequency=0), create one from scratch. + _energy_traj = self._filenames[index]["energy_traj"] + if _Path(_energy_traj).exists(): + _parquet_append(_energy_traj, df.iloc[-self._energy_per_block :]) + else: + _dataframe_to_parquet(df, metadata=metadata, filename=_energy_traj) else: # Update the starting block if necessary. @@ -2034,27 +2035,23 @@ def _checkpoint( format=["DCD"], ) - # Encode the configuration and lambda value as system properties. - system.set_property( - "config", self._config.as_dict(sire_compatible=True) + # Write the checkpoint system to file. + self._write_checkpoint_system( + system, index, context=context, gcmc_sampler=gcmc_sampler ) - system.set_property("lambda", lam) - - # Delete all frames from the system. - system.delete_all_frames() - - # Stream the checkpoint to file. - _sr.stream.save(system, self._filenames[index]["checkpoint"]) # Skip parquet creation for post-equilibration checkpoints. if not is_post_equilibration: # Create the parquet file name. filename = self._filenames[index]["energy_traj"] - # Create the parquet file. - if block == self._start_block: + # At the start block of a restart, append to the existing + # parquet so that historical data is preserved. For fresh + # runs, overwrite (or create) the parquet file. + if block == self._start_block and not ( + self._is_restart and _Path(filename).exists() + ): _dataframe_to_parquet(df, metadata=metadata, filename=filename) - # Append to the parquet file. else: _parquet_append( filename, @@ -2066,6 +2063,35 @@ def _checkpoint( return index, None + def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=None): + """ + Write the system state to the checkpoint file. + + Subclasses may override this to store state differently, e.g. repex + records the simulation time in the dynamics cache pickle instead of + streaming a per-replica file. + + Parameters + ---------- + + system: :class: `System ` + The committed system to checkpoint. + + index: int + The index of the lambda window. + + context: openmm.Context, optional + The OpenMM context. Unused in the base implementation. + + gcmc_sampler: GCMCSampler, optional + The GCMC sampler. Unused in the base implementation. + """ + lam = self._lambda_values[index] + system.set_property("config", self._config.as_dict(sire_compatible=True)) + system.set_property("lambda", lam) + system.delete_all_frames() + _sr.stream.save(system, self._filenames[index]["checkpoint"]) + def _backup_checkpoint(self, index): """ Create a backup of the previous checkpoint files. @@ -2088,6 +2114,17 @@ def _backup_checkpoint(self, index): self._filenames[index]["checkpoint"], str(self._filenames[index]["checkpoint"]) + ".bak", ) + except Exception as e: + return index, e + + try: + # Backup the existing compact numpy checkpoint file, if it exists. + path = _Path(self._filenames[index]["checkpoint_state"]) + if path.exists() and path.stat().st_size > 0: + _copyfile( + self._filenames[index]["checkpoint_state"], + str(self._filenames[index]["checkpoint_state"]) + ".bak", + ) traj_filename = self._filenames[index]["trajectory"] except Exception as e: return index, e diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index bf73b30e..8edcf5ba 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -102,6 +102,7 @@ def __init__( self._lambdas = lambdas self._rest2_scale_factors = rest2_scale_factors self._states = _np.array(range(len(lambdas))) + self._time = None self._openmm_states = [None] * len(lambdas) self._gcmc_samplers = [None] * len(lambdas) self._gcmc_states = [None] * len(lambdas) @@ -136,8 +137,12 @@ def __setstate__(self, state): n = len(self._lambdas) if not hasattr(self, "_gcmc_stats"): self._gcmc_stats = [None] * n + if not hasattr(self, "_gcmc_states"): + self._gcmc_states = [None] * n if not hasattr(self, "_terminal_flip_stats"): self._terminal_flip_stats = [[0, 0]] * n + if not hasattr(self, "_time"): + self._time = None def __getstate__(self): """ @@ -149,6 +154,7 @@ def __getstate__(self): "_lambdas": self._lambdas, "_rest2_scale_factors": self._rest2_scale_factors, "_states": self._states, + "_time": self._time, "_openmm_states": self._openmm_states, # Don't pickle the GCMC samplers since they need to be recreated. "_gcmc_samplers": len(self._gcmc_samplers) * [None], @@ -820,7 +826,27 @@ def __init__(self, system, config): else: _logger.debug("Restarting from file") - time = self._system[0].time() + # Load the dynamics cache first so we can read the simulation time + # from it (new format). Old-format restarts with .s3 files fall + # back to reading the time from the loaded Sire system. + try: + with open(self._repex_state, "rb") as f: + self._dynamics_cache = _pickle.load(f) + except Exception as e: + _logger.error( + f"Could not load dynamics cache from {self._repex_state}: {e}" + ) + raise e + + # Derive the simulation time: prefer the value stored in the + # pickle (_time is set by the new-format _write_checkpoint_system); + # fall back to the Sire system for old-format checkpoints. + if self._dynamics_cache._time is not None and not isinstance( + self._system, list + ): + time = self._dynamics_cache._time + else: + time = self._system[0].time() # Check to see if the simulation is already complete. if self._done_file.exists(): @@ -853,15 +879,6 @@ def __init__(self, system, config): f"Restarting at time {time}, time remaining = {self._config.runtime - time}" ) - try: - with open(self._repex_state, "rb") as f: - self._dynamics_cache = _pickle.load(f) - except Exception as e: - _logger.error( - f"Could not load dynamics cache from {self._repex_state}: {e}" - ) - raise e - # Make sure the number of replicas is the same. if len(self._dynamics_cache._lambdas) != self._config.num_lambda: _logger.error( @@ -911,7 +928,14 @@ def __init__(self, system, config): # If restarting, subtract the time already run from the total runtime if self._config.restart: - time = self._system[0].time() + time = ( + self._dynamics_cache._time + if ( + self._dynamics_cache._time is not None + and not isinstance(self._system, list) + ) + else self._system[0].time() + ) self._config.runtime = str(self._config.runtime - time) # Work out the current block number. @@ -1845,6 +1869,43 @@ def _assemble_results(self, results): return matrix + def _check_restart(self): + """ + Check the output directory for a valid restart state. + + If per-replica checkpoint stream files (.s3) exist the base class is + used to load them (old format, backwards compatible). Otherwise the + repex state pickle is used and the original input system is returned + directly, since positions and velocities come from the OpenMM states + stored in the pickle. + """ + from pathlib import Path as _Path_local + + checkpoint_path = _Path_local(self._filenames[0]["checkpoint"]) + if checkpoint_path.exists(): + # Old format: load per-replica .s3 files via base class. + return super()._check_restart() + + repex_state = self._config.output_directory / "repex_state.pkl" + if not repex_state.exists(): + return False, self._system + + _logger.info( + "No checkpoint stream files found; restarting from repex state pickle." + ) + return True, self._system + + def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=None): + """ + Record the current simulation time in the dynamics cache. + + For repex, per-replica stream files are not written. The simulation + time is stored in the dynamics cache pickle instead, and positions and + velocities are already stored as compact numpy arrays in the OpenMM + state dict. + """ + self._dynamics_cache._time = system.time() + def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): """ Checkpoint the simulation. @@ -1886,10 +1947,6 @@ def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): # Commit the current system. system = dynamics.commit() - # If performing GCMC, then we need to flag the ghost waters. - if gcmc_sampler is not None: - system = gcmc_sampler._flag_ghost_waters(system) - # Get the simulation speed. speed = dynamics.time_speed() diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 574eb9b5..00ea69d7 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -266,6 +266,69 @@ def run(self): # Cleanup backup files. self._cleanup() + def _check_restart(self): + """ + Check the output directory for a valid restart state. + + Detects new-format (.npz) checkpoints and falls back to the legacy + .s3 stream file format when only old checkpoints are present. + """ + from pathlib import Path as _Path + + npz_path = _Path(self._filenames[0]["checkpoint_state"]) + s3_path = _Path(self._filenames[0]["checkpoint"]) + + if npz_path.exists(): + _logger.info("Restarting from compact numpy checkpoint state.") + return True, self._system + elif s3_path.exists(): + return super()._check_restart() + else: + return False, self._system + + def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=None): + """ + Write the system state to a compact numpy checkpoint file. + + Saves positions, velocities, box vectors, simulation time, and (for + GCMC) ghost water indices to a .npz file. The legacy .s3 stream file + is not written. + + If no context is provided (should not happen in normal operation), + falls back to the base .s3 implementation. + """ + if context is None: + super()._write_checkpoint_system(system, index) + return + + import openmm.unit as _omm_unit + + state = context.getState(getPositions=True, getVelocities=True) + pos = state.getPositions(asNumpy=True).value_in_unit(_omm_unit.nanometer) + vel = state.getVelocities(asNumpy=True).value_in_unit( + _omm_unit.nanometer / _omm_unit.picosecond + ) + time_ps = system.time().to("ps") + + save_kwargs = { + "positions": pos, + "velocities": vel, + "time_ps": _np.array([time_ps]), + } + + box = state.getPeriodicBoxVectors(asNumpy=True) + if box is not None: + save_kwargs["box"] = box.value_in_unit(_omm_unit.nanometer) + + if gcmc_sampler is not None: + # water_state() returns 1 for active, 0 for ghost. + water_state = gcmc_sampler.water_state() + save_kwargs["ghost_water_indices"] = _np.where(water_state == 0)[0].astype( + _np.int32 + ) + + _np.savez(self._filenames[index]["checkpoint_state"], **save_kwargs) + def run_window(self, index): """ Run a single lamdba window. @@ -295,9 +358,16 @@ def run_window(self, index): if self._is_restart: _logger.debug(f"Restarting {_lam_sym} = {lambda_value} from file") - system = self._system[index].clone() - - time = system.time() + if isinstance(self._system, list): + # Old format: system with saved positions loaded from .s3 stream file. + system = self._system[index].clone() + time = system.time() + else: + # New format: original input system; time stored in .npz checkpoint. + system = self._system.clone() + time = _sr.u( + f"{float(_np.load(self._filenames[index]['checkpoint_state'])['time_ps'].item()):.6f} ps" + ) if time > self._config.runtime - self._config.timestep: _logger.success( f"{_lam_sym} = {lambda_value} already complete. Skipping." @@ -398,7 +468,14 @@ def _run( # Check for completion if this is a restart. if is_restart: - time = system.time() + if isinstance(self._system, list): + time = system.time() + else: + # New format: time stored in .npz, not in the Sire system. + time = _sr.u( + f"{float(_np.load(self._filenames[index]['checkpoint_state'])['time_ps'].item()):.6f} ps" + ) + system.set_time(time) if time > self._config.runtime - self._config.timestep: _logger.success( f"{_lam_sym} = {lambda_value} already complete. Skipping." @@ -644,6 +721,28 @@ def generate_lam_vals(lambda_base, increment=0.001): _logger.info(f"Writing OpenMM XML for {_lam_sym} = {lambda_value:.5f}") dynamics.to_xml(self._filenames[index]["xml"]) + # For new-format restarts, apply saved positions/velocities/box to context. + _new_format_restart = is_restart and not isinstance(self._system, list) + if _new_format_restart: + import openmm.unit as _omm_unit + + _npz_state = _np.load(self._filenames[index]["checkpoint_state"]) + dynamics.context().setPositions( + _npz_state["positions"] * _omm_unit.nanometer + ) + dynamics.context().setVelocities( + _npz_state["velocities"] * _omm_unit.nanometer / _omm_unit.picosecond + ) + if "box" in _npz_state: + from openmm import Vec3 as _Vec3 + + _box = _npz_state["box"] + dynamics.context().setPeriodicBoxVectors( + _Vec3(*_box[0]) * _omm_unit.nanometer, + _Vec3(*_box[1]) * _omm_unit.nanometer, + _Vec3(*_box[2]) * _omm_unit.nanometer, + ) + # Reset the GCMC sampler. This resets the sampling statistics and clears # the associated OpenMM forces. if gcmc_sampler is not None: @@ -655,31 +754,52 @@ def generate_lam_vals(lambda_base, increment=0.001): # If this is a restart, then we need to reset the GCMC water state # to match that of the restart system. if self._is_restart: - from openmm.unit import angstrom + if isinstance(self._system, list): + # Old format: restore ghost waters from cached indices/positions. + from openmm.unit import angstrom - gcmc_sampler.push() - try: - # First set all waters to non-ghosts. - gcmc_sampler._set_water_state( - dynamics.context(), - states=_np.ones(len(gcmc_sampler._water_indices)), - force=True, - ) + gcmc_sampler.push() + try: + # First set all waters to non-ghosts. + gcmc_sampler._set_water_state( + dynamics.context(), + states=_np.ones(len(gcmc_sampler._water_indices)), + force=True, + ) - # Now set the ghost waters. - gcmc_sampler._set_water_state( - dynamics.context(), - self._restart_ghost_waters[index], - states=_np.zeros(len(gcmc_sampler._water_indices)), - force=True, - ) - finally: - gcmc_sampler.pop() + # Now set the ghost waters. + gcmc_sampler._set_water_state( + dynamics.context(), + self._restart_ghost_waters[index], + states=_np.zeros(len(gcmc_sampler._water_indices)), + force=True, + ) + finally: + gcmc_sampler.pop() - # Finally, reset the context positions to match the restart system. - dynamics.context().setPositions( - self._restart_positions[index] * angstrom - ) + # Finally, reset the context positions to match the restart system. + dynamics.context().setPositions( + self._restart_positions[index] * angstrom + ) + else: + # New format: positions already applied; restore ghost water state. + ghost_idxs = _npz_state["ghost_water_indices"].tolist() + gcmc_sampler.push() + try: + gcmc_sampler._set_water_state( + dynamics.context(), + states=_np.ones(len(gcmc_sampler._water_indices)), + force=True, + ) + if ghost_idxs: + gcmc_sampler._set_water_state( + dynamics.context(), + ghost_idxs, + states=_np.zeros(len(gcmc_sampler._water_indices)), + force=True, + ) + finally: + gcmc_sampler.pop() # Otherwise, if we've performed equilibration, then we need to reset # the water state in the new context to match the equilibrated system. @@ -731,6 +851,8 @@ def generate_lam_vals(lambda_base, increment=0.001): speed=0.0, lambda_energy=lambda_energy, lambda_grad=lambda_grad, + context=dynamics.context(), + gcmc_sampler=gcmc_sampler, ) if error is not None: msg = ( @@ -940,10 +1062,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Commit the current system. system = dynamics.commit() - # If performing GCMC, then we need to flag the ghost waters. - if gcmc_sampler is not None: - system = gcmc_sampler._flag_ghost_waters(system) - # Record the end time. block_end = _timer() @@ -979,6 +1097,8 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_energy=lambda_energy, lambda_grad=lambda_grad, is_final_block=is_final_block, + context=dynamics.context(), + gcmc_sampler=gcmc_sampler, ) if error is not None: @@ -1097,6 +1217,8 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_energy=lambda_energy, lambda_grad=lambda_grad, is_final_block=True, + context=dynamics.context(), + gcmc_sampler=gcmc_sampler, ) # Delete all trajectory frames from the Sire system within the @@ -1300,6 +1422,8 @@ def generate_lam_vals(lambda_base, increment=0.001): lambda_energy=lambda_energy, lambda_grad=lambda_grad, is_final_block=True, + context=dynamics.context(), + gcmc_sampler=gcmc_sampler, ) if error is not None: diff --git a/tests/runner/test_restart.py b/tests/runner/test_restart.py index 6c1e2f6a..fda3fcf5 100644 --- a/tests/runner/test_restart.py +++ b/tests/runner/test_restart.py @@ -48,12 +48,13 @@ def test_restart(mols, request): [str(Path(tmpdir) / "system0.prm7"), str(Path(tmpdir) / "traj_0.00000.dcd")] ) - # Check that both config and lambda have been written - # as properties to the streamed checkpoint file. - checkpoint = sr.stream.load(str(Path(tmpdir) / "checkpoint_0.00000.s3")) - props = checkpoint.property_keys() - assert "config" in props - assert "lambda" in props + # Check that the compact numpy checkpoint file was written. + import numpy as np + + checkpoint_state = np.load(str(Path(tmpdir) / "checkpoint_0.00000.npz")) + assert "positions" in checkpoint_state + assert "velocities" in checkpoint_state + assert "time_ps" in checkpoint_state del runner @@ -199,26 +200,22 @@ def test_restart(mols, request): with pytest.raises(ValueError): runner_swapendstates = Runner(mols, Config(**config_diffswapendstates)) - # Need to test restart from sire checkpoint file - # this needs to be done last as it requires unlinking the config files + # Removing the config yaml should raise an OSError since the new-format + # checkpoint stores no config (the yaml is the sole validation source). for file in Path(tmpdir).glob("*.yaml"): file.unlink() - # This should work as the config is read from the lambda=0 checkpoint file - runner_noconfig = Runner(mols, Config(**config_new)) + with pytest.raises(OSError): + runner_noconfig = Runner(mols, Config(**config_new)) - # remove config again - for file in Path(tmpdir).glob("*.yaml"): - file.unlink() + # Write a config yaml with a wrong pressure value and verify restart fails. + import yaml - # Load the checkpoint file using sire and change the pressure option - sire_checkpoint = sr.stream.load(str(Path(tmpdir) / "checkpoint_0.00000.s3")) - cfg = sire_checkpoint.property("config") - cfg["pressure"] = "0.5 atm" - sire_checkpoint.set_property("config", cfg) - sr.stream.save(sire_checkpoint, str(Path(tmpdir) / "checkpoint_0.00000.s3")) + bad_config = config_new.copy() + bad_config["pressure"] = "0.5 atm" + with open(Path(tmpdir) / "config.yaml", "w") as f: + yaml.dump(bad_config, f) - # Load the new checkpoint file and make sure the restart fails with pytest.raises(ValueError): runner_badconfig = Runner(mols, Config(**config_new)) From 5187440cffab627bc0873d0c3ed80d4101997465 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 12:03:14 +0100 Subject: [PATCH 196/212] Report GCMC centre correctly on restart & don't pre-equilibrate on restart. --- src/somd2/runner/_repex.py | 43 +++++++++++++++++++++++++++---------- src/somd2/runner/_runner.py | 24 +++++++++++++-------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 8edcf5ba..7d7eec5f 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -103,6 +103,7 @@ def __init__( self._rest2_scale_factors = rest2_scale_factors self._states = _np.array(range(len(lambdas))) self._time = None + self._time_offset = None self._openmm_states = [None] * len(lambdas) self._gcmc_samplers = [None] * len(lambdas) self._gcmc_states = [None] * len(lambdas) @@ -143,6 +144,8 @@ def __setstate__(self, state): self._terminal_flip_stats = [[0, 0]] * n if not hasattr(self, "_time"): self._time = None + if not hasattr(self, "_time_offset"): + self._time_offset = None def __getstate__(self): """ @@ -304,15 +307,6 @@ def _create_dynamics( f"Created GCMC sampler for lambda {lam:.5f} on device {device}" ) - # Log the initial position of the GCMC sphere. - if self._gcmc_samplers[i]._reference is not None: - positions = _sr.io.get_coords_array(mols) - target = self._gcmc_samplers[i]._get_target_position(positions) - _logger.info( - f"Initial GCMC sphere centre for lambda {lam:.5f} on device {device}: " - f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" - ) - # Create the dynamics object. try: dynamics = mols.dynamics(**dynamics_kwargs) @@ -823,6 +817,7 @@ def __init__(self, system, config): output_directory=self._config.output_directory, xml_filenames=xml_filenames, ) + else: _logger.debug("Restarting from file") @@ -848,6 +843,11 @@ def __init__(self, system, config): else: time = self._system[0].time() + # Store the absolute start time so _write_checkpoint_system can + # compute the correct absolute time across multiple restarts. + if not isinstance(self._system, list): + self._dynamics_cache._time_offset = time + # Check to see if the simulation is already complete. if self._done_file.exists(): # The runtime may have been extended beyond the previous run. @@ -922,6 +922,23 @@ def __init__(self, system, config): if self._dynamics_cache._gcmc_stats[i] is not None: gcmc_sampler.restore_stats(self._dynamics_cache._gcmc_stats[i]) + # Log the GCMC sphere centre for each replica using the actual context + # positions (accurate for both fresh runs and restarts). + import openmm.unit as _omm_unit + + for i, lam in enumerate(self._lambda_values): + dynamics, gcmc_sampler = self._dynamics_cache.get(i) + if gcmc_sampler is not None and gcmc_sampler._reference is not None: + state = dynamics.context().getState(getPositions=True) + positions = state.getPositions(asNumpy=True).value_in_unit( + _omm_unit.angstrom + ) + target = gcmc_sampler._get_target_position(positions) + _logger.info( + f"Initial GCMC sphere centre for lambda {lam:.5f}: " + f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" + ) + # Conversion factor for reduced potential. kT = (_sr.units.k_boltz * self._config.temperature).to(_sr.units.kcal_per_mol) self._beta = 1.0 / kT @@ -1579,7 +1596,7 @@ def _minimise(self, index): # Get the dynamics object (and GCMC sampler). dynamics, gcmc_sampler = self._dynamics_cache.get(index) - if gcmc_sampler is not None: + if gcmc_sampler is not None and not self._is_restart: gcmc_sampler.push() try: _logger.info( @@ -1904,7 +1921,11 @@ def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=Non velocities are already stored as compact numpy arrays in the OpenMM state dict. """ - self._dynamics_cache._time = system.time() + offset = self._dynamics_cache._time_offset + elapsed = system.time() + self._dynamics_cache._time = ( + (offset + elapsed) if offset is not None else elapsed + ) def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): """ diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 00ea69d7..dcc24c01 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -530,15 +530,6 @@ def generate_lam_vals(lambda_base, increment=0.001): # Get the GCMC system. system = gcmc_sampler.system() - # Log the initial position of the GCMC sphere. - if gcmc_sampler._reference is not None: - positions = _sr.io.get_coords_array(system) - target = gcmc_sampler._get_target_position(positions) - _logger.info( - f"Initial GCMC sphere centre at {_lam_sym} = {lambda_value:.5f}: " - f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" - ) - else: gcmc_sampler = None @@ -825,6 +816,21 @@ def generate_lam_vals(lambda_base, increment=0.001): attempted, accepted = stats["terminal_flip"] terminal_flip_sampler.reset(attempted, accepted) + # Log the GCMC sphere centre using the actual context positions + # (accurate for both fresh runs and restarts). + if gcmc_sampler is not None and gcmc_sampler._reference is not None: + import openmm.unit as _omm_unit + + state = dynamics.context().getState(getPositions=True) + positions = state.getPositions(asNumpy=True).value_in_unit( + _omm_unit.angstrom + ) + target = gcmc_sampler._get_target_position(positions) + _logger.info( + f"Initial GCMC sphere centre at {_lam_sym} = {lambda_value:.5f}: " + f"[{target[0]:.3f}, {target[1]:.3f}, {target[2]:.3f}] A" + ) + # Set the number of neighbours used for the energy calculation. # If not None, then we add one to account for the extra windows # used for finite-difference gradient analysis. From cc4cb4fa6fecf8d124c293ef1e17b077203e4acb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 12:10:39 +0100 Subject: [PATCH 197/212] Time is already a Sire GeneralUnit. --- src/somd2/runner/_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index dcc24c01..08867cc2 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -883,7 +883,7 @@ def generate_lam_vals(lambda_base, increment=0.001): # Handle the case where the runtime is less than the checkpoint frequency. if frac < 1.0: frac = 1.0 - checkpoint_frequency = _sr.u(f"{time} ps") + checkpoint_frequency = time checkpoint_interval = checkpoint_frequency.to("ns") num_blocks = int(frac) From 8dfae403c9eee2eb36ef1bfd932f3d2e16e111b0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 12:18:22 +0100 Subject: [PATCH 198/212] Set the system time on restart. --- src/somd2/runner/_repex.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 7d7eec5f..5dfc8a8e 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -886,6 +886,11 @@ def __init__(self, system, config): f"does not match the number of replicas in the configuration ({self._config.num_lambda})." ) + # For new-format restarts, set the system time so that dynamics + # objects are initialised with the correct integrator step count. + if not isinstance(self._system, list): + self._system.set_time(time) + # Create the dynamics objects. self._dynamics_cache._create_dynamics( self._system, From b21b5652290099947bab40b627df147706b7848e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 12:24:29 +0100 Subject: [PATCH 199/212] Remove redundant time offset. --- src/somd2/runner/_repex.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 5dfc8a8e..3c9ff7b4 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -103,7 +103,6 @@ def __init__( self._rest2_scale_factors = rest2_scale_factors self._states = _np.array(range(len(lambdas))) self._time = None - self._time_offset = None self._openmm_states = [None] * len(lambdas) self._gcmc_samplers = [None] * len(lambdas) self._gcmc_states = [None] * len(lambdas) @@ -144,8 +143,6 @@ def __setstate__(self, state): self._terminal_flip_stats = [[0, 0]] * n if not hasattr(self, "_time"): self._time = None - if not hasattr(self, "_time_offset"): - self._time_offset = None def __getstate__(self): """ @@ -843,11 +840,6 @@ def __init__(self, system, config): else: time = self._system[0].time() - # Store the absolute start time so _write_checkpoint_system can - # compute the correct absolute time across multiple restarts. - if not isinstance(self._system, list): - self._dynamics_cache._time_offset = time - # Check to see if the simulation is already complete. if self._done_file.exists(): # The runtime may have been extended beyond the previous run. @@ -1926,11 +1918,7 @@ def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=Non velocities are already stored as compact numpy arrays in the OpenMM state dict. """ - offset = self._dynamics_cache._time_offset - elapsed = system.time() - self._dynamics_cache._time = ( - (offset + elapsed) if offset is not None else elapsed - ) + self._dynamics_cache._time = system.time() def _checkpoint(self, index, lambdas, block, num_blocks, is_final_block=False): """ From 519ce9225c015f46713e7fee84421e951544fa97 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 12:34:38 +0100 Subject: [PATCH 200/212] Only log for legacy restart path. --- src/somd2/runner/_repex.py | 5 +---- src/somd2/runner/_runner.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/somd2/runner/_repex.py b/src/somd2/runner/_repex.py index 3c9ff7b4..7da3ba37 100644 --- a/src/somd2/runner/_repex.py +++ b/src/somd2/runner/_repex.py @@ -1897,16 +1897,13 @@ def _check_restart(self): checkpoint_path = _Path_local(self._filenames[0]["checkpoint"]) if checkpoint_path.exists(): - # Old format: load per-replica .s3 files via base class. + _logger.info("Restarting from legacy stream file checkpoint.") return super()._check_restart() repex_state = self._config.output_directory / "repex_state.pkl" if not repex_state.exists(): return False, self._system - _logger.info( - "No checkpoint stream files found; restarting from repex state pickle." - ) return True, self._system def _write_checkpoint_system(self, system, index, context=None, gcmc_sampler=None): diff --git a/src/somd2/runner/_runner.py b/src/somd2/runner/_runner.py index 08867cc2..fb3980f6 100644 --- a/src/somd2/runner/_runner.py +++ b/src/somd2/runner/_runner.py @@ -279,9 +279,9 @@ def _check_restart(self): s3_path = _Path(self._filenames[0]["checkpoint"]) if npz_path.exists(): - _logger.info("Restarting from compact numpy checkpoint state.") return True, self._system elif s3_path.exists(): + _logger.info("Restarting from legacy stream file checkpoint.") return super()._check_restart() else: return False, self._system From caf297d6e8792f2a9e73feb95ad99d317d02c0f0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Jun 2026 19:53:15 +0100 Subject: [PATCH 201/212] Switch to reversible three-stage ring-breaking schedule. --- src/somd2/_utils/_schedules.py | 97 ++++++++++++------------------ tests/schedules/test_ring_break.py | 73 +++++++++++----------- 2 files changed, 75 insertions(+), 95 deletions(-) diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 1c4e48d0..06985ebe 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -151,14 +151,16 @@ def ring_break_morph(): """ Build a lambda schedule for ring-breaking perturbations. - Four stages: potential_swap → restraints_off → ring_open → morph. + Three stages: potential_swap → restraints_off → morph. - The ring-break softcore kappa ramps 0→1 through ring_open and is fixed at 1 - in morph. The ring-make equations mirror ring-break so that - ``ring_break_morph().reverse()`` is the correct schedule for the ring-making - direction (used by :func:`reverse_ring_break_morph`). Because ring_break_morph - is only used for ring-breaking perturbations (no ring-make force present), the - ring-make equations have no effect on forward simulations. + During restraints_off the Morse restraint ramps off (morse_soft: 1→0) while + the ring-break softcore simultaneously ramps on (alpha: 1→0, kappa: 0→1), + equations mirror ring-break so that ``ring_break_morph().reverse()`` is the + providing a smooth handover with no gap between the two forces. The ring-make + correct schedule for the ring-making direction (used by + :func:`reverse_ring_break_morph`). Because ring_break_morph is only used for + ring-breaking perturbations (no ring-make force present), the ring-make + equations have no effect on forward simulations. Returns ------- @@ -169,41 +171,13 @@ def ring_break_morph(): from sire.cas import LambdaSchedule as _LambdaSchedule s = _LambdaSchedule.standard_morph() - s.set_stage_weight("morph", 2) - - # ring_open: Morse is already off; ring-break nonbonded interaction ramps - # on (alpha: 1→0, kappa: 0→1) while non-bonded terms stay at initial and - # bonded terms remain at final. The softcore interaction gently pushes the - # atoms into the open-chain geometry before the full nonbonded morph begins, - # improving HREX overlap at the ring-break boundary. - # - # ring-make equations mirror ring-break so the reversed schedule is correct: - # after .reverse(), ring-make kappa ramps 1→0 through this stage, matching - # what ring-break does here in the forward direction. - s.prepend_stage("ring_open", s.initial(), weight=1) - s.set_equation(stage="ring_open", lever="morse_hard", equation=0) - s.set_equation(stage="ring_open", lever="morse_soft", equation=0) - s.set_equation(stage="ring_open", lever="bond_k", equation=s.final()) - s.set_equation(stage="ring_open", lever="bond_length", equation=s.final()) - s.set_equation(stage="ring_open", lever="angle_k", equation=s.final()) - s.set_equation(stage="ring_open", lever="angle_size", equation=s.final()) - s.set_equation(stage="ring_open", lever="torsion_k", equation=s.final()) - s.set_equation(stage="ring_open", lever="torsion_phase", equation=s.final()) - s.set_equation( - stage="ring_open", force="ring-break", lever="alpha", equation=1 - s.lam() - ) - s.set_equation( - stage="ring_open", force="ring-break", lever="kappa", equation=s.lam() - ) - # ring-make mirrors ring-break so reversed schedule ramps ring-make 1→0 here. - s.set_equation( - stage="ring_open", force="ring-make", lever="alpha", equation=1 - s.lam() - ) - s.set_equation( - stage="ring_open", force="ring-make", lever="kappa", equation=s.lam() - ) - s.prepend_stage("restraints_off", s.initial(), weight=1) + # restraints_off [1/3, 2/3): Morse ramps off while ring-break softcore ramps + # on simultaneously (alpha: 1→0, kappa: 0→1). Bonded terms (angles, torsions) + # interpolate initial→final over the same stage. ring-make mirrors ring-break + # so that after .reverse(), the ring-make softcore ramps off as morse_soft ramps + # on in the reversed restraints_off stage, correct for ring-making perturbations. + s.prepend_stage("restraints_off", s.initial()) s.set_equation(stage="restraints_off", lever="morse_soft", equation=1 - s.lam()) s.set_equation(stage="restraints_off", lever="morse_hard", equation=0) s.set_equation(stage="restraints_off", lever="bond_k", equation=s.final()) @@ -228,8 +202,20 @@ def ring_break_morph(): lever="torsion_phase", equation=(1 - s.lam()) * s.initial() + s.lam() * s.final(), ) + s.set_equation( + stage="restraints_off", force="ring-break", lever="alpha", equation=1 - s.lam() + ) + s.set_equation( + stage="restraints_off", force="ring-break", lever="kappa", equation=s.lam() + ) + s.set_equation( + stage="restraints_off", force="ring-make", lever="alpha", equation=1 - s.lam() + ) + s.set_equation( + stage="restraints_off", force="ring-make", lever="kappa", equation=s.lam() + ) - s.prepend_stage("potential_swap", s.initial(), weight=2) + s.prepend_stage("potential_swap", s.initial()) s.set_equation(stage="potential_swap", lever="morse_hard", equation=1 - s.lam()) s.set_equation(stage="potential_swap", lever="morse_soft", equation=0 + s.lam()) s.set_equation( @@ -247,10 +233,9 @@ def ring_break_morph(): s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.initial()) s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.initial()) - # morph: standard nonbonded morphing. Ring-break is fixed at fully open - # (kappa=1, alpha=0) since geometry has already relaxed in ring_open. - # ring-make mirrors ring-break: kappa=1, alpha=0 so that .reverse() gives - # kappa=1 at lam=0 of the reversed morph stage (ring-making direction start). + # morph [2/3, 1]: standard nonbonded morphing with ring-break/ring-make fixed + # at fully open (kappa=1, alpha=0). ring-make mirrors ring-break so .reverse() + # gives kappa=1 at lam=0 of the reversed morph stage (ring-making start). s.set_equation(stage="morph", lever="morse_hard", equation=0) s.set_equation(stage="morph", lever="morse_soft", equation=0) s.set_equation(stage="morph", lever="bond_k", equation=s.final()) @@ -264,21 +249,16 @@ def ring_break_morph(): s.set_equation(stage="morph", force="ring-make", lever="alpha", equation=0) s.set_equation(stage="morph", force="ring-make", lever="kappa", equation=1) - # coul_kappa decouples Coulomb onset from LJ onset: zero throughout the - # bonded stages so the CLJ exception carries no charge while atoms are at - # covalent distances, then ramps 0→1 during morph only (where the LJ - # softcore has already separated the atoms). ring-make mirrors ring-break - # so that .reverse() gives the correct reversed schedule (coul_kappa ramps - # 1→0 through the reversed morph stage for the ring-making direction). + # coul_kappa: zero through both bonded stages so the CLJ exception carries no + # charge while atoms are at covalent distances; ramps 0→1 in morph only once + # the softcore has already separated the atoms. ring-make mirrors ring-break + # so .reverse() gives coul_kappa ramps 1→0 through the reversed morph stage. s.set_equation( stage="potential_swap", force="ring-break", lever="coul_kappa", equation=0 ) s.set_equation( stage="restraints_off", force="ring-break", lever="coul_kappa", equation=0 ) - s.set_equation( - stage="ring_open", force="ring-break", lever="coul_kappa", equation=0 - ) s.set_equation( stage="morph", force="ring-break", lever="coul_kappa", equation=s.lam() ) @@ -288,7 +268,6 @@ def ring_break_morph(): s.set_equation( stage="restraints_off", force="ring-make", lever="coul_kappa", equation=0 ) - s.set_equation(stage="ring_open", force="ring-make", lever="coul_kappa", equation=0) s.set_equation( stage="morph", force="ring-make", lever="coul_kappa", equation=s.lam() ) @@ -300,9 +279,9 @@ def reverse_ring_break_morph(): """ Build a lambda schedule for ring-making perturbations (reverse ring-break). - Returns ``ring_break_morph().reverse()``: four stages in reversed order - (morph → ring_open → restraints_off → potential_swap) with all equations - reflected about λ=½ and initial/final end-states swapped. + Returns ``ring_break_morph().reverse()``: three stages in reversed order + (morph → restraints_off → potential_swap) with all equations reflected about + λ=½ and initial/final end-states swapped. This schedule is correct for two equivalent use-cases: diff --git a/tests/schedules/test_ring_break.py b/tests/schedules/test_ring_break.py index bf6cac55..bfc01466 100644 --- a/tests/schedules/test_ring_break.py +++ b/tests/schedules/test_ring_break.py @@ -129,47 +129,49 @@ def test_reverse_has_ring_make_not_ring_break(reverse_dynamics): # They are completely independent of Sire's energy formula and will continue to # work correctly regardless of changes to the softcore implementation. -# ring_break_morph kappa/alpha points: -# λ=0.00 potential_swap start kappa=0, alpha=1 -# λ=0.15 potential_swap mid kappa=0, alpha=1 -# λ=1/3 restraints_off start kappa=0, alpha=1 -# λ=0.45 restraints_off mid kappa=0, alpha=1 -# λ=0.50 ring_open start kappa=0, alpha=1 (within-stage lam=0) -# λ=0.55 ring_open mid kappa=0.3, alpha=0.7 -# λ=0.60 ring_open mid kappa=0.6, alpha=0.4 -# λ=2/3 morph start kappa=1, alpha=0 -# λ=0.85 morph mid kappa=1, alpha=0 -# λ=1.00 morph end kappa=1, alpha=0 +# ring_break_morph kappa/alpha points (3 equal stages: [0,1/3), [1/3,2/3), [2/3,1]): +# λ=0.00 potential_swap start kappa=0, alpha=1 +# λ=0.15 potential_swap mid kappa=0, alpha=1 +# λ=1/3 restraints_off start kappa=0, alpha=1 (within-stage lam=0) +# λ=0.45 restraints_off mid kappa=0.35, alpha=0.65 (within-stage lam=0.35) +# λ=0.50 restraints_off mid kappa=0.5, alpha=0.5 (within-stage lam=0.5) +# λ=0.55 restraints_off mid kappa=0.65, alpha=0.35 (within-stage lam=0.65) +# λ=0.60 restraints_off near end kappa=0.8, alpha=0.2 (within-stage lam=0.8) +# λ=2/3 morph start kappa=1, alpha=0 +# λ=0.85 morph mid kappa=1, alpha=0 +# λ=1.00 morph end kappa=1, alpha=0 _FWD_KAPPA_ALPHA = [ (0.00, 0.0, 1.0), (0.15, 0.0, 1.0), (1 / 3, 0.0, 1.0), - (0.45, 0.0, 1.0), - (0.50, 0.0, 1.0), - (0.55, 0.3, 0.7), - (0.60, 0.6, 0.4), + (0.45, 0.35, 0.65), + (0.50, 0.5, 0.5), + (0.55, 0.65, 0.35), + (0.60, 0.8, 0.2), (2 / 3, 1.0, 0.0), (0.85, 1.0, 0.0), (1.00, 1.0, 0.0), ] # reverse_ring_break_morph ring-make kappa/alpha points (mirror of forward): -# λ=0.00 morph start kappa=1, alpha=0 -# λ=0.15 morph mid kappa=1, alpha=0 -# λ=1/3 ring_open start kappa=1, alpha=0 (within-stage lam=0) -# λ=0.45 ring_open mid kappa=0.3, alpha=0.7 (within-stage lam=0.7) -# λ=0.50 restraints_off start kappa=0, alpha=1 -# λ=0.60 restraints_off mid kappa=0, alpha=1 -# λ=2/3 potential_swap start kappa=0, alpha=1 -# λ=0.85 potential_swap mid kappa=0, alpha=1 -# λ=1.00 potential_swap end kappa=0, alpha=1 +# λ=0.00 reversed morph start kappa=1, alpha=0 +# λ=0.15 reversed morph mid kappa=1, alpha=0 +# λ=1/3 reversed restraints_off start kappa=1, alpha=0 (within-stage lam=0) +# λ=0.45 reversed restraints_off mid kappa=0.65, alpha=0.35 +# λ=0.50 reversed restraints_off mid kappa=0.5, alpha=0.5 +# λ=0.55 reversed restraints_off mid kappa=0.35, alpha=0.65 +# λ=0.60 reversed restraints_off near end kappa=0.2, alpha=0.8 +# λ=2/3 reversed potential_swap start kappa=0, alpha=1 +# λ=0.85 reversed potential_swap mid kappa=0, alpha=1 +# λ=1.00 reversed potential_swap end kappa=0, alpha=1 _REV_KAPPA_ALPHA = [ (0.00, 1.0, 0.0), (0.15, 1.0, 0.0), (1 / 3, 1.0, 0.0), - (0.45, 0.3, 0.7), - (0.50, 0.0, 1.0), - (0.60, 0.0, 1.0), + (0.45, 0.65, 0.35), + (0.50, 0.5, 0.5), + (0.55, 0.35, 0.65), + (0.60, 0.2, 0.8), (2 / 3, 0.0, 1.0), (0.85, 0.0, 1.0), (1.00, 0.0, 1.0), @@ -194,10 +196,10 @@ def test_reverse_has_ring_make_not_ring_break(reverse_dynamics): ] # reverse_ring_break_morph ring-make coul_kappa points (initial=1, final=0): -# λ=0.00 morph start (reversed) coul_kappa=1.0 -# λ=0.15 morph mid coul_kappa=0.55 (1 - 0.15*3) -# λ=1/3 morph end / ring_open coul_kappa=0.0 -# λ=0.45–1.0 ring_open/restraints_off/potential_swap coul_kappa=0 +# λ=0.00 reversed morph start coul_kappa=1.0 +# λ=0.15 reversed morph mid coul_kappa=0.55 (1 - 0.15*3) +# λ=1/3 reversed morph end coul_kappa=0.0 +# λ=0.45–1.0 restraints_off/potential_swap coul_kappa=0 _REV_COUL_KAPPA = [ (0.00, 1.0), (0.15, 0.55), @@ -293,10 +295,9 @@ def test_reverse_ring_break_morph_coul_kappa(lam, expected_coul_kappa): @pytest.mark.parametrize("lam", [2 / 3, 1.0]) -def test_ring_break_active_after_ring_open(forward_dynamics, lam): +def test_ring_break_active_in_morph(forward_dynamics, lam): """ - Ring-break energy is clearly non-zero (kappa=1) at the end of ring_open - and throughout morph. + Ring-break energy is clearly non-zero (kappa=1) throughout the morph stage. """ e = _force_energy_kcal(forward_dynamics, lam, "ring-break") assert abs(e) > _ACTIVE_THRESHOLD, ( @@ -343,8 +344,8 @@ def test_ring_make_inactive_at_lambda_one(reverse_dynamics): # # Test points span zero and non-zero energy regions: # λ=0.0 → forward kappa=0, reverse at 1-λ=1.0 kappa=0 (both ≈0) -# λ=0.55 → forward ring_open (kappa=0.3), reverse ring_open at 0.45 (kappa=0.3) -# λ=2/3 → forward morph start (kappa=1), reverse ring_open start at 1/3 (kappa=1) +# λ=0.55 → forward restraints_off (kappa=0.65), reverse restraints_off at 0.45 (kappa=0.65) +# λ=2/3 → forward morph start (kappa=1), reverse restraints_off start at 1/3 (kappa=1) # λ=0.85 → forward morph (kappa=1), reverse reversed-morph at 0.15 (kappa=1) # λ=1.0 → forward morph end (kappa=1), reverse at 0.0 reversed-morph (kappa=1) From cb2761cc7924eb27cac96263db0cd5c7ef590e23 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 16:05:34 +0100 Subject: [PATCH 202/212] Raise exception when h_mass_factor limit is exceeded. --- src/somd2/config/_config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 9a605b7c..fdd3f30f 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -953,6 +953,13 @@ def h_mass_factor(self, h_mass_factor): "This will result in a reduction of the mass of hydrogen atoms, " "and will likely lead to undesired simulation behaviour." ) + if h_mass_factor > 4.0: + raise ValueError( + "Requested hydrogen mass repartitioning factor is greater than 4.0. " + "This would exceed the maximum hydrogen mass threshold used for " + "constraint detection and will result in hydrogen bonds not being " + "constrained." + ) self._h_mass_factor = h_mass_factor @property From a6ceed9a4851f4be0b271426e489f5481a16420b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 16:19:02 +0100 Subject: [PATCH 203/212] Update docstring. --- src/somd2/config/_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index fdd3f30f..3a8343c5 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -956,9 +956,9 @@ def h_mass_factor(self, h_mass_factor): if h_mass_factor > 4.0: raise ValueError( "Requested hydrogen mass repartitioning factor is greater than 4.0. " - "This would exceed the maximum hydrogen mass threshold used for " - "constraint detection and will result in hydrogen bonds not being " - "constrained." + "At this factor, repartitioned hydrogen masses would exceed the " + "4.5 g/mol threshold used for hydrogen detection in the OpenMM " + "conversion layer, causing hydrogen bonds not to be constrained." ) self._h_mass_factor = h_mass_factor From 433c382aea145e4eebe1eedf22d50cca12e36d85 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 16 Jun 2026 16:40:12 +0100 Subject: [PATCH 204/212] Adjust h_mass_factor limit. --- src/somd2/config/_config.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 3a8343c5..e27d6d6f 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -953,12 +953,13 @@ def h_mass_factor(self, h_mass_factor): "This will result in a reduction of the mass of hydrogen atoms, " "and will likely lead to undesired simulation behaviour." ) - if h_mass_factor > 4.0: + if h_mass_factor > 3.0: raise ValueError( - "Requested hydrogen mass repartitioning factor is greater than 4.0. " - "At this factor, repartitioned hydrogen masses would exceed the " - "4.5 g/mol threshold used for hydrogen detection in the OpenMM " - "conversion layer, causing hydrogen bonds not to be constrained." + "Requested hydrogen mass repartitioning factor is greater than 3.0. " + "Above this value, heavy atoms bonded to multiple hydrogens can have " + "their mass reduced below the 3.5 g/mol threshold used for hydrogen " + "detection in the OpenMM conversion layer, causing hydrogen bonds not " + "to be constrained." ) self._h_mass_factor = h_mass_factor From 64ebaff91191d60e51ab24d11ee2b5eebd73b307 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 19 Jun 2026 16:45:01 +0100 Subject: [PATCH 205/212] Defer output directory/logger setup from Config to Runner init. --- src/somd2/config/_config.py | 39 +++++++++++++++++++++++++------------ src/somd2/runner/_base.py | 4 ++++ tests/runner/test_config.py | 12 ++++++++---- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index e27d6d6f..8a6c0e91 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -2306,18 +2306,32 @@ def output_directory(self, output_directory): output_directory = _Path(output_directory) except Exception as e: raise ValueError(f"Could not convert output path. {e}") - if not _Path(output_directory).exists() or not _Path(output_directory).is_dir(): + # Directory creation and logger setup are deferred until the runner + # is created (see _setup_output_directory), since this setter can be + # called multiple times via the Python API (e.g. before the user + # overrides the default) and doing it here would create stale + # directories and duplicate logger sinks. + self._output_directory = output_directory + + def _setup_output_directory(self): + """ + Internal method to create the output directory (if needed) and + configure the logger to write to it. Called once a runner is + created, by which point the user's final choice of output + directory is known. + """ + + output_directory = self._output_directory + + if not output_directory.exists() or not output_directory.is_dir(): try: - _Path(output_directory).mkdir(parents=True, exist_ok=True) + output_directory.mkdir(parents=True, exist_ok=True) except: raise ValueError( f"Output directory {output_directory} does not exist and cannot be created" ) - if self.log_file is not None: - # Can now add the log file - _logger.add(output_directory / self.log_file, level=self.log_level.upper()) - _logger.debug(f"Logging to {output_directory / self.log_file}") - self._output_directory = output_directory + + self._reset_logger(_logger) @property def write_config(self): @@ -2540,8 +2554,9 @@ def _reset_logger(self, logger): """ Internal method to reset the logger. - This can be used when a parallel process is spawned to ensure that - the logger is correctly configured. + Removes any existing sinks and re-adds them based on the current + config state. Used both when a parallel process is spawned, and + when the output directory is finalised for the main process. """ import sys @@ -2549,6 +2564,6 @@ def _reset_logger(self, logger): logger.remove() logger.add(sys.stderr, level=self.log_level.upper(), enqueue=True) if self.log_file is not None and self.output_directory is not None: - logger.add( - self.output_directory / self.log_file, level=self.log_level.upper() - ) + log_path = self.output_directory / self.log_file + logger.add(log_path, level=self.log_level.upper()) + logger.debug(f"Logging to {log_path}") diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index c1ccdb7a..5f490ace 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -81,6 +81,10 @@ def __init__(self, system, config): self._config = config self._config._extra_args = {} + # Create the output directory and configure the logger now that the + # user's final choice of output directory is known. + self._config._setup_output_directory() + if self._config.replica_exchange and self._config.perturbed_system is not None: # Make sure the number of positions is correct. num_atoms = self._system.num_atoms() diff --git a/tests/runner/test_config.py b/tests/runner/test_config.py index 05a2f0b9..39bd948a 100644 --- a/tests/runner/test_config.py +++ b/tests/runner/test_config.py @@ -62,18 +62,22 @@ def test_dynamics_options(): def test_logfile_creation(): - # Test that the logfile is created by either the initialisation of the runner or of a config + # Test that the logfile is only created once a runner is initialised, not + # by the config alone - this is deferred so that a user can change + # output_directory after constructing a Config (e.g. via the Python API) + # without leaving behind a stale directory/duplicate log sink from the + # default value. with tempfile.TemporaryDirectory() as tmpdir: # Load the demo stream file. mols = sr.load(sr.expand(sr.tutorial_url, "merged_molecule.s3")) from pathlib import Path - # Test that a logfile is created once a config object is initialised + # A config object alone should not create the logfile. config = Config(output_directory=tmpdir, log_file="test.log") assert config.log_file is not None - assert Path.exists(config.output_directory / config.log_file) + assert not Path.exists(config.output_directory / config.log_file) - # Test that a logfile is created once a runner object is initialised + # Test that a logfile is created once a runner object is initialised. runner = Runner(mols, Config(output_directory=tmpdir, log_file="test1.log")) assert runner._config.log_file is not None assert Path.exists(runner._config.output_directory / runner._config.log_file) From b1f141c64456a3e5e1fc7123e56d2a1bf68b5699 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 19 Jun 2026 20:46:51 +0100 Subject: [PATCH 206/212] Improve dependency version reporting. --- src/somd2/__init__.py | 23 +++++++++++++++++++++++ src/somd2/runner/_base.py | 28 +++++++++++++--------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/somd2/__init__.py b/src/somd2/__init__.py index ac5eef1a..b3070df9 100644 --- a/src/somd2/__init__.py +++ b/src/somd2/__init__.py @@ -38,8 +38,31 @@ from sire import __version__ as _sire_version from sire import __revisionid__ as _sire_revisionid +# Store the BioSimSpace version. +from BioSimSpace import __version__ as _biosimspace_version + # Store the ghostly version. from ghostly import __version__ as _ghostly_version # Store the loch version. from loch import __version__ as _loch_version + + +def get_versions(): + """ + Return the versions of SOMD2 and the OpenBioSim packages that it depends + on. + + Returns + ------- + + versions: dict + A dictionary mapping package name to version string. + """ + return { + "somd2": __version__, + "sire": f"{_sire_version}+{_sire_revisionid}", + "biosimspace": _biosimspace_version, + "ghostly": _ghostly_version, + "loch": _loch_version, + } diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 5f490ace..739d45a7 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -98,21 +98,17 @@ def __init__(self, system, config): _logger.error(msg) raise ValueError(msg) - # Log the versions of somd2 and sire. - from somd2 import ( - __version__, - _sire_version, - _sire_revisionid, - _ghostly_version, - _loch_version, - ) + # Log the versions of somd2 and its OpenBioSim dependencies. + from somd2 import get_versions as _get_versions - _logger.info(f"somd2 version: {__version__}") - _logger.info(f"sire version: {_sire_version}+{_sire_revisionid}") + versions = _get_versions() + _logger.info(f"somd2 version: {versions['somd2']}") + _logger.info(f"sire version: {versions['sire']}") + _logger.info(f"biosimspace version: {versions['biosimspace']}") if self._config.ghost_modifications: - _logger.info(f"ghostly version: {_ghostly_version}") + _logger.info(f"ghostly version: {versions['ghostly']}") if self._config.gcmc: - _logger.info(f"loch version: {_loch_version}") + _logger.info(f"loch version: {versions['loch']}") # Flag whether frames are being saved. if ( @@ -1907,7 +1903,9 @@ def _checkpoint( """ try: - from somd2 import __version__, _sire_version, _sire_revisionid + from somd2 import get_versions as _get_versions + + versions = _get_versions() # Get the lambda value. lam = self._lambda_values[index] @@ -1929,8 +1927,8 @@ def _checkpoint( if not is_post_equilibration: metadata = { "attrs": df.attrs, - "somd2 version": __version__, - "sire version": f"{_sire_version}+{_sire_revisionid}", + "somd2 version": versions["somd2"], + "sire version": versions["sire"], "lambda": f"{lam:.5f}", "speed": speed, "temperature": str(self._config.temperature.value()), From 69049026fdb20fad0ef8b9b334c98ab80223070b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 23 Jun 2026 10:25:06 +0100 Subject: [PATCH 207/212] Add support for terminal flip MC during ring-break perturbations. --- src/somd2/runner/_samplers/_terminal_flip.py | 339 +++++++++++-------- 1 file changed, 197 insertions(+), 142 deletions(-) diff --git a/src/somd2/runner/_samplers/_terminal_flip.py b/src/somd2/runner/_samplers/_terminal_flip.py index 971a85ac..8eba9ac0 100644 --- a/src/somd2/runner/_samplers/_terminal_flip.py +++ b/src/somd2/runner/_samplers/_terminal_flip.py @@ -125,6 +125,180 @@ def _round_to_symmetry_angle(raw_angle, tolerance=10.0): return symmetry_angles[min_idx] +def _detect_for_view(mol, all_atoms, flip_angle, max_mobile_atoms, seen): + """ + Detect terminal ring groups using a single, self-consistent end-state + view of a molecule (i.e. its "connectivity" and "coordinates" properties + both resolve to the same end state). + + Parameters + ---------- + + mol : sire.legacy.Mol.Molecule + The molecule, already linked to one end state (via + :func:`sire.morph.link_to_reference` or + :func:`sire.morph.link_to_perturbed`). + + all_atoms : sire molecule view + All atoms in the system, used to obtain absolute (OpenMM) atom + indices. + + flip_angle : float or None + See :func:`detect_terminal_groups`. + + max_mobile_atoms : int or None + See :func:`detect_terminal_groups`. + + seen : set + Set of (anchor_abs, pivot_abs, frozenset(mobile_abs)) tuples already + found by a previous call (e.g. for the other end state). Updated in + place; groups already present are skipped so the same physical group + is not double-counted when it is identical at both end states. + + Returns + ------- + + list of tuple + Each entry is (angle, [anchor_idx, pivot_idx, mobile_idx_0, ...]) + using absolute atom indices. + """ + groups = [] + + try: + connectivity = mol.property("connectivity") + except Exception: + _logger.warning(f"Molecule {mol} has no 'connectivity' property. Skipping.") + return groups + + num_atoms = mol.num_atoms() + seen_bonds = set() + rdmol = None # lazily initialised if geometric detection fails + + for i in range(num_atoms): + atom_i_idx = _Mol.AtomIdx(i) + + # Only consider non-ring atoms as anchors. + if connectivity.in_ring(atom_i_idx): + continue + + # Skip dead-end atoms (e.g. hydrogen bonded only to a ring + # carbon): a valid anchor must be part of a chain, so it needs + # at least two connections (one to the pivot, one elsewhere). + if len(connectivity.connections_to(atom_i_idx)) < 2: + continue + + for neighbor_idx in connectivity.connections_to(atom_i_idx): + j = neighbor_idx.value() + + # Only consider ring atoms as pivots. + if not connectivity.in_ring(_Mol.AtomIdx(j)): + continue + + # Avoid processing the same bond twice. + bond_key = (min(i, j), max(i, j)) + if bond_key in seen_bonds: + continue + seen_bonds.add(bond_key) + + # Collect mobile atoms via BFS from the pivot, not crossing + # the anchor. The pivot itself does not move (it is the + # rotation centre), so it is excluded from the mobile list. + mobile = _bfs_mobile(connectivity, i, j, num_atoms) + + if not mobile: + continue + + # Skip groups with too many mobile atoms. + if max_mobile_atoms is not None and len(mobile) > max_mobile_atoms: + _logger.warning( + f"Terminal group at pivot atom {j} has {len(mobile)} mobile " + f"atoms (max_mobile_atoms={max_mobile_atoms}). Skipping group." + ) + continue + + # Map molecule-local indices to absolute system indices, and + # deduplicate against any group already found for the other end + # state (the same physical group away from a perturbed region + # will typically be identical at both end states). + anchor_abs = all_atoms.find(mol.atom(atom_i_idx)) + pivot_abs = all_atoms.find(mol.atom(_Mol.AtomIdx(j))) + mobile_abs = [all_atoms.find(mol.atom(_Mol.AtomIdx(k))) for k in mobile] + + dedup_key = (anchor_abs, pivot_abs, frozenset(mobile_abs)) + if dedup_key in seen: + continue + seen.add(dedup_key) + + # Determine the flip angle for this group. + if flip_angle is not None: + group_angle = flip_angle + else: + # Find the two ring neighbours of the pivot (mobile atoms + # directly bonded to the pivot that are in the ring). + mobile_set = set(mobile) + pivot_idx_obj = _Mol.AtomIdx(j) + ring_neighbors = [ + n.value() + for n in connectivity.connections_to(pivot_idx_obj) + if n.value() in mobile_set + and connectivity.in_ring(_Mol.AtomIdx(n.value())) + ] + + if len(ring_neighbors) != 2: + _logger.warning( + f"Expected 2 ring neighbours for pivot atom {j}, " + f"found {len(ring_neighbors)}. Skipping group." + ) + continue + + raw = _auto_flip_angle(mol, i, j, ring_neighbors) + group_angle = _round_to_symmetry_angle(raw) + + if group_angle is None: + # Geometric detection failed; fall back to hybridization. + try: + if rdmol is None: + from sire.convert import to_rdkit as _to_rdkit + from rdkit.Chem import HybridizationType as _HybType + + rdmol = _to_rdkit(mol) + hyb = rdmol.GetAtomWithIdx(j).GetHybridization() + if hyb == _HybType.SP2: + group_angle = 180.0 + elif hyb == _HybType.SP3: + group_angle = 120.0 + else: + _logger.warning( + f"Terminal group at pivot atom {j}: geometric " + f"detection gave unrecognised angle " + f"({raw:.1f}{_degree_sym}) and hybridization " + f"({hyb}) has no defined flip angle. Skipping." + ) + continue + _logger.warning( + f"Terminal group at pivot atom {j}: geometric " + f"detection gave unrecognised angle " + f"({raw:.1f}{_degree_sym}), using hybridization-based " + f"angle {group_angle}{_degree_sym} (pivot is {hyb.name})." + ) + except Exception as e: + _logger.warning( + f"Terminal group at pivot atom {j} has no recognised " + f"rotational symmetry (raw angle = {raw:.1f}{_degree_sym}) " + f"and hybridization fallback failed: {e}. Skipping." + ) + continue + + _logger.debug( + f"Terminal group at pivot atom {j}: auto-detected flip " + f"angle = {group_angle}{_degree_sym} (raw = {raw:.1f}{_degree_sym})." + ) + + groups.append((group_angle, [anchor_abs, pivot_abs] + mobile_abs)) + + return groups + + def detect_terminal_groups(system, flip_angle=None, max_mobile_atoms=None): """ Detect terminal ring groups in perturbable molecules using Sire's native @@ -136,6 +310,19 @@ def detect_terminal_groups(system, flip_angle=None, max_mobile_atoms=None): The mobile atoms are all atoms reachable from the pivot when the anchor-pivot bond is cut. + Each end state's connectivity is searched independently (rather than + requiring the two end states to share identical connectivity), so a + group that only exists as a genuine terminal ring at one end state (for + example, a ring fused to a second ring that breaks elsewhere in a + ring-breaking perturbation) is still detected. A group found identically + at both end states is only added once. A group detected from one end + state's connectivity may not correspond to a real, rotatable fragment at + the other end state (the rotation axis may still be part of a closed + ring there); attempting such a move is not unsafe, since the resulting + large bond stretch is simply rejected by the Metropolis criterion, but + it does waste a move attempt outside the lambda range where the group is + actually valid. + Parameters ---------- @@ -181,149 +368,17 @@ def detect_terminal_groups(system, flip_angle=None, max_mobile_atoms=None): import sire.morph as _morph for mol in pert_mols: - mol = _morph.link_to_reference(mol) - - try: - connectivity = mol.property("connectivity") - except Exception: - _logger.warning(f"Molecule {mol} has no 'connectivity' property. Skipping.") - continue - - # Skip molecules whose connectivity changes between end states (e.g. - # ring-breaking/growing perturbations). Terminal groups detected from - # the lambda=0 connectivity would be invalid at lambda=1. - try: - conn0 = mol.property("connectivity0") - conn1 = mol.property("connectivity1") - if conn0 != conn1: - _logger.warning( - f"Molecule {mol} has different connectivity at lambda=0 and " - "lambda=1 (ring-breaking/growing perturbation). Skipping " - "terminal flip detection for this molecule." - ) - continue - except Exception: - pass - - num_atoms = mol.num_atoms() - seen_bonds = set() - rdmol = None # lazily initialised if geometric detection fails - - for i in range(num_atoms): - atom_i_idx = _Mol.AtomIdx(i) - - # Only consider non-ring atoms as anchors. - if connectivity.in_ring(atom_i_idx): - continue - - # Skip dead-end atoms (e.g. hydrogen bonded only to a ring - # carbon): a valid anchor must be part of a chain, so it needs - # at least two connections (one to the pivot, one elsewhere). - if len(connectivity.connections_to(atom_i_idx)) < 2: - continue - - for neighbor_idx in connectivity.connections_to(atom_i_idx): - j = neighbor_idx.value() - - # Only consider ring atoms as pivots. - if not connectivity.in_ring(_Mol.AtomIdx(j)): - continue + seen = set() - # Avoid processing the same bond twice. - bond_key = (min(i, j), max(i, j)) - if bond_key in seen_bonds: - continue - seen_bonds.add(bond_key) - - # Collect mobile atoms via BFS from the pivot, not crossing - # the anchor. The pivot itself does not move (it is the - # rotation centre), so it is excluded from the mobile list. - mobile = _bfs_mobile(connectivity, i, j, num_atoms) - - if not mobile: - continue - - # Skip groups with too many mobile atoms. - if max_mobile_atoms is not None and len(mobile) > max_mobile_atoms: - _logger.warning( - f"Terminal group at pivot atom {j} has {len(mobile)} mobile " - f"atoms (max_mobile_atoms={max_mobile_atoms}). Skipping group." - ) - continue - - # Determine the flip angle for this group. - if flip_angle is not None: - group_angle = flip_angle - else: - # Find the two ring neighbours of the pivot (mobile atoms - # directly bonded to the pivot that are in the ring). - mobile_set = set(mobile) - pivot_idx_obj = _Mol.AtomIdx(j) - ring_neighbors = [ - n.value() - for n in connectivity.connections_to(pivot_idx_obj) - if n.value() in mobile_set - and connectivity.in_ring(_Mol.AtomIdx(n.value())) - ] - - if len(ring_neighbors) != 2: - _logger.warning( - f"Expected 2 ring neighbours for pivot atom {j}, " - f"found {len(ring_neighbors)}. Skipping group." - ) - continue - - raw = _auto_flip_angle(mol, i, j, ring_neighbors) - group_angle = _round_to_symmetry_angle(raw) - - if group_angle is None: - # Geometric detection failed; fall back to hybridization. - try: - if rdmol is None: - from sire.convert import to_rdkit as _to_rdkit - from rdkit.Chem import HybridizationType as _HybType - - rdmol = _to_rdkit(mol) - hyb = rdmol.GetAtomWithIdx(j).GetHybridization() - if hyb == _HybType.SP2: - group_angle = 180.0 - elif hyb == _HybType.SP3: - group_angle = 120.0 - else: - _logger.warning( - f"Terminal group at pivot atom {j}: geometric " - f"detection gave unrecognised angle " - f"({raw:.1f}{_degree_sym}) and hybridization " - f"({hyb}) has no defined flip angle. Skipping." - ) - continue - _logger.warning( - f"Terminal group at pivot atom {j}: geometric " - f"detection gave unrecognised angle " - f"({raw:.1f}{_degree_sym}), using hybridization-based " - f"angle {group_angle}{_degree_sym} (pivot is {hyb.name})." - ) - except Exception as e: - _logger.warning( - f"Terminal group at pivot atom {j} has no recognised " - f"rotational symmetry (raw angle = {raw:.1f}{_degree_sym}) " - f"and hybridization fallback failed: {e}. Skipping." - ) - continue - - _logger.debug( - f"Terminal group at pivot atom {j}: auto-detected flip " - f"angle = {group_angle}{_degree_sym} (raw = {raw:.1f}{_degree_sym})." - ) - - # Map molecule-local indices to absolute system indices. - anchor_abs = all_atoms.find(mol.atom(atom_i_idx)) - pivot_abs = all_atoms.find(mol.atom(_Mol.AtomIdx(j))) - mobile_abs = [all_atoms.find(mol.atom(_Mol.AtomIdx(k))) for k in mobile] + mol_ref = _morph.link_to_reference(mol) + terminal_groups.extend( + _detect_for_view(mol_ref, all_atoms, flip_angle, max_mobile_atoms, seen) + ) - terminal_groups.append( - (group_angle, [anchor_abs, pivot_abs] + mobile_abs) - ) + mol_pert = _morph.link_to_perturbed(mol) + terminal_groups.extend( + _detect_for_view(mol_pert, all_atoms, flip_angle, max_mobile_atoms, seen) + ) return terminal_groups @@ -524,7 +579,7 @@ def move(self, context): _logger.debug( f"Terminal flip accepted (group {group_idx}, " f"{_delta_sym} = {e_new - e_old:.2f} kJ/mol, " - f"acc = {min(1.0, _np.exp(-delta_e)):.3f})" + f"acc = {_np.exp(min(0.0, -delta_e)):.3f})" ) return True else: From 2dca3115f4f31c38fa02249793b16927251709d5 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 23 Jun 2026 12:06:00 +0100 Subject: [PATCH 208/212] Add option to control epsilon scaling for Beutler soft-core. --- src/somd2/config/_config.py | 21 ++++++++++++ src/somd2/runner/_base.py | 66 +++++++++++++++++++++++-------------- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/somd2/config/_config.py b/src/somd2/config/_config.py index 8a6c0e91..d4482be8 100644 --- a/src/somd2/config/_config.py +++ b/src/somd2/config/_config.py @@ -158,6 +158,7 @@ def __init__( softcore_form="zacharias", taylor_power=1, beutler_alpha=0.5, + beutler_fix_epsilon=True, output_directory="output", restart=False, use_backup=False, @@ -477,6 +478,15 @@ def __init__( form. Must be >= 0. The default is 0.5. Only used when softcore_form is "beutler". + beutler_fix_epsilon: bool + Whether to hold LJ epsilon fixed at its real-atom value for + ghost-decoupling molecules when softcore_form is "beutler", so that the + Beutler (1-alpha) prefactor provides the sole LJ decay pathway. The + default is True. This is automatically disabled (regardless of this + setting) for any alchemical ion added to maintain a constant charge, + since an ion's persisting atom is a real (non-ghost) mutation and needs + its LJ epsilon to interpolate normally rather than being held fixed. + output_directory: str Path to a directory to store output files. @@ -623,6 +633,7 @@ def __init__( self.softcore_form = softcore_form self.taylor_power = taylor_power self.beutler_alpha = beutler_alpha + self.beutler_fix_epsilon = beutler_fix_epsilon self.somd1_compatibility = somd1_compatibility self.pert_file = pert_file self.auto_fix_minimise = auto_fix_minimise @@ -2141,6 +2152,16 @@ def beutler_alpha(self, beutler_alpha): raise ValueError("'beutler_alpha' must be >= 0") self._beutler_alpha = beutler_alpha + @property + def beutler_fix_epsilon(self): + return self._beutler_fix_epsilon + + @beutler_fix_epsilon.setter + def beutler_fix_epsilon(self, beutler_fix_epsilon): + if not isinstance(beutler_fix_epsilon, bool): + raise TypeError("'beutler_fix_epsilon' must be of type 'bool'") + self._beutler_fix_epsilon = beutler_fix_epsilon + @property def use_backup(self): return self._use_backup diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 739d45a7..09487806 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -253,31 +253,6 @@ def __init__(self, system, config): self._config._extra_args["use_gcmc_lrc"] = True self._config._extra_args["num_gcmc_waters"] = self._config.gcmc_num_waters - # Set the soft-core form. - if self._config.softcore_form == "taylor": - self._config._extra_args["use_taylor_softening"] = True - self._config._extra_args["taylor_power"] = self._config.taylor_power - elif self._config.softcore_form == "beutler": - schedule_name = self._config._lambda_schedule_name - if schedule_name not in (None, "annihilate", "decouple"): - raise ValueError( - "The Beutler soft-core form is only supported with the 'annihilate' " - "or 'decouple' lambda schedules, or a custom schedule." - ) - self._config._extra_args["use_beutler_softening"] = True - self._config._extra_args["beutler_alpha"] = self._config.beutler_alpha - - # Build deferred schedules now that the softcore form is known. - fix_epsilon = self._config.softcore_form == "beutler" - if self._config._lambda_schedule_name == "annihilate": - from .._utils._schedules import annihilate as _annihilate - - self._config._lambda_schedule = _annihilate(fix_epsilon=fix_epsilon) - elif self._config._lambda_schedule_name == "decouple": - from .._utils._schedules import decouple as _decouple - - self._config._lambda_schedule = _decouple(fix_epsilon=fix_epsilon) - # We're running in SOMD1 compatibility mode. if self._config.somd1_compatibility: from .._utils._somd1 import make_compatible @@ -407,6 +382,47 @@ def __init__(self, system, config): coalchemical_restraints ) + # Set the soft-core form. + if self._config.softcore_form == "taylor": + self._config._extra_args["use_taylor_softening"] = True + self._config._extra_args["taylor_power"] = self._config.taylor_power + elif self._config.softcore_form == "beutler": + schedule_name = self._config._lambda_schedule_name + if schedule_name not in (None, "annihilate", "decouple"): + raise ValueError( + "The Beutler soft-core form is only supported with the 'annihilate' " + "or 'decouple' lambda schedules, or a custom schedule." + ) + self._config._extra_args["use_beutler_softening"] = True + self._config._extra_args["beutler_alpha"] = self._config.beutler_alpha + + # Build deferred schedules now that the softcore form is known. Epsilon is + # only held fixed (with LJ decay handled entirely by the Beutler soft-core + # prefactor) for molecules undergoing a ghost-atom decoupling/annihilation. + # An alchemical ion is a real (non-ghost) atom mutating identity (e.g. a + # water oxygen turning into Na+), so its LJ epsilon needs to interpolate + # normally; fixing it would leave the ion's persisting atom stuck at its + # initial LJ parameters for the whole stage. Disable fix_epsilon whenever + # an alchemical ion has been added, regardless of the configured value. + fix_epsilon = ( + self._config.softcore_form == "beutler" and self._config.beutler_fix_epsilon + ) + if fix_epsilon and charge_diff != 0: + _logger.info( + "Disabling Beutler 'fix_epsilon' since an alchemical ion has been " + "added: the ion's persisting atom is a real (non-ghost) mutation " + "and needs its LJ epsilon to interpolate normally." + ) + fix_epsilon = False + if self._config._lambda_schedule_name == "annihilate": + from .._utils._schedules import annihilate as _annihilate + + self._config._lambda_schedule = _annihilate(fix_epsilon=fix_epsilon) + elif self._config._lambda_schedule_name == "decouple": + from .._utils._schedules import decouple as _decouple + + self._config._lambda_schedule = _decouple(fix_epsilon=fix_epsilon) + # Set the lambda values. if self._config.lambda_values: self._lambda_values = self._config.lambda_values From 892a181296c3e571c40967f176e3b0c9b0cb9c71 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 26 Jun 2026 15:26:17 +0100 Subject: [PATCH 209/212] Apply setAmberWater unconditionally to ensure fully rigid water constraints --- src/somd2/runner/_base.py | 87 ++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 48 deletions(-) diff --git a/src/somd2/runner/_base.py b/src/somd2/runner/_base.py index 09487806..c9734001 100644 --- a/src/somd2/runner/_base.py +++ b/src/somd2/runner/_base.py @@ -266,54 +266,6 @@ def __init__(self, system, config): ) self._system = _sr.morph.link_to_reference(self._system) - # Next, swap the water topology so that it is in AMBER format. - - try: - waters = self._system["water"] - except: - waters = [] - - if len(waters) > 0: - from sire.legacy.IO import isAmberWater as _isAmberWater - from sire.legacy.IO import setAmberWater as _setAmberWater - - if not _isAmberWater(waters[0]): - num_atoms = waters[0].num_atoms() - - if num_atoms == 3: - # Here we assume TIP3P for any 3-point water model. - model = "tip3p" - elif num_atoms == 4: - # Check for OPC water. - try: - if ( - waters[0] - .search("element Xx") - .atoms()[0] - .charge() - .value() - < -1.1 - ): - model = "opc" - else: - model = "tip4p" - except: - model = "tip4p" - elif num_atoms == 5: - model = "tip5p" - try: - self._system = _System( - _setAmberWater(self._system._system, model) - ) - _logger.info( - "Converting water topology to AMBER format for SOMD1 compatibility." - ) - except Exception as e: - _logger.error( - "Unable to convert water topology to AMBER format for SOMD1 compatibility." - ) - raise e - # Ghost atoms are considered light when adding bond constraints. self._config._extra_args["ghosts_are_light"] = True @@ -334,6 +286,45 @@ def __init__(self, system, config): _logger.error(msg) raise RuntimeError(msg) + # Convert water topology to AMBER format if not already done. AMBER + # format adds an explicit H-H bond, giving fully rigid water (O-H and + # H-H constraints) rather than just O-H constraints under h_bonds. + try: + waters = self._system["water"] + except: + waters = [] + + if len(waters) > 0: + from sire.legacy.IO import isAmberWater as _isAmberWater + from sire.legacy.IO import setAmberWater as _setAmberWater + + if not _isAmberWater(waters[0]): + num_atoms = waters[0].num_atoms() + + if num_atoms == 3: + model = "tip3p" + elif num_atoms == 4: + try: + if ( + waters[0].search("element Xx").atoms()[0].charge().value() + < -1.1 + ): + model = "opc" + else: + model = "tip4p" + except: + model = "tip4p" + elif num_atoms == 5: + model = "tip5p" + try: + self._system = _System(_setAmberWater(self._system._system, model)) + _logger.info( + f"Converting water topology to AMBER {model.upper()} format." + ) + except Exception as e: + _logger.error("Unable to convert water topology to AMBER format.") + raise e + # Check the end state constraints. self._check_end_state_constraints() From e5fee269dd4c4e3db8401e227e7a681adee75d74 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 14:22:03 +0100 Subject: [PATCH 210/212] Pin ring-break/make alpha=1, kappa=0 in potential_swap as explicit constants --- CHANGELOG.md | 22 ++++++++++++++++++++++ src/somd2/_utils/_schedules.py | 10 ++++++++++ 2 files changed, 32 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..db23c822 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +Changelog +========= + +[2026.1.0](https://github.com/openbiosim/somd2/compare/2025.1.0...2026.1.0) - Jun 2026 +-------------------------------------------------------------------------------------- + +* Improve constraint handling during minimisation and equilibration [#80](https://github.com/OpenBioSim/somd2/pull/80) +* Add support for GCMC on the OpenCL platform [#115](https://github.com/OpenBioSim/somd2/pull/115) +* Expose ring-breaking/making lambda schedules [#129](https://github.com/OpenBioSim/somd2/pull/129) +* Add support for Terminal Flip Monte Carlo [#138](https://github.com/OpenBioSim/somd2/pull/138) +* Add support for per-force energy decomposition [#143](https://github.com/OpenBioSim/somd2/pull/143) +* Add support for long-range dispersion correction and Beutler softcore [#147](https://github.com/OpenBioSim/somd2/pull/147) +* Add support for GCMC in the osmotic ensemble [#151](https://github.com/OpenBioSim/somd2/pull/151) +* Improve handling of simulation restarts via a `.done` sentinel file [#153](https://github.com/OpenBioSim/somd2/pull/153) +* Reduce checkpoint memory footprint by storing `NumPy` arrays in the replica exchange state pickle file [#155](https://github.com/OpenBioSim/somd2/pull/155) +* Remove redundant `s3` checkpoint files [#157](https://github.com/OpenBioSim/somd2/pull/157) +* Unconditionally apply AMBER water topology conversion to ensure fully rigid water constraints [#163](https://github.com/OpenBioSim/somd2/pull/163) + +[2025.1.0](https://github.com/OpenBioSim/loch/releases/tag/2025.1.0) - Nov 2025 +------------------------------------------------------------------------------- + +* Initial public release. diff --git a/src/somd2/_utils/_schedules.py b/src/somd2/_utils/_schedules.py index 06985ebe..4c49a0ea 100644 --- a/src/somd2/_utils/_schedules.py +++ b/src/somd2/_utils/_schedules.py @@ -232,6 +232,16 @@ def ring_break_morph(): s.set_equation(stage="potential_swap", lever="angle_size", equation=s.initial()) s.set_equation(stage="potential_swap", lever="torsion_k", equation=s.initial()) s.set_equation(stage="potential_swap", lever="torsion_phase", equation=s.initial()) + # Softcore off throughout potential_swap: explicit constants so the schedule + # visualises correctly regardless of the initial/final values passed by the caller. + s.set_equation( + stage="potential_swap", force="ring-break", lever="alpha", equation=1 + ) + s.set_equation( + stage="potential_swap", force="ring-break", lever="kappa", equation=0 + ) + s.set_equation(stage="potential_swap", force="ring-make", lever="alpha", equation=1) + s.set_equation(stage="potential_swap", force="ring-make", lever="kappa", equation=0) # morph [2/3, 1]: standard nonbonded morphing with ring-break/ring-make fixed # at fully open (kappa=1, alpha=0). ring-make mirrors ring-break so .reverse() From 095a396cc4ebf793c3907849bc5cbb58d56447ed Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 14:24:51 +0100 Subject: [PATCH 211/212] Add BioSimSpace compatibility pin. --- pixi.toml | 5 ++++- recipes/somd2/recipe.yaml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pixi.toml b/pixi.toml index f3890e85..e77834c8 100644 --- a/pixi.toml +++ b/pixi.toml @@ -5,7 +5,10 @@ platforms = ["linux-64", "osx-arm64"] [dependencies] python = ">=3.10" -biosimspace = "*" +# main +biosimspace = ">=2026.1.0,<2026.2.0" +# devel +#biosimspace = "==2026.2.0.dev" filelock = "*" ghostly = "*" loch = "*" diff --git a/recipes/somd2/recipe.yaml b/recipes/somd2/recipe.yaml index 9fd813b5..7303d8ed 100644 --- a/recipes/somd2/recipe.yaml +++ b/recipes/somd2/recipe.yaml @@ -19,7 +19,10 @@ requirements: - setuptools - versioningit run: - - biosimspace + # main + - biosimspace >=2026.1.0,<2026.2.0 + # devel + #- biosimspace ==2026.2.0.dev - filelock - ghostly - loch From da6e8393253b416ac3ffb24aae875ccd2726f129 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 29 Jun 2026 16:09:53 +0100 Subject: [PATCH 212/212] Add CHANGELOG file. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db23c822..1df7d6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Changelog * Remove redundant `s3` checkpoint files [#157](https://github.com/OpenBioSim/somd2/pull/157) * Unconditionally apply AMBER water topology conversion to ensure fully rigid water constraints [#163](https://github.com/OpenBioSim/somd2/pull/163) -[2025.1.0](https://github.com/OpenBioSim/loch/releases/tag/2025.1.0) - Nov 2025 +[2025.1.0](https://github.com/OpenBioSim/somd2/releases/tag/2025.1.0) - Nov 2025 ------------------------------------------------------------------------------- * Initial public release.