From 7ab5c8df7222561d90dcacba5f92b7c57947504e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 21 Feb 2024 14:13:05 +0000 Subject: [PATCH 01/36] Add support for using a reference system for position restraints. --- python/BioSimSpace/Process/_amber.py | 27 ++++++++++++++++- python/BioSimSpace/Process/_gromacs.py | 26 +++++++++++++++-- python/BioSimSpace/Process/_namd.py | 11 ++++++- python/BioSimSpace/Process/_openmm.py | 40 ++++++++++++++++++++++---- python/BioSimSpace/Process/_process.py | 27 +++++++++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 0f19e0e16..c5840d7f5 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -69,6 +69,7 @@ def __init__( self, system, protocol, + reference_system=None, exe=None, name="amber", work_dir=None, @@ -89,6 +90,11 @@ def __init__( protocol : :class:`Protocol ` The protocol for the AMBER process. + reference_system : :class:`System ` or None + An optional system to use as a source of reference coordinates for position + restraints. It is assumed that this system has the same topology as "system". + If this is None, then "system" is used as a reference. + exe : str The full path to the AMBER executable. @@ -118,6 +124,7 @@ def __init__( super().__init__( system, protocol, + reference_system=reference_system, name=name, work_dir=work_dir, seed=seed, @@ -172,6 +179,7 @@ def __init__( # The names of the input files. self._rst_file = "%s/%s.rst7" % (self._work_dir, name) self._top_file = "%s/%s.prm7" % (self._work_dir, name) + self._ref_file = "%s/%s_ref.rst7" % (self._work_dir, name) # The name of the trajectory file. self._traj_file = "%s/%s.nc" % (self._work_dir, name) @@ -182,6 +190,10 @@ def __init__( # Create the list of input files. self._input_files = [self._config_file, self._rst_file, self._top_file] + # Add the reference file if there are position restraints. + if self._protocol.getRestraint() is not None: + self._input_files.append(self._ref_file) + # Now set up the working directory for the process. self._setup() @@ -210,6 +222,19 @@ def _setup(self): else: raise IOError(msg) from None + # Reference file for position restraints. + try: + file = _os.path.splitext(self._ref_file)[0] + _IO.saveMolecules( + file, self._reference_system, "rst7", property_map=self._property_map + ) + except Exception as e: + msg = "Failed to write reference system to 'RST7' format." + if _isVerbose(): + raise IOError(msg) from e + else: + raise IOError(msg) from None + # PRM file (topology). try: file = _os.path.splitext(self._top_file)[0] @@ -315,7 +340,7 @@ def _generate_args(self): # Append a reference file if a position restraint is specified. if isinstance(self._protocol, _PositionRestraintMixin): if self._protocol.getRestraint() is not None: - self.setArg("-ref", "%s.rst7" % self._name) + self.setArg("-ref", self._ref_file) # Append a trajectory file if this anything other than a minimisation. if not isinstance(self._protocol, _Protocol.Minimisation): diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index e4531dad3..7c22f60e0 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -76,6 +76,7 @@ def __init__( self, system, protocol, + reference_system=None, exe=None, name="gromacs", work_dir=None, @@ -99,6 +100,11 @@ def __init__( protocol : :class:`Protocol ` The protocol for the GROMACS process. + reference_system : :class:`System ` or None + An optional system to use as a source of reference coordinates for position + restraints. It is assumed that this system has the same topology as "system". + If this is None, then "system" is used as a reference. + exe : str The full path to the GROMACS executable. @@ -142,6 +148,7 @@ def __init__( super().__init__( system, protocol, + reference_system=reference_system, name=name, work_dir=work_dir, seed=seed, @@ -193,6 +200,7 @@ def __init__( # The names of the input files. self._gro_file = "%s/%s.gro" % (self._work_dir, name) self._top_file = "%s/%s.top" % (self._work_dir, name) + self._ref_file = "%s/%s_ref.gro" % (self._work_dir, name) # The name of the trajectory file. self._traj_file = "%s/%s.trr" % (self._work_dir, name) @@ -206,6 +214,10 @@ def __init__( # Create the list of input files. self._input_files = [self._config_file, self._gro_file, self._top_file] + # Add the reference file if there are position restraints. + if self._protocol.getRestraint() is not None: + self._input_files.append(self._ref_file) + # Initialise the PLUMED interface object. self._plumed = None @@ -263,6 +275,16 @@ def _setup(self): file, system, "gro87", match_water=False, property_map=self._property_map ) + # Reference file. + file = _os.path.splitext(self._ref_file)[0] + _IO.saveMolecules( + file, + self._reference_system, + "gro87", + match_water=False, + property_map=self._property_map, + ) + # TOP file. file = _os.path.splitext(self._top_file)[0] _IO.saveMolecules( @@ -1992,8 +2014,8 @@ def _add_position_restraints(self): property_map["parallel"] = _SireBase.wrap(False) property_map["sort"] = _SireBase.wrap(False) - # Create a copy of the system. - system = self._system.copy() + # Create a copy of the reference system. + system = self._reference_system.copy() # Convert to the lambda = 0 state if this is a perturbable system. system = self._checkPerturbable(system) diff --git a/python/BioSimSpace/Process/_namd.py b/python/BioSimSpace/Process/_namd.py index 25ecaa5f3..f996c9555 100644 --- a/python/BioSimSpace/Process/_namd.py +++ b/python/BioSimSpace/Process/_namd.py @@ -63,6 +63,7 @@ def __init__( self, system, protocol, + reference_system=None, exe=None, name="namd", work_dir=None, @@ -81,6 +82,11 @@ def __init__( protocol : :class:`Protocol ` The protocol for the NAMD process. + reference_system : :class:`System ` or None + An optional system to use as a source of reference coordinates for position + restraints. It is assumed that this system has the same topology as "system". + If this is None, then "system" is used as a reference. + exe : str The full path to the NAMD executable. @@ -103,6 +109,7 @@ def __init__( super().__init__( system, protocol, + reference_system=reference_system, name=name, work_dir=work_dir, seed=seed, @@ -421,7 +428,9 @@ def _generate_config(self): restraint = self._protocol.getRestraint() if restraint is not None: # Create a restrained system. - restrained = self._createRestrainedSystem(self._system, restraint) + restrained = self._createRestrainedSystem( + self._reference_system, restraint + ) # Create a PDB object, mapping the "occupancy" property to "restrained". prop = self._property_map.get("occupancy", "occupancy") diff --git a/python/BioSimSpace/Process/_openmm.py b/python/BioSimSpace/Process/_openmm.py index 557bef80e..76bb8451a 100644 --- a/python/BioSimSpace/Process/_openmm.py +++ b/python/BioSimSpace/Process/_openmm.py @@ -72,6 +72,7 @@ def __init__( self, system, protocol, + reference_system=None, exe=None, name="openmm", platform="CPU", @@ -91,6 +92,11 @@ def __init__( protocol : :class:`Protocol ` The protocol for the OpenMM process. + reference_system : :class:`System ` or None + An optional system to use as a source of reference coordinates for position + restraints. It is assumed that this system has the same topology as "system". + If this is None, then "system" is used as a reference. + exe : str The full path to the Python interpreter used to run OpenMM. @@ -120,6 +126,7 @@ def __init__( super().__init__( system, protocol, + reference_system=reference_system, name=name, work_dir=work_dir, seed=seed, @@ -175,6 +182,7 @@ def __init__( # are self-contained, but could equally work with GROMACS files. self._rst_file = "%s/%s.rst7" % (self._work_dir, name) self._top_file = "%s/%s.prm7" % (self._work_dir, name) + self._ref_file = "%s/%s_ref.rst7" % (self._work_dir, name) # The name of the trajectory file. self._traj_file = "%s/%s.dcd" % (self._work_dir, name) @@ -186,6 +194,10 @@ def __init__( # Create the list of input files. self._input_files = [self._config_file, self._rst_file, self._top_file] + # Add the reference file if there are position restraints. + if self._protocol.getRestraint() is not None: + self._input_files.append(self._ref_file) + # Initialise the log file header. self._header = None @@ -249,6 +261,18 @@ def _setup(self): else: raise IOError(msg) from None + # Reference coordinate file for position restraints. + if self._protocol.getRestraint() is not None: + try: + file = _os.path.splitext(self._ref_file)[0] + _IO.saveMolecules(file, system, "rst7", property_map=self._property_map) + except Exception as e: + msg = "Failed to write reference system to 'RST7' format." + if _isVerbose(): + raise IOError(msg) from e + else: + raise IOError(msg) from None + # PRM file (topology). try: file = _os.path.splitext(self._top_file)[0] @@ -308,7 +332,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" + f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -377,7 +401,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" + f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -561,7 +585,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" + f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -760,7 +784,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" + f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -2140,11 +2164,15 @@ def _add_config_restraints(self): if restraint is not None: # Search for the atoms to restrain by keyword. if isinstance(restraint, str): - restrained_atoms = self._system.getRestraintAtoms(restraint) + restrained_atoms = self._reference_system.getRestraintAtoms(restraint) # Use the user-defined list of indices. else: restrained_atoms = restraint + self.addToConfig( + f"ref_prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + ) + # Get the force constant in units of kJ_per_mol/nanometer**2 force_constant = self._protocol.getForceConstant()._sire_unit force_constant = force_constant.to( @@ -2161,7 +2189,7 @@ def _add_config_restraints(self): "nonbonded = [f for f in system.getForces() if isinstance(f, NonbondedForce)][0]" ) self.addToConfig("dummy_indices = []") - self.addToConfig("positions = prm.positions") + self.addToConfig("positions = ref_prm.positions") self.addToConfig(f"restrained_atoms = {restrained_atoms}") self.addToConfig("for i in restrained_atoms:") self.addToConfig(" j = system.addParticle(0)") diff --git a/python/BioSimSpace/Process/_process.py b/python/BioSimSpace/Process/_process.py index 255dee9df..1865b128d 100644 --- a/python/BioSimSpace/Process/_process.py +++ b/python/BioSimSpace/Process/_process.py @@ -70,6 +70,7 @@ def __init__( self, system, protocol, + reference_system=None, name=None, work_dir=None, seed=None, @@ -89,6 +90,11 @@ def __init__( protocol : :class:`Protocol ` The protocol for the process. + reference_system : :class:`System ` or None + An optional system to use as a source of reference coordinates for position + restraints. It is assumed that this system has the same topology as "system". + If this is None, then "system" is used as a reference. + name : str The name of the process. @@ -137,6 +143,27 @@ def __init__( if not isinstance(protocol, _Protocol): raise TypeError("'protocol' must be of type 'BioSimSpace.Protocol'") + # Check that the reference system is valid. + if reference_system is not None: + if not isinstance(reference_system, _System): + raise TypeError( + "'reference_system' must be of type 'BioSimSpace._SireWrappers.System'" + ) + + # Make sure that the reference system contains the same number + # of molecules, residues, and atoms as the system. + if ( + not reference_system.nMolecules() == system.nMolecules() + or not reference_system.nResidues() == system.nResidues() + or not reference_system.nAtoms() == system.nAtoms() + ): + raise _IncompatibleError( + "'refence_system' must have the same topology as 'system'" + ) + self._reference_system = reference_system + else: + self._reference_system = system.copy() + # Check that the working directory is valid. if work_dir is not None and not isinstance(work_dir, (str, _Utils.WorkDir)): raise TypeError( From 5dc399ad6ff4121d8a1e3a6d08717e07f6cd22db Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 21 Feb 2024 16:15:29 +0000 Subject: [PATCH 02/36] Add support for FEP with AMBER molecular dynamics engine. --- python/BioSimSpace/Align/_merge.py | 78 + python/BioSimSpace/Align/_squash.py | 725 ++ python/BioSimSpace/FreeEnergy/_relative.py | 89 +- python/BioSimSpace/Process/_amber.py | 1193 ++- .../Sandpit/Exscientia/Process/_amber.py | 58 +- python/BioSimSpace/_Config/_amber.py | 131 +- python/BioSimSpace/_SireWrappers/_molecule.py | 23 +- tests/Align/test_squash.py | 204 + tests/Process/test_amber.py | 74 + tests/input/merged_tripeptide.pickle | Bin 0 -> 65668 bytes tests/output/amber_fep.out | 6720 +++++++++++++++++ tests/output/amber_fep_min.out | 2059 +++++ 12 files changed, 11188 insertions(+), 166 deletions(-) create mode 100644 python/BioSimSpace/Align/_squash.py create mode 100644 tests/Align/test_squash.py create mode 100644 tests/input/merged_tripeptide.pickle create mode 100644 tests/output/amber_fep.out create mode 100644 tests/output/amber_fep_min.out diff --git a/python/BioSimSpace/Align/_merge.py b/python/BioSimSpace/Align/_merge.py index 9c2b96309..f43eb3a5e 100644 --- a/python/BioSimSpace/Align/_merge.py +++ b/python/BioSimSpace/Align/_merge.py @@ -27,6 +27,7 @@ __all__ = ["merge"] from sire.legacy import Base as _SireBase +from sire.legacy import IO as _SireIO from sire.legacy import MM as _SireMM from sire.legacy import Mol as _SireMol from sire.legacy import Units as _SireUnits @@ -1389,3 +1390,80 @@ def _is_on_ring(idx, conn): # If we get this far, then the atom is not adjacent to a ring. return False + + +def _removeDummies(molecule, is_lambda1): + """ + Internal function which removes the dummy atoms from one of the endstates + of a merged molecule. + + Parameters + ---------- + + molecule : BioSimSpace._SireWrappers.Molecule + The molecule. + + is_lambda1 : bool + Whether to use the molecule at lambda = 1. + """ + if not molecule._is_perturbable: + raise _IncompatibleError("'molecule' is not a perturbable molecule") + + # Always use the coordinates at lambda = 0. + coordinates = molecule._sire_object.property("coordinates0") + + # Generate a molecule with all dummies present. + molecule = molecule.copy()._toRegularMolecule( + is_lambda1=is_lambda1, generate_intrascale=True + ) + + # Set the coordinates to those at lambda = 0 + molecule._sire_object = ( + molecule._sire_object.edit().setProperty("coordinates", coordinates).commit() + ) + + # Extract all the nondummy indices + nondummy_indices = [ + i + for i, atom in enumerate(molecule.getAtoms()) + if "du" not in atom._sire_object.property("ambertype") + ] + + # Create an AtomSelection. + selection = molecule._sire_object.selection() + + # Unselect all of the atoms. + selection.selectNone() + + # Now add all of the nondummy atoms. + for idx in nondummy_indices: + selection.select(_SireMol.AtomIdx(idx)) + + # Create a partial molecule and extract the atoms. + partial_molecule = ( + _SireMol.PartialMolecule(molecule._sire_object, selection).extract().molecule() + ) + + # Remove the incorrect intrascale property. + partial_molecule = ( + partial_molecule.edit().removeProperty("intrascale").molecule().commit() + ) + + # Recreate a BioSimSpace molecule object. + molecule = _Molecule(partial_molecule) + + # Parse the molecule as a GROMACS topology, which will recover the intrascale + # matrix. + gro_top = _SireIO.GroTop(molecule.toSystem()._sire_object) + + # Convert back to a Sire system. + gro_sys = gro_top.toSystem() + + # Add the intrascale property back into the merged molecule. + edit_mol = molecule._sire_object.edit() + edit_mol = edit_mol.setProperty( + "intrascale", gro_sys[_SireMol.MolIdx(0)].property("intrascale") + ) + molecule = _Molecule(edit_mol.commit()) + + return molecule diff --git a/python/BioSimSpace/Align/_squash.py b/python/BioSimSpace/Align/_squash.py new file mode 100644 index 000000000..fa3d6ad79 --- /dev/null +++ b/python/BioSimSpace/Align/_squash.py @@ -0,0 +1,725 @@ +import itertools as _it +import numpy as _np +import os as _os +import parmed as _pmd +import shutil as _shutil +import tempfile + +from sire.legacy import IO as _SireIO +from sire.legacy import Mol as _SireMol + +from ._merge import _removeDummies +from ..IO import readMolecules as _readMolecules, saveMolecules as _saveMolecules +from .._SireWrappers import Molecule as _Molecule + + +def _squash(system, explicit_dummies=False): + """ + Internal function which converts a merged BioSimSpace system into an + AMBER-compatible format, where all perturbed molecules are represented + sequentially, instead of in a mixed topology, like in GROMACS. In the + current implementation, all perturbed molecules are moved at the end of + the squashed system. For example, if we have an input system, containing + regular molecules (M) and perturbed molecules (P): + + M0 - M1 - P0 - M2 - P1 - M3 + + This function will return the following squashed system: + + M0 - M1 - M2 - M3 - P0_A - PO_B - P1_A - P1_B + + Where A and B denote the dummyless lambda=0 and lambda=1 states. In + addition, we also return a mapping between the old unperturbed molecule + indices and the new ones. This mapping can be used during coordinate update. + Updating the coordinates of the perturbed molecules, however, has to be + done manually through the Python layer. + + Parameters + ---------- + + system : BioSimSpace._SireWrappers.System + The system. + + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + + Returns + ------- + + system : BioSimSpace._SireWrappers.System + The output squashed system. + + mapping : dict(sire.legacy.Mol.MolIdx, sire.legacy.Mol.MolIdx) + The corresponding molecule-to-molecule mapping. Only the non-perturbable + molecules are contained in this mapping as the perturbable ones do not + have a one-to-one mapping and cannot be expressed as a dictionary. + """ + # Create a copy of the original system. + new_system = system.copy() + + # Get the perturbable molecules and their corresponding indices. + pertmol_idxs = [ + i + for i, molecule in enumerate(system.getMolecules()) + if molecule.isPerturbable() + ] + pert_mols = system.getPerturbableMolecules() + + # Remove the perturbable molecules from the system. + new_system.removeMolecules(pert_mols) + + # Add them back at the end of the system. This is generally faster than + # keeping their order the same. + new_indices = list(range(system.nMolecules())) + for pertmol_idx, pert_mol in zip(pertmol_idxs, pert_mols): + new_indices.remove(pertmol_idx) + new_system += _squash_molecule(pert_mol, explicit_dummies=explicit_dummies) + + # Create the old molecule index to new molecule index mapping. + mapping = { + _SireMol.MolIdx(idx): _SireMol.MolIdx(i) for i, idx in enumerate(new_indices) + } + + return new_system, mapping + + +def _squash_molecule(molecule, explicit_dummies=False): + """ + This internal function converts a perturbed molecule to a system that is + recognisable to the AMBER alchemical code. If the molecule contains a single + residue, then the squashed system is just the two separate pure endstate + molecules in order. If the molecule contains regular (R) and perturbable (P) + resides of the form: + + R0 - R1 - P0 - R2 - P1 - R3 + + Then a system containing a single molecule will be returned, which is generated + by ParmEd's tiMerge as follows: + + R0 - R1 - P0_A - R2 - P1_A - R3 - P0_B - P1_B + + Where A and B denote the dummyless lambda=0 and lambda=1 states. + + Parameters + ---------- + + molecule : BioSimSpace._SireWrappers.Molecule + The input molecule. + + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + + Returns + ------- + + system : BioSimSpace._SireWrappers.System + The output squashed system. + """ + if not molecule.isPerturbable(): + return molecule + + if explicit_dummies: + # Get the common core atoms + atom_mapping0_common = _squashed_atom_mapping( + molecule, + is_lambda1=False, + explicit_dummies=explicit_dummies, + environment=False, + dummies=False, + ) + atom_mapping1_common = _squashed_atom_mapping( + molecule, + is_lambda1=True, + explicit_dummies=explicit_dummies, + environment=False, + dummies=False, + ) + if set(atom_mapping0_common) != set(atom_mapping1_common): + raise RuntimeError("The MCS atoms don't match between the two endstates") + common_atoms = set(atom_mapping0_common) + + # We make sure we use the same coordinates for the common core at both endstates. + c = molecule.copy()._sire_object.cursor() + for i, atom in enumerate(c.atoms()): + if i in common_atoms: + atom["coordinates1"] = atom["coordinates0"] + molecule = _Molecule(c.commit()) + + # Generate a "system" from the molecule at lambda = 0 and another copy at lambda = 1. + if explicit_dummies: + mol0 = molecule.copy()._toRegularMolecule( + is_lambda1=False, convert_amber_dummies=True, generate_intrascale=True + ) + mol1 = molecule.copy()._toRegularMolecule( + is_lambda1=True, convert_amber_dummies=True, generate_intrascale=True + ) + else: + mol0 = _removeDummies(molecule, False) + mol1 = _removeDummies(molecule, True) + system = (mol0 + mol1).toSystem() + + # We only need to call tiMerge for multi-residue molecules + if molecule.nResidues() == 1: + return system + + # Perform the multi-residue squashing with ParmEd as it is much easier and faster. + with tempfile.TemporaryDirectory() as tempdir: + # Load in ParmEd. + _saveMolecules(f"{tempdir}/temp", mol0 + mol1, "prm7,rst7") + _shutil.move(f"{tempdir}/temp.prm7", f"{tempdir}/temp.parm7") + parm = _pmd.load_file(f"{tempdir}/temp.parm7", xyz=f"{tempdir}/temp.rst7") + + # Determine the molecule masks. + mol_mask0 = f"@1-{mol0.nAtoms()}" + mol_mask1 = f"@{mol0.nAtoms() + 1}-{system.nAtoms()}" + + # Determine the residue masks. + atom0_offset, atom1_offset = 0, mol0.nAtoms() + res_atoms0, res_atoms1 = [], [] + for res0, res1, res01 in zip( + mol0.getResidues(), mol1.getResidues(), molecule.getResidues() + ): + if _is_perturbed(res01) or molecule.nResidues() == 1: + res_atoms0 += list(range(atom0_offset, atom0_offset + res0.nAtoms())) + res_atoms1 += list(range(atom1_offset, atom1_offset + res1.nAtoms())) + atom0_offset += res0.nAtoms() + atom1_offset += res1.nAtoms() + res_mask0 = _amber_mask_from_indices(res_atoms0) + res_mask1 = _amber_mask_from_indices(res_atoms1) + + # Merge the residues. + action = _pmd.tools.tiMerge(parm, mol_mask0, mol_mask1, res_mask0, res_mask1) + action.output = open(_os.devnull, "w") # Avoid some of the spam + action.execute() + + # Reload into BioSimSpace. + # TODO: prm7/rst7 doesn't work for some reason so we need to use gro/top + parm.save(f"{tempdir}/squashed.gro", overwrite=True) + parm.save(f"{tempdir}/squashed.top", overwrite=True) + squashed_mol = _readMolecules( + [f"{tempdir}/squashed.gro", f"{tempdir}/squashed.top"] + ) + + return squashed_mol + + +def _unsquash(system, squashed_system, mapping, **kwargs): + """ + Internal function which converts an alchemical AMBER system where the + perturbed molecules are defined sequentially and updates the coordinates + and velocities of an input unsquashed system. Refer to the _squash() + function documentation to see the structure of the squashed system + relative to the unsquashed one. + + Parameters + ---------- + + system : BioSimSpace._SireWrappers.System + The regular unsquashed system. + + squashed_system : BioSimSpace._SireWrappers.System + The corresponding squashed system. + + mapping : dict(sire.legacy.Mol.MolIdx, sire.legacy.Mol.MolIdx) + The molecule-molecule mapping generated by _squash(). + + kwargs : dict + A dictionary of optional keyword arguments to supply to _unsquash_molecule(). + + Returns + ------- + system : BioSimSpace._SireWrappers.System + The output unsquashed system. + """ + # Create a copy of the original new_system. + new_system = system.copy() + + # Update the unperturbed molecule coordinates in the original new_system + # using the mapping. + if mapping: + new_system._sire_object, _ = _SireIO.updateCoordinatesAndVelocities( + new_system._sire_object, squashed_system._sire_object, mapping + ) + + # From now on we handle all perturbed molecules. + pertmol_idxs = [ + i + for i, molecule in enumerate(new_system.getMolecules()) + if molecule.isPerturbable() + ] + + # Get the molecule mapping and combine it with the lambda=0 molecule + # being prioritised + molecule_mapping0 = _squashed_molecule_mapping(new_system, is_lambda1=False) + molecule_mapping1 = _squashed_molecule_mapping(new_system, is_lambda1=True) + molecule_mapping0_rev = {v: k for k, v in molecule_mapping0.items()} + molecule_mapping1_rev = {v: k for k, v in molecule_mapping1.items()} + molecule_mapping_rev = {**molecule_mapping1_rev, **molecule_mapping0_rev} + molecule_mapping_rev = { + k: v for k, v in molecule_mapping_rev.items() if v in pertmol_idxs + } + + # Update the perturbed molecule coordinates based on the molecule mapping + for merged_idx in set(molecule_mapping_rev.values()): + pertmol = new_system[merged_idx] + squashed_idx0 = molecule_mapping0[merged_idx] + squashed_idx1 = molecule_mapping1[merged_idx] + + if squashed_idx0 == squashed_idx1: + squashed_molecules = squashed_system[squashed_idx0].toSystem() + else: + squashed_molecules = ( + squashed_system[squashed_idx0] + squashed_system[squashed_idx1] + ).toSystem() + + new_pertmol = _unsquash_molecule(pertmol, squashed_molecules, **kwargs) + new_system.updateMolecule(merged_idx, new_pertmol) + + return new_system + + +def _unsquash_molecule(molecule, squashed_molecules, explicit_dummies=False): + """ + This internal function loads the coordinates and velocities of squashed + molecules as defined by the _squash_molecule() function into an unsquashed + merged molecule. + + Parameters + ---------- + + molecule : BioSimSpace._SireWrappers.Molecule + The unsquashed merged molecule whose coordinates and velocities are to be updated. + + squashed_molecules : BioSimSpace._SireWrappers.Molecules + The corresponding squashed molecule(s) whose coordinates are to be used for updating. + + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + + Returns + ------- + + molecule : BioSimSpace._SireWrappers.Molecule + The output updated merged molecule. + """ + # Get the common core atoms + atom_mapping0_common = _squashed_atom_mapping( + molecule, + is_lambda1=False, + explicit_dummies=explicit_dummies, + environment=False, + dummies=False, + ) + atom_mapping1_common = _squashed_atom_mapping( + molecule, + is_lambda1=True, + explicit_dummies=explicit_dummies, + environment=False, + dummies=False, + ) + if set(atom_mapping0_common) != set(atom_mapping1_common): + raise RuntimeError("The MCS atoms don't match between the two endstates") + common_atoms = set(atom_mapping0_common) + + # Get the atom mapping from both endstates + atom_mapping0 = _squashed_atom_mapping( + molecule, is_lambda1=False, explicit_dummies=explicit_dummies + ) + atom_mapping1 = _squashed_atom_mapping( + molecule, is_lambda1=True, explicit_dummies=explicit_dummies + ) + update_velocity = squashed_molecules[0]._sire_object.hasProperty("velocity") + + # Even though the common core of the two molecules should have the same coordinates, + # they might be PBC wrapped differently. + # Here we take the first common core atom and translate the second molecule. + if len(squashed_molecules) == 2: + first_common_atom = list(sorted(common_atoms))[0] + pertatom0 = squashed_molecules.getAtom(atom_mapping0[first_common_atom]) + pertatom1 = squashed_molecules.getAtom(atom_mapping1[first_common_atom]) + pertatom_coords0 = pertatom0._sire_object.property("coordinates") + pertatom_coords1 = pertatom1._sire_object.property("coordinates") + translation_vec = pertatom_coords1 - pertatom_coords0 + + # Update the coordinates and velocities. + siremol = molecule.copy()._sire_object.edit() + for merged_atom_idx in range(molecule.nAtoms()): + # Get the relevant atom indices + merged_atom = siremol.atom(_SireMol.AtomIdx(merged_atom_idx)) + if merged_atom_idx in atom_mapping0: + squashed_atom_idx0 = atom_mapping0[merged_atom_idx] + else: + squashed_atom_idx0 = atom_mapping1[merged_atom_idx] + if merged_atom_idx in atom_mapping1: + squashed_atom_idx1 = atom_mapping1[merged_atom_idx] + apply_translation_vec = True + else: + squashed_atom_idx1 = atom_mapping0[merged_atom_idx] + apply_translation_vec = False + + # Get the coordinates. + squashed_atom0 = squashed_molecules.getAtom(squashed_atom_idx0) + squashed_atom1 = squashed_molecules.getAtom(squashed_atom_idx1) + coordinates0 = squashed_atom0._sire_object.property("coordinates") + coordinates1 = squashed_atom1._sire_object.property("coordinates") + + # Apply the translation if the atom is coming from the second molecule. + if len(squashed_molecules) == 2 and apply_translation_vec: + # This is a dummy atom so we need to translate coordinates0 as well + if squashed_atom_idx0 == squashed_atom_idx1: + coordinates0 -= translation_vec + coordinates1 -= translation_vec + + siremol = merged_atom.setProperty("coordinates0", coordinates0).molecule() + siremol = merged_atom.setProperty("coordinates1", coordinates1).molecule() + + # Update the velocities. + if update_velocity: + velocities0 = squashed_atom0._sire_object.property("velocity") + velocities1 = squashed_atom1._sire_object.property("velocity") + siremol = merged_atom.setProperty("velocity0", velocities0).molecule() + siremol = merged_atom.setProperty("velocity1", velocities1).molecule() + + return _Molecule(siremol.commit()) + + +def _squashed_molecule_mapping(system, is_lambda1=False): + """ + This internal function returns a dictionary whose keys correspond to the + molecule index of the each molecule in the original merged system, and + whose values contain the corresponding index of the same molecule at the + specified endstate in the squashed system. + + Parameters + ---------- + + system : BioSimSpace._SireWrappers.System + The input merged system. + + is_lambda1 : bool + Whether to use the lambda=1 endstate. + + Returns + ------- + + mapping : dict(int, int) + The corresponding molecule mapping. + """ + # Get the perturbable molecules and their corresponding indices. + pertmol_idxs = [i for i, molecule in enumerate(system) if molecule.isPerturbable()] + + # Add them back at the end of the system. This is generally faster than keeping their order the same. + new_indices = list(range(system.nMolecules())) + for pertmol_idx in pertmol_idxs: + new_indices.remove(pertmol_idx) + + # Multi-residue molecules are squashed to one molecule with extra residues. + if system[pertmol_idx].nResidues() > 1: + new_indices.append(pertmol_idx) + # Since we have two squashed molecules, we pick the first one at lambda=0 and the second one at lambda = 1. + elif not is_lambda1: + new_indices.extend([pertmol_idx, None]) + else: + new_indices.extend([None, pertmol_idx]) + + # Create the old molecule index to new molecule index mapping. + mapping = {idx: i for i, idx in enumerate(new_indices) if idx is not None} + + return mapping + + +def _squashed_atom_mapping(system, is_lambda1=False, environment=True, **kwargs): + """ + This internal function returns a dictionary whose keys correspond to the atom + index of the each atom in the original merged system, and whose values + contain the corresponding index of the same atom at the specified endstate + in the squashed system. + + Parameters + ---------- + + system : BioSimSpace._SireWrappers.System + The input merged system. + + is_lambda1 : bool + Whether to use the lambda=1 endstate. + + environment : bool + Whether to include all environment atoms (i.e. ones that are not perturbed). + + kwargs : + Keyword arguments to pass to _squashed_atom_mapping_molecule(). + + Returns + ------- + + mapping : dict(int, int) + The corresponding atom mapping. + """ + if isinstance(system, _Molecule): + return _squashed_atom_mapping( + system.toSystem(), is_lambda1=is_lambda1, environment=environment, **kwargs + ) + + # Both mappings start from 0 and we add all offsets at the end. + atom_mapping = {} + atom_idx, squashed_atom_idx, squashed_atom_idx_perturbed = 0, 0, 0 + squashed_offset = sum(x.nAtoms() for x in system if not x.isPerturbable()) + for molecule in system: + if molecule.isPerturbable(): + residue_atom_mapping, n_squashed_atoms = _squashed_atom_mapping_molecule( + molecule, + offset_merged=atom_idx, + offset_squashed=squashed_offset + squashed_atom_idx_perturbed, + is_lambda1=is_lambda1, + environment=environment, + **kwargs, + ) + atom_mapping.update(residue_atom_mapping) + atom_idx += molecule.nAtoms() + squashed_atom_idx_perturbed += n_squashed_atoms + else: + atom_indices = _np.arange(atom_idx, atom_idx + molecule.nAtoms()) + squashed_atom_indices = _np.arange( + squashed_atom_idx, squashed_atom_idx + molecule.nAtoms() + ) + if environment: + atom_mapping.update(dict(zip(atom_indices, squashed_atom_indices))) + atom_idx += molecule.nAtoms() + squashed_atom_idx += molecule.nAtoms() + + # Convert from NumPy integers to Python integers. + return {int(k): int(v) for k, v in atom_mapping.items()} + + +def _squashed_atom_mapping_molecule( + molecule, + offset_merged=0, + offset_squashed=0, + is_lambda1=False, + environment=True, + common=True, + dummies=True, + explicit_dummies=False, +): + """ + This internal function returns a dictionary whose keys correspond to the atom + index of the each atom in the original merged molecule, and whose values + contain the corresponding index of the same atom at the specified endstate + in the squashed molecule at a particular offset. + + Parameters + ---------- + + molecule : BioSimSpace._SireWrappers.Molecule + The input merged molecule. + + offset_merged : int + The index at which to start the merged atom numbering. + + offset_squashed : int + The index at which to start the squashed atom numbering. + + is_lambda1 : bool + Whether to use the lambda=1 endstate. + + environment : bool + Whether to include all environment atoms (i.e. ones that are not perturbed). + + common : bool + Whether to include all common atoms (i.e. ones that are perturbed but are + not dummies in the endstate of interest). + + dummies : bool + Whether to include all dummy atoms (i.e. ones that are perturbed and are + dummies in the endstate of interest). + + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + + Returns + ------- + + mapping : dict(int, int) + The corresponding atom mapping. + + n_atoms : int + The number of squashed atoms that correspond to the squashed molecule. + """ + if not molecule.isPerturbable(): + if environment: + return { + offset_merged + i: offset_squashed + i for i in range(molecule.nAtoms()) + }, molecule.nAtoms() + else: + return {}, molecule.nAtoms() + + # Both mappings start from 0 and we add all offsets at the end. + mapping, mapping_lambda1 = {}, {} + atom_idx_merged, atom_idx_squashed, atom_idx_squashed_lambda1 = 0, 0, 0 + for residue in molecule.getResidues(): + if not (_is_perturbed(residue) or molecule.nResidues() == 1): + # The residue is not perturbed. + if common: + mapping.update( + { + atom_idx_merged + i: atom_idx_squashed + i + for i in range(residue.nAtoms()) + } + ) + atom_idx_merged += residue.nAtoms() + atom_idx_squashed += residue.nAtoms() + else: + # The residue is perturbed. + + # Determine the dummy and the non-dummy atoms. + types0 = [ + atom._sire_object.property("ambertype0") for atom in residue.getAtoms() + ] + types1 = [ + atom._sire_object.property("ambertype1") for atom in residue.getAtoms() + ] + + if explicit_dummies: + # If both endstates are dummies then we treat them as common core atoms + dummy0 = dummy1 = _np.asarray( + [("du" in x) or ("du" in y) for x, y in zip(types0, types1)] + ) + common0 = common1 = ~dummy0 + in_mol0 = in_mol1 = _np.asarray([True] * residue.nAtoms()) + else: + in_mol0 = _np.asarray(["du" not in x for x in types0]) + in_mol1 = _np.asarray(["du" not in x for x in types1]) + dummy0 = ~in_mol1 + dummy1 = ~in_mol0 + common0 = _np.logical_and(in_mol0, ~dummy0) + common1 = _np.logical_and(in_mol1, ~dummy1) + + ndummy0 = residue.nAtoms() - sum(in_mol1) + ndummy1 = residue.nAtoms() - sum(in_mol0) + ncommon = residue.nAtoms() - ndummy0 - ndummy1 + natoms0 = ncommon + ndummy0 + natoms1 = ncommon + ndummy1 + + # Determine the full mapping indices for the merged and squashed systems. + if not is_lambda1: + atom_indices = _np.arange( + atom_idx_merged, atom_idx_merged + residue.nAtoms() + )[in_mol0] + squashed_atom_indices = _np.arange( + atom_idx_squashed, atom_idx_squashed + natoms0 + ) + mapping_to_update = mapping + else: + atom_indices = _np.arange( + atom_idx_merged, atom_idx_merged + residue.nAtoms() + )[in_mol1] + squashed_atom_indices = _np.arange( + atom_idx_squashed_lambda1, atom_idx_squashed_lambda1 + natoms1 + ) + mapping_to_update = mapping_lambda1 + + # Determine which atoms to return. + in_mol_mask = in_mol1 if is_lambda1 else in_mol0 + common_mask = common1 if is_lambda1 else common0 + dummy_mask = dummy1 if is_lambda1 else dummy0 + update_mask = _np.asarray([False] * atom_indices.size) + + if common: + update_mask = _np.logical_or(update_mask, common_mask[in_mol_mask]) + if dummies: + update_mask = _np.logical_or(update_mask, dummy_mask[in_mol_mask]) + + # Finally update the relevant mapping + mapping_to_update.update( + dict(zip(atom_indices[update_mask], squashed_atom_indices[update_mask])) + ) + + # Increment the offsets and continue. + atom_idx_merged += residue.nAtoms() + atom_idx_squashed += natoms0 + atom_idx_squashed_lambda1 += natoms1 + + # Finally add the appropriate offsets + if explicit_dummies: + all_ndummy1 = 0 + else: + all_ndummy1 = sum( + "du" in x for x in molecule._sire_object.property("ambertype0").toVector() + ) + + offset_squashed_lambda1 = molecule.nAtoms() - all_ndummy1 + res = { + **{offset_merged + k: offset_squashed + v for k, v in mapping.items()}, + **{ + offset_merged + k: offset_squashed + offset_squashed_lambda1 + v + for k, v in mapping_lambda1.items() + }, + } + + return res, atom_idx_squashed + atom_idx_squashed_lambda1 + + +def _is_perturbed(residue): + """ + This determines whether a merged residue is actually perturbed. Note that + it is possible that this function returns false negatives. + + Parameters + ---------- + + residue : BioSimSpace._SireWrappers.Residue + The input residue. + + Returns + ------- + + res : bool + Whether the residue is perturbed. + """ + # If the elements are different, then we are definitely perturbing. + elem0 = [atom._sire_object.property("element0") for atom in residue.getAtoms()] + elem1 = [atom._sire_object.property("element1") for atom in residue.getAtoms()] + return elem0 != elem1 + + +def _amber_mask_from_indices(atom_idxs): + """ + Internal helper function to create an AMBER mask from a list of atom indices. + + Parameters + ---------- + + atom_idxs : [int] + A list of atom indices. + + Returns + ------- + + mask : str + The AMBER mask. + """ + # AMBER has a restriction on the number of characters in the restraint + # mask (not documented) so we can't just use comma-separated atom + # indices. Instead we loop through the indices and use hyphens to + # separate contiguous blocks of indices, e.g. 1-23,34-47,... + + if atom_idxs: + # AMBER masks are 1-indexed, while BioSimSpace indices are 0-indexed. + atom_idxs = [x + 1 for x in sorted(list(set(atom_idxs)))] + if not all(isinstance(x, int) for x in atom_idxs): + raise TypeError("'atom_idxs' must be a list of 'int' types.") + groups = [] + initial_idx = atom_idxs[0] + for prev_idx, curr_idx in _it.zip_longest(atom_idxs, atom_idxs[1:]): + if curr_idx != prev_idx + 1 or curr_idx is None: + if initial_idx == prev_idx: + groups += [str(initial_idx)] + else: + groups += [f"{initial_idx}-{prev_idx}"] + initial_idx = curr_idx + mask = "@" + ",".join(groups) + else: + mask = "" + + return mask diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index 8db00c5a8..ee1af9e7b 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -123,7 +123,7 @@ class Relative: """Class for configuring and running relative free-energy perturbation simulations.""" # Create a list of supported molecular dynamics engines. (For running simulations.) - _engines = ["GROMACS", "SOMD"] + _engines = ["AMBER", "GROMACS", "SOMD"] # Create a list of supported molecular dynamics engines. (For analysis.) _engines_analysis = ["AMBER", "GROMACS", "SOMD", "SOMD2"] @@ -140,6 +140,7 @@ def __init__( extra_options={}, extra_lines=[], property_map={}, + **kwargs, ): """ Constructor. @@ -157,6 +158,9 @@ def __init__( :class:`Protocol.FreeEnergyProduction ` The simulation protocol. + reference_system : :class:`System ` + A reference system to use for position restraints. + work_dir : str The working directory for the free-energy perturbation simulation. @@ -194,6 +198,10 @@ def __init__( A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their own naming scheme, e.g. { "charge" : "my-charge" } + + kwargs : dict + Additional keyword arguments to pass to the underlying Process + objects. """ # Validate the input. @@ -203,7 +211,7 @@ def __init__( "'system' must be of type 'BioSimSpace._SireWrappers.System'" ) else: - # Store a copy of solvated system. + # Store a copy of the system. self._system = system.copy() # Validate the user specified molecular dynamics engine. @@ -221,19 +229,12 @@ def __init__( "Supported engines are: %r." % ", ".join(self._engines) ) - # Make sure GROMACS is installed if GROMACS engine is selected. - if engine == "GROMACS": - if _gmx_exe is None: - raise _MissingSoftwareError( - "Cannot use GROMACS engine as GROMACS is not installed!" - ) - - # The system must have a perturbable molecule. - if system.nPerturbableMolecules() == 0: - raise ValueError( - "The system must contain a perturbable molecule! " - "Use the 'BioSimSpace.Align' package to map and merge molecules." - ) + # The system must have a perturbable molecule. + if system.nPerturbableMolecules() == 0: + raise ValueError( + "The system must contain a perturbable molecule! " + "Use the 'BioSimSpace.Align' package to map and merge molecules." + ) else: # Use SOMD as a default. @@ -322,6 +323,11 @@ def __init__( raise TypeError("'property_map' must be of type 'dict'") self._property_map = property_map + # Validate the kwargs. + if not isinstance(kwargs, dict): + raise TypeError("'kwargs' must be of type 'dict'.") + self._kwargs = kwargs + # Create fake instance methods for 'analyse', 'checkOverlap', # and 'difference'. These pass instance data through to the # staticmethod versions. @@ -2004,7 +2010,7 @@ def _initialise_runner(self, system): processes = [] # Convert to an appropriate water topology. - if self._engine == "SOMD": + if self._engine in ["AMBER", "SOMD"]: system._set_water_topology("AMBER", property_map=self._property_map) elif self._engine == "GROMACS": system._set_water_topology("GROMACS", property_map=self._property_map) @@ -2042,6 +2048,7 @@ def _initialise_runner(self, system): extra_options=self._extra_options, extra_lines=self._extra_lines, property_map=self._property_map, + **self._kwargs, ) if self._setup_only: del first_process @@ -2059,10 +2066,24 @@ def _initialise_runner(self, system): extra_options=self._extra_options, extra_lines=self._extra_lines, property_map=self._property_map, + **self._kwargs, ) if not self._setup_only: processes.append(first_process) + # AMBER. + elif self._engine == "AMBER": + first_process = _Process.Amber( + system, + self._protocol, + exe=self._exe, + work_dir=first_dir, + extra_options=self._extra_options, + extra_lines=self._extra_lines, + property_map=self._property_map, + **self._kwargs, + ) + # Loop over the rest of the lambda values. for x, lam in enumerate(lam_vals[1:]): # Name the directory. @@ -2164,6 +2185,7 @@ def _initialise_runner(self, system): process._std_err_file = new_dir + "/gromacs.err" process._gro_file = new_dir + "/gromacs.gro" process._top_file = new_dir + "/gromacs.top" + process._ref_file = new_dir + "/gromacs_ref.gro" process._traj_file = new_dir + "/gromacs.trr" process._config_file = new_dir + "/gromacs.mdp" process._tpr_file = new_dir + "/gromacs.tpr" @@ -2175,6 +2197,41 @@ def _initialise_runner(self, system): ] processes.append(process) + # AMBER. + elif self._engine == "AMBER": + new_config = [] + with open(new_dir + "/amber.cfg", "r") as f: + for line in f: + if "clambda" in line: + new_config.append(" clambda=%s,\n" % lam) + else: + new_config.append(line) + with open(new_dir + "/amber.cfg", "w") as f: + for line in new_config: + f.write(line) + + # Create a copy of the process and update the working + # directory. + if not self._setup_only: + process = _copy.copy(first_process) + process._system = first_process._system.copy() + process._protocol = self._protocol + process._work_dir = new_dir + process._std_out_file = new_dir + "/amber.out" + process._std_err_file = new_dir + "/amber.err" + process._rst_file = new_dir + "/amber.rst7" + process._top_file = new_dir + "/amber.prm7" + process._ref_file = new_dir + "/amber_ref.rst7" + process._traj_file = new_dir + "/amber.nc" + process._config_file = new_dir + "/amber.cfg" + process._nrg_file = new_dir + "/amber.nrg" + process._input_files = [ + process._config_file, + process._rst_file, + process._top_file, + ] + processes.append(process) + if not self._setup_only: # Initialise the process runner. All processes have already been nested # inside the working directory so no need to re-nest. diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index c5840d7f5..fb25ecc73 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -43,6 +43,7 @@ from sire.legacy import Mol as _SireMol from .. import _amber_home, _isVerbose +from ..Align._squash import _squash, _unsquash from .._Config import Amber as _AmberConfig from .._Exceptions import IncompatibleError as _IncompatibleError from .._Exceptions import MissingSoftwareError as _MissingSoftwareError @@ -70,6 +71,7 @@ def __init__( system, protocol, reference_system=None, + explicit_dummies=False, exe=None, name="amber", work_dir=None, @@ -95,6 +97,9 @@ def __init__( restraints. It is assumed that this system has the same topology as "system". If this is None, then "system" is used as a reference. + explicit_dummies : bool + Whether to keep dummy atoms explicit at alchemical end states, or remove them. + exe : str The full path to the AMBER executable. @@ -133,12 +138,6 @@ def __init__( property_map=property_map, ) - # Catch unsupported protocols. - if isinstance(protocol, _FreeEnergyMixin): - raise _IncompatibleError( - "Unsupported protocol: '%s'" % self._protocol.__class__.__name__ - ) - # Set the package name. self._package_name = "AMBER" @@ -168,9 +167,37 @@ def __init__( else: raise IOError("AMBER executable doesn't exist: '%s'" % exe) + if not isinstance(explicit_dummies, bool): + raise TypeError("'explicit_dummies' must be of type 'bool'") + self._explicit_dummies = explicit_dummies + # Initialise the energy dictionary and header. self._stdout_dict = _process._MultiDict() + # Initialise dictionaries to hold stdout records for all possible + # regions. For regular simulations there will be one, for free-energy + # simulations there can be up to four, i.e. one for each of the TI regions + # and one for the soft-core part of the system in each region, if present. + # The order of the dictionaries is: + # - TI region 1 + # - TI region 1 (soft-core part) + # - TI region 2 + # - TI region 2 (soft-core part) + self._stdout_dict = [ + _process._MultiDict(), + _process._MultiDict(), + _process._MultiDict(), + _process._MultiDict(), + ] + + # Initialise mappings between "universal" stdout keys, and the actual + # record key used for the different regions (and soft-core parts) from + # in the AMBER output. Ordering is the same as for the stdout_dicts above. + self._stdout_key = [{}, {}, {}, {}] + + # Flag for the current record region in the AMBER output file. + self._current_region = 0 + # Initialise log file parsing flags. self._has_results = False self._finished_results = False @@ -208,8 +235,15 @@ def _setup(self): # Convert the water model topology so that it matches the AMBER naming convention. system._set_water_topology("AMBER", property_map=self._property_map) - # Check for perturbable molecules and convert to the chosen end state. - system = self._checkPerturbable(system) + # Create the squashed system. + if isinstance(self._protocol, _FreeEnergyMixin): + system, self._mapping = _squash( + system, explicit_dummies=self._explicit_dummies + ) + self._squashed_system = system + else: + # Check for perturbable molecules and convert to the chosen end state. + system = self._checkPerturbable(system) # RST file (coordinates). try: @@ -312,7 +346,10 @@ def _generate_config(self): # Create the configuration. self.setConfig( amber_config.createConfig( - is_pmemd=is_pmemd, extra_options=extra_options, extra_lines=extra_lines + is_pmemd=is_pmemd, + explicit_dummies=self._explicit_dummies, + extra_options=extra_options, + extra_lines=extra_lines, ) ) @@ -340,7 +377,7 @@ def _generate_args(self): # Append a reference file if a position restraint is specified. if isinstance(self._protocol, _PositionRestraintMixin): if self._protocol.getRestraint() is not None: - self.setArg("-ref", self._ref_file) + self.setArg("-ref", "%s_ref.rst7" % self._name) # Append a trajectory file if this anything other than a minimisation. if not isinstance(self._protocol, _Protocol.Minimisation): @@ -447,23 +484,51 @@ def getSystem(self, block="AUTO"): # Create a copy of the existing system object. old_system = self._system.copy() - # Update the coordinates and velocities and return a mapping between - # the molecule indices in the two systems. - sire_system, mapping = _SireIO.updateCoordinatesAndVelocities( - old_system._sire_object, - new_system._sire_object, - self._mapping, - is_lambda1, - self._property_map, - self._property_map, - ) + if isinstance(self._protocol, _FreeEnergyMixin): + # Udpate the coordinates and velocities and return a mapping between + # the molecule indices in the two systems. + mapping = { + _SireMol.MolIdx(x): _SireMol.MolIdx(x) + for x in range(0, self._squashed_system.nMolecules()) + } + ( + self._squashed_system._sire_object, + _, + ) = _SireIO.updateCoordinatesAndVelocities( + self._squashed_system._sire_object, + new_system._sire_object, + mapping, + is_lambda1, + self._property_map, + self._property_map, + ) - # Update the underlying Sire object. - old_system._sire_object = sire_system + # Update the unsquashed system based on the updated squashed system. + old_system = _unsquash( + old_system, + self._squashed_system, + self._mapping, + explicit_dummies=self._explicit_dummies, + ) - # Store the mapping between the MolIdx in both systems so we don't - # need to recompute it next time. - self._mapping = mapping + else: + # Update the coordinates and velocities and return a mapping between + # the molecule indices in the two systems. + sire_system, mapping = _SireIO.updateCoordinatesAndVelocities( + old_system._sire_object, + new_system._sire_object, + self._mapping, + is_lambda1, + self._property_map, + self._property_map, + ) + + # Update the underlying Sire object. + old_system._sire_object = sire_system + + # Store the mapping between the MolIdx in both systems so we don't + # need to recompute it next time. + self._mapping = mapping # Update the box information in the original system. if "space" in new_system._sire_object.propertyKeys(): @@ -605,7 +670,65 @@ def getFrame(self, index): except: return None - def getRecord(self, key, time_series=False, unit=None, block="AUTO"): + def getRecordKey(self, record, region=0, soft_core=False): + """ + Parameters + ---------- + + record : str + The record used in the AMBER standard output, e.g. 'TEMP(K)'. + Please consult the current AMBER manual for details: + https://ambermd.org/Manuals.php + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + + Returns + ------- + + key : str + The universal record key that can be used with getRecord. + """ + + # Validate the record string. + if not isinstance(record, str): + raise TypeError("'record' must be of type 'str'") + + # Validate the region. + if not isinstance(region, int): + raise TypeError("'region' must be of type 'int'") + else: + if region < 0 or region > 1: + raise ValueError("'region' must be in range [0, 1]") + + # Validate the soft-core flag. + if not isinstance(soft_core, bool): + raise TypeError("'soft_core' must be of type 'bool'.") + + # Convert to the full index. + idx = 2 * region + int(soft_core) + + # Strip whitespace from the beginning and end of the record and convert + # to upper case. + cleaned_record = record.strip().upper() + + # Make sure the record exists in the key mapping. + if not cleaned_record in self._stdout_key[idx].values(): + raise ValueError(f"No key found for record '{record}'") + + return list(self._stdout_key[idx].keys())[ + list(self._stdout_key[idx].values()).index(cleaned_record) + ] + + def getRecord( + self, key, time_series=False, unit=None, region=0, soft_core=False, block="AUTO" + ): """ Get a record from the stdout dictionary. @@ -613,7 +736,10 @@ def getRecord(self, key, time_series=False, unit=None, block="AUTO"): ---------- key : str - The record key. + A universal record key based on the key used in the AMBER standard + output. Use 'getRecordKey(record)` to generate the key. The records + are those used in the AMBER standard output, e.g. 'TEMP(K)'. Please + consult the current AMBER manual for details: https://ambermd.org/Manuals.php time_series : bool Whether to return a list of time series records. @@ -621,6 +747,15 @@ def getRecord(self, key, time_series=False, unit=None, block="AUTO"): unit : :class:`Unit ` The unit to convert the record to. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -642,10 +777,16 @@ def getRecord(self, key, time_series=False, unit=None, block="AUTO"): _warnings.warn("The process exited with an error!") return self._get_stdout_record( - key.strip().upper(), time_series=time_series, unit=unit + key.strip().upper(), + time_series=time_series, + unit=unit, + region=region, + soft_core=soft_core, ) - def getCurrentRecord(self, key, time_series=False, unit=None): + def getCurrentRecord( + self, key, time_series=False, unit=None, region=0, soft_core=False + ): """ Get a current record from the stdout dictionary. @@ -653,7 +794,10 @@ def getCurrentRecord(self, key, time_series=False, unit=None): ---------- key : str - The record key. + A universal record key based on the key used in the AMBER standard + output. Use 'getRecordKey(record)` to generate the key. The records + are those used in the AMBER standard output, e.g. 'TEMP(K)'. Please + consult the current AMBER manual for details: https://ambermd.org/Manuals.php time_series : bool Whether to return a list of time series records. @@ -661,6 +805,15 @@ def getCurrentRecord(self, key, time_series=False, unit=None): unit : :class:`Unit ` The unit to convert the record to. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- @@ -673,16 +826,29 @@ def getCurrentRecord(self, key, time_series=False, unit=None): _warnings.warn("The process exited with an error!") return self._get_stdout_record( - key.strip().upper(), time_series=time_series, unit=unit + key.strip().upper(), + time_series=time_series, + unit=unit, + region=region, + soft_core=soft_core, ) - def getRecords(self, block="AUTO"): + def getRecords(self, region=0, soft_core=False, block="AUTO"): """ Return the dictionary of stdout time-series records. Parameters ---------- + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -693,6 +859,20 @@ def getRecords(self, block="AUTO"): The dictionary of time-series records. """ + # Validate the region. + if not isinstance(region, int): + raise TypeError("'region' must be of type 'int'") + else: + if region < 0 or region > 1: + raise ValueError("'region' must be in range [0, 1]") + + # Validate the soft-core flag. + if not isinstance(soft_core, bool): + raise TypeError("'soft_core' must be of type 'bool'.") + + # Convert to the full index, region + soft_core. + idx = 2 * region + int(soft_core) + # Wait for the process to finish. if block is True: self.wait() @@ -704,21 +884,34 @@ def getRecords(self, block="AUTO"): _warnings.warn("The process exited with an error!") self.stdout(0) - return self._stdout_dict.copy() - def getCurrentRecords(self): + return self._stdout_dict[idx].copy() + + def getCurrentRecords(self, region=0, soft_core=False): """ Return the current dictionary of stdout time-series records. + Parameters + ---------- + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- records : :class:`MultiDict ` The dictionary of time-series records. """ - return self.getRecords(block=False) + return self.getRecords(region=region, soft_core=soft_core, block=False) - def getTime(self, time_series=False, block="AUTO"): + def getTime(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the simulation time. @@ -728,6 +921,15 @@ def getTime(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -743,7 +945,14 @@ def getTime(self, time_series=False, block="AUTO"): return None # Get the list of time steps. - time_steps = self.getRecord("TIME(PS)", time_series=time_series, block=block) + time_steps = self.getRecord( + "TIME(PS)", + time_series=time_series, + unit=None, + region=region, + soft_core=soft_core, + block=block, + ) # Convert from picoseconds to nanoseconds. if time_steps is not None: @@ -754,7 +963,7 @@ def getTime(self, time_series=False, block="AUTO"): else: return (time_steps * _Units.Time.picosecond)._to_default_unit() - def getCurrentTime(self, time_series=False): + def getCurrentTime(self, time_series=False, region=0, soft_core=False): """ Get the current simulation time. @@ -764,15 +973,26 @@ def getCurrentTime(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- time : :class:`Time ` The current simulation time in nanoseconds. """ - return self.getTime(time_series, block=False) + return self.getTime( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getStep(self, time_series=False, block="AUTO"): + def getStep(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the number of integration steps. @@ -782,6 +1002,15 @@ def getStep(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -791,9 +1020,16 @@ def getStep(self, time_series=False, block="AUTO"): step : int The current number of integration steps. """ - return self.getRecord("NSTEP", time_series=time_series, block=block) + return self.getRecord( + "NSTEP", + time_series=time_series, + unit=None, + region=region, + soft_core=soft_core, + block=block, + ) - def getCurrentStep(self, time_series=False): + def getCurrentStep(self, time_series=False, region=0, soft_core=False): """ Get the current number of integration steps. @@ -803,15 +1039,26 @@ def getCurrentStep(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- step : int The current number of integration steps. """ - return self.getStep(time_series, block=False) + return self.getStep( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getBondEnergy(self, time_series=False, block="AUTO"): + def getBondEnergy(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the bond energy. @@ -821,6 +1068,10 @@ def getBondEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -834,10 +1085,12 @@ def getBondEnergy(self, time_series=False, block="AUTO"): "BOND", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentBondEnergy(self, time_series=False): + def getCurrentBondEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current bond energy. @@ -847,15 +1100,28 @@ def getCurrentBondEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The bond energy. """ - return self.getBondEnergy(time_series, block=False) + return self.getBondEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getAngleEnergy(self, time_series=False, block="AUTO"): + def getAngleEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the angle energy. @@ -865,6 +1131,15 @@ def getAngleEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -878,10 +1153,12 @@ def getAngleEnergy(self, time_series=False, block="AUTO"): "ANGLE", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentAngleEnergy(self, time_series=False): + def getCurrentAngleEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current angle energy. @@ -891,15 +1168,28 @@ def getCurrentAngleEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The angle energy. """ - return self.getAngleEnergy(time_series, block=False) + return self.getAngleEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getDihedralEnergy(self, time_series=False, block="AUTO"): + def getDihedralEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the total dihedral energy (proper + improper). @@ -909,6 +1199,15 @@ def getDihedralEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -922,10 +1221,12 @@ def getDihedralEnergy(self, time_series=False, block="AUTO"): "DIHED", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentDihedralEnergy(self, time_series=False): + def getCurrentDihedralEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current total dihedral energy (proper + improper). @@ -935,15 +1236,28 @@ def getCurrentDihedralEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The total dihedral energy. """ - return self.getDihedralEnergy(time_series, block=False) + return self.getDihedralEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getElectrostaticEnergy(self, time_series=False, block="AUTO"): + def getElectrostaticEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the electrostatic energy. @@ -953,6 +1267,15 @@ def getElectrostaticEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -963,13 +1286,17 @@ def getElectrostaticEnergy(self, time_series=False, block="AUTO"): The electrostatic energy. """ return self.getRecord( - "EELEC", + "EEL", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentElectrostaticEnergy(self, time_series=False): + def getCurrentElectrostaticEnergy( + self, time_series=False, region=0, soft_core=False + ): """ Get the current dihedral energy. @@ -979,15 +1306,28 @@ def getCurrentElectrostaticEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The electrostatic energy. """ - return self.getElectrostaticEnergy(time_series, block=False) + return self.getElectrostaticEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getElectrostaticEnergy14(self, time_series=False, block="AUTO"): + def getElectrostaticEnergy14( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the electrostatic energy between atoms 1 and 4. @@ -997,6 +1337,15 @@ def getElectrostaticEnergy14(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1007,13 +1356,17 @@ def getElectrostaticEnergy14(self, time_series=False, block="AUTO"): The electrostatic energy between atoms 1 and 4. """ return self.getRecord( - "1-4 EEL", + "14EEL", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentElectrostaticEnergy14(self, time_series=False): + def getCurrentElectrostaticEnergy14( + self, time_series=False, region=0, soft_core=False + ): """ Get the current electrostatic energy between atoms 1 and 4. @@ -1023,15 +1376,28 @@ def getCurrentElectrostaticEnergy14(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The electrostatic energy between atoms 1 and 4. """ - return self.getElectrostaticEnergy14(time_series, block=False) + return self.getElectrostaticEnergy14( + time_series=time_series, region=region, soft_core=False, block=False + ) - def getVanDerWaalsEnergy(self, time_series=False, block="AUTO"): + def getVanDerWaalsEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the Van der Vaals energy. @@ -1041,6 +1407,15 @@ def getVanDerWaalsEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1051,13 +1426,15 @@ def getVanDerWaalsEnergy(self, time_series=False, block="AUTO"): The Van der Vaals energy. """ return self.getRecord( - "VDWAALS", + "VDW", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentVanDerWaalsEnergy(self, time_series=False): + def getCurrentVanDerWaalsEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current Van der Vaals energy. @@ -1067,15 +1444,98 @@ def getCurrentVanDerWaalsEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The Van der Vaals energy. """ - return self.getVanDerWaalsEnergy(time_series, block=False) + return self.getVanDerWaalsEnergy( + time_series=time_series, block=False, region=region, soft_core=soft_core + ) + + def getVanDerWaalsEnergy14( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): + """ + Get the Van der Vaals energy between atoms 1 and 4. + + Parameters + ---------- + + time_series : bool + Whether to return a list of time series records. + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + + block : bool + Whether to block until the process has finished running. + + Returns + ------- + + energy : :class:`Energy ` + The Van der Vaals energy between atoms 1 and 4. + """ + return self.getRecord( + "14VDW", + time_series=time_series, + unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, + block=block, + ) - def getHydrogenBondEnergy(self, time_series=False, block="AUTO"): + def getCurrentVanDerWaalsEnergy14( + self, time_series=False, region=0, soft_core=False + ): + """ + Get the current Van der Vaals energy between atoms 1 and 4. + + Parameters + ---------- + + time_series : bool + Whether to return a list of time series records. + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + + Returns + ------- + + energy : :class:`Energy ` + The Van der Vaals energy between atoms 1 and 4. + """ + return self.getVanDerWaalsEnergy( + time_series=time_series, block=False, region=region, soft_core=soft_core + ) + + def getHydrogenBondEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the hydrogen bond energy. @@ -1085,6 +1545,15 @@ def getHydrogenBondEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1098,10 +1567,14 @@ def getHydrogenBondEnergy(self, time_series=False, block="AUTO"): "EHBOND", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentHydrogenBondEnergy(self, time_series=False): + def getCurrentHydrogenBondEnergy( + self, time_series=False, region=0, soft_core=False + ): """ Get the current hydrogen bond energy. @@ -1111,15 +1584,28 @@ def getCurrentHydrogenBondEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The hydrogen bond energy. """ - return self.getHydrogenBondEnergy(time_series, block=False) + return self.getHydrogenBondEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getRestraintEnergy(self, time_series=False, block="AUTO"): + def getRestraintEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the restraint energy. @@ -1129,6 +1615,15 @@ def getRestraintEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1142,10 +1637,12 @@ def getRestraintEnergy(self, time_series=False, block="AUTO"): "RESTRAINT", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentRestraintEnergy(self, time_series=False): + def getCurrentRestraintEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current restraint energy. @@ -1155,6 +1652,15 @@ def getCurrentRestraintEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1164,9 +1670,13 @@ def getCurrentRestraintEnergy(self, time_series=False): energy : :class:`Energy ` The restraint energy. """ - return self.getRestraintEnergy(time_series, block=False) + return self.getRestraintEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getPotentialEnergy(self, time_series=False, block="AUTO"): + def getPotentialEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the potential energy. @@ -1176,6 +1686,15 @@ def getPotentialEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1189,10 +1708,12 @@ def getPotentialEnergy(self, time_series=False, block="AUTO"): "EPTOT", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentPotentialEnergy(self, time_series=False): + def getCurrentPotentialEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current potential energy. @@ -1202,15 +1723,28 @@ def getCurrentPotentialEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The potential energy. """ - return self.getPotentialEnergy(time_series, block=False) + return self.getPotentialEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getKineticEnergy(self, time_series=False, block="AUTO"): + def getKineticEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the kinetic energy. @@ -1220,6 +1754,15 @@ def getKineticEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1233,10 +1776,12 @@ def getKineticEnergy(self, time_series=False, block="AUTO"): "EKTOT", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentKineticEnergy(self, time_series=False): + def getCurrentKineticEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current kinetic energy. @@ -1246,15 +1791,28 @@ def getCurrentKineticEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The kinetic energy. """ - return self.getKineticEnergy(time_series, block=False) + return self.getKineticEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getNonBondedEnergy14(self, time_series=False, block="AUTO"): + def getNonBondedEnergy14( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the non-bonded energy between atoms 1 and 4. @@ -1264,6 +1822,15 @@ def getNonBondedEnergy14(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1274,13 +1841,15 @@ def getNonBondedEnergy14(self, time_series=False, block="AUTO"): The non-bonded energy between atoms 1 and 4. """ return self.getRecord( - "1-4 NB", + "14NB", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentNonBondedEnergy14(self, time_series=False): + def getCurrentNonBondedEnergy14(self, time_series=False, region=0, soft_core=False): """ Get the current non-bonded energy between atoms 1 and 4. @@ -1290,15 +1859,28 @@ def getCurrentNonBondedEnergy14(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The non-bonded energy between atoms 1 and 4. """ - return self.getNonBondedEnergy14(time_series, block=False) + return self.getNonBondedEnergy14( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getTotalEnergy(self, time_series=False, block="AUTO"): + def getTotalEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the total energy. @@ -1308,6 +1890,15 @@ def getTotalEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1317,11 +1908,27 @@ def getTotalEnergy(self, time_series=False, block="AUTO"): energy : :class:`Energy ` The total energy. """ - if isinstance(self._protocol, _Protocol.Minimisation): + + if not isinstance(region, int): + raise TypeError("'region' must be of type 'int'") + else: + if region < 0 or region > 1: + raise ValueError("'region' must be in range [0, 1]") + + # Validate the soft-core flag. + if not isinstance(soft_core, bool): + raise TypeError("'soft_core' must be of type 'bool'.") + + # Convert to the full index, region + soft_core. + idx = 2 * region + int(soft_core) + + if isinstance(self._protocol, _Protocol.Minimisation) and not soft_core: return self.getRecord( "ENERGY", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) else: @@ -1329,10 +1936,12 @@ def getTotalEnergy(self, time_series=False, block="AUTO"): "ETOT", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentTotalEnergy(self, time_series=False): + def getCurrentTotalEnergy(self, time_series=False, region=0, soft_core=False): """ Get the current total energy. @@ -1342,15 +1951,28 @@ def getCurrentTotalEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The total energy. """ - return self.getTotalEnergy(time_series, block=False) + return self.getTotalEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getCentreOfMassKineticEnergy(self, time_series=False, block="AUTO"): + def getCentreOfMassKineticEnergy( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the kinetic energy of the centre of mass in translation. @@ -1360,6 +1982,15 @@ def getCentreOfMassKineticEnergy(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1373,10 +2004,14 @@ def getCentreOfMassKineticEnergy(self, time_series=False, block="AUTO"): "EKCMT", time_series=time_series, unit=_Units.Energy.kcal_per_mol, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentCentreOfMassKineticEnergy(self, time_series=False): + def getCurrentCentreOfMassKineticEnergy( + self, time_series=False, region=0, soft_core=False + ): """ Get the current kinetic energy of the centre of mass in translation. @@ -1386,15 +2021,26 @@ def getCurrentCentreOfMassKineticEnergy(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- energy : :class:`Energy ` The centre of mass kinetic energy. """ - return self.getCentreOfMassKineticEnergy(time_series, block=False) + return self.getCentreOfMassKineticEnergy( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getVirial(self, time_series=False, block="AUTO"): + def getVirial(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the virial. @@ -1404,6 +2050,15 @@ def getVirial(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1413,9 +2068,15 @@ def getVirial(self, time_series=False, block="AUTO"): virial : float The virial. """ - return self.getRecord("VIRIAL", time_series=time_series, block=block) + return self.getRecord( + "VIRIAL", + time_series=time_series, + region=region, + soft_core=soft_core, + block=block, + ) - def getCurrentVirial(self, time_series=False): + def getCurrentVirial(self, time_series=False, region=0, soft_core=False): """ Get the current virial. @@ -1425,15 +2086,28 @@ def getCurrentVirial(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- virial : float The virial. """ - return self.getVirial(time_series, block=False) + return self.getVirial( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getTemperature(self, time_series=False, block="AUTO"): + def getTemperature( + self, time_series=False, region=0, soft_core=False, block="AUTO" + ): """ Get the temperature. @@ -1443,6 +2117,15 @@ def getTemperature(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1456,10 +2139,12 @@ def getTemperature(self, time_series=False, block="AUTO"): "TEMP(K)", time_series=time_series, unit=_Units.Temperature.kelvin, + region=region, + soft_core=soft_core, block=block, ) - def getCurrentTemperature(self, time_series=False): + def getCurrentTemperature(self, time_series=False, region=0, soft_core=False): """ Get the current temperature. @@ -1469,15 +2154,26 @@ def getCurrentTemperature(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- temperature : :class:`Temperature ` The temperature. """ - return self.getTemperature(time_series, block=False) + return self.getTemperature( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getPressure(self, time_series=False, block="AUTO"): + def getPressure(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the pressure. @@ -1487,6 +2183,15 @@ def getPressure(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1497,10 +2202,15 @@ def getPressure(self, time_series=False, block="AUTO"): The pressure. """ return self.getRecord( - "PRESS", time_series=time_series, unit=_Units.Pressure.bar, block=block + "PRESS", + time_series=time_series, + unit=_Units.Pressure.bar, + region=region, + soft_core=soft_core, + block=block, ) - def getCurrentPressure(self, time_series=False): + def getCurrentPressure(self, time_series=False, region=0, soft_core=False): """ Get the current pressure. @@ -1510,15 +2220,26 @@ def getCurrentPressure(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- pressure : :class:`Pressure ` The pressure. """ - return self.getPressure(time_series, block=False) + return self.getPressure( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getVolume(self, time_series=False, block="AUTO"): + def getVolume(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the volume. @@ -1528,6 +2249,15 @@ def getVolume(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1538,10 +2268,15 @@ def getVolume(self, time_series=False, block="AUTO"): The volume. """ return self.getRecord( - "VOLUME", time_series=time_series, unit=_Units.Volume.angstrom3, block=block + "VOLUME", + time_series=time_series, + unit=_Units.Volume.angstrom3, + region=region, + soft_core=soft_core, + block=block, ) - def getCurrentVolume(self, time_series=False): + def getCurrentVolume(self, time_series=False, region=0, soft_core=False): """ Get the current volume. @@ -1551,15 +2286,26 @@ def getCurrentVolume(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- volume : :class:`Volume ` The volume. """ - return self.getVolume(time_series, block=False) + return self.getVolume( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) - def getDensity(self, time_series=False, block="AUTO"): + def getDensity(self, time_series=False, region=0, soft_core=False, block="AUTO"): """ Get the density. @@ -1569,6 +2315,15 @@ def getDensity(self, time_series=False, block="AUTO"): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + block : bool Whether to block until the process has finished running. @@ -1578,9 +2333,15 @@ def getDensity(self, time_series=False, block="AUTO"): density : float The density. """ - return self.getRecord("DENSITY", time_series=time_series, block=block) + return self.getRecord( + "DENSITY", + time_series=time_series, + region=region, + soft_core=soft_core, + block=block, + ) - def getCurrentDensity(self, time_series=False): + def getCurrentDensity(self, time_series=False, region=0, soft_core=False): """ Get the current density. @@ -1590,13 +2351,89 @@ def getCurrentDensity(self, time_series=False): time_series : bool Whether to return a list of time series records. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- density : float The density. """ - return self.getDensity(time_series, block=False) + return self.getDensity( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) + + def getDVDL(self, time_series=False, region=0, soft_core=False, block="AUTO"): + """ + Get the gradient of the total energy with respect to lambda. + + Parameters + ---------- + + time_series : bool + Whether to return a list of time series records. + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + + block : bool + Whether to block until the process has finished running. + + Returns + ------- + + dv_dl : float + The gradient of the total energy with respect to lambda. + """ + return self.getRecord( + "DVDL", + time_series=time_series, + region=region, + soft_core=soft_core, + block=block, + ) + + def getCurrentDVDL(self, time_series=False, region=0, soft_core=False): + """ + Get the current gradient of the total energy with respect to lambda. + + Parameters + ---------- + + time_series : bool + Whether to return a list of time series records. + + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + + Returns + ------- + + dv_dl : float + The current gradient of the total energy with respect to lambda. + """ + return self.getDVDL( + time_series=time_series, region=region, soft_core=soft_core, block=False + ) def stdout(self, n=10): """ @@ -1621,15 +2458,39 @@ def stdout(self, n=10): self._stdout.append(line.rstrip()) line = line.strip() + # Swap dictionary based on the protocol and the degre of freedom to + # which the next block of records correspond. + if isinstance(self._protocol, _FreeEnergyMixin): + if "TI region 1" in line: + self._current_region = 0 + elif "TI region 2" in line: + self._current_region = 2 + elif "Softcore part" in line and self._current_region == 0: + self._current_region = 1 + elif "Softcore part" in line and self._current_region == 2: + self._current_region = 3 + elif "Detailed TI info" in line: + # This flags that we should skip records until the start of + # the next set for the first TI region. + self._current_region = 4 + # Default stdout dictionary. + else: + self._current_region = 0 + + # Continue if we are ignoring this record block. + if self._current_region == 4: + continue + + stdout_dict = self._stdout_dict[self._current_region] + stdout_key = self._stdout_key[self._current_region] + # Skip empty lines and summary reports. - if ( - len(line) > 0 - and line[0] != "|" - and line[0] != "-" - and not line.startswith("EAMBER") - ): + if len(line) > 0 and line[0] != "|" and line[0] != "-": + # Skip EAMBER records. + if "EAMBER (non-restraint)" in line: + continue # Flag that we've started recording results. - if not self._has_results and line.startswith("NSTEP"): + elif not self._has_results and line.startswith("NSTEP"): self._has_results = True self._finished_results = False # Flag that we've finished recording results. @@ -1638,7 +2499,7 @@ def stdout(self, n=10): # Parse the results. if self._has_results and not self._finished_results: - # The output format is different for minimisation protocols. + # The first line of output has different formatting for minimisation protocols. if isinstance(self._protocol, _Protocol.Minimisation): # No equals sign in the line. if "NSTEP" in line and "=" not in line: @@ -1658,32 +2519,58 @@ def stdout(self, n=10): # The file hasn't been updated. if ( - "NSTEP" in self._stdout_dict - and data[0] == self._stdout_dict["NSTEP"][-1] + "NSTEP" in stdout_dict + and data[0] == stdout_dict["NSTEP"][-1] ): self._finished_results = True continue # Add the timestep and energy records to the dictionary. - self._stdout_dict["NSTEP"] = data[0] - self._stdout_dict["ENERGY"] = data[1] + stdout_dict["NSTEP"] = data[0] + stdout_dict["ENERGY"] = data[1] + + # Add the keys to the mapping + stdout_key["NSTEP"] = "NSTEP" + stdout_key["ENERGY"] = "ENERGY" # Turn off the header flag now that the data has been recorded. self._is_header = False - # All other protocols have output that is formatted as RECORD = VALUE. + # All other records are formatted as RECORD = VALUE. # Use a regex search to split the line into record names and values. records = _re.findall( - r"(\d*\-*\d*\s*[A-Z]+\(*[A-Z]*\)*)\s*=\s*(\-*\d+\.?\d*)", + r"([SC_]*[EEL_]*[RES_]*[VDW_]*\d*\-*\d*\s*[A-Z/]+\(*[A-Z]*\)*)\s*=\s*(\-*\d+\.?\d*|\**)", line.upper(), ) # Append each record to the dictionary. for key, value in records: - # Strip whitespace from the record key. + # Strip whitespace from beginning and end. key = key.strip() - self._stdout_dict[key] = value + + # Format key so it can be re-used for records corresponding to + # different regions, which use different abbreviations. + universal_key = ( + key.replace("SC_", "") + .replace(" ", "") + .replace("-", "") + .replace("EELEC", "EEL") + .replace("VDWAALS", "VDW") + ) + + # Handle missing values, which will appear as asterisks, e.g. + # PRESS=******** + try: + tmp = float(value) + except: + value = None + + # Store the record using the original key. + stdout_dict[key] = value + + # Map the universal key to the original. + stdout_key[universal_key] = key # Get the current number of lines. num_lines = len(self._stdout) @@ -1705,7 +2592,9 @@ def kill(self): if not self._process is None and self._process.isRunning(): self._process.kill() - def _get_stdout_record(self, key, time_series=False, unit=None): + def _get_stdout_record( + self, key, time_series=False, unit=None, region=0, soft_core=False + ): """ Helper function to get a stdout record from the dictionary. @@ -1713,7 +2602,7 @@ def _get_stdout_record(self, key, time_series=False, unit=None): ---------- key : str - The record key. + The universal record key. time_series : bool Whether to return a time series of records. @@ -1721,6 +2610,15 @@ def _get_stdout_record(self, key, time_series=False, unit=None): unit : :class:`Type ` The unit to convert the record to. + region : int + The region to which the record corresponds. There will only be more + than one region for FreeEnergy protocols, where 1 indicates the second + TI region. + + soft_core : bool + Whether to get the record for the soft-core part of the system for the + chosen region. + Returns ------- @@ -1744,18 +2642,41 @@ def _get_stdout_record(self, key, time_series=False, unit=None): if not isinstance(unit, _Type): raise TypeError("'unit' must be of type 'BioSimSpace.Types'") + # Validate the region. + if not isinstance(region, int): + raise TypeError("'region' must be of type 'int'") + else: + if region < 0 or region > 1: + raise ValueError("'region' must be in range [0, 1]") + + # Validate the soft-core flag. + if not isinstance(soft_core, bool): + raise TypeError("'soft_core' must be of type 'bool'.") + + # Convert to the full index, region + soft_core. + idx = 2 * region + int(soft_core) + + # Extract the dictionary of stdout records for the specified region and soft-core flag. + stdout_dict = self._stdout_dict[idx] + + # Map the universal key to the original key used for this region. + try: + key = self._stdout_key[idx][key] + except: + return None + # Return the list of dictionary values. if time_series: try: if key == "NSTEP": - return [int(x) for x in self._stdout_dict[key]] + return [int(x) for x in stdout_dict[key]] else: if unit is None: - return [float(x) for x in self._stdout_dict[key]] + return [float(x) if x else None for x in stdout_dict[key]] else: return [ - (float(x) * unit)._to_default_unit() - for x in self._stdout_dict[key] + (float(x) * unit)._to_default_unit() if x else None + for x in stdout_dict[key] ] except KeyError: @@ -1765,14 +2686,20 @@ def _get_stdout_record(self, key, time_series=False, unit=None): else: try: if key == "NSTEP": - return int(self._stdout_dict[key][-1]) + return int(stdout_dict[key][-1]) else: if unit is None: - return float(self._stdout_dict[key][-1]) + try: + return float(stdout_dict[key][-1]) + except: + return None else: - return ( - float(self._stdout_dict[key][-1]) * unit - )._to_default_unit() + try: + return ( + float(stdout_dict[key][-1]) * unit + )._to_default_unit() + except: + return None except KeyError: return None diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py index 7c9f66309..257deef09 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py @@ -805,23 +805,51 @@ def getFrame(self, index): # Create a copy of the existing system object. old_system = self._system.copy() - # Update the coordinates and velocities and return a mapping between - # the molecule indices in the two systems. - sire_system, mapping = _SireIO.updateCoordinatesAndVelocities( - old_system._sire_object, - new_system._sire_object, - self._mapping, - is_lambda1, - self._property_map, - self._property_map, - ) + if isinstance(self._protocol, _Protocol._FreeEnergyMixin): + # Udpate the coordinates and velocities and return a mapping between + # the molecule indices in the two systems. + mapping = { + _SireMol.MolIdx(x): _SireMol.MolIdx(x) + for x in range(0, self._squashed_system.nMolecules()) + } + ( + self._squashed_system._sire_object, + _, + ) = _SireIO.updateCoordinatesAndVelocities( + self._squashed_system._sire_object, + new_system._sire_object, + mapping, + is_lambda1, + self._property_map, + self._property_map, + ) + + # Update the unsquashed system based on the updated squashed system. + old_system = _unsquash( + old_system, + self._squashed_system, + self._mapping, + explicit_dummies=self._explicit_dummies, + ) + + else: + # Update the coordinates and velocities and return a mapping between + # the molecule indices in the two systems. + sire_system, mapping = _SireIO.updateCoordinatesAndVelocities( + old_system._sire_object, + new_system._sire_object, + self._mapping, + is_lambda1, + self._property_map, + self._property_map, + ) - # Update the underlying Sire object. - old_system._sire_object = sire_system + # Update the underlying Sire object. + old_system._sire_object = sire_system - # Store the mapping between the MolIdx in both systems so we don't - # need to recompute it next time. - self._mapping = mapping + # Store the mapping between the MolIdx in both systems so we don't + # need to recompute it next time. + self._mapping = mapping # Update the box information in the original system. if "space" in new_system._sire_object.propertyKeys(): diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index a25a5109c..d92eb1145 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -31,7 +31,9 @@ from sire.legacy import Units as _SireUnits +from ..Align._squash import _amber_mask_from_indices, _squashed_atom_mapping from .. import Protocol as _Protocol +from ..Protocol._free_energy_mixin import _FreeEnergyMixin from ..Protocol._position_restraint_mixin import _PositionRestraintMixin from ._config import Config as _Config @@ -63,7 +65,12 @@ def __init__(self, system, protocol, property_map={}): super().__init__(system, protocol, property_map=property_map) def createConfig( - self, version=None, is_pmemd=False, extra_options={}, extra_lines=[] + self, + version=None, + is_pmemd=False, + explicit_dummies=False, + extra_options={}, + extra_lines=[], ): """ Create the list of configuration strings. @@ -74,6 +81,9 @@ def createConfig( is_pmemd : bool Whether the configuration is for a simulation using PMEMD. + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + extra_options : dict A dictionary containing extra options. Overrides the defaults generated by the protocol. @@ -96,6 +106,9 @@ def createConfig( if not isinstance(is_pmemd, bool): raise TypeError("'is_pmemd' must be of type 'bool'.") + if not isinstance(explicit_dummies, bool): + raise TypeError("'explicit_dummies' must be of type 'bool'.") + if not isinstance(extra_options, dict): raise TypeError("'extra_options' must be of type 'dict'.") else: @@ -134,6 +147,9 @@ def createConfig( # Only read coordinates from file. protocol_dict["ntx"] = 1 + # Initialise a null timestep. + timestep = None + # Minimisation. if isinstance(self._protocol, _Protocol.Minimisation): # Work out the number of steepest descent cycles. @@ -197,6 +213,19 @@ def createConfig( else: atom_idxs = restraint + # Convert to a squashed representation, if needed + if isinstance(self._protocol, _FreeEnergyMixin): + atom_mapping0 = _squashed_atom_mapping( + self.system, is_lambda1=False + ) + atom_mapping1 = _squashed_atom_mapping( + self._system, is_lambda1=True + ) + atom_idxs = sorted( + {atom_mapping0[x] for x in atom_idxs if x in atom_mapping0} + | {atom_mapping1[x] for x in atom_idxs if x in atom_mapping1} + ) + # Don't add restraints if there are no atoms to restrain. if len(atom_idxs) > 0: # Generate the restraint mask based on atom indices. @@ -303,6 +332,39 @@ def createConfig( # Final temperature. protocol_dict["temp0"] = f"{temp:.2f}" + # Free energies. + if isinstance(self._protocol, _FreeEnergyMixin): + # Free energy mode. + protocol_dict["icfe"] = 1 + # Use softcore potentials. + protocol_dict["ifsc"] = 1 + # Remove SHAKE constraints. + protocol_dict["ntf"] = 1 + + # Get the list of lambda values. + lambda_values = [f"{x:.5f}" for x in self._protocol.getLambdaValues()] + + # Number of states in the MBAR calculation. (Number of lambda values.) + protocol_dict["mbar_states"] = len(lambda_values) + + # Lambda values for the MBAR calculation. + protocol_dict["mbar_lambda"] = ", ".join(lambda_values) + + # Current lambda value. + protocol_dict["clambda"] = "{:.5f}".format(self._protocol.getLambda()) + + if isinstance(self._protocol, _Protocol.Production): + # Calculate MBAR energies. + protocol_dict["ifmbar"] = 1 + # Output dVdl + protocol_dict["logdvdl"] = 1 + + # Atom masks. + protocol_dict = { + **protocol_dict, + **self._generate_amber_fep_masks(timestep), + } + # Put everything together in a line-by-line format. total_dict = {**protocol_dict, **extra_options} dict_lines = [self._protocol.__class__.__name__, "&cntrl"] @@ -389,3 +451,70 @@ def _create_restraint_mask(self, atom_idxs): restraint_mask += f",{idx+1}" return restraint_mask + + def _generate_amber_fep_masks(self, timestep, explicit_dummies=False): + """ + Internal helper function which generates timasks and scmasks based + on the system. + + Parameters + ---------- + + timestep : [float] + The timestep in ps for the FEP perturbation. Generates a different + mask based on this. + + explicit_dummies : bool + Whether to keep the dummy atoms explicit at the endstates or remove them. + + Returns + ------- + + option_dict : dict + A dictionary of AMBER-compatible options. + """ + # Get the merged to squashed atom mapping of the whole system for both endpoints. + kwargs = dict(environment=False, explicit_dummies=explicit_dummies) + mcs_mapping0 = _squashed_atom_mapping( + self._system, is_lambda1=False, common=True, dummies=False, **kwargs + ) + mcs_mapping1 = _squashed_atom_mapping( + self._system, is_lambda1=True, common=True, dummies=False, **kwargs + ) + dummy_mapping0 = _squashed_atom_mapping( + self._system, is_lambda1=False, common=False, dummies=True, **kwargs + ) + dummy_mapping1 = _squashed_atom_mapping( + self._system, is_lambda1=True, common=False, dummies=True, **kwargs + ) + + # Generate the TI and dummy masks. + mcs0_indices, mcs1_indices, dummy0_indices, dummy1_indices = [], [], [], [] + for i in range(self._system.nAtoms()): + if i in dummy_mapping0: + dummy0_indices.append(dummy_mapping0[i]) + if i in dummy_mapping1: + dummy1_indices.append(dummy_mapping1[i]) + if i in mcs_mapping0: + mcs0_indices.append(mcs_mapping0[i]) + if i in mcs_mapping1: + mcs1_indices.append(mcs_mapping1[i]) + ti0_indices = mcs0_indices + dummy0_indices + ti1_indices = mcs1_indices + dummy1_indices + + # SHAKE should be used for timestep > 2 fs. + if timestep is not None and timestep >= 0.002: + no_shake_mask = "" + else: + no_shake_mask = _amber_mask_from_indices(ti0_indices + ti1_indices) + + # Create an option dict with amber masks generated from the above indices. + option_dict = { + "timask1": f'"{_amber_mask_from_indices(ti0_indices)}"', + "timask2": f'"{_amber_mask_from_indices(ti1_indices)}"', + "scmask1": f'"{_amber_mask_from_indices(dummy0_indices)}"', + "scmask2": f'"{_amber_mask_from_indices(dummy1_indices)}"', + "noshakemask": f'"{no_shake_mask}"', + } + + return option_dict diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index 02b662c14..953bb76ea 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -1593,7 +1593,11 @@ def _fixCharge(self, property_map={}): self._sire_object = edit_mol.commit() def _toRegularMolecule( - self, property_map={}, is_lambda1=False, convert_amber_dummies=False + self, + property_map={}, + is_lambda1=False, + convert_amber_dummies=False, + generate_intrascale=False, ): """ Internal function to convert a merged molecule to a regular molecule. @@ -1615,6 +1619,9 @@ def _toRegularMolecule( non-FEP simulations. This will replace the "du" ambertype and "Xx" element with the properties from the other end state. + generate_intrascale : bool + Whether to regenerate the intrascale matrix. + Returns ------- @@ -1628,6 +1635,9 @@ def _toRegularMolecule( if not isinstance(convert_amber_dummies, bool): raise TypeError("'convert_amber_dummies' must be of type 'bool'") + if not isinstance(generate_intrascale, bool): + raise TypeError("'generate_intrascale' must be of type 'bool'") + if is_lambda1: lam = "1" else: @@ -1710,6 +1720,17 @@ def _toRegularMolecule( mol = mol.removeProperty("element0").molecule() mol = mol.removeProperty("element1").molecule() + if generate_intrascale: + # First we regenerate the connectivity based on the bonds. + conn = _SireMol.Connectivity(mol.info()).edit() + for bond in mol.property("bond").potentials(): + conn.connect(bond.atom0(), bond.atom1()) + mol.setProperty("connectivity", conn.commit()) + + # Now we have the correct connectivity, we can regenerate the exclusions. + gro_sys = _SireIO.GroTop(_System(mol)._sire_object).toSystem() + mol.setProperty("intrascale", gro_sys[0].property("intrascale")) + # Return the updated molecule. return Molecule(mol.commit()) diff --git a/tests/Align/test_squash.py b/tests/Align/test_squash.py new file mode 100644 index 000000000..ac829a2fc --- /dev/null +++ b/tests/Align/test_squash.py @@ -0,0 +1,204 @@ +import os +import numpy as np +import pickle +import pytest + +import sire + +from sire.maths import Vector + +import BioSimSpace as BSS + +# Make sure AMBER is installed. +if BSS._amber_home is not None: + exe = "%s/bin/sander" % BSS._amber_home + if os.path.isfile(exe): + has_amber = True + else: + has_amber = False +else: + has_amber = False + + +@pytest.fixture(scope="session") +def perturbed_system(): + # N_atoms are: 12, 15, 18, 21, 24, 27 and 30. + mol_smiles = [ + "c1ccccc1", + "c1ccccc1C", + "c1ccccc1CC", + "c1ccccc1CCC", + "c1ccccc1CCCC", + "c1ccccc1CCCCC", + "c1ccccc1CCCCCC", + ] + mols = [BSS.Parameters.gaff(smi).getMolecule() for smi in mol_smiles] + pert_mols = [ + mols[0], + BSS.Align.merge(mols[1], mols[2]), + mols[3], + mols[4], + BSS.Align.merge(mols[5], mols[6]), + ] + system = BSS._SireWrappers.System(pert_mols) + return system + + +@pytest.fixture(scope="session") +def dual_topology_system(): + mol_smiles = ["c1ccccc1", "c1ccccc1C"] + mols = [BSS.Parameters.gaff(smi).getMolecule() for smi in mol_smiles] + pertmol = BSS.Align.merge(mols[0], mols[1], mapping={0: 0}) + c = pertmol._sire_object.cursor() + # Translate all atoms so that we have different coordinates between both endstates + for atom in c.atoms(): + atom["coordinates1"] = atom["coordinates0"] + Vector(1, 1, 1) + pertmol = BSS._SireWrappers.Molecule(c.commit()) + system = pertmol.toSystem() + return system + + +@pytest.fixture +def perturbed_tripeptide(): + return pickle.load(open(f"tests/input/merged_tripeptide.pickle", "rb")) + + +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") +@pytest.mark.parametrize( + "explicit,expected_n_atoms", + [ + (False, [12, 21, 24, 15, 18, 27, 30]), + (True, [12, 21, 24, 18, 18, 30, 30]), + ], +) +def test_squash(perturbed_system, explicit, expected_n_atoms): + squashed_system, mapping = BSS.Align._squash._squash( + perturbed_system, explicit_dummies=explicit + ) + assert len(squashed_system) == 7 + n_atoms = [mol.nAtoms() for mol in squashed_system] + assert squashed_system[-2].getResidues()[0].name() == "LIG" + assert squashed_system[-1].getResidues()[0].name() == "LIG" + # First we must have the unperturbed molecules, and then the perturbed ones. + assert n_atoms == expected_n_atoms + python_mapping = {k.value(): v.value() for k, v in mapping.items()} + assert python_mapping == {0: 0, 2: 1, 3: 2} + + +@pytest.mark.parametrize("explicit", [False, True]) +def test_squash_multires(perturbed_tripeptide, explicit): + squashed_system, mapping = BSS.Align._squash._squash( + perturbed_tripeptide, explicit_dummies=explicit + ) + assert len(squashed_system) == 1 + assert len(squashed_system[0].getResidues()) == 4 + + +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") +@pytest.mark.parametrize("is_lambda1", [False, True]) +def test_squashed_molecule_mapping(perturbed_system, is_lambda1): + res = BSS.Align._squash._squashed_molecule_mapping( + perturbed_system, is_lambda1=is_lambda1 + ) + if not is_lambda1: + expected = {0: 0, 2: 1, 3: 2, 1: 3, 4: 5} + else: + expected = {0: 0, 2: 1, 3: 2, 1: 4, 4: 6} + assert res == expected + + +@pytest.mark.parametrize("is_lambda1", [False, True]) +def test_squashed_atom_mapping_implicit(perturbed_tripeptide, is_lambda1): + res = BSS.Align._squash._squashed_atom_mapping( + perturbed_tripeptide, is_lambda1=is_lambda1, explicit_dummies=False + ) + if not is_lambda1: + merged_indices = list(range(16)) + list(range(16, 30)) + list(range(43, 51)) + squashed_indices = list(range(16)) + list(range(16, 30)) + list(range(30, 38)) + else: + merged_indices = ( + list(range(16)) + + list(range(16, 21)) + + list(range(23, 26)) + + list(range(30, 43)) + + list(range(43, 51)) + ) + squashed_indices = list(range(16)) + list(range(38, 59)) + list(range(30, 38)) + expected = dict(zip(merged_indices, squashed_indices)) + assert res == expected + + +@pytest.mark.parametrize("is_lambda1", [False, True]) +def test_squashed_atom_mapping_explicit(perturbed_tripeptide, is_lambda1): + res = BSS.Align._squash._squashed_atom_mapping( + perturbed_tripeptide, is_lambda1=is_lambda1, explicit_dummies=True + ) + merged_indices = list(range(51)) + if not is_lambda1: + squashed_indices = list(range(16)) + list(range(16, 43)) + list(range(43, 51)) + else: + squashed_indices = list(range(16)) + list(range(51, 78)) + list(range(43, 51)) + expected = dict(zip(merged_indices, squashed_indices)) + assert res == expected + + +@pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") +@pytest.mark.parametrize("explicit", [False, True]) +def test_unsquash(dual_topology_system, explicit): + squashed_system, mapping = BSS.Align._squash._squash( + dual_topology_system, explicit_dummies=explicit + ) + new_perturbed_system = BSS.Align._squash._unsquash( + dual_topology_system, squashed_system, mapping, explicit_dummies=explicit + ) + assert [ + mol0.nAtoms() == mol1.nAtoms() + for mol0, mol1 in zip(dual_topology_system, new_perturbed_system) + ] + assert [ + mol0.isPerturbable() == mol1.isPerturbable() + for mol0, mol1 in zip(dual_topology_system, new_perturbed_system) + ] + if explicit: + # Check that we have loaded the correct coordinates + coords0_before = sire.io.get_coords_array( + dual_topology_system[0]._sire_object, map={"coordinates": "coordinates0"} + ) + coords1_before = sire.io.get_coords_array( + dual_topology_system[0]._sire_object, map={"coordinates": "coordinates1"} + ) + coords0_after = sire.io.get_coords_array( + new_perturbed_system[0]._sire_object, map={"coordinates": "coordinates0"} + ) + coords1_after = sire.io.get_coords_array( + new_perturbed_system[0]._sire_object, map={"coordinates": "coordinates1"} + ) + + # The coordinates at the first endstate should be completely preserved + # Because in this case they are either common core, or separate dummies at lambda = 0 + assert np.allclose(coords0_before, coords0_after) + + # The coordinates at the first endstate should be partially preserved + # The common core must have the same coordinates as lambda = 0 + # Here this is just a single atom in the beginning + # The extra atoms which are dummies at lambda = 0 have separate coordinates here + assert np.allclose(coords0_before[:1, :], coords1_after[:1, :]) + assert np.allclose(coords1_before[1:, :], coords1_after[1:, :]) + + +@pytest.mark.parametrize("explicit", [False, True]) +def test_unsquash_multires(perturbed_tripeptide, explicit): + squashed_system, mapping = BSS.Align._squash._squash( + perturbed_tripeptide, explicit_dummies=explicit + ) + new_perturbed_system = BSS.Align._squash._unsquash( + perturbed_tripeptide, squashed_system, mapping, explicit_dummies=explicit + ) + assert [ + mol0.nAtoms() == mol1.nAtoms() + for mol0, mol1 in zip(perturbed_tripeptide, new_perturbed_system) + ] + assert [ + mol0.isPerturbable() == mol1.isPerturbable() + for mol0, mol1 in zip(perturbed_tripeptide, new_perturbed_system) + ] diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index 89355c80b..600b4ff81 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -1,5 +1,7 @@ from collections import OrderedDict + import pytest +import shutil import BioSimSpace as BSS @@ -31,6 +33,17 @@ def large_protein_system(): ) +@pytest.fixture(scope="module") +def perturbable_system(): + """Re-use the same perturbable system for each test.""" + return BSS.IO.readPerturbableSystem( + f"{url}/perturbable_system0.prm7", + f"{url}/perturbable_system0.rst7", + f"{url}/perturbable_system1.prm7", + f"{url}/perturbable_system1.rst7", + ) + + @pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") @pytest.mark.parametrize("restraint", restraints) def test_minimise(system, restraint): @@ -286,3 +299,64 @@ def run_process(system, protocol, check_data=False): for k, v in data.items(): assert len(v) == nrec + + +@pytest.mark.skipif( + has_amber is False, reason="Requires AMBER and pyarrow to be installed." +) +@pytest.mark.parametrize( + "protocol", + [ + BSS.Protocol.FreeEnergy(temperature=298 * BSS.Units.Temperature.kelvin), + BSS.Protocol.FreeEnergyMinimisation(), + ], +) +def test_parse_fep_output(perturbable_system, protocol): + """Make sure that we can correctly parse AMBER FEP output.""" + + # Copy the system. + system_copy = perturbable_system.copy() + + # Create a process using any system and the protocol. + process = BSS.Process.Amber(system_copy, protocol) + + # Assign the path to the output file. + if isinstance(protocol, BSS.Protocol.FreeEnergy): + out_file = "tests/output/amber_fep.out" + else: + out_file = "tests/output/amber_fep_min.out" + + # Copy the existing output file into the working directory. + shutil.copyfile(out_file, process.workDir() + "/amber.out") + + # Update the stdout record dictionaries. + process.stdout(0) + + # Get back the records for each region and soft-core part. + records_ti0 = process.getRecords(region=0) + records_sc0 = process.getRecords(region=0, soft_core=True) + records_ti1 = process.getRecords(region=1) + records_sc1 = process.getRecords(region=1, soft_core=True) + + # Make sure NSTEP is present. + assert "NSTEP" in records_ti0 + + # Get the number of records. + num_records = len(records_ti0["NSTEP"]) + + # Now make sure that the records for the two TI regions contain the + # same number of values. + for v0, v1 in zip(records_ti0.values(), records_ti1.values()): + assert len(v0) == len(v1) == num_records + + # Now check that are records for the soft-core parts contain the correct + # number of values. + for v in records_sc0.values(): + assert len(v) == num_records + for k, v in records_sc1.items(): + assert len(v) == num_records + if isinstance(protocol, BSS.Protocol.FreeEnergy): + assert len(records_sc0) == len(records_sc1) + else: + assert len(records_sc0) == 0 + assert len(records_sc1) != 0 diff --git a/tests/input/merged_tripeptide.pickle b/tests/input/merged_tripeptide.pickle new file mode 100644 index 0000000000000000000000000000000000000000..20abb1d6437cc5f51cc76b84cfdc3ed38303a5f0 GIT binary patch literal 65668 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 Date: Tue, 12 Mar 2024 14:42:17 +0000 Subject: [PATCH 03/36] Add custom _find_exe function for AMBER. --- python/BioSimSpace/MD/_md.py | 79 +++++++++---------- python/BioSimSpace/Process/_amber.py | 111 +++++++++++++++++++++++---- tests/Process/test_amber.py | 8 +- 3 files changed, 143 insertions(+), 55 deletions(-) diff --git a/python/BioSimSpace/MD/_md.py b/python/BioSimSpace/MD/_md.py index cc0a9d6f8..e5d3e510c 100644 --- a/python/BioSimSpace/MD/_md.py +++ b/python/BioSimSpace/MD/_md.py @@ -68,7 +68,7 @@ # Whether each engine supports free energy simulations. This dictionary needs to # be updated as support for different engines is added. _free_energy = { - "AMBER": False, + "AMBER": True, "GROMACS": True, "NAMD": False, "OPENMM": False, @@ -96,7 +96,7 @@ } -def _find_md_engines(system, protocol, engine="auto", gpu_support=False): +def _find_md_engines(system, protocol, engine="AUTO", gpu_support=False): """ Find molecular dynamics engines on the system that support the given protocol and GPU requirements. @@ -173,20 +173,45 @@ def _find_md_engines(system, protocol, engine="auto", gpu_support=False): and (not is_metadynamics or _metadynamics[engine]) and (not is_steering or _steering[engine]) ): - # Check whether this engine exists on the system and has the desired - # GPU support. - for exe, gpu in _md_engines[engine].items(): - # If the user has requested GPU support make sure the engine - # supports it. - if not gpu_support or gpu: - # AMBER - if engine == "AMBER": - # Search AMBERHOME, if set. - if _amber_home is not None: - _exe = "%s/bin/%s" % (_amber_home, exe) - if _os.path.isfile(_exe): + # Special handling for AMBER which has a custom executable finding + # function. + if engine == "AMBER": + from ..Process._amber import _find_exe + + try: + exe = _find_exe(is_gpu=gpu_support, is_free_energy=is_free_energy) + found_engines.append(engine) + found_exes.append(exe) + except: + pass + else: + # Check whether this engine exists on the system and has the desired + # GPU support. + for exe, gpu in _md_engines[engine].items(): + # If the user has requested GPU support make sure the engine + # supports it. + if not gpu_support or gpu: + # GROMACS + if engine == "GROMACS": + if ( + _gmx_exe is not None + and _os.path.basename(_gmx_exe) == exe + ): found_engines.append(engine) - found_exes.append(_exe) + found_exes.append(_gmx_exe) + # OPENMM + elif engine == "OPENMM": + found_engines.append(engine) + found_exes.append(_SireBase.getBinDir() + "/sire_python") + # SOMD + elif engine == "SOMD": + found_engines.append(engine) + if is_free_energy: + found_exes.append( + _SireBase.getBinDir() + "/somd-freenrg" + ) + else: + found_exes.append(_SireBase.getBinDir() + "/somd") # Search system PATH. else: try: @@ -195,30 +220,6 @@ def _find_md_engines(system, protocol, engine="auto", gpu_support=False): found_exes.append(exe) except: pass - # GROMACS - elif engine == "GROMACS": - if _gmx_exe is not None and _os.path.basename(_gmx_exe) == exe: - found_engines.append(engine) - found_exes.append(_gmx_exe) - # OPENMM - elif engine == "OPENMM": - found_engines.append(engine) - found_exes.append(_SireBase.getBinDir() + "/sire_python") - # SOMD - elif engine == "SOMD": - found_engines.append(engine) - if is_free_energy: - found_exes.append(_SireBase.getBinDir() + "/somd-freenrg") - else: - found_exes.append(_SireBase.getBinDir() + "/somd") - # Search system PATH. - else: - try: - exe = _SireBase.findExe(exe).absoluteFilePath() - found_engines.append(engine) - found_exes.append(exe) - except: - pass # No engine was found. if len(found_engines) == 0: diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index fb25ecc73..586a04f21 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -73,6 +73,7 @@ def __init__( reference_system=None, explicit_dummies=False, exe=None, + is_gpu=False, name="amber", work_dir=None, seed=None, @@ -103,6 +104,9 @@ def __init__( exe : str The full path to the AMBER executable. + is_gpu : bool + Whether to use the GPU accelerated version of AMBER. + name : str The name of the process. @@ -144,22 +148,18 @@ def __init__( # This process can generate trajectory data. self._has_trajectory = True + if not isinstance(is_gpu, bool): + raise TypeError("'is_gpu' must be of type 'bool'") + # If the path to the executable wasn't specified, then search - # for it in $PATH. For now, we'll just search for 'sander', which - # is available free as part of AmberTools. In future, we will - # look for all possible executables in order of preference: pmemd.cuda, - # pmemd, sander, etc., as well as their variants, e.g. pmemd.MPI. + # for it in AMBERHOME and the PATH. if exe is None: - # Search AMBERHOME, if set. - if _amber_home is not None: - exe = "%s/bin/sander" % _amber_home - if _os.path.isfile(exe): - self._exe = exe - else: - raise _MissingSoftwareError( - "'BioSimSpace.Process.Amber' is not supported. " - "Please install AMBER (http://ambermd.org)." - ) + if isinstance(protocol, _FreeEnergyMixin): + is_free_energy = True + else: + is_free_energy = False + + self._exe = _find_exe(is_gpu=is_gpu, is_free_energy=is_free_energy) else: # Make sure executable exists. if _os.path.isfile(exe): @@ -2703,3 +2703,86 @@ def _get_stdout_record( except KeyError: return None + + +def _find_exe(is_gpu=False, is_free_energy=False): + """ + Helper function to search for an AMBER executable. + + Parameters + ---------- + + is_gpu : bool + Whether to search for a GPU-enabled executable. + + is_free_energy : bool + Whether the executable is for a free energy protocol. + + Returns + ------- + + exe : str + The path to the executable. + """ + + if not isinstance(is_gpu, bool): + raise TypeError("'is_gpu' must be of type 'bool'.") + + if not isinstance(is_free_energy, bool): + raise TypeError("'is_free_energy' must be of type 'bool'.") + + # If the user has requested a GPU-enabled executable, search for pmemd.cuda only. + if is_gpu: + targets = ["pmemd.cuda"] + else: + # If the this is a free energy simulation, then only use pmemd or pmemd.cuda. + if is_free_energy: + targets = ["pmemd"] + else: + targets = ["pmemd", "sander"] + + # Search for the executable. + + import os as _os + import pathlib as _pathlib + + from glob import glob as _glob + + # Get the current path. + path = _os.environ["PATH"].split(_os.pathsep) + + # If AMBERHOME is set, then prepend to the path. + if _amber_home is not None: + path = [_amber_home + "/bin"] + path + + # Helper function to check whether a file is executable. + def is_exe(fpath): + return _os.path.isfile(fpath) and _os.access(fpath, _os.X_OK) + + # Loop over each directory in the path and search for the executable. + for p in path: + # Loop over each target. + for t in targets: + # Glob for the executable. + results = _glob(f"{t}*", root_dir=p) + # If we find a match, check that it's executable and return the path. + # Note that this returns the first match, not the best match. If a + # user requires a specific version of the executable, they should + # order their path accordingly, or use the exe keyword argument. + if results: + for exe in results: + exe = _pathlib.Path(p) / exe + if is_exe(exe): + return str(exe) + + msg = ( + "'BioSimSpace.Process.Amber' is not supported. " + "Unable to find AMBER executable in AMBERHOME or PATH. " + "Please install AMBER (http://ambermd.org)." + ) + + if is_free_energy: + msg += " Free energy simulations require 'pmemd' or 'pmemd.cuda'." + + # If we don't find the executable, raise an error. + raise _MissingSoftwareError(msg) diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index 600b4ff81..bd7a8a703 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -314,11 +314,15 @@ def run_process(system, protocol, check_data=False): def test_parse_fep_output(perturbable_system, protocol): """Make sure that we can correctly parse AMBER FEP output.""" + from sire.legacy.Base import findExe + # Copy the system. system_copy = perturbable_system.copy() - # Create a process using any system and the protocol. - process = BSS.Process.Amber(system_copy, protocol) + # Use the first instance of sander in the path so that we can + # test without pmemd. + exe = findExe("sander").absoluteFilePath() + process = BSS.Process.Amber(system_copy, protocol, exe=exe) # Assign the path to the output file. if isinstance(protocol, BSS.Protocol.FreeEnergy): From 004dbb3aa0d897fb453ac8b431a32f701cd7c4d4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 12 Mar 2024 15:40:45 +0000 Subject: [PATCH 04/36] Appears that igb=6 is only needed for pmemd.cuda. --- python/BioSimSpace/Process/_amber.py | 10 +++++----- python/BioSimSpace/_Config/_amber.py | 11 ++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 586a04f21..5b9bda3c9 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -299,11 +299,11 @@ def _setup(self): def _generate_config(self): """Generate AMBER configuration file strings.""" - # Work out whether we're generating a config for PMEMD. - if "pmemd" in self._exe.lower(): - is_pmemd = True + # Is this a CUDA enabled version of AMBER? + if "cuda" in self._exe.lower(): + is_pmemd_cuda = True else: - is_pmemd = False + is_pmemd_cuda = False extra_options = self._extra_options.copy() extra_lines = self._extra_lines.copy() @@ -346,7 +346,7 @@ def _generate_config(self): # Create the configuration. self.setConfig( amber_config.createConfig( - is_pmemd=is_pmemd, + is_pmemd_cuda=is_pmemd_cuda, explicit_dummies=self._explicit_dummies, extra_options=extra_options, extra_lines=extra_lines, diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index d92eb1145..d8de5295f 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -68,6 +68,7 @@ def createConfig( self, version=None, is_pmemd=False, + is_pmemd_cuda=False, explicit_dummies=False, extra_options={}, extra_lines=[], @@ -78,8 +79,8 @@ def createConfig( version : float The AMBER version. - is_pmemd : bool - Whether the configuration is for a simulation using PMEMD. + is_pmemd_cuda : bool + Whether the configuration is for a simulation using PMEMD with CUDA. explicit_dummies : bool Whether to keep the dummy atoms explicit at the endstates or remove them. @@ -103,8 +104,8 @@ def createConfig( if version and not isinstance(version, float): raise TypeError("'version' must be of type 'float'.") - if not isinstance(is_pmemd, bool): - raise TypeError("'is_pmemd' must be of type 'bool'.") + if not isinstance(is_pmemd_cuda, bool): + raise TypeError("'is_pmemd_cuda' must be of type 'bool'.") if not isinstance(explicit_dummies, bool): raise TypeError("'explicit_dummies' must be of type 'bool'.") @@ -192,7 +193,7 @@ def createConfig( protocol_dict["ntb"] = 0 # Non-bonded cut-off. protocol_dict["cut"] = "999." - if is_pmemd: + if is_pmemd_cuda: # Use vacuum generalised Born model. protocol_dict["igb"] = "6" else: From cdc962c82d848052cd71db4391769f7e2222fa93 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 12 Mar 2024 15:41:27 +0000 Subject: [PATCH 05/36] Remove invalid kwarg. --- python/BioSimSpace/FreeEnergy/_relative.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index ee1af9e7b..a0f04741e 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -2076,7 +2076,6 @@ def _initialise_runner(self, system): first_process = _Process.Amber( system, self._protocol, - exe=self._exe, work_dir=first_dir, extra_options=self._extra_options, extra_lines=self._extra_lines, From 5b68d729aaa1e6edd198d66a2df6f64ee4b2ca2f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 12 Mar 2024 16:18:43 +0000 Subject: [PATCH 06/36] Append process object for first lambda window. --- python/BioSimSpace/FreeEnergy/_relative.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index a0f04741e..eb2889d9b 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -2082,6 +2082,10 @@ def _initialise_runner(self, system): property_map=self._property_map, **self._kwargs, ) + if self._setup_only: + del first_process + else: + processes.append(first_process) # Loop over the rest of the lambda values. for x, lam in enumerate(lam_vals[1:]): From 34e2730d83b944ac37c92605e68033938dfaa0a1 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 13 Mar 2024 12:38:30 +0000 Subject: [PATCH 07/36] Add way of specifying extra command-line arguments for AMBER and GROMACS --- python/BioSimSpace/FreeEnergy/_relative.py | 75 +--------------------- python/BioSimSpace/Process/_amber.py | 9 +++ python/BioSimSpace/Process/_gromacs.py | 14 ++++ python/BioSimSpace/Process/_process.py | 19 +++++- 4 files changed, 42 insertions(+), 75 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index eb2889d9b..a54a6d707 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -135,10 +135,6 @@ def __init__( work_dir=None, engine=None, setup_only=False, - ignore_warnings=False, - show_errors=True, - extra_options={}, - extra_lines=[], property_map={}, **kwargs, ): @@ -177,23 +173,6 @@ def __init__( can be useful when you don't intend to use BioSimSpace to run the simulation. Note that a 'work_dir' must also be specified. - ignore_warnings : bool - Whether to ignore warnings when generating the binary run file. - This option is specific to GROMACS and will be ignored when a - different molecular dynamics engine is chosen. - - show_errors : bool - Whether to show warning/error messages when generating the binary - run file. This option is specific to GROMACS and will be ignored - when a different molecular dynamics engine is chosen. - - extra_options : dict - A dictionary containing extra options. Overrides the defaults generated - by the protocol. - - extra_lines : [str] - A list of extra lines to put at the end of the configuration file. - property_map : dict A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their @@ -293,31 +272,6 @@ def __init__( # Create the working directory. self._work_dir = _Utils.WorkDir(work_dir) - if not isinstance(ignore_warnings, bool): - raise ValueError("'ignore_warnings' must be of type 'bool.") - self._ignore_warnings = ignore_warnings - - if not isinstance(show_errors, bool): - raise ValueError("'show_errors' must be of type 'bool.") - self._show_errors = show_errors - - # Check the extra options. - if not isinstance(extra_options, dict): - raise TypeError("'extra_options' must be of type 'dict'.") - else: - keys = extra_options.keys() - if not all(isinstance(k, str) for k in keys): - raise TypeError("Keys of 'extra_options' must be of type 'str'.") - self._extra_options = extra_options - - # Check the extra lines. - if not isinstance(extra_lines, list): - raise TypeError("'extra_lines' must be of type 'list'.") - else: - if not all(isinstance(line, str) for line in extra_lines): - raise TypeError("Lines in 'extra_lines' must be of type 'str'.") - self._extra_lines = extra_lines - # Check that the map is valid. if not isinstance(property_map, dict): raise TypeError("'property_map' must be of type 'dict'") @@ -2045,8 +1999,6 @@ def _initialise_runner(self, system): self._protocol, platform=platform, work_dir=first_dir, - extra_options=self._extra_options, - extra_lines=self._extra_lines, property_map=self._property_map, **self._kwargs, ) @@ -2061,10 +2013,6 @@ def _initialise_runner(self, system): system, self._protocol, work_dir=first_dir, - ignore_warnings=self._ignore_warnings, - show_errors=self._show_errors, - extra_options=self._extra_options, - extra_lines=self._extra_lines, property_map=self._property_map, **self._kwargs, ) @@ -2077,8 +2025,6 @@ def _initialise_runner(self, system): system, self._protocol, work_dir=first_dir, - extra_options=self._extra_options, - extra_lines=self._extra_lines, property_map=self._property_map, **self._kwargs, ) @@ -2173,8 +2119,7 @@ def _initialise_runner(self, system): gro, tpr, first_process._exe, - ignore_warnings=self._ignore_warnings, - show_errors=self._show_errors, + **self._kwargs, ) # Create a copy of the process and update the working @@ -2240,24 +2185,6 @@ def _initialise_runner(self, system): # inside the working directory so no need to re-nest. self._runner = _Process.ProcessRunner(processes) - def _update_run_args(self, args): - """ - Internal function to update run arguments for all subprocesses. - - Parameters - ---------- - - args : dict, collections.OrderedDict - A dictionary which contains the new command-line arguments - for the process executable. - """ - - if not isinstance(args, dict): - raise TypeError("'args' must be of type 'dict'") - - for process in self._runner.processes(): - process.setArgs(args) - def getData(name="data", file_link=False, work_dir=None): """ diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 5b9bda3c9..eddc02107 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -79,6 +79,7 @@ def __init__( seed=None, extra_options={}, extra_lines=[], + extra_args={}, property_map={}, ): """ @@ -123,6 +124,9 @@ def __init__( extra_lines : [str] A list of extra lines to put at the end of the configuration file. + extra_args : dict + A dictionary of extra command-line arguments to pass to the AMBER executable. + property_map : dict A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their @@ -139,6 +143,7 @@ def __init__( seed=seed, extra_options=extra_options, extra_lines=extra_lines, + extra_args=extra_args, property_map=property_map, ) @@ -383,6 +388,10 @@ def _generate_args(self): if not isinstance(self._protocol, _Protocol.Minimisation): self.setArg("-x", "%s.nc" % self._name) + # Add the extra arguments. + for key, value in self._extra_args.items(): + self.setArg(key, value) + def start(self): """ Start the AMBER process. diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index 7c22f60e0..c4e6e36a9 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -83,6 +83,7 @@ def __init__( seed=None, extra_options={}, extra_lines=[], + extra_args={}, property_map={}, ignore_warnings=False, show_errors=True, @@ -124,6 +125,10 @@ def __init__( extra_lines : [str] A list of extra lines to put at the end of the configuration file. + extra_args : dict + A dictionary of extra command-line arguments to pass to the GROMACS + executable. + property_map : dict A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their @@ -154,6 +159,7 @@ def __init__( seed=seed, extra_options=extra_options, extra_lines=extra_lines, + extra_args=extra_args, property_map=property_map, ) @@ -444,6 +450,10 @@ def _generate_args(self): if isinstance(self._protocol, (_Protocol.Metadynamics, _Protocol.Steering)): self.setArg("-plumed", "plumed.dat") + # Add any extra arguments. + for key, value in self._extra_args.items(): + self.setArg(key, value) + @staticmethod def _generate_binary_run_file( mdp_file, @@ -455,6 +465,7 @@ def _generate_binary_run_file( checkpoint_file=None, ignore_warnings=False, show_errors=True, + **kwargs, ): """ Use grommp to generate the binary run input file. @@ -494,6 +505,9 @@ def _generate_binary_run_file( show_errors : bool Whether to show warning/error messages when generating the binary run file. + + **kwargs : dict + Additional keyword arguments. """ if not isinstance(mdp_file, str): diff --git a/python/BioSimSpace/Process/_process.py b/python/BioSimSpace/Process/_process.py index 1865b128d..a4ef0ebf2 100644 --- a/python/BioSimSpace/Process/_process.py +++ b/python/BioSimSpace/Process/_process.py @@ -76,6 +76,7 @@ def __init__( seed=None, extra_options={}, extra_lines=[], + extra_args={}, property_map={}, ): """ @@ -115,6 +116,9 @@ def __init__( extra_lines : [str] A list of extra lines to put at the end of the configuration file. + extra_args : dict + A dictionary containing extra command-line arguments. + property_map : dict A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their @@ -189,6 +193,14 @@ def __init__( if not all(isinstance(line, str) for line in extra_lines): raise TypeError("Lines in 'extra_lines' must be of type 'str'.") + # Check the extra arguments. + if not isinstance(extra_args, dict): + raise TypeError("'extra_args' must be of type 'dict'.") + else: + keys = extra_args.keys() + if not all(isinstance(k, str) for k in keys): + raise TypeError("Keys of 'extra_args' must be of type 'str'.") + # Check that the map is valid. if not isinstance(property_map, dict): raise TypeError("'property_map' must be of type 'dict'") @@ -244,9 +256,10 @@ def __init__( self._is_seeded = True self.setSeed(seed) - # Set the extra options and lines. + # Set the extra options, lines, and args. self._extra_options = extra_options self._extra_lines = extra_lines + self._extra_args = extra_args # Set the map. self._property_map = property_map.copy() @@ -1461,6 +1474,10 @@ def setArgs(self, args): "'args' must be of type 'dict' or 'collections.OrderedDict'" ) + # Add extra arguments. + if self._extra_args: + self.addArgs(self._extra_args) + def setArg(self, arg, value): """ Set a specific command-line argument. From d01f254ea8f2a542ac0d3e11d42d72bbe167e7e4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 13 Mar 2024 12:43:32 +0000 Subject: [PATCH 08/36] Add **kwargs to all Process classes. --- python/BioSimSpace/Process/_amber.py | 4 ++++ python/BioSimSpace/Process/_namd.py | 4 ++++ python/BioSimSpace/Process/_openmm.py | 4 ++++ python/BioSimSpace/Process/_somd.py | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index eddc02107..4bf4a5f1c 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -81,6 +81,7 @@ def __init__( extra_lines=[], extra_args={}, property_map={}, + **kwargs, ): """ Constructor. @@ -131,6 +132,9 @@ def __init__( A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their own naming scheme, e.g. { "charge" : "my-charge" } + + kwargs : dict + Additional keyword arguments. """ # Call the base class constructor. diff --git a/python/BioSimSpace/Process/_namd.py b/python/BioSimSpace/Process/_namd.py index f996c9555..c50165947 100644 --- a/python/BioSimSpace/Process/_namd.py +++ b/python/BioSimSpace/Process/_namd.py @@ -69,6 +69,7 @@ def __init__( work_dir=None, seed=None, property_map={}, + **kwargs, ): """ Constructor. @@ -103,6 +104,9 @@ def __init__( A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their own naming scheme, e.g. { "charge" : "my-charge" } + + kwargs : dict + Additional keyword arguments. """ # Call the base class constructor. diff --git a/python/BioSimSpace/Process/_openmm.py b/python/BioSimSpace/Process/_openmm.py index 76bb8451a..66c461495 100644 --- a/python/BioSimSpace/Process/_openmm.py +++ b/python/BioSimSpace/Process/_openmm.py @@ -79,6 +79,7 @@ def __init__( work_dir=None, seed=None, property_map={}, + **kwargs, ): """ Constructor. @@ -120,6 +121,9 @@ def __init__( A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their own naming scheme, e.g. { "charge" : "my-charge" } + + kwargs : dict + Additional keyword arguments. """ # Call the base class constructor. diff --git a/python/BioSimSpace/Process/_somd.py b/python/BioSimSpace/Process/_somd.py index 885afa398..c64f8e689 100644 --- a/python/BioSimSpace/Process/_somd.py +++ b/python/BioSimSpace/Process/_somd.py @@ -79,6 +79,7 @@ def __init__( extra_options={}, extra_lines=[], property_map={}, + **kwargs, ): """ Constructor. @@ -121,6 +122,9 @@ def __init__( A dictionary that maps system "properties" to their user defined values. This allows the user to refer to properties with their own naming scheme, e.g. { "charge" : "my-charge" } + + kwargs : dict + Additional keyword arguments. """ # Call the base class constructor. From 43999051b28c2d064269f0cb6a0672cc36d6ccda Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 13 Mar 2024 13:57:49 +0000 Subject: [PATCH 09/36] Don't use pmemd.cuda for vacuum FEP simulations. --- python/BioSimSpace/MD/_md.py | 12 ++++++- python/BioSimSpace/Process/_amber.py | 39 +++++++++++++++++++---- python/BioSimSpace/_Config/_amber.py | 8 +++-- python/BioSimSpace/_Config/_config.py | 44 ++++++++++++++++++++++---- python/BioSimSpace/_Config/_gromacs.py | 8 +++-- python/BioSimSpace/_Config/_somd.py | 8 +++-- 6 files changed, 99 insertions(+), 20 deletions(-) diff --git a/python/BioSimSpace/MD/_md.py b/python/BioSimSpace/MD/_md.py index e5d3e510c..d70e8d7ab 100644 --- a/python/BioSimSpace/MD/_md.py +++ b/python/BioSimSpace/MD/_md.py @@ -176,10 +176,20 @@ def _find_md_engines(system, protocol, engine="AUTO", gpu_support=False): # Special handling for AMBER which has a custom executable finding # function. if engine == "AMBER": + from .._Config import Amber as _AmberConfig from ..Process._amber import _find_exe + # Is this a vacuum simulation. + is_vacuum = not ( + _AmberConfig.hasBox(system) or _AmberConfig.hasWater(system) + ) + try: - exe = _find_exe(is_gpu=gpu_support, is_free_energy=is_free_energy) + exe = _find_exe( + is_gpu=gpu_support, + is_free_energy=is_free_energy, + is_vacuum=is_vacuum, + ) found_engines.append(engine) found_exes.append(exe) except: diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 4bf4a5f1c..8cf36cc39 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -168,7 +168,15 @@ def __init__( else: is_free_energy = False - self._exe = _find_exe(is_gpu=is_gpu, is_free_energy=is_free_energy) + # Check whether this is a vacuum simulation. + is_vacuum = not ( + _AmberConfig.hasBox(self._system, self._property_map) + or _AmberConfig.hasWater(self._system) + ) + + self._exe = _find_exe( + is_gpu=is_gpu, is_free_energy=is_free_energy, is_vacuum=is_vacuum + ) else: # Make sure executable exists. if _os.path.isfile(exe): @@ -2718,7 +2726,7 @@ def _get_stdout_record( return None -def _find_exe(is_gpu=False, is_free_energy=False): +def _find_exe(is_gpu=False, is_free_energy=False, is_vacuum=False): """ Helper function to search for an AMBER executable. @@ -2729,7 +2737,10 @@ def _find_exe(is_gpu=False, is_free_energy=False): Whether to search for a GPU-enabled executable. is_free_energy : bool - Whether the executable is for a free energy protocol. + Whether this is a free energy simulation. + + is_vacuum : bool + Whether this is a vacuum simulation. Returns ------- @@ -2744,13 +2755,27 @@ def _find_exe(is_gpu=False, is_free_energy=False): if not isinstance(is_free_energy, bool): raise TypeError("'is_free_energy' must be of type 'bool'.") - # If the user has requested a GPU-enabled executable, search for pmemd.cuda only. + if not isinstance(is_vacuum, bool): + raise TypeError("'is_vacuum' must be of type 'bool'.") + + # It is not possible to use implicit solvent for free energy simulations + # on GPU, so we fall back to pmemd for vacuum free energy simulations. + + if is_gpu and is_free_energy and is_vacuum: + _warnings.warn( + "Implicit solvent is not supported for free energy simulations on GPU. " + "Falling back to pmemd for vacuum free energy simulations." + ) + is_gpu = False + if is_gpu: targets = ["pmemd.cuda"] else: - # If the this is a free energy simulation, then only use pmemd or pmemd.cuda. - if is_free_energy: - targets = ["pmemd"] + if is_free_energy and not is_vacuum: + if is_vacuum: + targets = ["pmemd"] + else: + targets = ["pmemd", "pmemd.cuda"] else: targets = ["pmemd", "sander"] diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index d8de5295f..4fb9d4761 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -188,7 +188,9 @@ def createConfig( protocol_dict["ntf"] = 2 # Periodic boundary conditions. - if not self.hasBox() or not self.hasWater(): + if not self.hasBox(self._system, self._property_map) or not self.hasWater( + self._system + ): # No periodic box. protocol_dict["ntb"] = 0 # Non-bonded cut-off. @@ -277,7 +279,9 @@ def createConfig( if not isinstance(self._protocol, _Protocol.Minimisation): if self._protocol.getPressure() is not None: # Don't use barostat for vacuum simulations. - if self.hasBox() and self.hasWater(): + if self.hasBox(self._system, self._property_map) and self.hasWater( + self._system + ): # Isotropic pressure scaling. protocol_dict["ntp"] = 1 # Pressure in bar. diff --git a/python/BioSimSpace/_Config/_config.py b/python/BioSimSpace/_Config/_config.py index 3aea03484..9005dbb78 100644 --- a/python/BioSimSpace/_Config/_config.py +++ b/python/BioSimSpace/_Config/_config.py @@ -79,22 +79,43 @@ def __init__(self, system, protocol, property_map={}): self._protocol = protocol self._property_map = property_map - def hasBox(self): + @staticmethod + def hasBox(system, property_map={}): """ Whether the system has a box. + Parameters + ---------- + + system : :class:`System ` + The molecular system. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. This allows the user to refer to properties with their + own naming scheme, e.g. { "charge" : "my-charge" } + Returns ------- has_box : bool Whether the system has a simulation box. """ - space_prop = self._property_map.get("space", "space") - if space_prop in self._system._sire_object.propertyKeys(): + + if not isinstance(system, _System): + raise TypeError( + "'system' must be of type 'BioSimSpace._SireWrappers.System'" + ) + + if not isinstance(property_map, dict): + raise TypeError("'property_map' must be of type 'dict'") + + space_prop = property_map.get("space", "space") + if space_prop in system._sire_object.propertyKeys(): try: # Make sure that we have a periodic box. The system will now have # a default cartesian space. - box = self._system._sire_object.property(space_prop) + box = system._sire_object.property(space_prop) has_box = box.isPeriodic() except: has_box = False @@ -104,17 +125,28 @@ def hasBox(self): return has_box - def hasWater(self): + @staticmethod + def hasWater(system): """ Whether the system is contains water molecules. + Parameters + + system : :class:`System ` + The molecular system. + Returns ------- has_water : bool Whether the system contains water molecules. """ - return self._system.nWaterMolecules() > 0 + if not isinstance(system, _System): + raise TypeError( + "'system' must be of type 'BioSimSpace._SireWrappers.System'" + ) + + return system.nWaterMolecules() > 0 def reportInterval(self): """ diff --git a/python/BioSimSpace/_Config/_gromacs.py b/python/BioSimSpace/_Config/_gromacs.py index 23c50e613..9be324be2 100644 --- a/python/BioSimSpace/_Config/_gromacs.py +++ b/python/BioSimSpace/_Config/_gromacs.py @@ -145,7 +145,9 @@ def createConfig(self, version=None, extra_options={}, extra_lines=[]): protocol_dict["pbc"] = "xyz" # Use Verlet pair lists. protocol_dict["cutoff-scheme"] = "Verlet" - if self.hasBox() and self.hasWater(): + if self.hasBox(self._system, self._property_map) and self.hasWater( + self._system + ): # Use a grid to search for neighbours. protocol_dict["ns-type"] = "grid" # Rebuild neighbour list every 20 steps. @@ -186,7 +188,9 @@ def createConfig(self, version=None, extra_options={}, extra_lines=[]): if not isinstance(self._protocol, _Protocol.Minimisation): if self._protocol.getPressure() is not None: # Don't use barostat for vacuum simulations. - if self.hasBox() and self.hasWater(): + if self.hasBox(self._system, self._property_map) and self.hasWater( + self._system + ): # Barostat type. if version and version >= 2021: protocol_dict["pcoupl"] = "c-rescale" diff --git a/python/BioSimSpace/_Config/_somd.py b/python/BioSimSpace/_Config/_somd.py index 12ce38b7d..7ee18fa7a 100644 --- a/python/BioSimSpace/_Config/_somd.py +++ b/python/BioSimSpace/_Config/_somd.py @@ -176,7 +176,9 @@ def createConfig(self, extra_options={}, extra_lines=[]): if self.hasWater(): # Solvated box. protocol_dict["reaction field dielectric"] = "78.3" - if not self.hasBox() or not self.hasWater(): + if not self.hasBox(self._system, self._property_map) or not self.hasWater( + self._system + ): # No periodic box. protocol_dict["cutoff type"] = "cutoffnonperiodic" else: @@ -199,7 +201,9 @@ def createConfig(self, extra_options={}, extra_lines=[]): if not isinstance(self._protocol, _Protocol.Minimisation): if self._protocol.getPressure() is not None: # Don't use barostat for vacuum simulations. - if self.hasBox() and self.hasWater(): + if self.hasBox(self._system, self._property_map) and self.hasWater( + self._system + ): # Enable barostat. protocol_dict["barostat"] = True pressure = self._protocol.getPressure().atm().value() From 777d4590846de474b0dfc8219e5b2770ce248438 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 14 Mar 2024 12:30:32 +0000 Subject: [PATCH 10/36] Add AMBER FEP analysis test. --- tests/FreeEnergy/test_relative.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/FreeEnergy/test_relative.py b/tests/FreeEnergy/test_relative.py index f93f49e61..6d01fe81f 100644 --- a/tests/FreeEnergy/test_relative.py +++ b/tests/FreeEnergy/test_relative.py @@ -54,8 +54,9 @@ def expected_results(): """A dictionary of expected FEP results.""" return { - "somd": {"mbar": -6.3519, "ti": -6.3209}, + "amber": {"mbar": -12.5939, "ti": -13.6850}, "gromacs": {"mbar": -6.0238, "ti": -8.4158}, + "somd": {"mbar": -6.3519, "ti": -6.3209}, } @@ -73,7 +74,7 @@ def test_setup_gromacs(perturbable_system): @pytest.mark.skipif( has_alchemlyb is False, reason="Requires alchemlyb to be installed." ) -@pytest.mark.parametrize("engine", ["somd", "gromacs"]) +@pytest.mark.parametrize("engine", ["amber", "gromacs", "somd"]) @pytest.mark.parametrize("estimator", ["mbar", "ti"]) def test_analysis(fep_output, engine, estimator, expected_results): """Test that the free energy analysis works as expected.""" From 31b34df77762975720672994ddd047729d824fe0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 14 Mar 2024 12:34:36 +0000 Subject: [PATCH 11/36] Add SOMD1 compatibility mode as an option for AMBER/GROMACS FEP. --- python/BioSimSpace/Process/_amber.py | 20 +- python/BioSimSpace/Process/_gromacs.py | 11 +- python/BioSimSpace/Process/_somd.py | 501 +++++++++++++++++++++++++ 3 files changed, 529 insertions(+), 3 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 8cf36cc39..901319a8a 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -239,9 +239,9 @@ def __init__( self._input_files.append(self._ref_file) # Now set up the working directory for the process. - self._setup() + self._setup(**kwargs) - def _setup(self): + def _setup(self, **kwargs): """Setup the input files and working directory ready for simulation.""" # Create the input files... @@ -254,6 +254,22 @@ def _setup(self): # Create the squashed system. if isinstance(self._protocol, _FreeEnergyMixin): + # Check that the system contains a perturbable molecule. + if self._system.nPerturbableMolecules() == 0: + raise ValueError( + "'BioSimSpace.Protocol.FreeEnergy' requires a " + "perturbable molecule!" + ) + + # Apply SOMD1 compatibility to the perturbation. + if ( + "somd1_compatibility" in kwargs + and kwargs.get("somd1_compatibility") is True + ): + from ._somd import _somd1_compatibility + + system = _somd1_compatibility(system) + system, self._mapping = _squash( system, explicit_dummies=self._explicit_dummies ) diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index c4e6e36a9..21e661a12 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -241,7 +241,7 @@ def __init__( ) # Now set up the working directory for the process. - self._setup() + self._setup(**kwargs) def _setup(self): """Setup the input files and working directory ready for simulation.""" @@ -268,6 +268,15 @@ def _setup(self): ) raise NotImplementedError(msg) + # Apply SOMD1 compatibility to the perturbation. + if ( + "somd1_compatibility" in kwargs + and kwargs.get("somd1_compatibility") is True + ): + from ._somd import _somd1_compatibility + + system = _somd1_compatibility(system) + else: # Check for perturbable molecules and convert to the chosen end state. system = self._checkPerturbable(system) diff --git a/python/BioSimSpace/Process/_somd.py b/python/BioSimSpace/Process/_somd.py index c64f8e689..1f3094daf 100644 --- a/python/BioSimSpace/Process/_somd.py +++ b/python/BioSimSpace/Process/_somd.py @@ -3047,3 +3047,504 @@ def _random_suffix(basename, size=4, chars=_string.ascii_uppercase + _string.dig + "AMBER atom names can only be 4 characters wide." ) return "".join(_random.choice(chars) for _ in range(size - basename_size)) + + +def _somd1_compatibility(system): + """ + Makes a perturbation SOMD1 compatible. + + Parameters + ---------- + + system : :class:`System ` + The system containing the molecules to be perturbed. + + Returns + ------- + + system : :class:`System ` + The updated system. + """ + + # Check the system is a Sire system. + if not isinstance(system, _System): + raise TypeError("'system' must of type 'BioSimSpace._SireWrappers.System'") + + # Search for perturbable molecules. + pert_mols = system.getPerturbableMolecules() + if len(pert_mols) == 0: + raise KeyError("No perturbable molecules in the system") + + # Store a dummy element. + dummy = _SireMol.Element("Xx") + + for mol in pert_mols: + # Get the underlying Sire molecule. + mol = mol._sire_object + + # Store the molecule info. + info = mol.info() + + # Get an editable version of the molecule. + edit_mol = mol.edit() + + ########################## + # First process the bonds. + ########################## + + new_bonds0 = _SireMM.TwoAtomFunctions(mol.info()) + new_bonds1 = _SireMM.TwoAtomFunctions(mol.info()) + + # Extract the bonds at lambda = 0 and 1. + bonds0 = mol.property("bond0").potentials() + bonds1 = mol.property("bond1").potentials() + + # Dictionaries to store the BondIDs at lambda = 0 and 1. + bonds0_idx = {} + bonds1_idx = {} + + # Loop over all bonds at lambda = 0. + for idx, bond in enumerate(bonds0): + # Get the AtomIdx for the atoms in the bond. + idx0 = info.atom_idx(bond.atom0()) + idx1 = info.atom_idx(bond.atom1()) + + # Create the BondID. + bond_id = _SireMol.BondID(idx0, idx1) + + # Add to the list of ids. + bonds0_idx[bond_id] = idx + + # Loop over all bonds at lambda = 1. + for idx, bond in enumerate(bonds1): + # Get the AtomIdx for the atoms in the bond. + idx0 = info.atom_idx(bond.atom0()) + idx1 = info.atom_idx(bond.atom1()) + + # Create the BondID. + bond_id = _SireMol.BondID(idx0, idx1) + + # Add to the list of ids. + if bond_id.mirror() in bonds0_idx: + bonds1_idx[bond_id.mirror()] = idx + else: + bonds1_idx[bond_id] = idx + + # Now work out the BondIDs that are unique at lambda = 0 and 1 + # as well as those that are shared. + bonds0_unique_idx = {} + bonds1_unique_idx = {} + bonds_shared_idx = {} + + # lambda = 0. + for idx in bonds0_idx.keys(): + if idx not in bonds1_idx.keys(): + bonds0_unique_idx[idx] = bonds0_idx[idx] + else: + bonds_shared_idx[idx] = (bonds0_idx[idx], bonds1_idx[idx]) + + # lambda = 1. + for idx in bonds1_idx.keys(): + if idx not in bonds0_idx.keys(): + bonds1_unique_idx[idx] = bonds1_idx[idx] + elif idx not in bonds_shared_idx.keys(): + bonds_shared_idx[idx] = (bonds0_idx[idx], bonds1_idx[idx]) + + # Loop over the shared bonds. + for idx0, idx1 in bonds_shared_idx.values(): + # Get the bond potentials. + p0 = bonds0[idx0] + p1 = bonds1[idx1] + + # Get the AtomIdx for the atoms in the angle. + idx0 = p0.atom0() + idx1 = p0.atom1() + + # Check whether a dummy atoms are present in the lambda = 0 + # and lambda = 1 states. + initial_dummy = _has_dummy(mol, [idx0, idx1]) + final_dummy = _has_dummy(mol, [idx0, idx1], True) + + # If there is a dummy, then set the potential to the opposite state. + # This should already be the case, but we explicitly set it here. + + if initial_dummy: + new_bonds0.set(idx0, idx1, p1.function()) + new_bonds1.set(idx0, idx1, p1.function()) + elif final_dummy: + new_bonds0.set(idx0, idx1, p0.function()) + new_bonds1.set(idx0, idx1, p0.function()) + else: + new_bonds0.set(idx0, idx1, p0.function()) + new_bonds1.set(idx0, idx1, p1.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() + + ######################### + # Now process the angles. + ######################### + + new_angles0 = _SireMM.ThreeAtomFunctions(mol.info()) + new_angles1 = _SireMM.ThreeAtomFunctions(mol.info()) + + # Extract the angles at lambda = 0 and 1. + angles0 = mol.property("angle0").potentials() + angles1 = mol.property("angle1").potentials() + + # Dictionaries to store the AngleIDs at lambda = 0 and 1. + angles0_idx = {} + angles1_idx = {} + + # Loop over all angles at lambda = 0. + for idx, angle in enumerate(angles0): + # Get the AtomIdx for the atoms in the angle. + idx0 = info.atom_idx(angle.atom0()) + idx1 = info.atom_idx(angle.atom1()) + idx2 = info.atom_idx(angle.atom2()) + + # Create the AngleID. + angle_id = _SireMol.AngleID(idx0, idx1, idx2) + + # Add to the list of ids. + angles0_idx[angle_id] = idx + + # Loop over all angles at lambda = 1. + for idx, angle in enumerate(angles1): + # Get the AtomIdx for the atoms in the angle. + idx0 = info.atom_idx(angle.atom0()) + idx1 = info.atom_idx(angle.atom1()) + idx2 = info.atom_idx(angle.atom2()) + + # Create the AngleID. + angle_id = _SireMol.AngleID(idx0, idx1, idx2) + + # Add to the list of ids. + if angle_id.mirror() in angles0_idx: + angles1_idx[angle_id.mirror()] = idx + else: + angles1_idx[angle_id] = idx + + # Now work out the AngleIDs that are unique at lambda = 0 and 1 + # as well as those that are shared. + angles0_unique_idx = {} + angles1_unique_idx = {} + angles_shared_idx = {} + + # lambda = 0. + for idx in angles0_idx.keys(): + if idx not in angles1_idx.keys(): + angles0_unique_idx[idx] = angles0_idx[idx] + else: + angles_shared_idx[idx] = (angles0_idx[idx], angles1_idx[idx]) + + # lambda = 1. + for idx in angles1_idx.keys(): + if idx not in angles0_idx.keys(): + angles1_unique_idx[idx] = angles1_idx[idx] + elif idx not in angles_shared_idx.keys(): + angles_shared_idx[idx] = (angles0_idx[idx], angles1_idx[idx]) + + # Loop over the angles. + for idx0, idx1 in angles_shared_idx.values(): + # Get the angle potentials. + p0 = angles0[idx0] + p1 = angles1[idx1] + + # Get the AtomIdx for the atoms in the angle. + idx0 = p0.atom0() + idx1 = p0.atom1() + idx2 = p0.atom2() + + # Check whether a dummy atoms are present in the lambda = 0 + # and lambda = 1 states. + initial_dummy = _has_dummy(mol, [idx0, idx1, idx2]) + final_dummy = _has_dummy(mol, [idx0, idx1, idx2], True) + + # If both end states contain a dummy, the use null potentials. + if initial_dummy and final_dummy: + theta = _SireCAS.Symbol("theta") + null_angle = _SireMM.AmberAngle(0.0, theta).to_expression(theta) + new_angles0.set(idx0, idx1, idx2, null_angle) + new_angles1.set(idx0, idx1, idx2, null_angle) + # If the initial state contains a dummy, then use the potential from the final state. + # This should already be the case, but we explicitly set it here. + elif initial_dummy: + new_angles0.set(idx0, idx1, idx2, p1.function()) + new_angles1.set(idx0, idx1, idx2, p1.function()) + # If the final state contains a dummy, then use the potential from the initial state. + # This should already be the case, but we explicitly set it here. + elif final_dummy: + new_angles0.set(idx0, idx1, idx2, p0.function()) + new_angles1.set(idx0, idx1, idx2, p0.function()) + # Otherwise, use the potentials from the initial and final states. + else: + new_angles0.set(idx0, idx1, idx2, p0.function()) + new_angles1.set(idx0, idx1, idx2, p1.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() + + ############################ + # Now process the dihedrals. + ############################ + + new_dihedrals0 = _SireMM.FourAtomFunctions(mol.info()) + new_dihedrals1 = _SireMM.FourAtomFunctions(mol.info()) + + # Extract the dihedrals at lambda = 0 and 1. + dihedrals0 = mol.property("dihedral0").potentials() + dihedrals1 = mol.property("dihedral1").potentials() + + # Dictionaries to store the DihedralIDs at lambda = 0 and 1. + dihedrals0_idx = {} + dihedrals1_idx = {} + + # Loop over all dihedrals at lambda = 0. + for idx, dihedral in enumerate(dihedrals0): + # Get the AtomIdx for the atoms in the dihedral. + idx0 = info.atom_idx(dihedral.atom0()) + idx1 = info.atom_idx(dihedral.atom1()) + idx2 = info.atom_idx(dihedral.atom2()) + idx3 = info.atom_idx(dihedral.atom3()) + + # Create the DihedralID. + dihedral_id = _SireMol.DihedralID(idx0, idx1, idx2, idx3) + + # Add to the list of ids. + dihedrals0_idx[dihedral_id] = idx + + # Loop over all dihedrals at lambda = 1. + for idx, dihedral in enumerate(dihedrals1): + # Get the AtomIdx for the atoms in the dihedral. + idx0 = info.atom_idx(dihedral.atom0()) + idx1 = info.atom_idx(dihedral.atom1()) + idx2 = info.atom_idx(dihedral.atom2()) + idx3 = info.atom_idx(dihedral.atom3()) + + # Create the DihedralID. + dihedral_id = _SireMol.DihedralID(idx0, idx1, idx2, idx3) + + # Add to the list of ids. + if dihedral_id.mirror() in dihedrals0_idx: + dihedrals1_idx[dihedral_id.mirror()] = idx + else: + dihedrals1_idx[dihedral_id] = idx + + # Now work out the DihedralIDs that are unique at lambda = 0 and 1 + # as well as those that are shared. + dihedrals0_unique_idx = {} + dihedrals1_unique_idx = {} + dihedrals_shared_idx = {} + + # lambda = 0. + for idx in dihedrals0_idx.keys(): + if idx not in dihedrals1_idx.keys(): + dihedrals0_unique_idx[idx] = dihedrals0_idx[idx] + else: + dihedrals_shared_idx[idx] = (dihedrals0_idx[idx], dihedrals1_idx[idx]) + + # lambda = 1. + for idx in dihedrals1_idx.keys(): + if idx not in dihedrals0_idx.keys(): + dihedrals1_unique_idx[idx] = dihedrals1_idx[idx] + elif idx not in dihedrals_shared_idx.keys(): + dihedrals_shared_idx[idx] = (dihedrals0_idx[idx], dihedrals1_idx[idx]) + + # Loop over the dihedrals. + for idx0, idx1 in dihedrals_shared_idx.values(): + # Get the dihedral potentials. + p0 = dihedrals0[idx0] + p1 = dihedrals1[idx1] + + # Get the AtomIdx for the atoms in the dihedral. + idx0 = info.atom_idx(p0.atom0()) + idx1 = info.atom_idx(p0.atom1()) + idx2 = info.atom_idx(p0.atom2()) + idx3 = info.atom_idx(p0.atom3()) + + # Whether any atom in each end state is a dummy. + has_dummy_initial = _has_dummy(mol, [idx0, idx1, idx2, idx3]) + has_dummy_final = _has_dummy(mol, [idx0, idx1, idx2, idx3], True) + + # Whether all atoms in each state are dummies. + all_dummy_initial = all(_is_dummy(mol, [idx0, idx1, idx2, idx3])) + all_dummy_final = all(_is_dummy(mol, [idx0, idx1, idx2, idx3], True)) + + # If both end states contain a dummy, the use null potentials. + if has_dummy_initial and has_dummy_final: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_dihedrals0.set(idx0, idx1, idx2, idx3, null_dihedral) + new_dihedrals1.set(idx0, idx1, idx2, idx3, null_dihedral) + elif has_dummy_initial: + # If all the atoms are dummy, then use the potential from the final state. + if all_dummy_initial: + new_dihedrals0.set(idx0, idx1, idx2, idx3, p1.function()) + new_dihedrals1.set(idx0, idx1, idx2, idx3, p1.function()) + # Otherwise, zero the potential. + else: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_dihedrals0.set(idx0, idx1, idx2, idx3, null_dihedral) + new_dihedrals1.set(idx0, idx1, idx2, idx3, p1.function()) + elif has_dummy_final: + # If all the atoms are dummy, then use the potential from the initial state. + if all_dummy_final: + new_dihedrals0.set(idx0, idx1, idx2, idx3, p0.function()) + new_dihedrals1.set(idx0, idx1, idx2, idx3, p0.function()) + # Otherwise, zero the potential. + else: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_dihedrals0.set(idx0, idx1, idx2, idx3, p0.function()) + new_dihedrals1.set(idx0, idx1, idx2, idx3, null_dihedral) + else: + new_dihedrals0.set(idx0, idx1, idx2, idx3, p0.function()) + new_dihedrals1.set(idx0, idx1, idx2, idx3, p1.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() + + ############################ + # Now process the impropers. + ############################ + + new_impropers0 = _SireMM.FourAtomFunctions(mol.info()) + new_impropers1 = _SireMM.FourAtomFunctions(mol.info()) + + # Extract the impropers at lambda = 0 and 1. + impropers0 = mol.property("improper0").potentials() + impropers1 = mol.property("improper1").potentials() + + # Dictionaries to store the ImproperIDs at lambda = 0 and 1. + impropers0_idx = {} + impropers1_idx = {} + + # Loop over all impropers at lambda = 0. + for idx, improper in enumerate(impropers0): + # Get the AtomIdx for the atoms in the improper. + idx0 = info.atom_idx(improper.atom0()) + idx1 = info.atom_idx(improper.atom1()) + idx2 = info.atom_idx(improper.atom2()) + idx3 = info.atom_idx(improper.atom3()) + + # Create the ImproperID. + improper_id = _SireMol.ImproperID(idx0, idx1, idx2, idx3) + + # Add to the list of ids. + impropers0_idx[improper_id] = idx + + # Loop over all impropers at lambda = 1. + for idx, improper in enumerate(impropers1): + # Get the AtomIdx for the atoms in the improper. + idx0 = info.atom_idx(improper.atom0()) + idx1 = info.atom_idx(improper.atom1()) + idx2 = info.atom_idx(improper.atom2()) + idx3 = info.atom_idx(improper.atom3()) + + # Create the ImproperID. + improper_id = _SireMol.ImproperID(idx0, idx1, idx2, idx3) + + # Add to the list of ids. + # You cannot mirror an improper! + impropers1_idx[improper_id] = idx + + # Now work out the ImproperIDs that are unique at lambda = 0 and 1 + # as well as those that are shared. Note that the ordering of + # impropers is inconsistent between molecular topology formats so + # we test all permutations of atom ordering to find matches. This + # is achieved using the ImproperID.equivalent() method. + impropers0_unique_idx = {} + impropers1_unique_idx = {} + impropers_shared_idx = {} + + # lambda = 0. + for idx0 in impropers0_idx.keys(): + for idx1 in impropers1_idx.keys(): + if idx0.equivalent(idx1): + impropers_shared_idx[idx0] = ( + impropers0_idx[idx0], + impropers1_idx[idx1], + ) + break + else: + impropers0_unique_idx[idx0] = impropers0_idx[idx0] + + # lambda = 1. + for idx1 in impropers1_idx.keys(): + for idx0 in impropers0_idx.keys(): + if idx1.equivalent(idx0): + # Don't store duplicates. + if not idx0 in impropers_shared_idx.keys(): + impropers_shared_idx[idx1] = ( + impropers0_idx[idx0], + impropers1_idx[idx1], + ) + break + else: + impropers1_unique_idx[idx1] = impropers1_idx[idx1] + + # Loop over the impropers. + for idx0, idx1 in impropers_shared_idx.values(): + # Get the improper potentials. + p0 = impropers0[idx0] + p1 = impropers1[idx1] + + # Get the AtomIdx for the atoms in the dihedral. + idx0 = info.atom_idx(p0.atom0()) + idx1 = info.atom_idx(p0.atom1()) + idx2 = info.atom_idx(p0.atom2()) + idx3 = info.atom_idx(p0.atom3()) + + # Whether any atom in each end state is a dummy. + has_dummy_initial = _has_dummy(mol, [idx0, idx1, idx2, idx3]) + has_dummy_final = _has_dummy(mol, [idx0, idx1, idx2, idx3], True) + + # Whether all atoms in each state are dummies. + all_dummy_initial = all(_is_dummy(mol, [idx0, idx1, idx2, idx3])) + all_dummy_final = all(_is_dummy(mol, [idx0, idx1, idx2, idx3], True)) + + if has_dummy_initial and has_dummy_final: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_impropers0.set(idx0, idx1, idx2, idx3, null_dihedral) + new_impropers1.set(idx0, idx1, idx2, idx3, null_dihedral) + elif has_dummy_initial: + # If all the atoms are dummy, then use the potential from the final state. + if all_dummy_initial: + new_impropers0.set(idx0, idx1, idx2, idx3, p1.function()) + new_impropers1.set(idx0, idx1, idx2, idx3, p1.function()) + # Otherwise, zero the potential. + else: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_impropers0.set(idx0, idx1, idx2, idx3, null_dihedral) + new_impropers1.set(idx0, idx1, idx2, idx3, p1.function()) + elif has_dummy_final: + # If all the atoms are dummy, then use the potential from the initial state. + if all_dummy_final: + new_impropers0.set(idx0, idx1, idx2, idx3, p0.function()) + new_impropers1.set(idx0, idx1, idx2, idx3, p0.function()) + # Otherwise, zero the potential. + else: + phi = _SireCAS.Symbol("phi") + null_dihedral = _SireMM.AmberDihedral(0.0, phi).to_expression(phi) + new_impropers0.set(idx0, idx1, idx2, idx3, p0.function()) + new_impropers1.set(idx0, idx1, idx2, idx3, null_dihedral) + else: + new_impropers0.set(idx0, idx1, idx2, idx3, p0.function()) + new_impropers1.set(idx0, idx1, idx2, idx3, p1.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() + + # Commit the changes and update the molecule in the system. + system._sire_object.update(edit_mol.commit()) + + # Return the updated system. + return system From b87d1ed1ade50cc2bdf3aa046c68d0d40e94379e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 14 Mar 2024 13:04:42 +0000 Subject: [PATCH 12/36] Need to pass system to hasWater method. --- python/BioSimSpace/_Config/_somd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/BioSimSpace/_Config/_somd.py b/python/BioSimSpace/_Config/_somd.py index 7ee18fa7a..a5ba74106 100644 --- a/python/BioSimSpace/_Config/_somd.py +++ b/python/BioSimSpace/_Config/_somd.py @@ -173,7 +173,7 @@ def createConfig(self, extra_options={}, extra_lines=[]): pass # Periodic boundary conditions. - if self.hasWater(): + if self.hasWater(self._system): # Solvated box. protocol_dict["reaction field dielectric"] = "78.3" if not self.hasBox(self._system, self._property_map) or not self.hasWater( From 68bcc2c43af6ed15ad2a9b8a787e70eac24adc24 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 22 Mar 2024 12:31:18 +0000 Subject: [PATCH 13/36] Pass explicit_dummies kwargs through to _generate_amber_fep_masks. --- python/BioSimSpace/_Config/_amber.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 5b81d4b40..2dd446749 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -367,7 +367,9 @@ def createConfig( # Atom masks. protocol_dict = { **protocol_dict, - **self._generate_amber_fep_masks(timestep), + **self._generate_amber_fep_masks( + timestep, explicit_dummies=explicit_dummies + ), } # Put everything together in a line-by-line format. From 6da2b9d407cc866ed0274b06fe0c4518e49a0199 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 22 Mar 2024 13:15:54 +0000 Subject: [PATCH 14/36] Remove redundant openff.Topology import. --- python/BioSimSpace/Parameters/_Protocol/_openforcefield.py | 2 -- .../Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index 2f6358893..0367b79b0 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -76,12 +76,10 @@ if _have_imported(_openff): from openff.interchange import Interchange as _Interchange from openff.toolkit.topology import Molecule as _OpenFFMolecule - from openff.toolkit.topology import Topology as _OpenFFTopology from openff.toolkit.typing.engines.smirnoff import ForceField as _Forcefield else: _Interchange = _openff _OpenFFMolecule = _openff - _OpenFFTopology = _openff _Forcefield = _openff # Reset stderr. diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py index 2f6358893..0367b79b0 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py @@ -76,12 +76,10 @@ if _have_imported(_openff): from openff.interchange import Interchange as _Interchange from openff.toolkit.topology import Molecule as _OpenFFMolecule - from openff.toolkit.topology import Topology as _OpenFFTopology from openff.toolkit.typing.engines.smirnoff import ForceField as _Forcefield else: _Interchange = _openff _OpenFFMolecule = _openff - _OpenFFTopology = _openff _Forcefield = _openff # Reset stderr. From 33bdd2dd3c339a304285a6f8fa5c5eb030a13937 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 28 Mar 2024 13:23:45 +0000 Subject: [PATCH 15/36] Need to use tishake=1 and an appropriate noshakemask in vacuum. --- python/BioSimSpace/Process/_amber.py | 21 +++++++--- python/BioSimSpace/_Config/_amber.py | 58 +++++++++++++++++++++------- 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 901319a8a..a0fa1300c 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -160,6 +160,12 @@ def __init__( if not isinstance(is_gpu, bool): raise TypeError("'is_gpu' must be of type 'bool'") + # Check whether this is a vacuum simulation. + is_vacuum = not ( + _AmberConfig.hasBox(self._system, self._property_map) + or _AmberConfig.hasWater(self._system) + ) + # If the path to the executable wasn't specified, then search # for it in AMBERHOME and the PATH. if exe is None: @@ -168,12 +174,6 @@ def __init__( else: is_free_energy = False - # Check whether this is a vacuum simulation. - is_vacuum = not ( - _AmberConfig.hasBox(self._system, self._property_map) - or _AmberConfig.hasWater(self._system) - ) - self._exe = _find_exe( is_gpu=is_gpu, is_free_energy=is_free_energy, is_vacuum=is_vacuum ) @@ -184,6 +184,15 @@ def __init__( else: raise IOError("AMBER executable doesn't exist: '%s'" % exe) + # pmemd.cuda doesn't support vacuum free-energy simulations. + if isinstance(protocol, _FreeEnergyMixin): + is_cuda = "cuda" in self._exe.lower() + + if is_cuda and is_vacuum: + _warnings.warn( + "pmemd.cuda doesn't support vacuum free-energy simulations!" + ) + if not isinstance(explicit_dummies, bool): raise TypeError("'explicit_dummies' must be of type 'bool'") self._explicit_dummies = explicit_dummies diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 2dd446749..ed8269633 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -123,6 +123,14 @@ def createConfig( if not all(isinstance(line, str) for line in extra_lines): raise TypeError("Lines in 'extra_lines' must be of type 'str'.") + # Vaccum simulation. + if not self.hasBox(self._system, self._property_map) or not self.hasWater( + self._system + ): + is_vacuum = True + else: + is_vacuum = False + # Initialise the protocol lines. protocol_lines = [] @@ -173,8 +181,15 @@ def createConfig( # Report energies every 100 steps. protocol_dict["ntpr"] = 100 else: - # Define the timestep + # Get the time step. timestep = self._protocol.getTimeStep().picoseconds().value() + # For free-energy calculations, we can only use a 1fs time step in + # vacuum. + if isinstance(self._protocol, _FreeEnergyMixin): + if is_vacuum and timestep > 0.001: + raise ValueError( + "AMBER free-energy calculations in vacuum must use a 1fs time step." + ) # Set the integration time step. protocol_dict["dt"] = f"{timestep:.3f}" # Number of integration steps. @@ -368,7 +383,7 @@ def createConfig( protocol_dict = { **protocol_dict, **self._generate_amber_fep_masks( - timestep, explicit_dummies=explicit_dummies + self._system, is_vacuum, explicit_dummies=explicit_dummies ), } @@ -459,7 +474,7 @@ def _create_restraint_mask(self, atom_idxs): return restraint_mask - def _generate_amber_fep_masks(self, timestep, explicit_dummies=False): + def _generate_amber_fep_masks(self, system, is_vacuum, explicit_dummies=False): """ Internal helper function which generates timasks and scmasks based on the system. @@ -467,9 +482,11 @@ def _generate_amber_fep_masks(self, timestep, explicit_dummies=False): Parameters ---------- - timestep : [float] - The timestep in ps for the FEP perturbation. Generates a different - mask based on this. + system : :class:`System ` + The molecular system. + + is_vacuum : bool + Whether this is a vacuum simulation. explicit_dummies : bool Whether to keep the dummy atoms explicit at the endstates or remove them. @@ -509,19 +526,34 @@ def _generate_amber_fep_masks(self, timestep, explicit_dummies=False): ti0_indices = mcs0_indices + dummy0_indices ti1_indices = mcs1_indices + dummy1_indices - # SHAKE should be used for timestep > 2 fs. - if timestep is not None and timestep >= 0.002: - no_shake_mask = "" - else: - no_shake_mask = _amber_mask_from_indices(ti0_indices + ti1_indices) - # Create an option dict with amber masks generated from the above indices. option_dict = { "timask1": f'"{_amber_mask_from_indices(ti0_indices)}"', "timask2": f'"{_amber_mask_from_indices(ti1_indices)}"', "scmask1": f'"{_amber_mask_from_indices(dummy0_indices)}"', "scmask2": f'"{_amber_mask_from_indices(dummy1_indices)}"', - "noshakemask": f'"{no_shake_mask}"', + "tishake": 1 if is_vacuum else 0, } + # Add a noshakemask for the perturbed residue. + if is_vacuum: + # Get the perturbable molecules. + pert_mols = system.getPerturbableMolecules() + + # Initialise the noshakemask string. + noshakemask = "" + + # Loop over all perturbable molecules and add residues to the mask. + for mol in pert_mols: + if noshakemask == "": + noshakemask += ":" + for res in mol.getResidues(): + noshakemask += f"{system.getIndex(res) + 1}," + + # Strip the trailing comma. + noshakemask = noshakemask[:-1] + + # Add the noshakemask to the option dict. + option_dict["noshakemask"] = f'"{noshakemask}"' + return option_dict From 188fb683c2685b059e2aaa7195921a508844cd7d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 28 Mar 2024 14:33:11 +0000 Subject: [PATCH 16/36] Use is_vacuum flag. --- python/BioSimSpace/_Config/_amber.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index ed8269633..b5d782fa1 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -203,9 +203,7 @@ def createConfig( protocol_dict["ntf"] = 2 # Periodic boundary conditions. - if not self.hasBox(self._system, self._property_map) or not self.hasWater( - self._system - ): + if is_vacuum: # No periodic box. protocol_dict["ntb"] = 0 # Non-bonded cut-off. From 7f11fbdd2ffdd146716378676f844701acb99c17 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 8 Apr 2024 14:16:01 +0100 Subject: [PATCH 17/36] Make sure kwargs are supported in __init__. --- python/BioSimSpace/Process/_gromacs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index 18ac8230f..ec6fb6dea 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -88,6 +88,7 @@ def __init__( ignore_warnings=False, show_errors=True, checkpoint_file=None, + **kwargs, ): """ Constructor. @@ -147,6 +148,9 @@ def __init__( The path to a checkpoint file from a previous run. This can be used to continue an existing simulation. Currently we only support the use of checkpoint files for Equilibration protocols. + + kwargs : dict + Additional keyword arguments. """ # Call the base class constructor. @@ -243,7 +247,7 @@ def __init__( # Now set up the working directory for the process. self._setup(**kwargs) - def _setup(self): + def _setup(self, **kwargs): """Setup the input files and working directory ready for simulation.""" # Create the input files... From 044ad4a565d72e0305ca09f7e79a978a7400f1d0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 8 Apr 2024 15:18:44 +0100 Subject: [PATCH 18/36] Handle vaccum FEP simulations with pmemd and pmemd.cuda. --- python/BioSimSpace/Process/_amber.py | 104 +++++++++++++++------------ python/BioSimSpace/_Config/_amber.py | 60 +++++++++------- 2 files changed, 92 insertions(+), 72 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index a0fa1300c..9ca469071 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -161,11 +161,14 @@ def __init__( raise TypeError("'is_gpu' must be of type 'bool'") # Check whether this is a vacuum simulation. - is_vacuum = not ( + self._is_vacuum = not ( _AmberConfig.hasBox(self._system, self._property_map) or _AmberConfig.hasWater(self._system) ) + # Flag to indicate whether the original system has a box. + self._has_box = _AmberConfig.hasBox(self._system, self._property_map) + # If the path to the executable wasn't specified, then search # for it in AMBERHOME and the PATH. if exe is None: @@ -175,7 +178,7 @@ def __init__( is_free_energy = False self._exe = _find_exe( - is_gpu=is_gpu, is_free_energy=is_free_energy, is_vacuum=is_vacuum + is_gpu=is_gpu, is_free_energy=is_free_energy, is_vacuum=self._is_vacuum ) else: # Make sure executable exists. @@ -184,14 +187,16 @@ def __init__( else: raise IOError("AMBER executable doesn't exist: '%s'" % exe) - # pmemd.cuda doesn't support vacuum free-energy simulations. - if isinstance(protocol, _FreeEnergyMixin): - is_cuda = "cuda" in self._exe.lower() - - if is_cuda and is_vacuum: - _warnings.warn( - "pmemd.cuda doesn't support vacuum free-energy simulations!" - ) + # Is this a CUDA enabled version of AMBER? + if "cuda" in self._exe.lower(): + self._is_pmemd_cuda = True + self._is_pmemd = False + else: + self._is_pmemd_cuda = False + if "pmemd" in self._exe.lower(): + self._is_pmemd = True + else: + self._is_pmemd = False if not isinstance(explicit_dummies, bool): raise TypeError("'explicit_dummies' must be of type 'bool'") @@ -270,6 +275,33 @@ def _setup(self, **kwargs): "perturbable molecule!" ) + # If this is vacuum simulation with pmemd.cuda then + # we need to add a simulation box. + if self._is_vacuum and self._is_pmemd_cuda: + # Get the existing box information. + box, _ = system.getBox() + + # We need to add a box. + if box is None: + from ..Box import cubic as _cubic + from ..Units.Length import angstrom as _angstrom + + # Get the bounding box of the system. + box_min, box_max = system.getAxisAlignedBoundingBox() + + # Work out the box size from the difference in the coordinates. + box_size = [y - x for x, y in zip(box_min, box_max)] + + # Work out the size of the box assuming an 8 Angstrom non-bonded cutoff. + padding = 8 * _angstrom + box_length = max(box_size) + padding + # Small box fix. Should be patched in future versions of pmemd.cuda. + if box_length < 30 * _angstrom: + box_length = 30 * _angstrom + + # Set the simulation box. + system.setBox(*_cubic(box_length)) + # Apply SOMD1 compatibility to the perturbation. if ( "somd1_compatibility" in kwargs @@ -283,6 +315,7 @@ def _setup(self, **kwargs): system, explicit_dummies=self._explicit_dummies ) self._squashed_system = system + else: # Check for perturbable molecules and convert to the chosen end state. system = self._checkPerturbable(system) @@ -341,12 +374,6 @@ def _setup(self, **kwargs): def _generate_config(self): """Generate AMBER configuration file strings.""" - # Is this a CUDA enabled version of AMBER? - if "cuda" in self._exe.lower(): - is_pmemd_cuda = True - else: - is_pmemd_cuda = False - extra_options = self._extra_options.copy() extra_lines = self._extra_lines.copy() @@ -388,7 +415,8 @@ def _generate_config(self): # Create the configuration. self.setConfig( amber_config.createConfig( - is_pmemd_cuda=is_pmemd_cuda, + is_pmemd=self._is_pmemd, + is_pmemd_cuda=self._is_pmemd_cuda, explicit_dummies=self._explicit_dummies, extra_options=extra_options, extra_lines=extra_lines, @@ -577,12 +605,13 @@ def getSystem(self, block="AUTO"): self._mapping = mapping # Update the box information in the original system. - if "space" in new_system._sire_object.propertyKeys(): - box = new_system._sire_object.property("space") - if box.isPeriodic(): - old_system._sire_object.setProperty( - self._property_map.get("space", "space"), box - ) + if self._has_box: + if "space" in new_system._sire_object.propertyKeys(): + box = new_system._sire_object.property("space") + if box.isPeriodic(): + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) return old_system @@ -705,11 +734,12 @@ def getFrame(self, index): self._mapping = mapping # Update the box information in the original system. - if "space" in new_system._sire_object.propertyKeys(): - box = new_system._sire_object.property("space") - old_system._sire_object.setProperty( - self._property_map.get("space", "space"), box - ) + if self._has_box: + if "space" in new_system._sire_object.propertyKeys(): + box = new_system._sire_object.property("space") + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) return old_system @@ -2783,26 +2813,10 @@ def _find_exe(is_gpu=False, is_free_energy=False, is_vacuum=False): if not isinstance(is_vacuum, bool): raise TypeError("'is_vacuum' must be of type 'bool'.") - # It is not possible to use implicit solvent for free energy simulations - # on GPU, so we fall back to pmemd for vacuum free energy simulations. - - if is_gpu and is_free_energy and is_vacuum: - _warnings.warn( - "Implicit solvent is not supported for free energy simulations on GPU. " - "Falling back to pmemd for vacuum free energy simulations." - ) - is_gpu = False - if is_gpu: targets = ["pmemd.cuda"] else: - if is_free_energy and not is_vacuum: - if is_vacuum: - targets = ["pmemd"] - else: - targets = ["pmemd", "pmemd.cuda"] - else: - targets = ["pmemd", "sander"] + targets = ["pmemd", "sander"] # Search for the executable. diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index b5d782fa1..381401fdd 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -79,6 +79,9 @@ def createConfig( version : float The AMBER version. + is_pmemd : bool + Whether the configuration is for a simulation using PMEMD. + is_pmemd_cuda : bool Whether the configuration is for a simulation using PMEMD with CUDA. @@ -104,6 +107,9 @@ def createConfig( if version and not isinstance(version, float): raise TypeError("'version' must be of type 'float'.") + if not isinstance(is_pmemd, bool): + raise TypeError("'is_pmemd' must be of type 'bool'.") + if not isinstance(is_pmemd_cuda, bool): raise TypeError("'is_pmemd_cuda' must be of type 'bool'.") @@ -186,9 +192,9 @@ def createConfig( # For free-energy calculations, we can only use a 1fs time step in # vacuum. if isinstance(self._protocol, _FreeEnergyMixin): - if is_vacuum and timestep > 0.001: + if is_vacuum and not is_pmemd_cuda and timestep > 0.001: raise ValueError( - "AMBER free-energy calculations in vacuum must use a 1fs time step." + "AMBER free-energy calculations in vacuum using pmemd must use a 1fs time step." ) # Set the integration time step. protocol_dict["dt"] = f"{timestep:.3f}" @@ -203,7 +209,9 @@ def createConfig( protocol_dict["ntf"] = 2 # Periodic boundary conditions. - if is_vacuum: + if is_vacuum and not ( + is_pmemd_cuda and isinstance(self._protocol, _FreeEnergyMixin) + ): # No periodic box. protocol_dict["ntb"] = 0 # Non-bonded cut-off. @@ -381,7 +389,11 @@ def createConfig( protocol_dict = { **protocol_dict, **self._generate_amber_fep_masks( - self._system, is_vacuum, explicit_dummies=explicit_dummies + self._system, + is_vacuum, + is_pmemd_cuda, + timestep, + explicit_dummies=explicit_dummies, ), } @@ -472,7 +484,9 @@ def _create_restraint_mask(self, atom_idxs): return restraint_mask - def _generate_amber_fep_masks(self, system, is_vacuum, explicit_dummies=False): + def _generate_amber_fep_masks( + self, system, is_vacuum, is_pmemd_cuda, timestep, explicit_dummies=False + ): """ Internal helper function which generates timasks and scmasks based on the system. @@ -486,6 +500,12 @@ def _generate_amber_fep_masks(self, system, is_vacuum, explicit_dummies=False): is_vacuum : bool Whether this is a vacuum simulation. + is_pmemd_cuda : bool + Whether this is a CUDA simulation. + + timestep : float + The timestep of the simulation in femtoseconds. + explicit_dummies : bool Whether to keep the dummy atoms explicit at the endstates or remove them. @@ -524,34 +544,20 @@ def _generate_amber_fep_masks(self, system, is_vacuum, explicit_dummies=False): ti0_indices = mcs0_indices + dummy0_indices ti1_indices = mcs1_indices + dummy1_indices + # SHAKE should be used for timestep >= 2 fs. + if timestep is None or timestep >= 0.002: + no_shake_mask = "" + else: + no_shake_mask = _amber_mask_from_indices(ti0_indices + ti1_indices) + # Create an option dict with amber masks generated from the above indices. option_dict = { "timask1": f'"{_amber_mask_from_indices(ti0_indices)}"', "timask2": f'"{_amber_mask_from_indices(ti1_indices)}"', "scmask1": f'"{_amber_mask_from_indices(dummy0_indices)}"', "scmask2": f'"{_amber_mask_from_indices(dummy1_indices)}"', - "tishake": 1 if is_vacuum else 0, + "tishake": 0 if is_pmemd_cuda else 1, + "noshakemask": f'"{no_shake_mask}"', } - # Add a noshakemask for the perturbed residue. - if is_vacuum: - # Get the perturbable molecules. - pert_mols = system.getPerturbableMolecules() - - # Initialise the noshakemask string. - noshakemask = "" - - # Loop over all perturbable molecules and add residues to the mask. - for mol in pert_mols: - if noshakemask == "": - noshakemask += ":" - for res in mol.getResidues(): - noshakemask += f"{system.getIndex(res) + 1}," - - # Strip the trailing comma. - noshakemask = noshakemask[:-1] - - # Add the noshakemask to the option dict. - option_dict["noshakemask"] = f'"{noshakemask}"' - return option_dict From f1fb052cbe66ba3db63182187cdbc3ea9e502b66 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 8 Apr 2024 16:04:43 +0100 Subject: [PATCH 19/36] Remove the parameters property before creating a partial molecule. [ref OpenBioSim/sire#183] --- python/BioSimSpace/Align/_merge.py | 6 ++++++ python/BioSimSpace/Sandpit/Exscientia/Align/_merge.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/python/BioSimSpace/Align/_merge.py b/python/BioSimSpace/Align/_merge.py index f43eb3a5e..f4693e83e 100644 --- a/python/BioSimSpace/Align/_merge.py +++ b/python/BioSimSpace/Align/_merge.py @@ -1417,6 +1417,12 @@ def _removeDummies(molecule, is_lambda1): is_lambda1=is_lambda1, generate_intrascale=True ) + # Remove the parameters property, if it exists. + if "parameters" in molecule._sire_object.propertyKeys(): + molecule._sire_object = ( + molecule._sire_object.edit().removeProperty("parameters").commit() + ) + # Set the coordinates to those at lambda = 0 molecule._sire_object = ( molecule._sire_object.edit().setProperty("coordinates", coordinates).commit() diff --git a/python/BioSimSpace/Sandpit/Exscientia/Align/_merge.py b/python/BioSimSpace/Sandpit/Exscientia/Align/_merge.py index 859662a16..29dbf731f 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Align/_merge.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Align/_merge.py @@ -1561,6 +1561,12 @@ def _removeDummies(molecule, is_lambda1): is_lambda1=is_lambda1, generate_intrascale=True ) + # Remove the parameters property, if it exists. + if "parameters" in molecule._sire_object.propertyKeys(): + molecule._sire_object = ( + molecule._sire_object.edit().removeProperty("parameters").commit() + ) + # Set the coordinates to those at lambda = 0 molecule._sire_object = ( molecule._sire_object.edit().setProperty("coordinates", coordinates).commit() From 185ccaa54b5c575b327a0361c5a1fcc3260e1d3b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 8 Apr 2024 16:10:28 +0100 Subject: [PATCH 20/36] Remove the parameters property when extracting a partial molecule. [ref OpenBioSim/sire#183] --- .../Sandpit/Exscientia/_SireWrappers/_molecule.py | 13 ++++++++++--- python/BioSimSpace/_SireWrappers/_molecule.py | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 6de6095dd..e9df3208e 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -353,10 +353,17 @@ def extract(self, indices, renumber=False, property_map={}): for idx in indices_: selection.select(idx) + # Store the Sire molecule. + sire_mol = self._sire_object + + # Remove the "parameters" property, if it exists. + if sire_mol.hasProperty("parameters"): + sire_mol = ( + sire_mol.edit().removeProperty("parameters").commit().molecule() + ) + partial_mol = ( - _SireMol.PartialMolecule(self._sire_object, selection) - .extract() - .molecule() + _SireMol.PartialMolecule(sire_mol, selection).extract().molecule() ) except Exception as e: msg = "Unable to create partial molecule!" diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index a7f510e17..efa7fb4fa 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -353,10 +353,17 @@ def extract(self, indices, renumber=False, property_map={}): for idx in indices_: selection.select(idx) + # Store the Sire molecule. + sire_mol = self._sire_object + + # Remove the "parameters" property, if it exists. + if sire_mol.hasProperty("parameters"): + sire_mol = ( + sire_mol.edit().removeProperty("parameters").commit().molecule() + ) + partial_mol = ( - _SireMol.PartialMolecule(self._sire_object, selection) - .extract() - .molecule() + _SireMol.PartialMolecule(sire_mol, selection).extract().molecule() ) except Exception as e: msg = "Unable to create partial molecule!" From 698473b6382679f98b16f0dcd0d9296536843dfb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 9 Apr 2024 10:45:40 +0100 Subject: [PATCH 21/36] Add pmemd single-point energy tests. --- tests/Process/test_amber.py | 141 ++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index bd7a8a703..6c726264d 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -1,7 +1,9 @@ from collections import OrderedDict +import math import pytest import shutil +import socket import BioSimSpace as BSS @@ -44,6 +46,17 @@ def perturbable_system(): ) +@pytest.fixture(scope="module") +def solvated_perturbable_system(): + """Re-use the same solvated perturbable system for each test.""" + return BSS.IO.readPerturbableSystem( + f"{url}/solvated_perturbable_system0.prm7", + f"{url}/solvated_perturbable_system0.rst7", + f"{url}/solvated_perturbable_system1.prm7", + f"{url}/solvated_perturbable_system1.rst7", + ) + + @pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") @pytest.mark.parametrize("restraint", restraints) def test_minimise(system, restraint): @@ -364,3 +377,131 @@ def test_parse_fep_output(perturbable_system, protocol): else: assert len(records_sc0) == 0 assert len(records_sc1) != 0 + + +@pytest.mark.skipif( + socket.gethostname() != "porridge", + reason="Local test requiring pmemd installation.", +) +def test_pmemd(system): + """Single-point energy tests for pmemd.""" + + # Path to the pmemd conda environment bin directory. + bin_dir = "/home/lester/.conda/envs/pmemd/bin" + + # Single-point minimisation protocol. + protocol = BSS.Protocol.Minimisation(steps=1) + + # First perform single-point comparisons in solvent. + + # Compute the single-point energy using sander. + process = BSS.Process.Amber(system, protocol) + process.start() + process.wait() + assert not process.isError() + nrg_sander = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd. + process = BSS.Process.Amber(system, protocol, exe=f"{bin_dir}/pmemd") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd.cuda. + process = BSS.Process.Amber(system, protocol, exe=f"{bin_dir}/pmemd.cuda") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd_cuda = process.getTotalEnergy().value() + + # Check that the energies are the same. + assert math.isclose(nrg_sander, nrg_pmemd, rel_tol=1e-4) + assert math.isclose(nrg_sander, nrg_pmemd_cuda, rel_tol=1e-4) + + # Now perform single-point comparisons in vacuum. + + vac_system = system[0].toSystem() + + # Compute the single-point energy using sander. + process = BSS.Process.Amber(vac_system, protocol) + process.start() + process.wait() + assert not process.isError() + nrg_sander = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd. + process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd.cuda. + process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd.cuda") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd_cuda = process.getTotalEnergy().value() + + # Check that the energies are the same. + assert math.isclose(nrg_sander, nrg_pmemd, rel_tol=1e-4) + assert math.isclose(nrg_sander, nrg_pmemd_cuda, rel_tol=1e-4) + + +@pytest.mark.skipif( + socket.gethostname() != "porridge", + reason="Local test requiring pmemd installation.", +) +def test_pmemd_fep(solvated_perturbable_system): + """Single-point FEP energy tests for pmemd.""" + + # Path to the pmemd conda environment bin directory. + bin_dir = "/home/lester/.conda/envs/pmemd/bin" + + # Single-point minimisation protocol. + protocol = BSS.Protocol.FreeEnergyMinimisation(steps=1) + + # First perform single-point comparisons in solvent. + + # Compute the single-point energy using pmemd. + process = BSS.Process.Amber( + solvated_perturbable_system, protocol, exe=f"{bin_dir}/pmemd" + ) + process.start() + process.wait() + assert not process.isError() + nrg_pmemd = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd.cuda. + process = BSS.Process.Amber( + solvated_perturbable_system, protocol, exe=f"{bin_dir}/pmemd.cuda" + ) + process.start() + process.wait() + assert not process.isError() + nrg_pmemd_cuda = process.getTotalEnergy().value() + + # Check that the energies are the same. + assert math.isclose(nrg_pmemd, nrg_pmemd_cuda, rel_tol=1e-4) + + # Now perform single-point comparisons in vacuum. + + vac_system = solvated_perturbable_system[0].toSystem() + + # Compute the single-point energy using pmemd. + process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd = process.getTotalEnergy().value() + + # Compute the single-point energy using pmemd.cuda. + process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd.cuda") + process.start() + process.wait() + assert not process.isError() + nrg_pmemd_cuda = process.getTotalEnergy().value() + + # Vaccum energies currently differ between pmemd and pmemd.cuda. + assert not math.isclose(nrg_pmemd, nrg_pmemd_cuda, rel_tol=1e-4) From 692849e2eee68b727bc19b2d7da685681fc9165e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 9 Apr 2024 13:07:15 +0100 Subject: [PATCH 22/36] Use list rather than set so search strings are reproducible. [closes #270] --- .../Sandpit/Exscientia/_SireWrappers/_utils.py | 12 ++++++------ python/BioSimSpace/_SireWrappers/_utils.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_utils.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_utils.py index 6d735cc65..9f2b3eac3 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_utils.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_utils.py @@ -22,8 +22,8 @@ Utilities. """ -# A set of protein residues. Taken from MDAnalysis. -_prot_res = { +# A list of protein residues. Taken from MDAnalysis. +_prot_res = [ # CHARMM top_all27_prot_lipid.rtf "ALA", "ARG", @@ -135,10 +135,10 @@ "CMET", "CME", "ASF", -} +] -# A set of nucleic acid residues. Taken from MDAnalysis. -_nucl_res = { +# A list of nucleic acid residues. Taken from MDAnalysis. +_nucl_res = [ "ADE", "URA", "CYT", @@ -173,7 +173,7 @@ "RU3", "RG3", "RC3", -} +] # A list of ion elements. _ions = [ diff --git a/python/BioSimSpace/_SireWrappers/_utils.py b/python/BioSimSpace/_SireWrappers/_utils.py index 6d735cc65..9f2b3eac3 100644 --- a/python/BioSimSpace/_SireWrappers/_utils.py +++ b/python/BioSimSpace/_SireWrappers/_utils.py @@ -22,8 +22,8 @@ Utilities. """ -# A set of protein residues. Taken from MDAnalysis. -_prot_res = { +# A list of protein residues. Taken from MDAnalysis. +_prot_res = [ # CHARMM top_all27_prot_lipid.rtf "ALA", "ARG", @@ -135,10 +135,10 @@ "CMET", "CME", "ASF", -} +] -# A set of nucleic acid residues. Taken from MDAnalysis. -_nucl_res = { +# A list of nucleic acid residues. Taken from MDAnalysis. +_nucl_res = [ "ADE", "URA", "CYT", @@ -173,7 +173,7 @@ "RU3", "RG3", "RC3", -} +] # A list of ion elements. _ions = [ From 81d82338953c60a787dceb71b991e7c5aa602304 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 9 Apr 2024 14:50:48 +0100 Subject: [PATCH 23/36] Join protein and nucleic acid residue strings correctly. --- python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py | 2 ++ python/BioSimSpace/_SireWrappers/_system.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py index 56bf3aebe..7dc882868 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py @@ -1888,6 +1888,7 @@ def getRestraintAtoms( string = ( "(not water) and (resname " + ",".join(_prot_res) + + "," + ",".join(_nucl_res) + ") and (atomname N,CA,C,O,P,/C5'/,/C3'/,/O3'/,/O5'/)" ) @@ -1943,6 +1944,7 @@ def getRestraintAtoms( string = ( "(not water) and (resname " + ",".join(_prot_res) + + "," + ",".join(_nucl_res) + ") and (atomname N,CA,C,O,P,/C5'/,/C3'/,/O3'/,/O5'/)" ) diff --git a/python/BioSimSpace/_SireWrappers/_system.py b/python/BioSimSpace/_SireWrappers/_system.py index 380a3b736..2d458890b 100644 --- a/python/BioSimSpace/_SireWrappers/_system.py +++ b/python/BioSimSpace/_SireWrappers/_system.py @@ -1809,6 +1809,7 @@ def getRestraintAtoms( string = ( "(not water) and (resname " + ",".join(_prot_res) + + "," + ",".join(_nucl_res) + ") and (atomname N,CA,C,O,P,/C5'/,/C3'/,/O3'/,/O5'/)" ) @@ -1864,6 +1865,7 @@ def getRestraintAtoms( string = ( "(not water) and (resname " + ",".join(_prot_res) + + "," + ",".join(_nucl_res) + ") and (atomname N,CA,C,O,P,/C5'/,/C3'/,/O3'/,/O5'/)" ) From f44b35ea0b649f0a9a5910bb097fcf3cf1f78abc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 9 Apr 2024 15:15:48 +0100 Subject: [PATCH 24/36] Use try/except when matching by coordinates. --- .../Sandpit/Exscientia/_SireWrappers/_molecule.py | 12 ++++++++++-- python/BioSimSpace/_SireWrappers/_molecule.py | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 6de6095dd..b4fa03ab7 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -792,7 +792,11 @@ def makeCompatibleWith( if len(matches) < num_atoms0: # Atom names or order might have changed. Try to match by coordinates. matcher = _SireMol.AtomCoordMatcher() - matches = matcher.match(mol0, mol1) + + try: + matches = matcher.match(mol0, mol1) + except: + matches = [] # We need to rename the atoms. is_renamed = True @@ -1003,7 +1007,11 @@ def makeCompatibleWith( matcher = _SireMol.AtomCoordMatcher() # Get the matches for this molecule and append to the list. - match = matcher.match(mol0, mol) + try: + match = matcher.match(mol0, mol) + except: + match = [] + matches.append(match) num_matches += len(match) diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index 089c88146..27fa121f0 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -748,7 +748,11 @@ def makeCompatibleWith( if len(matches) < num_atoms0: # Atom names or order might have changed. Try to match by coordinates. matcher = _SireMol.AtomCoordMatcher() - matches = matcher.match(mol0, mol1) + + try: + matches = matcher.match(mol0, mol1) + except: + matches = [] # We need to rename the atoms. is_renamed = True @@ -959,7 +963,11 @@ def makeCompatibleWith( matcher = _SireMol.AtomCoordMatcher() # Get the matches for this molecule and append to the list. - match = matcher.match(mol0, mol) + try: + match = matcher.match(mol0, mol) + except: + match = [] + matches.append(match) num_matches += len(match) From 9b870cd9f2aa3fd735bb17032e068346ec19579a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 11:19:57 +0100 Subject: [PATCH 25/36] Exclude sander from free-energy simulations. --- python/BioSimSpace/Process/_amber.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 9ca469071..7a105a582 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -2816,7 +2816,10 @@ def _find_exe(is_gpu=False, is_free_energy=False, is_vacuum=False): if is_gpu: targets = ["pmemd.cuda"] else: - targets = ["pmemd", "sander"] + if is_free_energy: + targets = ["pmemd"] + else: + targets = ["pmemd", "sander"] # Search for the executable. From 11f59ffaaffb476b363ab87fdd8a07a38da19db8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 11:34:49 +0100 Subject: [PATCH 26/36] Update to black 24. --- .../FreeEnergy/_restraint_search.py | 6 +- .../Sandpit/Exscientia/Protocol/_config.py | 110 +++++++++--------- python/BioSimSpace/_Config/_amber.py | 6 +- python/BioSimSpace/_Config/_gromacs.py | 6 +- recipes/biosimspace/template.yaml | 2 +- 5 files changed, 65 insertions(+), 65 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py index f7ec62dd4..31a53e956 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py @@ -2145,9 +2145,9 @@ def _getRestraintDict(u, pairs_ordered): } # If this is the first pair, add it as the permanent distance restraint. if i == 0: - restraint_dict[ - "permanent_distance_restraint" - ] = individual_restraint_dict + restraint_dict["permanent_distance_restraint"] = ( + individual_restraint_dict + ) else: restraint_dict["distance_restraints"].append( individual_restraint_dict diff --git a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py index a08935d9b..5b9f53f6f 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py @@ -217,9 +217,9 @@ def generateAmberConfig(self, extra_options=None, extra_lines=None): protocol_dict["imin"] = 1 # Minimisation simulation. protocol_dict["ntmin"] = 2 # Set the minimisation method to XMIN protocol_dict["maxcyc"] = self._steps # Set the number of steps. - protocol_dict[ - "ncyc" - ] = num_steep # Set the number of steepest descent steps. + protocol_dict["ncyc"] = ( + num_steep # Set the number of steepest descent steps. + ) # FIX need to remove and fix this, only for initial testing timestep = 0.004 else: @@ -318,9 +318,9 @@ def generateAmberConfig(self, extra_options=None, extra_lines=None): # Don't use barostat for vacuum simulations. if self._has_box and self._has_water: protocol_dict["ntp"] = 1 # Isotropic pressure scaling. - protocol_dict[ - "pres0" - ] = f"{self.protocol.getPressure().bar().value():.5f}" # Pressure in bar. + protocol_dict["pres0"] = ( + f"{self.protocol.getPressure().bar().value():.5f}" # Pressure in bar. + ) if isinstance(self.protocol, _Protocol.Equilibration): protocol_dict["barostat"] = 1 # Berendsen barostat. else: @@ -466,23 +466,23 @@ def generateGromacsConfig( protocol_dict["cutoff-scheme"] = "Verlet" # Use Verlet pair lists. if self._has_box and self._has_water: protocol_dict["ns-type"] = "grid" # Use a grid to search for neighbours. - protocol_dict[ - "nstlist" - ] = "20" # Rebuild neighbour list every 20 steps. Recommended in the manual for parallel simulations and/or non-bonded force calculation on the GPU. + protocol_dict["nstlist"] = ( + "20" # Rebuild neighbour list every 20 steps. Recommended in the manual for parallel simulations and/or non-bonded force calculation on the GPU. + ) protocol_dict["rlist"] = "0.8" # Set short-range cutoff. protocol_dict["rvdw"] = "0.8" # Set van der Waals cutoff. protocol_dict["rcoulomb"] = "0.8" # Set Coulomb cutoff. protocol_dict["coulombtype"] = "PME" # Fast smooth Particle-Mesh Ewald. - protocol_dict[ - "DispCorr" - ] = "EnerPres" # Dispersion corrections for energy and pressure. + protocol_dict["DispCorr"] = ( + "EnerPres" # Dispersion corrections for energy and pressure. + ) else: # Perform vacuum simulations by implementing pseudo-PBC conditions, # i.e. run calculation in a near-infinite box (333.3 nm). # c.f.: https://pubmed.ncbi.nlm.nih.gov/29678588 - protocol_dict[ - "nstlist" - ] = "1" # Single neighbour list (all particles interact). + protocol_dict["nstlist"] = ( + "1" # Single neighbour list (all particles interact). + ) protocol_dict["rlist"] = "333.3" # "Infinite" short-range cutoff. protocol_dict["rvdw"] = "333.3" # "Infinite" van der Waals cutoff. protocol_dict["rcoulomb"] = "333.3" # "Infinite" Coulomb cutoff. @@ -503,12 +503,12 @@ def generateGromacsConfig( # 4ps time constant for pressure coupling. # As the tau-p has to be 10 times larger than nstpcouple * dt (4 fs) protocol_dict["tau-p"] = 4 - protocol_dict[ - "ref-p" - ] = f"{self.protocol.getPressure().bar().value():.5f}" # Pressure in bar. - protocol_dict[ - "compressibility" - ] = "4.5e-5" # Compressibility of water. + protocol_dict["ref-p"] = ( + f"{self.protocol.getPressure().bar().value():.5f}" # Pressure in bar. + ) + protocol_dict["compressibility"] = ( + "4.5e-5" # Compressibility of water. + ) else: _warnings.warn( "Cannot use a barostat for a vacuum or non-periodic simulation" @@ -518,9 +518,9 @@ def generateGromacsConfig( if not isinstance(self.protocol, _Protocol.Minimisation): protocol_dict["integrator"] = "md" # leap-frog dynamics. protocol_dict["tcoupl"] = "v-rescale" - protocol_dict[ - "tc-grps" - ] = "system" # A single temperature group for the entire system. + protocol_dict["tc-grps"] = ( + "system" # A single temperature group for the entire system. + ) protocol_dict["tau-t"] = "{:.5f}".format( self.protocol.getTauT().picoseconds().value() ) # Collision frequency (ps). @@ -535,12 +535,12 @@ def generateGromacsConfig( timestep = self.protocol.getTimeStep().picoseconds().value() end_time = _math.floor(timestep * self._steps) - protocol_dict[ - "annealing" - ] = "single" # Single sequence of annealing points. - protocol_dict[ - "annealing-npoints" - ] = 2 # Two annealing points for "system" temperature group. + protocol_dict["annealing"] = ( + "single" # Single sequence of annealing points. + ) + protocol_dict["annealing-npoints"] = ( + 2 # Two annealing points for "system" temperature group. + ) # Linearly change temperature between start and end times. protocol_dict["annealing-time"] = "0 %d" % end_time @@ -609,20 +609,20 @@ def tranform(charge, LJ): "temperature", ]: if name in LambdaValues: - protocol_dict[ - "{:<20}".format("{}-lambdas".format(name)) - ] = " ".join( - list(map("{:.5f}".format, LambdaValues[name].to_list())) + protocol_dict["{:<20}".format("{}-lambdas".format(name))] = ( + " ".join( + list(map("{:.5f}".format, LambdaValues[name].to_list())) + ) ) - protocol_dict[ - "init-lambda-state" - ] = self.protocol.getLambdaIndex() # Current lambda value. - protocol_dict[ - "nstcalcenergy" - ] = self._report_interval # Calculate energies every report_interval steps. - protocol_dict[ - "nstdhdl" - ] = self._report_interval # Write gradients every report_interval steps. + protocol_dict["init-lambda-state"] = ( + self.protocol.getLambdaIndex() + ) # Current lambda value. + protocol_dict["nstcalcenergy"] = ( + self._report_interval + ) # Calculate energies every report_interval steps. + protocol_dict["nstdhdl"] = ( + self._report_interval + ) # Write gradients every report_interval steps. # Handle the combination of multiple distance restraints and perturbation type # of "release_restraint". In this case, the force constant of the "permanent" @@ -829,18 +829,18 @@ def generateSomdConfig( # Free energies. if isinstance(self.protocol, _Protocol._FreeEnergyMixin): if not isinstance(self.protocol, _Protocol.Minimisation): - protocol_dict[ - "constraint" - ] = "hbonds-notperturbed" # Handle hydrogen perturbations. - protocol_dict[ - "energy frequency" - ] = 250 # Write gradients every 250 steps. + protocol_dict["constraint"] = ( + "hbonds-notperturbed" # Handle hydrogen perturbations. + ) + protocol_dict["energy frequency"] = ( + 250 # Write gradients every 250 steps. + ) protocol = [str(x) for x in self.protocol.getLambdaValues()] protocol_dict["lambda array"] = ", ".join(protocol) - protocol_dict[ - "lambda_val" - ] = self.protocol.getLambda() # Current lambda value. + protocol_dict["lambda_val"] = ( + self.protocol.getLambda() + ) # Current lambda value. try: # RBFE res_num = ( @@ -857,9 +857,9 @@ def generateSomdConfig( .value() ) - protocol_dict[ - "perturbed residue number" - ] = res_num # Perturbed residue number. + protocol_dict["perturbed residue number"] = ( + res_num # Perturbed residue number. + ) # Put everything together in a line-by-line format. total_dict = {**protocol_dict, **extra_options} diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 381401fdd..7551fb1b9 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -306,9 +306,9 @@ def createConfig( # Isotropic pressure scaling. protocol_dict["ntp"] = 1 # Pressure in bar. - protocol_dict[ - "pres0" - ] = f"{self._protocol.getPressure().bar().value():.5f}" + protocol_dict["pres0"] = ( + f"{self._protocol.getPressure().bar().value():.5f}" + ) if isinstance(self._protocol, _Protocol.Equilibration): # Berendsen barostat. protocol_dict["barostat"] = 1 diff --git a/python/BioSimSpace/_Config/_gromacs.py b/python/BioSimSpace/_Config/_gromacs.py index 6724f2904..1355b274c 100644 --- a/python/BioSimSpace/_Config/_gromacs.py +++ b/python/BioSimSpace/_Config/_gromacs.py @@ -199,9 +199,9 @@ def createConfig(self, version=None, extra_options={}, extra_lines=[]): # 1ps time constant for pressure coupling. protocol_dict["tau-p"] = 1 # Pressure in bar. - protocol_dict[ - "ref-p" - ] = f"{self._protocol.getPressure().bar().value():.5f}" + protocol_dict["ref-p"] = ( + f"{self._protocol.getPressure().bar().value():.5f}" + ) # Compressibility of water. protocol_dict["compressibility"] = "4.5e-5" else: diff --git a/recipes/biosimspace/template.yaml b/recipes/biosimspace/template.yaml index 4e3a7edeb..fd9f0a4c4 100644 --- a/recipes/biosimspace/template.yaml +++ b/recipes/biosimspace/template.yaml @@ -27,7 +27,7 @@ test: - SIRE_SILENT_PHONEHOME requires: - pytest <8 - - black 23 # [linux and x86_64 and py==312] + - black 24 # [linux and x86_64 and py==312] - pytest-black # [linux and x86_64 and py==312] - ambertools # [linux and x86_64] - gromacs # [linux and x86_64] From 83fe05fabf37221b5ffdb4382ff60883c3b16ad8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 13:27:31 +0100 Subject: [PATCH 27/36] Add AMBER as an example engine in hydration free-energy tutorial. --- doc/source/tutorials/hydration_freenrg.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/source/tutorials/hydration_freenrg.rst b/doc/source/tutorials/hydration_freenrg.rst index 5ff767df8..2b9a416d6 100644 --- a/doc/source/tutorials/hydration_freenrg.rst +++ b/doc/source/tutorials/hydration_freenrg.rst @@ -167,6 +167,20 @@ Let's examine the directory for the :math:`{\lambda=0}` window of the free leg: gromacs.err gromacs.mdp gromacs.out.mdp gromacs.tpr gromacs.gro gromacs.out gromacs.top +Similarly, we can also set up the same simulations using AMBER: + +.. code-block:: python + + free_amber = BSS.FreeEnergy.Relative(solvated, protocol, engine="amber", work_dir="freenrg_amber/free") + vac_amber = BSS.FreeEnergy.Relative(merged.toSystem(), protocol, engine="amber", work_dir="freenrg_amber/vacuum") + +Let's examine the directory for the :math:`{\lambda=0}` window of the free leg: + +.. code-block:: bash + + $ ls freenrg_amber/free/lambda_0.0000 + amber.cfg amber.err amber.out amber.prm7 amber_ref.rst7 amber.rst7 + There you go! This tutorial has shown you how BioSimSpace can be used to easily set up everything that is needed for complex alchemical free energy simulations. Please visit the :data:`API documentation ` for further information. From 621b75aa2dae9a57b8bfae0e8f58bb08951b6005 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 14:06:49 +0100 Subject: [PATCH 28/36] Improve comment regarding single-point energy difference. --- tests/Process/test_amber.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index 6c726264d..a02627f03 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -504,4 +504,6 @@ def test_pmemd_fep(solvated_perturbable_system): nrg_pmemd_cuda = process.getTotalEnergy().value() # Vaccum energies currently differ between pmemd and pmemd.cuda. + # pmemd appears to be wrong as the CUDA version is consistent with + # sander and the result of non-FEP simulation. assert not math.isclose(nrg_pmemd, nrg_pmemd_cuda, rel_tol=1e-4) From 2ec7ee7cfb07398424f326c86ccb6d36786429d9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 14:29:06 +0100 Subject: [PATCH 29/36] Use readMolecules to read NAMD output since MoleculeParser segfaults. --- python/BioSimSpace/Process/_namd.py | 4 +--- python/BioSimSpace/Sandpit/Exscientia/Process/_namd.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/python/BioSimSpace/Process/_namd.py b/python/BioSimSpace/Process/_namd.py index c50165947..9811d26ab 100644 --- a/python/BioSimSpace/Process/_namd.py +++ b/python/BioSimSpace/Process/_namd.py @@ -774,9 +774,7 @@ def getSystem(self, block="AUTO"): is_lambda1 = False # Load the restart file. - new_system = _System( - _SireIO.MoleculeParser.read(files, self._property_map) - ) + new_system = _IO.readMolecules(files, property_map=self._property_map) # Create a copy of the existing system object. old_system = self._system.copy() diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_namd.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_namd.py index 314f31a92..ca02c05b9 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_namd.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_namd.py @@ -759,9 +759,7 @@ def getSystem(self, block="AUTO"): is_lambda1 = False # Load the restart file. - new_system = _System( - _SireIO.MoleculeParser.read(files, self._property_map) - ) + new_system = _IO.readMolecules(files, property_map=self._property_map) # Create a copy of the existing system object. old_system = self._system.copy() From 844fb1d462fc2db11411a2937740881779e228dd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 15:31:41 +0100 Subject: [PATCH 30/36] Don't use SHAKE for minimisation. [ci skip] --- python/BioSimSpace/_Config/_amber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 7551fb1b9..e0ee8f2f3 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -545,7 +545,7 @@ def _generate_amber_fep_masks( ti1_indices = mcs1_indices + dummy1_indices # SHAKE should be used for timestep >= 2 fs. - if timestep is None or timestep >= 0.002: + if timestep is not None and timestep >= 0.002: no_shake_mask = "" else: no_shake_mask = _amber_mask_from_indices(ti0_indices + ti1_indices) From 6ded910223edfdc9ff5e4ff0bb3a313fde213b15 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 18:11:35 +0100 Subject: [PATCH 31/36] Figured out how to match vacuum energies. --- python/BioSimSpace/_Config/_amber.py | 2 ++ tests/Process/test_amber.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index e0ee8f2f3..861fa5d72 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -558,6 +558,8 @@ def _generate_amber_fep_masks( "scmask2": f'"{_amber_mask_from_indices(dummy1_indices)}"', "tishake": 0 if is_pmemd_cuda else 1, "noshakemask": f'"{no_shake_mask}"', + "gti_add_sc": 1, + "gti_bat_sc": 1, } return option_dict diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index a02627f03..a6626047e 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -490,20 +490,25 @@ def test_pmemd_fep(solvated_perturbable_system): vac_system = solvated_perturbable_system[0].toSystem() # Compute the single-point energy using pmemd. - process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd") + process = BSS.Process.Amber( + vac_system, protocol, exe=f"{bin_dir}/pmemd", extra_options={"gti_bat_sc": 2} + ) process.start() process.wait() assert not process.isError() nrg_pmemd = process.getTotalEnergy().value() # Compute the single-point energy using pmemd.cuda. - process = BSS.Process.Amber(vac_system, protocol, exe=f"{bin_dir}/pmemd.cuda") + process = BSS.Process.Amber( + vac_system, + protocol, + exe=f"{bin_dir}/pmemd.cuda", + extra_options={"gti_bat_sc": 2}, + ) process.start() process.wait() assert not process.isError() nrg_pmemd_cuda = process.getTotalEnergy().value() - # Vaccum energies currently differ between pmemd and pmemd.cuda. - # pmemd appears to be wrong as the CUDA version is consistent with - # sander and the result of non-FEP simulation. - assert not math.isclose(nrg_pmemd, nrg_pmemd_cuda, rel_tol=1e-4) + # Check that the energies are the same. + assert math.isclose(nrg_pmemd, nrg_pmemd_cuda, rel_tol=1e-3) From 72948411f73df2906c9e31c1d9b38f01c02f187f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 18:20:37 +0100 Subject: [PATCH 32/36] Use correct file for reference system. --- python/BioSimSpace/Process/_openmm.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Process/_openmm.py b/python/BioSimSpace/Process/_openmm.py index 66c461495..2b7a45de8 100644 --- a/python/BioSimSpace/Process/_openmm.py +++ b/python/BioSimSpace/Process/_openmm.py @@ -248,6 +248,9 @@ def _setup(self): # Convert the water model topology so that it matches the AMBER naming convention. system._set_water_topology("AMBER", property_map=self._property_map) + self._reference_system._set_water_topology( + "AMBER", property_map=self._property_map + ) # Check for perturbable molecules and convert to the chosen end state. system = self._checkPerturbable(system) @@ -269,7 +272,12 @@ def _setup(self): if self._protocol.getRestraint() is not None: try: file = _os.path.splitext(self._ref_file)[0] - _IO.saveMolecules(file, system, "rst7", property_map=self._property_map) + _IO.saveMolecules( + file, + self._reference_system, + "rst7", + property_map=self._property_map, + ) except Exception as e: msg = "Failed to write reference system to 'RST7' format." if _isVerbose(): @@ -2174,7 +2182,7 @@ def _add_config_restraints(self): restrained_atoms = restraint self.addToConfig( - f"ref_prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + f"ref_prm = parmed.load_file('{self._top_file}', '{self._ref_file}')" ) # Get the force constant in units of kJ_per_mol/nanometer**2 From daad0e1c9329cb0f189ddd4b5b2f2e66cf4102d4 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 18:20:51 +0100 Subject: [PATCH 33/36] Convert water topology of reference system so naming matches. --- python/BioSimSpace/Process/_amber.py | 3 +++ python/BioSimSpace/Process/_gromacs.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 7a105a582..466216e87 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -265,6 +265,9 @@ def _setup(self, **kwargs): # Convert the water model topology so that it matches the AMBER naming convention. system._set_water_topology("AMBER", property_map=self._property_map) + self._reference_system._set_water_topology( + "AMBER", property_map=self._property_map + ) # Create the squashed system. if isinstance(self._protocol, _FreeEnergyMixin): diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index ec6fb6dea..a35fc3531 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -287,6 +287,9 @@ def _setup(self, **kwargs): # Convert the water model topology so that it matches the GROMACS naming convention. system._set_water_topology("GROMACS", property_map=self._property_map) + self._reference_system._set_water_topology( + "GROMACS", property_map=self._property_map + ) # GRO87 file. file = _os.path.splitext(self._gro_file)[0] From d33004c7be504cf1858a93ed5c798eea281fbc33 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 19:15:41 +0100 Subject: [PATCH 34/36] Try debugging Windows error. --- tests/Process/test_openmm.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Process/test_openmm.py b/tests/Process/test_openmm.py index 7925bd91b..9f45d2fff 100644 --- a/tests/Process/test_openmm.py +++ b/tests/Process/test_openmm.py @@ -129,6 +129,9 @@ def run_process(system, protocol): # Wait for the process to end. process.wait() + print(process.stdout(1000)) + print(process.stderr(1000)) + # Make sure the process didn't error. assert not process.isError() From 59323f5629880c4bcddf90494fc5d359f59b18e6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 20:18:06 +0100 Subject: [PATCH 35/36] Remove debugging statements. --- tests/Process/test_openmm.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Process/test_openmm.py b/tests/Process/test_openmm.py index 9f45d2fff..7925bd91b 100644 --- a/tests/Process/test_openmm.py +++ b/tests/Process/test_openmm.py @@ -129,9 +129,6 @@ def run_process(system, protocol): # Wait for the process to end. process.wait() - print(process.stdout(1000)) - print(process.stderr(1000)) - # Make sure the process didn't error. assert not process.isError() From 3951502184724cecfa2df18b4f418b8df6a7ac88 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 10 Apr 2024 20:19:06 +0100 Subject: [PATCH 36/36] Use relative file names in OpenMM Python script. --- python/BioSimSpace/Process/_openmm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/BioSimSpace/Process/_openmm.py b/python/BioSimSpace/Process/_openmm.py index 2b7a45de8..d11974d22 100644 --- a/python/BioSimSpace/Process/_openmm.py +++ b/python/BioSimSpace/Process/_openmm.py @@ -344,7 +344,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -413,7 +413,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -597,7 +597,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -796,7 +796,7 @@ def _generate_config(self): "\n# We use ParmEd due to issues with the built in AmberPrmtopFile for certain triclinic spaces." ) self.addToConfig( - f"prm = parmed.load_file('{self._top_file}', '{self._rst_file}')" + f"prm = parmed.load_file('{self._name}.prm7', '{self._name}.rst7')" ) # Don't use a cut-off if this is a vacuum simulation or if box information @@ -2182,7 +2182,7 @@ def _add_config_restraints(self): restrained_atoms = restraint self.addToConfig( - f"ref_prm = parmed.load_file('{self._top_file}', '{self._ref_file}')" + f"ref_prm = parmed.load_file('{self._name}.prm7', '{self._name}_ref.rst7')" ) # Get the force constant in units of kJ_per_mol/nanometer**2