From d8f2e475cbc06a8f6be0a03bb1c8f323012f5c77 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 18 Jan 2024 09:19:52 +0000 Subject: [PATCH 01/32] Backport fixes from PR #233. [ci skip] --- python/BioSimSpace/Align/_align.py | 7 +++++-- python/BioSimSpace/Parameters/_Protocol/_amber.py | 5 +++-- .../BioSimSpace/Parameters/_Protocol/_openforcefield.py | 5 +++-- python/BioSimSpace/Sandpit/Exscientia/Align/_align.py | 7 +++++-- .../Sandpit/Exscientia/Parameters/_Protocol/_amber.py | 5 +++-- .../Exscientia/Parameters/_Protocol/_openforcefield.py | 5 +++-- .../Sandpit/Exscientia/Trajectory/_trajectory.py | 1 - python/BioSimSpace/Sandpit/Exscientia/__init__.py | 8 -------- python/BioSimSpace/Trajectory/_trajectory.py | 1 - python/BioSimSpace/__init__.py | 8 -------- 10 files changed, 22 insertions(+), 30 deletions(-) diff --git a/python/BioSimSpace/Align/_align.py b/python/BioSimSpace/Align/_align.py index cd0bb9a2c..4713693c9 100644 --- a/python/BioSimSpace/Align/_align.py +++ b/python/BioSimSpace/Align/_align.py @@ -394,10 +394,13 @@ def generateNetwork( if len(records) > 2: new_line += " " + " ".join(records[2:]) + # Store the path to the new file. + lf = f"{work_dir}/inputs/lomap_links_file.txt" + # Write the updated lomap links file. - with open(f"{work_dir}/inputs/lomap_links_file.txt", "w") as lf: + with open(lf, "w") as f: for line in new_lines: - lf.write(line) + f.write(line) else: lf = None diff --git a/python/BioSimSpace/Parameters/_Protocol/_amber.py b/python/BioSimSpace/Parameters/_Protocol/_amber.py index 5b1601686..0b05329a2 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_amber.py +++ b/python/BioSimSpace/Parameters/_Protocol/_amber.py @@ -44,6 +44,7 @@ # Temporarily redirect stderr to suppress import warnings. import sys as _sys +_orig_stderr = _sys.stderr _sys.stderr = open(_os.devnull, "w") _openff = _try_import("openff") @@ -54,8 +55,8 @@ _OpenFFMolecule = _openff # Reset stderr. -_sys.stderr = _sys.__stderr__ -del _sys +_sys.stderr = _orig_stderr +del _sys, _orig_stderr from sire.legacy import IO as _SireIO from sire.legacy import Mol as _SireMol diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index 35882a12c..a30b7ab3e 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -62,6 +62,7 @@ import sys as _sys # Temporarily redirect stderr to suppress import warnings. +_orig_stderr = _sys.stderr _sys.stderr = open(_os.devnull, "w") _openmm = _try_import("openmm") @@ -85,8 +86,8 @@ _Forcefield = _openff # Reset stderr. -_sys.stderr = _sys.__stderr__ -del _sys +_sys.stderr = _orig_stderr +del _sys, _orig_stderr from sire.legacy import IO as _SireIO from sire.legacy import Mol as _SireMol diff --git a/python/BioSimSpace/Sandpit/Exscientia/Align/_align.py b/python/BioSimSpace/Sandpit/Exscientia/Align/_align.py index 1ae4ffa92..01b1001e9 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Align/_align.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Align/_align.py @@ -394,10 +394,13 @@ def generateNetwork( if len(records) > 2: new_line += " " + " ".join(records[2:]) + # Store the path to the new file. + lf = f"{work_dir}/inputs/lomap_links_file.txt" + # Write the updated lomap links file. - with open(f"{work_dir}/inputs/lomap_links_file.txt", "w") as lf: + with open(lf, "w") as f: for line in new_lines: - lf.write(line) + f.write(line) else: lf = None diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py index 5b1601686..0b05329a2 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py @@ -44,6 +44,7 @@ # Temporarily redirect stderr to suppress import warnings. import sys as _sys +_orig_stderr = _sys.stderr _sys.stderr = open(_os.devnull, "w") _openff = _try_import("openff") @@ -54,8 +55,8 @@ _OpenFFMolecule = _openff # Reset stderr. -_sys.stderr = _sys.__stderr__ -del _sys +_sys.stderr = _orig_stderr +del _sys, _orig_stderr from sire.legacy import IO as _SireIO from sire.legacy import Mol as _SireMol diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py index 35882a12c..a30b7ab3e 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py @@ -62,6 +62,7 @@ import sys as _sys # Temporarily redirect stderr to suppress import warnings. +_orig_stderr = _sys.stderr _sys.stderr = open(_os.devnull, "w") _openmm = _try_import("openmm") @@ -85,8 +86,8 @@ _Forcefield = _openff # Reset stderr. -_sys.stderr = _sys.__stderr__ -del _sys +_sys.stderr = _orig_stderr +del _sys, _orig_stderr from sire.legacy import IO as _SireIO from sire.legacy import Mol as _SireMol diff --git a/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py b/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py index 1dc7e7d24..2332e74f6 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py @@ -32,7 +32,6 @@ _mdtraj = _try_import("mdtraj") import copy as _copy -import logging as _logging import os as _os import shutil as _shutil import uuid as _uuid diff --git a/python/BioSimSpace/Sandpit/Exscientia/__init__.py b/python/BioSimSpace/Sandpit/Exscientia/__init__.py index 60edd670b..d99b13fb7 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/__init__.py +++ b/python/BioSimSpace/Sandpit/Exscientia/__init__.py @@ -248,11 +248,3 @@ def _isVerbose(): from ... import _version __version__ = _version.get_versions()["version"] - -import logging as _logging - -for _name, _logger in _logging.root.manager.loggerDict.items(): - _logger.disabled = True -del _logger -del _logging -del _name diff --git a/python/BioSimSpace/Trajectory/_trajectory.py b/python/BioSimSpace/Trajectory/_trajectory.py index 1dc7e7d24..2332e74f6 100644 --- a/python/BioSimSpace/Trajectory/_trajectory.py +++ b/python/BioSimSpace/Trajectory/_trajectory.py @@ -32,7 +32,6 @@ _mdtraj = _try_import("mdtraj") import copy as _copy -import logging as _logging import os as _os import shutil as _shutil import uuid as _uuid diff --git a/python/BioSimSpace/__init__.py b/python/BioSimSpace/__init__.py index 4d5c2eab5..92fe48f0b 100644 --- a/python/BioSimSpace/__init__.py +++ b/python/BioSimSpace/__init__.py @@ -257,11 +257,3 @@ def _isVerbose(): __version__ = get_versions()["version"] del get_versions - -import logging as _logging - -for _name, _logger in _logging.root.manager.loggerDict.items(): - _logger.disabled = True -del _logger -del _logging -del _name From 37f52ed9701f471027873b35ee24e51a21cc042c Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 7 Feb 2024 10:05:14 +0000 Subject: [PATCH 02/32] Backport fix from PR #237. [ci skip] --- python/BioSimSpace/FreeEnergy/_relative.py | 24 +-- python/BioSimSpace/Process/_gromacs.py | 167 ++++++++++++++--- .../FreeEnergy/_alchemical_free_energy.py | 22 +-- .../Sandpit/Exscientia/Process/_gromacs.py | 169 +++++++++++++++--- 4 files changed, 299 insertions(+), 83 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index 2b0f11096..8db00c5a8 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -2060,9 +2060,7 @@ def _initialise_runner(self, system): extra_lines=self._extra_lines, property_map=self._property_map, ) - if self._setup_only: - del first_process - else: + if not self._setup_only: processes.append(first_process) # Loop over the rest of the lambda values. @@ -2139,30 +2137,20 @@ def _initialise_runner(self, system): f.write(line) mdp = new_dir + "/gromacs.mdp" - mdp_out = new_dir + "/gromacs.out.mdp" gro = new_dir + "/gromacs.gro" top = new_dir + "/gromacs.top" tpr = new_dir + "/gromacs.tpr" # Use grompp to generate the portable binary run input file. - command = "%s grompp -f %s -po %s -c %s -p %s -r %s -o %s" % ( - _gmx_exe, + _Process.Gromacs._generate_binary_run_file( mdp, - mdp_out, gro, top, gro, tpr, - ) - - # Run the command. If this worked for the first lambda value, - # then it should work for all others. - proc = _subprocess.run( - _Utils.command_split(command), - shell=False, - text=True, - stdout=_subprocess.PIPE, - stderr=_subprocess.PIPE, + first_process._exe, + ignore_warnings=self._ignore_warnings, + show_errors=self._show_errors, ) # Create a copy of the process and update the working diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index c70c9ccc0..3e8239ad5 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -421,40 +421,129 @@ def _generate_args(self): if isinstance(self._protocol, (_Protocol.Metadynamics, _Protocol.Steering)): self.setArg("-plumed", "plumed.dat") - def _generate_binary_run_file(self): - """Use grommp to generate the binary run input file.""" + @staticmethod + def _generate_binary_run_file( + mdp_file, + gro_file, + top_file, + ref_file, + tpr_file, + exe, + checkpoint_file=None, + ignore_warnings=False, + show_errors=True, + ): + """ + Use grommp to generate the binary run input file. + + Parameters + ---------- + + mdp_file : str + The path to the input mdp file. + + gro_file : str + The path to the input coordinate file. + + top_file : str + The path to the input topology file. + + ref_file : str + The path to the input reference coordinate file to be used for + position restraints. + + tpr_file : str + The path to the output binary run file. + + exe : str + The path to the GROMACS executable. + + checkpoint_file : str + 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. + + ignore_warnings : bool + Whether to ignore warnings when generating the binary run file + with 'gmx grompp'. By default, these warnings are elevated to + errors and will halt the program. + + show_errors : bool + Whether to show warning/error messages when generating the binary + run file. + """ + + if not isinstance(mdp_file, str): + raise ValueError("'mdp_file' must be of type 'str'.") + if not _os.path.isfile(mdp_file): + raise IOError(f"'mdp_file' doesn't exist: '{mdp_file}'") + + if not isinstance(gro_file, str): + raise ValueError("'gro_file' must be of type 'str'.") + if not _os.path.isfile(gro_file): + raise IOError(f"'gro_file' doesn't exist: '{gro_file}'") + + if not isinstance(top_file, str): + raise ValueError("'top_file' must be of type 'str'.") + if not _os.path.isfile(top_file): + raise IOError(f"'top_file' doesn't exist: '{top_file}'") + + if not isinstance(ref_file, str): + raise ValueError("'ref_file' must be of type 'str'.") + if not _os.path.isfile(ref_file): + raise IOError(f"'ref_file' doesn't exist: '{ref_file}'") + + if not isinstance(tpr_file, str): + raise ValueError("'tpr_file' must be of type 'str'.") + + if not isinstance(exe, str): + raise ValueError("'exe' must be of type 'str'.") + if not _os.path.isfile(exe): + raise IOError(f"'exe' doesn't exist: '{exe}'") + + if checkpoint_file is not None: + if not isinstance(checkpoint_file, str): + raise ValueError("'checkpoint_file' must be of type 'str'.") + if not _os.path.isfile(checkpoint_file): + raise IOError(f"'checkpoint_file' doesn't exist: '{checkpoint_file}'") + + if not isinstance(ignore_warnings, bool): + raise ValueError("'ignore_warnings' must be of type 'bool'") + + if not isinstance(show_errors, bool): + raise ValueError("'show_errors' must be of type 'bool'") # Create the name of the output mdp file. mdp_out = ( - _os.path.dirname(self._config_file) - + "/%s.out.mdp" % _os.path.basename(self._config_file).split(".")[0] + _os.path.dirname(mdp_file) + + "/%s.out.mdp" % _os.path.basename(mdp_file).split(".")[0] ) # Use grompp to generate the portable binary run input file. - if self._checkpoint_file is not None: + if checkpoint_file is not None: command = "%s grompp -f %s -po %s -c %s -p %s -r %s -t %s -o %s" % ( - self._exe, - self._config_file, + exe, + mdp_file, mdp_out, - self._gro_file, - self._top_file, - self._gro_file, - self._checkpoint_file, - self._tpr_file, + gro_file, + top_file, + ref_file, + checkpoint_file, + tpr_file, ) else: command = "%s grompp -f %s -po %s -c %s -p %s -r %s -o %s" % ( - self._exe, - self._config_file, + exe, + mdp_file, mdp_out, - self._gro_file, - self._top_file, - self._gro_file, - self._tpr_file, + gro_file, + top_file, + ref_file, + tpr_file, ) # Warnings don't trigger an error. - if self._ignore_warnings: + if ignore_warnings: command += " --maxwarn 9999" # Run the command. @@ -469,7 +558,7 @@ def _generate_binary_run_file(self): # Check that grompp ran successfully. if proc.returncode != 0: # Handle errors and warnings. - if self._show_errors: + if show_errors: # Capture errors and warnings from the grompp output. errors = [] warnings = [] @@ -531,14 +620,34 @@ def addToConfig(self, config): super().addToConfig(config) # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._gro_file, + self._tpr_file, + self._exe, + self._checkpoint_file, + self._ignore_warnings, + self._show_errors, + ) def resetConfig(self): """Reset the configuration parameters.""" self._generate_config() # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._gro_file, + self._tpr_file, + self._exe, + self._checkpoint_file, + self._ignore_warnings, + self._show_errors, + ) def setConfig(self, config): """ @@ -556,7 +665,17 @@ def setConfig(self, config): super().setConfig(config) # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._gro_file, + self._tpr_file, + self._exe, + self._checkpoint_file, + self._ignore_warnings, + self._show_errors, + ) def start(self): """ diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py index 95692bdc5..104408902 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_alchemical_free_energy.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -1373,7 +1373,7 @@ def _initialise_runner(self, system): extra_lines=self._extra_lines, ) - if self._setup_only: + if self._setup_only and not self._engine == "GROMACS": del first_process else: processes.append(first_process) @@ -1462,30 +1462,20 @@ def _initialise_runner(self, system): f.write(line) mdp = new_dir + "/gromacs.mdp" - mdp_out = new_dir + "/gromacs.out.mdp" gro = new_dir + "/gromacs.gro" top = new_dir + "/gromacs.top" tpr = new_dir + "/gromacs.tpr" # Use grompp to generate the portable binary run input file. - command = "%s grompp -f %s -po %s -c %s -p %s -r %s -o %s" % ( - self._exe, + _Process.Gromacs._generate_binary_run_file( mdp, - mdp_out, gro, top, gro, tpr, - ) - - # Run the command. If this worked for the first lambda value, - # then it should work for all others. - proc = _subprocess.run( - _Utils.command_split(command), - shell=False, - text=True, - stdout=_subprocess.PIPE, - stderr=_subprocess.PIPE, + first_process._exe, + ignore_warnings=self._ignore_warnings, + show_errors=self._show_errors, ) # Create a copy of the process and update the working diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 93c86f6d4..303a22764 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -512,40 +512,129 @@ def _generate_args(self): if isinstance(self._protocol, (_Protocol.Metadynamics, _Protocol.Steering)): self.setArg("-plumed", "plumed.dat") - def _generate_binary_run_file(self): - """Use grommp to generate the binary run input file.""" + @staticmethod + def _generate_binary_run_file( + mdp_file, + gro_file, + top_file, + ref_file, + tpr_file, + exe, + checkpoint_file=None, + ignore_warnings=False, + show_errors=True, + ): + """ + Use grommp to generate the binary run input file. + + Parameters + ---------- + + mdp_file : str + The path to the input mdp file. + + gro_file : str + The path to the input coordinate file. + + top_file : str + The path to the input topology file. + + ref_file : str + The path to the input reference coordinate file to be used for + position restraints. + + tpr_file : str + The path to the output binary run file. + + exe : str + The path to the GROMACS executable. + + checkpoint_file : str + 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. + + ignore_warnings : bool + Whether to ignore warnings when generating the binary run file + with 'gmx grompp'. By default, these warnings are elevated to + errors and will halt the program. + + show_errors : bool + Whether to show warning/error messages when generating the binary + run file. + """ + + if not isinstance(mdp_file, str): + raise ValueError("'mdp_file' must be of type 'str'.") + if not _os.path.isfile(mdp_file): + raise IOError(f"'mdp_file' doesn't exist: '{mdp_file}'") + + if not isinstance(gro_file, str): + raise ValueError("'gro_file' must be of type 'str'.") + if not _os.path.isfile(gro_file): + raise IOError(f"'gro_file' doesn't exist: '{gro_file}'") + + if not isinstance(top_file, str): + raise ValueError("'top_file' must be of type 'str'.") + if not _os.path.isfile(top_file): + raise IOError(f"'top_file' doesn't exist: '{top_file}'") + + if not isinstance(ref_file, str): + raise ValueError("'ref_file' must be of type 'str'.") + if not _os.path.isfile(ref_file): + raise IOError(f"'ref_file' doesn't exist: '{ref_file}'") + + if not isinstance(tpr_file, str): + raise ValueError("'tpr_file' must be of type 'str'.") + + if not isinstance(exe, str): + raise ValueError("'exe' must be of type 'str'.") + if not _os.path.isfile(exe): + raise IOError(f"'exe' doesn't exist: '{exe}'") + + if checkpoint_file is not None: + if not isinstance(checkpoint_file, str): + raise ValueError("'checkpoint_file' must be of type 'str'.") + if not _os.path.isfile(checkpoint_file): + raise IOError(f"'checkpoint_file' doesn't exist: '{checkpoint_file}'") + + if not isinstance(ignore_warnings, bool): + raise ValueError("'ignore_warnings' must be of type 'bool'") + + if not isinstance(show_errors, bool): + raise ValueError("'show_errors' must be of type 'bool'") # Create the name of the output mdp file. mdp_out = ( - _os.path.dirname(self._config_file) - + "/%s.out.mdp" % _os.path.basename(self._config_file).split(".")[0] + _os.path.dirname(mdp_file) + + "/%s.out.mdp" % _os.path.basename(mdp_file).split(".")[0] ) # Use grompp to generate the portable binary run input file. - if self._checkpoint_file is not None: + if checkpoint_file is not None: command = "%s grompp -f %s -po %s -c %s -p %s -r %s -t %s -o %s" % ( - self._exe, - self._config_file, + exe, + mdp_file, mdp_out, - self._gro_file, - self._top_file, - self._ref_file, - self._checkpoint_file, - self._tpr_file, + gro_file, + top_file, + ref_file, + checkpoint_file, + tpr_file, ) else: command = "%s grompp -f %s -po %s -c %s -p %s -r %s -o %s" % ( - self._exe, - self._config_file, + exe, + mdp_file, mdp_out, - self._gro_file, - self._top_file, - self._ref_file, - self._tpr_file, + gro_file, + top_file, + ref_file, + tpr_file, ) - # Warnings don't trigger an error. Set to a suitably large number. - if self._ignore_warnings: + # Warnings don't trigger an error. + if ignore_warnings: command += " --maxwarn 9999" # Run the command. @@ -560,7 +649,7 @@ def _generate_binary_run_file(self): # Check that grompp ran successfully. if proc.returncode != 0: # Handle errors and warnings. - if self._show_errors: + if show_errors: # Capture errors and warnings from the grompp output. errors = [] warnings = [] @@ -622,14 +711,34 @@ def addToConfig(self, config): super().addToConfig(config) # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._ref_file, + self._tpr_file, + self._exe, + checkpoint_file=self._checkpoint_file, + ignore_warnings=self._ignore_warnings, + show_errors=self._show_errors, + ) def resetConfig(self): """Reset the configuration parameters.""" self._generate_config() # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._ref_file, + self._tpr_file, + self._exe, + checkpoint_file=self._checkpoint_file, + ignore_warnings=self._ignore_warnings, + show_errors=self._show_errors, + ) def setConfig(self, config): """ @@ -647,7 +756,17 @@ def setConfig(self, config): super().setConfig(config) # Use grompp to generate the portable binary run input file. - self._generate_binary_run_file() + self._generate_binary_run_file( + self._config_file, + self._gro_file, + self._top_file, + self._ref_file, + self._tpr_file, + self._exe, + checkpoint_file=self._checkpoint_file, + ignore_warnings=self._ignore_warnings, + show_errors=self._show_errors, + ) def start(self): """ From 913504da5ff6ed18018b680b4e4d0bb4c3a7accd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 19 Feb 2024 19:57:41 +0000 Subject: [PATCH 03/32] Backport fixes from PR #244. [ci skip] --- python/BioSimSpace/IO/_io.py | 22 ++++++++++++-- .../Parameters/_Protocol/_openforcefield.py | 9 ++---- python/BioSimSpace/Process/_amber.py | 5 ++-- python/BioSimSpace/Process/_gromacs.py | 29 +++++++++++++------ .../BioSimSpace/Sandpit/Exscientia/IO/_io.py | 22 ++++++++++++-- .../Parameters/_Protocol/_openforcefield.py | 9 ++---- .../Sandpit/Exscientia/Process/_gromacs.py | 26 ++++++++++++----- 7 files changed, 85 insertions(+), 37 deletions(-) diff --git a/python/BioSimSpace/IO/_io.py b/python/BioSimSpace/IO/_io.py index 705e8f9d6..3fcf08e23 100644 --- a/python/BioSimSpace/IO/_io.py +++ b/python/BioSimSpace/IO/_io.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -566,7 +566,7 @@ def readMolecules( prop = property_map.get("time", "time") time = system.property(prop) system.removeSharedProperty(prop) - system.setProperties(prop, time) + system.setProperty(prop, time) except: pass @@ -1148,6 +1148,24 @@ def readPerturbableSystem(top0, coords0, top1, coords1, property_map={}): # Update the molecule in the original system. system0.updateMolecules(mol) + # Remove "space" and "time" shared properties since this causes incorrect + # behaviour when extracting molecules and recombining them to make other + # systems. + try: + # Space. + prop = property_map.get("space", "space") + space = system0._sire_object.property(prop) + system0._sire_object.removeSharedProperty(prop) + system0._sire_object.setProperty(prop, space) + + # Time. + prop = property_map.get("time", "time") + time = system0._sire_object.property(prop) + system0._sire_object.removeSharedProperty(prop) + system0._sire_object.setProperty(prop, time) + except: + pass + return system0 diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index a30b7ab3e..bd86f5371 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -37,7 +37,6 @@ import os as _os -_parmed = _try_import("parmed") import queue as _queue import subprocess as _subprocess @@ -189,10 +188,6 @@ def run(self, molecule, work_dir=None, queue=None): else: is_smiles = False - # The following is adapted from the Open Force Field examples, where an - # OpenFF system is converted to AMBER format files using ParmEd: - # https://github.com/openforcefield/openff-toolkit/blob/master/examples/using_smirnoff_in_amber_or_gromacs/convert_to_amber_gromacs.ipynb - if is_smiles: # Convert SMILES string to an OpenFF molecule. try: @@ -353,7 +348,7 @@ def run(self, molecule, work_dir=None, queue=None): if par_mol.nMolecules() == 1: par_mol = par_mol.getMolecules()[0] except Exception as e: - msg = "Failed to read molecule from: 'parmed.prmtop', 'parmed.inpcrd'" + msg = "Failed to read molecule from: 'interchange.prmtop', 'interchange.inpcrd'" if _isVerbose(): msg += ": " + getattr(e, "message", repr(e)) raise IOError(msg) from e diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index ec22a4a6e..0f19e0e16 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -46,6 +46,7 @@ from .._Config import Amber as _AmberConfig from .._Exceptions import IncompatibleError as _IncompatibleError from .._Exceptions import MissingSoftwareError as _MissingSoftwareError +from ..Protocol._free_energy_mixin import _FreeEnergyMixin from ..Protocol._position_restraint_mixin import _PositionRestraintMixin from .._SireWrappers import System as _System from ..Types._type import Type as _Type @@ -126,7 +127,7 @@ def __init__( ) # Catch unsupported protocols. - if isinstance(protocol, _Protocol.FreeEnergy): + if isinstance(protocol, _FreeEnergyMixin): raise _IncompatibleError( "Unsupported protocol: '%s'" % self._protocol.__class__.__name__ ) diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index 3e8239ad5..e4531dad3 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -52,6 +52,7 @@ from .. import _isVerbose from .._Config import Gromacs as _GromacsConfig from .._Exceptions import MissingSoftwareError as _MissingSoftwareError +from ..Protocol._free_energy_mixin import _FreeEnergyMixin from ..Protocol._position_restraint_mixin import _PositionRestraintMixin from .._SireWrappers import System as _System from ..Types._type import Type as _Type @@ -232,7 +233,7 @@ def _setup(self): # Create a copy of the system. system = self._system.copy() - if isinstance(self._protocol, _Protocol.FreeEnergy): + if isinstance(self._protocol, _FreeEnergyMixin): # Check that the system contains a perturbable molecule. if self._system.nPerturbableMolecules() == 0: raise ValueError( @@ -2544,10 +2545,15 @@ def _getFinalFrame(self): space_prop in old_system._sire_object.propertyKeys() and space_prop 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 - ) + # Get the original space. + box = old_system._sire_object.property("space") + + # Only update the box if the space is periodic. + if box.isPeriodic(): + box = new_system._sire_object.property("space") + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) # If this is a vacuum simulation, then translate the centre of mass # of the system back to the origin. @@ -2655,11 +2661,16 @@ def _getFrame(self, time): space_prop in old_system._sire_object.propertyKeys() and space_prop in new_system._sire_object.propertyKeys() ): - box = new_system._sire_object.property("space") + # Get the original space. + box = old_system._sire_object.property("space") + + # Only update the box if the space is periodic. if box.isPeriodic(): - old_system._sire_object.setProperty( - self._property_map.get("space", "space"), box - ) + box = new_system._sire_object.property("space") + if box.isPeriodic(): + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) # If this is a vacuum simulation, then translate the centre of mass # of the system back to the origin. diff --git a/python/BioSimSpace/Sandpit/Exscientia/IO/_io.py b/python/BioSimSpace/Sandpit/Exscientia/IO/_io.py index 705e8f9d6..97ac66348 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/IO/_io.py +++ b/python/BioSimSpace/Sandpit/Exscientia/IO/_io.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -566,7 +566,7 @@ def readMolecules( prop = property_map.get("time", "time") time = system.property(prop) system.removeSharedProperty(prop) - system.setProperties(prop, time) + system.setProperty(prop, time) except: pass @@ -1148,6 +1148,24 @@ def readPerturbableSystem(top0, coords0, top1, coords1, property_map={}): # Update the molecule in the original system. system0.updateMolecules(mol) + # Remove "space" and "time" shared properties since this causes incorrect + # behaviour when extracting molecules and recombining them to make other + # systems. + try: + # Space. + prop = property_map.get("space", "space") + space = system0._sire_object.property(prop) + system0._sire_object.removeSharedProperty(prop) + system0._sire_object.setProperty(prop, space) + + # Time. + prop = property_map.get("time", "time") + time = system0._sire_object.property(prop) + system0._sire_object.removeSharedProperty(prop) + system0._sire_object.setPropery(prop, time) + except: + pass + return system0 diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py index a30b7ab3e..bd86f5371 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -37,7 +37,6 @@ import os as _os -_parmed = _try_import("parmed") import queue as _queue import subprocess as _subprocess @@ -189,10 +188,6 @@ def run(self, molecule, work_dir=None, queue=None): else: is_smiles = False - # The following is adapted from the Open Force Field examples, where an - # OpenFF system is converted to AMBER format files using ParmEd: - # https://github.com/openforcefield/openff-toolkit/blob/master/examples/using_smirnoff_in_amber_or_gromacs/convert_to_amber_gromacs.ipynb - if is_smiles: # Convert SMILES string to an OpenFF molecule. try: @@ -353,7 +348,7 @@ def run(self, molecule, work_dir=None, queue=None): if par_mol.nMolecules() == 1: par_mol = par_mol.getMolecules()[0] except Exception as e: - msg = "Failed to read molecule from: 'parmed.prmtop', 'parmed.inpcrd'" + msg = "Failed to read molecule from: 'interchange.prmtop', 'interchange.inpcrd'" if _isVerbose(): msg += ": " + getattr(e, "message", repr(e)) raise IOError(msg) from e diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 303a22764..6d0bf4278 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -2638,10 +2638,15 @@ def _getFinalFrame(self): space_prop in old_system._sire_object.propertyKeys() and space_prop 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 - ) + # Get the original space. + box = old_system._sire_object.property("space") + + # Only update the box if the space is periodic. + if box.isPeriodic(): + box = new_system._sire_object.property("space") + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) # If this is a vacuum simulation, then translate the centre of mass # of the system back to the origin. @@ -2749,11 +2754,16 @@ def _getFrame(self, time): space_prop in old_system._sire_object.propertyKeys() and space_prop in new_system._sire_object.propertyKeys() ): - box = new_system._sire_object.property("space") + # Get the original space. + box = old_system._sire_object.property("space") + + # Only update the box if the space is periodic. if box.isPeriodic(): - old_system._sire_object.setProperty( - self._property_map.get("space", "space"), box - ) + box = new_system._sire_object.property("space") + if box.isPeriodic(): + old_system._sire_object.setProperty( + self._property_map.get("space", "space"), box + ) # If this is a vacuum simulation, then translate the centre of mass # of the system back to the origin. From ffce4b2fd19f5fda7ef0c6265d8fc59cae929ffc Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 23 Feb 2024 09:16:46 +0000 Subject: [PATCH 04/32] Backport fixes from PR #247. [ci skip] --- .../CollectiveVariable/_funnel.py | 4 +-- .../Parameters/_Protocol/_openforcefield.py | 8 +++--- .../CollectiveVariable/_funnel.py | 4 +-- .../Parameters/_Protocol/_openforcefield.py | 8 +++--- .../Exscientia/_SireWrappers/_molecule.py | 28 ++++--------------- python/BioSimSpace/_SireWrappers/_molecule.py | 28 ++++--------------- 6 files changed, 24 insertions(+), 56 deletions(-) diff --git a/python/BioSimSpace/Metadynamics/CollectiveVariable/_funnel.py b/python/BioSimSpace/Metadynamics/CollectiveVariable/_funnel.py index b0f4c019e..ee73ca15f 100644 --- a/python/BioSimSpace/Metadynamics/CollectiveVariable/_funnel.py +++ b/python/BioSimSpace/Metadynamics/CollectiveVariable/_funnel.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -592,7 +592,7 @@ def getCorrection( volume = _Volume(_math.pi * result, "nanometers cubed") # Estimate the average area of the restraint (in Angstrom squared). - area = (volume / proj_max).angstroms2() + area = (volume / (proj_max - proj_min)).angstroms2() # Compute the correction. (1/1660 A-3 is the standard concentration.) correction = _Energy(_math.log((area / 1660).value()), "kt") diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index bd86f5371..2f6358893 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -329,8 +329,8 @@ def run(self, molecule, work_dir=None, queue=None): # Export AMBER format files. try: - interchange.to_prmtop(prefix + "interchange.prmtop") - interchange.to_inpcrd(prefix + "interchange.inpcrd") + interchange.to_prmtop(prefix + "interchange.prm7") + interchange.to_inpcrd(prefix + "interchange.rst7") except Exception as e: msg = "Unable to write Interchange object to AMBER format!" if _isVerbose(): @@ -342,13 +342,13 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "interchange.prmtop", prefix + "interchange.inpcrd"] + [prefix + "interchange.prm7", prefix + "interchange.rst7"] ) # Extract single molecules. if par_mol.nMolecules() == 1: par_mol = par_mol.getMolecules()[0] except Exception as e: - msg = "Failed to read molecule from: 'interchange.prmtop', 'interchange.inpcrd'" + msg = "Failed to read molecule from: 'interchange.prm7', 'interchange.rst7'" if _isVerbose(): msg += ": " + getattr(e, "message", repr(e)) raise IOError(msg) from e diff --git a/python/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_funnel.py b/python/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_funnel.py index b0f4c019e..ee73ca15f 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_funnel.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Metadynamics/CollectiveVariable/_funnel.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -592,7 +592,7 @@ def getCorrection( volume = _Volume(_math.pi * result, "nanometers cubed") # Estimate the average area of the restraint (in Angstrom squared). - area = (volume / proj_max).angstroms2() + area = (volume / (proj_max - proj_min)).angstroms2() # Compute the correction. (1/1660 A-3 is the standard concentration.) correction = _Energy(_math.log((area / 1660).value()), "kt") diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py index bd86f5371..2f6358893 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py @@ -329,8 +329,8 @@ def run(self, molecule, work_dir=None, queue=None): # Export AMBER format files. try: - interchange.to_prmtop(prefix + "interchange.prmtop") - interchange.to_inpcrd(prefix + "interchange.inpcrd") + interchange.to_prmtop(prefix + "interchange.prm7") + interchange.to_inpcrd(prefix + "interchange.rst7") except Exception as e: msg = "Unable to write Interchange object to AMBER format!" if _isVerbose(): @@ -342,13 +342,13 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "interchange.prmtop", prefix + "interchange.inpcrd"] + [prefix + "interchange.prm7", prefix + "interchange.rst7"] ) # Extract single molecules. if par_mol.nMolecules() == 1: par_mol = par_mol.getMolecules()[0] except Exception as e: - msg = "Failed to read molecule from: 'interchange.prmtop', 'interchange.inpcrd'" + msg = "Failed to read molecule from: 'interchange.prm7', 'interchange.rst7'" if _isVerbose(): msg += ": " + getattr(e, "message", repr(e)) raise IOError(msg) from e diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 60f43224d..702d888a6 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -80,13 +80,16 @@ def __init__(self, molecule): if isinstance(molecule, _SireMol._Mol.Molecule): super().__init__(molecule) if self._sire_object.hasProperty("is_perturbable"): - self._convertFromMergedMolecule() + # Flag that the molecule is perturbable. + self._is_perturbable = True + + # Extract the end states. if molecule.hasProperty("molecule0"): - self._molecule = Molecule(molecule.property("molecule0")) + self._molecule0 = Molecule(molecule.property("molecule0")) else: self._molecule0, _ = self._extractMolecule() if molecule.hasProperty("molecule1"): - self._molecule = Molecule(molecule.property("molecule1")) + self._molecule1 = Molecule(molecule.property("molecule1")) else: self._molecule1, _ = self._extractMolecule(is_lambda1=True) @@ -1572,25 +1575,6 @@ def _getPropertyMap1(self): return property_map - def _convertFromMergedMolecule(self): - """Convert from a merged molecule.""" - - # Extract the components of the merged molecule. - try: - mol0 = self._sire_object.property("molecule0") - mol1 = self._sire_object.property("molecule1") - except: - raise _IncompatibleError( - "The merged molecule doesn't have the required properties!" - ) - - # Store the components. - self._molecule0 = Molecule(mol0) - self._molecule1 = Molecule(mol1) - - # Flag that the molecule is perturbable. - self._is_perturbable = True - def _fixCharge(self, property_map={}): """ Make the molecular charge an integer value. diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index 424d6196d..182cb5abb 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -80,13 +80,16 @@ def __init__(self, molecule): if isinstance(molecule, _SireMol._Mol.Molecule): super().__init__(molecule) if self._sire_object.hasProperty("is_perturbable"): - self._convertFromMergedMolecule() + # Flag that the molecule is perturbable. + self._is_perturbable = True + + # Extract the end states. if molecule.hasProperty("molecule0"): - self._molecule = Molecule(molecule.property("molecule0")) + self._molecule0 = Molecule(molecule.property("molecule0")) else: self._molecule0, _ = self._extractMolecule() if molecule.hasProperty("molecule1"): - self._molecule = Molecule(molecule.property("molecule1")) + self._molecule1 = Molecule(molecule.property("molecule1")) else: self._molecule1, _ = self._extractMolecule(is_lambda1=True) @@ -1528,25 +1531,6 @@ def _getPropertyMap1(self): return property_map - def _convertFromMergedMolecule(self): - """Convert from a merged molecule.""" - - # Extract the components of the merged molecule. - try: - mol0 = self._sire_object.property("molecule0") - mol1 = self._sire_object.property("molecule1") - except: - raise _IncompatibleError( - "The merged molecule doesn't have the required properties!" - ) - - # Store the components. - self._molecule0 = Molecule(mol0) - self._molecule1 = Molecule(mol1) - - # Flag that the molecule is perturbable. - self._is_perturbable = True - def _fixCharge(self, property_map={}): """ Make the molecular charge an integer value. From c5bcd2bedd3fb5e31a87bd1717ba27ed0e218d8d Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 29 Feb 2024 10:00:29 +0000 Subject: [PATCH 05/32] Backport fixes from PR #251 and #253. [ci skip] --- .../Sandpit/Exscientia/Align/_decouple.py | 36 ++------- .../Sandpit/Exscientia/Process/_somd.py | 77 ++++++++++++++++--- .../Exscientia/_SireWrappers/_molecule.py | 6 +- python/BioSimSpace/_SireWrappers/_molecule.py | 6 +- .../Sandpit/Exscientia/Align/test_decouple.py | 12 ++- .../FreeEnergy/test_alchemical_free_energy.py | 32 +++++++- 6 files changed, 118 insertions(+), 51 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Align/_decouple.py b/python/BioSimSpace/Sandpit/Exscientia/Align/_decouple.py index 1ab4ca7c7..5a953632a 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Align/_decouple.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Align/_decouple.py @@ -76,7 +76,7 @@ def decouple( if not isinstance(value, bool): raise ValueError(f"{value} in {name} must be bool.") - # Change names of charge and LJ tuples to avoid clashes with properties + # Change names of charge and LJ tuples to avoid clashes with properties. charge_tuple = charge LJ_tuple = LJ @@ -86,7 +86,7 @@ def decouple( # Invert the user property mappings. inv_property_map = {v: k for k, v in property_map.items()} - # Create a copy of this molecule and Sire object to check properties + # Create a copy of this molecule and Sire object to check properties. mol = _Molecule(molecule) mol_sire = mol._sire_object @@ -97,7 +97,7 @@ def decouple( element = inv_property_map.get("element", "element") ambertype = inv_property_map.get("ambertype", "ambertype") - # Check for missing information + # Check for missing information. if not mol_sire.hasProperty(ff): raise _IncompatibleError("Cannot determine 'forcefield' of 'molecule'!") if not mol_sire.hasProperty(LJ): @@ -107,7 +107,7 @@ def decouple( if not mol_sire.hasProperty(element): raise _IncompatibleError("Cannot determine elements in molecule") - # Check for ambertype property (optional) + # Check for ambertype property (optional). has_ambertype = True if not mol_sire.hasProperty(ambertype): has_ambertype = False @@ -115,10 +115,10 @@ def decouple( if not isinstance(intramol, bool): raise TypeError("'intramol' must be of type 'bool'") - # Edit the molecule + # Edit the molecule. mol_edit = mol_sire.edit() - # Create dictionary to store charge and LJ tuples + # Create dictionary to store charge and LJ tuples. mol_edit.setProperty( "decouple", {"charge": charge_tuple, "LJ": LJ_tuple, "intramol": intramol} ) @@ -126,35 +126,13 @@ def decouple( # Set the "forcefield0" property. mol_edit.setProperty("forcefield0", molecule._sire_object.property(ff)) - # Set starting properties based on fully-interacting molecule + # Set starting properties based on fully-interacting molecule. mol_edit.setProperty("charge0", molecule._sire_object.property(charge)) mol_edit.setProperty("LJ0", molecule._sire_object.property(LJ)) mol_edit.setProperty("element0", molecule._sire_object.property(element)) if has_ambertype: mol_edit.setProperty("ambertype0", molecule._sire_object.property(ambertype)) - # Set final charges and LJ terms to 0, elements to "X" and (if required) ambertypes to du - for atom in mol_sire.atoms(): - mol_edit = ( - mol_edit.atom(atom.index()) - .setProperty("charge1", 0 * _SireUnits.e_charge) - .molecule() - ) - mol_edit = ( - mol_edit.atom(atom.index()) - .setProperty("LJ1", _SireMM.LJParameter()) - .molecule() - ) - mol_edit = ( - mol_edit.atom(atom.index()) - .setProperty("element1", _SireMol.Element(0)) - .molecule() - ) - if has_ambertype: - mol_edit = ( - mol_edit.atom(atom.index()).setProperty("ambertype1", "du").molecule() - ) - mol_edit.setProperty("annihilated", _SireBase.wrap(intramol)) # Flag that this molecule is decoupled. diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_somd.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_somd.py index 8db9fc8be..4466dd0fc 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_somd.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_somd.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -26,10 +26,10 @@ __all__ = ["Somd"] -from .._Utils import _try_import - import os as _os +from .._Utils import _try_import + _pygtail = _try_import("pygtail") import glob as _glob import random as _random @@ -38,23 +38,21 @@ import timeit as _timeit import warnings as _warnings -from sire.legacy import Base as _SireBase from sire.legacy import CAS as _SireCAS from sire.legacy import IO as _SireIO from sire.legacy import MM as _SireMM +from sire.legacy import Base as _SireBase from sire.legacy import Mol as _SireMol +from sire.legacy import Units as _SireUnits -from .. import _isVerbose +from .. import IO as _IO +from .. import Protocol as _Protocol +from .. import Trajectory as _Trajectory +from .. import _isVerbose, _Utils from .._Exceptions import IncompatibleError as _IncompatibleError from .._Exceptions import MissingSoftwareError as _MissingSoftwareError from .._SireWrappers import Molecule as _Molecule from .._SireWrappers import System as _System - -from .. import IO as _IO -from .. import Protocol as _Protocol -from .. import Trajectory as _Trajectory -from .. import _Utils - from . import _process @@ -1001,6 +999,9 @@ def _to_pert_file( if not isinstance(perturbation_type, str): raise TypeError("'perturbation_type' must be of type 'str'") + if not isinstance(property_map, dict): + raise TypeError("'property_map' must be of type 'dict'") + # Convert to lower case and strip whitespace. perturbation_type = perturbation_type.lower().replace(" ", "") @@ -1027,6 +1028,60 @@ def _to_pert_file( # Extract and copy the Sire molecule. mol = molecule._sire_object.__deepcopy__() + # If the molecule is decoupled (for an ABFE calculation), then we need to + # set the end-state properties of the molecule. + if molecule.isDecoupled(): + # Invert the user property mappings. + inv_property_map = {v: k for k, v in property_map.items()} + + # Get required properties. + lj = inv_property_map.get("LJ", "LJ") + charge = inv_property_map.get("charge", "charge") + element = inv_property_map.get("element", "element") + ambertype = inv_property_map.get("ambertype", "ambertype") + + # Check for missing information. + if not mol.hasProperty(lj): + raise _IncompatibleError("Cannot determine LJ terms for molecule") + if not mol.hasProperty(charge): + raise _IncompatibleError("Cannot determine charges for molecule") + if not mol.hasProperty(element): + raise _IncompatibleError("Cannot determine elements in molecule") + + # Check for ambertype property. + has_ambertype = True + if not mol.hasProperty(ambertype): + has_ambertype = False + + mol_edit = mol.edit() + + # Set final charges and LJ terms to 0, elements to "X" and (if required) ambertypes to du + for atom in mol.atoms(): + mol_edit = ( + mol_edit.atom(atom.index()) + .setProperty("charge1", 0 * _SireUnits.e_charge) + .molecule() + ) + mol_edit = ( + mol_edit.atom(atom.index()) + .setProperty("LJ1", _SireMM.LJParameter()) + .molecule() + ) + mol_edit = ( + mol_edit.atom(atom.index()) + .setProperty("element1", _SireMol.Element(0)) + .molecule() + ) + if has_ambertype: + mol_edit = ( + mol_edit.atom(atom.index()) + .setProperty("ambertype1", "du") + .molecule() + ) + + # Update the Sire molecule object of the new molecule. + mol = mol_edit.commit() + # First work out the indices of atoms that are perturbed. pert_idxs = [] diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 702d888a6..7bebe86ee 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -1814,7 +1814,11 @@ def _extractMolecule(self, property_map={}, is_lambda1=False): try: search_result = mol.search(query, property_map) except: - search_result = [] + msg = "All atoms in the selection are dummies. Unable to extract." + if _isVerbose(): + raise _IncompatibleError(msg) from e + else: + raise _IncompatibleError(msg) from None # If there are no dummies, then simply return this molecule. if len(search_result) == mol.nAtoms(): diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index 182cb5abb..9a3a3d70e 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -1746,7 +1746,11 @@ def _extractMolecule(self, property_map={}, is_lambda1=False): try: search_result = mol.search(query, property_map) except: - search_result = [] + msg = "All atoms in the selection are dummies. Unable to extract." + if _isVerbose(): + raise _IncompatibleError(msg) from e + else: + raise _IncompatibleError(msg) from None # If there are no dummies, then simply return this molecule. if len(search_result) == mol.nAtoms(): diff --git a/tests/Sandpit/Exscientia/Align/test_decouple.py b/tests/Sandpit/Exscientia/Align/test_decouple.py index 150938ddd..480ff8d44 100644 --- a/tests/Sandpit/Exscientia/Align/test_decouple.py +++ b/tests/Sandpit/Exscientia/Align/test_decouple.py @@ -81,8 +81,11 @@ def test_topology(mol, tmp_path): def test_end_types(mol): - """Check that the correct properties have been set at either - end of the perturbation.""" + """ + Check that the correct properties have been set for the 0 + end of the perturbation. Note that for SOMD, the 1 end state + properties are set in Process.Somd._to_pert_file. + """ decoupled_mol = decouple(mol) assert decoupled_mol._sire_object.property("charge0") == mol._sire_object.property( @@ -95,8 +98,3 @@ def test_end_types(mol): assert decoupled_mol._sire_object.property( "ambertype0" ) == mol._sire_object.property("ambertype") - for atom in decoupled_mol._sire_object.atoms(): - assert atom.property("charge1") == 0 * _SireUnits.e_charge - assert atom.property("LJ1") == _SireMM.LJParameter() - assert atom.property("element1") == _SireMol.Element(0) - assert atom.property("ambertype1") == "du" diff --git a/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py b/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py index 873c654d5..5aa89d5b4 100644 --- a/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py +++ b/tests/Sandpit/Exscientia/FreeEnergy/test_alchemical_free_energy.py @@ -1,4 +1,5 @@ import bz2 +from math import exp import pandas as pd import pathlib import pytest @@ -270,9 +271,11 @@ def freenrg(): return freenrg def test_files_exist(self, freenrg): - """Test if the files have been created. Note that e.g. gradients.dat + """ + Test if the files have been created. Note that e.g. gradients.dat are not created until later in the simulation, so their presence is - not tested for.""" + not tested for. + """ path = pathlib.Path(freenrg.workDir()) for lam in ["0.0000", "0.5000", "1.0000"]: assert (path / f"lambda_{lam}" / "simfile.dat").is_file() @@ -282,6 +285,31 @@ def test_files_exist(self, freenrg): assert (path / f"lambda_{lam}" / "somd.prm7").is_file() assert (path / f"lambda_{lam}" / "somd.err").is_file() assert (path / f"lambda_{lam}" / "somd.out").is_file() + assert (path / f"lambda_{lam}" / "somd.pert").is_file() + + def test_correct_pert_file(self, freenrg): + """Check that pert file is correct.""" + path = pathlib.Path(freenrg.workDir()) / "lambda_0.0000" + with open(os.path.join(path, "somd.pert"), "rt") as f: + lines = f.readlines() + + for i, line in enumerate(lines): + # Check that the end-state properties are correct. + if "final_type" in line: + assert "final_type du" in line + if "final_LJ" in line: + assert "final_LJ 0.00000 0.00000" in line + if "final_charge" in line: + assert "final_charge 0.00000" in line + # Check that the initial state properties are correct for the first and last atoms. + if line == " name C1\n": + assert "initial_type c" in lines[i + 1] + assert "initial_LJ 3.39967 0.08600" in lines[i + 3] + assert "initial_charge 0.67120" in lines[i + 5] + if "name O3" in line: + assert "initial_type o" in lines[i + 1] + assert "initial_LJ 2.95992 0.21000" in lines[i + 3] + assert "initial_charge -0.52110" in lines[i + 5] def test_correct_conf_file(self, freenrg): """Check that lambda data is correct in somd.cfg""" From 3c6b55f2598abfe331c6997dd931128d3d90300d Mon Sep 17 00:00:00 2001 From: Zhiyi Wu Date: Mon, 4 Mar 2024 09:14:37 +0000 Subject: [PATCH 06/32] Only write couple molecule when the decoupled mol is not perturbable (#36) --- .../Sandpit/Exscientia/Protocol/_config.py | 47 ++++++++++--------- .../Exscientia/Protocol/test_config.py | 44 +++++++++++++++++ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py index 4496107d7..6aca895fc 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py @@ -565,34 +565,35 @@ def generateGromacsConfig( [ mol, ] = self.system.getDecoupledMolecules() - decouple_dict = mol._sire_object.property("decouple") - protocol_dict["couple-moltype"] = mol._sire_object.name().value() - - def tranform(charge, LJ): - if charge and LJ: - return "vdw-q" - elif charge and not LJ: - return "q" - elif not charge and LJ: - return "vdw" - else: - return "none" + if not mol.isPerturbable(): + decouple_dict = mol._sire_object.property("decouple") + protocol_dict["couple-moltype"] = mol._sire_object.name().value() + + def tranform(charge, LJ): + if charge and LJ: + return "vdw-q" + elif charge and not LJ: + return "q" + elif not charge and LJ: + return "vdw" + else: + return "none" - protocol_dict["couple-lambda0"] = tranform( - decouple_dict["charge"][0], decouple_dict["LJ"][0] - ) - protocol_dict["couple-lambda1"] = tranform( - decouple_dict["charge"][1], decouple_dict["LJ"][1] - ) + protocol_dict["couple-lambda0"] = tranform( + decouple_dict["charge"][0], decouple_dict["LJ"][0] + ) + protocol_dict["couple-lambda1"] = tranform( + decouple_dict["charge"][1], decouple_dict["LJ"][1] + ) + if decouple_dict["intramol"].value(): + # The intramol is being coupled to the lambda change and thus being annihilated. + protocol_dict["couple-intramol"] = "yes" + else: + protocol_dict["couple-intramol"] = "no" # Add the soft-core parameters for the ABFE protocol_dict["sc-alpha"] = 0.5 protocol_dict["sc-power"] = 1 protocol_dict["sc-sigma"] = 0.3 - if decouple_dict["intramol"].value(): - # The intramol is being coupled to the lambda change and thus being annihilated. - protocol_dict["couple-intramol"] = "yes" - else: - protocol_dict["couple-intramol"] = "no" elif nDecoupledMolecules > 1: raise ValueError( "Gromacs cannot handle more than one decoupled molecule." diff --git a/tests/Sandpit/Exscientia/Protocol/test_config.py b/tests/Sandpit/Exscientia/Protocol/test_config.py index efb52898a..49e2d7b8a 100644 --- a/tests/Sandpit/Exscientia/Protocol/test_config.py +++ b/tests/Sandpit/Exscientia/Protocol/test_config.py @@ -18,6 +18,7 @@ from BioSimSpace.Sandpit.Exscientia.Units.Energy import kcal_per_mol from BioSimSpace.Sandpit.Exscientia.Units.Temperature import kelvin from BioSimSpace.Sandpit.Exscientia.FreeEnergy import Restraint +from BioSimSpace.Sandpit.Exscientia._SireWrappers import Molecule from BioSimSpace.Sandpit.Exscientia._Utils import _try_import, _have_imported @@ -301,6 +302,49 @@ def test_decouple_vdw_q(self, system): assert "couple-lambda1 = none" in mdp_text assert "couple-intramol = yes" in mdp_text + + def test_decouple_perturbable(self, system): + m, protocol = system + mol = decouple(m) + sire_mol = mol._sire_object + c = sire_mol.cursor() + for key in [ + "charge", + "LJ", + "bond", + "angle", + "dihedral", + "improper", + "forcefield", + "intrascale", + "mass", + "element", + "atomtype", + "coordinates", + "velocity", + "ambertype", + ]: + if f"{key}1" not in c and key in c: + c[f"{key}0"] = c[key] + c[f"{key}1"] = c[key] + + c["is_perturbable"] = True + sire_mol = c.commit() + mol = Molecule(sire_mol) + + freenrg = BSS.FreeEnergy.AlchemicalFreeEnergy( + mol.toSystem(), + protocol, + engine="GROMACS", + ) + with open(f"{freenrg._work_dir}/lambda_6/gromacs.mdp", "r") as f: + mdp_text = f.read() + assert "couple-moltype" not in mdp_text + assert "couple-lambda0" not in mdp_text + assert "couple-lambda1" not in mdp_text + assert "couple-intramol" not in mdp_text + + @pytest.mark.skipif( has_gromacs is False, reason="Requires GROMACS to be installed." ) From cb3748522a38715cfba7e4ebd275052d47dded5c Mon Sep 17 00:00:00 2001 From: Zhiyi Wu Date: Tue, 5 Mar 2024 15:07:28 +0000 Subject: [PATCH 07/32] Langevin integrator for Free Energy calculations (#37) --- .../Sandpit/Exscientia/Protocol/_config.py | 7 +++++-- tests/Sandpit/Exscientia/Protocol/test_config.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py index 6aca895fc..a0319ea2b 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py @@ -516,8 +516,11 @@ def generateGromacsConfig( # Temperature control. if not isinstance(self.protocol, _Protocol.Minimisation): - protocol_dict["integrator"] = "md" # leap-frog dynamics. - protocol_dict["tcoupl"] = "v-rescale" + if isinstance(self.protocol, _Protocol._FreeEnergyMixin): + protocol_dict["integrator"] = "sd" # langevin dynamics. + else: + 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. diff --git a/tests/Sandpit/Exscientia/Protocol/test_config.py b/tests/Sandpit/Exscientia/Protocol/test_config.py index 49e2d7b8a..b35ba1288 100644 --- a/tests/Sandpit/Exscientia/Protocol/test_config.py +++ b/tests/Sandpit/Exscientia/Protocol/test_config.py @@ -111,6 +111,18 @@ def test_tau_t(self, system, protocol): expected_res = {"tau-t = 2.00000"} assert expected_res.issubset(res) + @pytest.mark.parametrize( + "protocol", [Production, FreeEnergy] + ) + def test_integrator(self, system, protocol): + config = ConfigFactory(system, protocol(tau_t=BSS.Types.Time(2, "picosecond"))) + res = config.generateGromacsConfig() + if isinstance(protocol(), BSS.Protocol._FreeEnergyMixin): + expected_res = {'integrator = sd'} + else: + expected_res = {'integrator = md', 'tcoupl = v-rescale'} + assert expected_res.issubset(res) + @pytest.mark.skipif( has_gromacs is False, reason="Requires GROMACS to be installed." ) @@ -410,6 +422,7 @@ def test_sc_parameters(self, system): assert "sc-alpha = 0.5" in mdp_text + @pytest.mark.skipif( has_antechamber is False or has_openff is False, reason="Requires ambertools/antechamber and openff to be installed", From 3709cda1cf7017ec054175569d5767a7e733b2ed Mon Sep 17 00:00:00 2001 From: Zhiyi Wu Date: Mon, 11 Mar 2024 10:03:24 +0000 Subject: [PATCH 08/32] Allow Gromacs to write dihedral_restraints in the presence of restraint-lambdas (#38) --- .../Exscientia/FreeEnergy/_restraint.py | 134 ++++++++++++++---- .../Sandpit/Exscientia/Process/_gromacs.py | 2 + .../Exscientia/FreeEnergy/test_restraint.py | 26 ++++ .../Exscientia/Process/test_gromacs.py | 128 ++++++++++------- 4 files changed, 210 insertions(+), 80 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py index 499846255..74d9652ee 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py @@ -380,7 +380,7 @@ def system(self, system): # Store a copy of solvated system. self._system = system.copy() - def _gromacs_boresch(self, perturbation_type=None): + def _gromacs_boresch(self, perturbation_type=None, restraint_lambda=False): """Format the Gromacs string for boresch restraint.""" # Format the atoms into index list @@ -398,9 +398,18 @@ def format_index(key_list): return " ".join(formated_index) parameters_string = "{eq0:<10} {fc0:<10} {eq1:<10} {fc1:<10}" + # The Gromacs dihedral restraints has the format of + # phi dphi fc and we don't want dphi for the restraint, it is hence zero + dihedral_restraints_parameters_string = ( + "{eq0:<10} 0.00 {fc0:<10} {eq1:<10} 0.00 {fc1:<10}" + ) # Format the parameters for the bonds def format_bond(equilibrium_values, force_constants): + """ + Format the bonds equilibrium values and force constant + in into the Gromacs topology format. + """ converted_equ_val = ( self._restraint_dict["equilibrium_values"][equilibrium_values] / _nanometer @@ -416,14 +425,33 @@ def format_bond(equilibrium_values, force_constants): ) # Format the parameters for the angles and dihedrals - def format_angle(equilibrium_values, force_constants): + def format_angle(equilibrium_values, force_constants, restraint_lambda): + """ + Format the angle equilibrium values and force constant + in into the Gromacs topology format. + + For Boresch restraint, we might want the dihedral to be stored + under the [ dihedral_restraints ] and controlled by restraint-lambdas. + Instead of under the [ dihedrals ] directive and controlled by bonded-lambdas. + + However, for dihedrals, the [ dihedral_restraints ] has a different function type + compared with [ dihedrals ] and more values for the force constant, so we need + to format them differently. + + When restraint_lambda is True, the dihedrals will be stored in the dihedral_restraints. + """ converted_equ_val = ( self._restraint_dict["equilibrium_values"][equilibrium_values] / _degree ) converted_fc = self._restraint_dict["force_constants"][force_constants] / ( _kj_per_mol / (_radian * _radian) ) - return parameters_string.format( + par_string = ( + dihedral_restraints_parameters_string + if restraint_lambda + else parameters_string + ) + return par_string.format( eq0="{:.3f}".format(converted_equ_val), fc0="{:.2f}".format(0), eq1="{:.3f}".format(converted_equ_val), @@ -444,14 +472,28 @@ def write_angle(key_list, equilibrium_values, force_constants): return master_string.format( index=format_index(key_list), func_type=1, - parameters=format_angle(equilibrium_values, force_constants), + parameters=format_angle( + equilibrium_values, force_constants, restraint_lambda=False + ), ) - def write_dihedral(key_list, equilibrium_values, force_constants): + def write_dihedral( + key_list, equilibrium_values, force_constants, restraint_lambda + ): + if restraint_lambda: + # In [ dihedral_restraints ], function type 1 + # means the dihedral is restrained harmonically. + func_type = 1 + else: + # In [ dihedrals ], function type 2 + # means the dihedral is restrained harmonically. + func_type = 2 return master_string.format( index=format_index(key_list), - func_type=2, - parameters=format_angle(equilibrium_values, force_constants), + func_type=func_type, + parameters=format_angle( + equilibrium_values, force_constants, restraint_lambda + ), ) # Writing the string @@ -473,16 +515,43 @@ def write_dihedral(key_list, equilibrium_values, force_constants): # Angles: r1-l1-l2 (thetaB0, kthetaB) output.append(write_angle(("r1", "l1", "l2"), "thetaB0", "kthetaB")) - output.append("[ dihedrals ]") + if restraint_lambda: + output.append("[ dihedral_restraints ]") + output.append( + "; ai aj ak al type phiA dphiA fcA phiB dphiB fcB" + ) + else: + output.append("[ dihedrals ]") + output.append( + "; ai aj ak al type phiA fcA phiB fcB" + ) + # Dihedrals: r3-r2-r1-l1 (phiA0, kphiA) output.append( - "; ai aj ak al type phiA fcA phiB fcB" + write_dihedral( + ("r3", "r2", "r1", "l1"), + "phiA0", + "kphiA", + restraint_lambda=restraint_lambda, + ) ) - # Dihedrals: r3-r2-r1-l1 (phiA0, kphiA) - output.append(write_dihedral(("r3", "r2", "r1", "l1"), "phiA0", "kphiA")) # Dihedrals: r2-r1-l1-l2 (phiB0, kphiB) - output.append(write_dihedral(("r2", "r1", "l1", "l2"), "phiB0", "kphiB")) + output.append( + write_dihedral( + ("r2", "r1", "l1", "l2"), + "phiB0", + "kphiB", + restraint_lambda=restraint_lambda, + ) + ) # Dihedrals: r1-l1-l2-l3 (phiC0, kphiC) - output.append(write_dihedral(("r1", "l1", "l2", "l3"), "phiC0", "kphiC")) + output.append( + write_dihedral( + ("r1", "l1", "l2", "l3"), + "phiC0", + "kphiC", + restraint_lambda=restraint_lambda, + ) + ) return "\n".join(output) @@ -702,7 +771,7 @@ def _add_restr_to_str(restr, restr_string): ) return standard_restr_string + permanent_restr_string[:-2] + "}" - def toString(self, engine, perturbation_type=None): + def toString(self, engine, perturbation_type=None, restraint_lambda=False): """ The method for convert the restraint to a format that could be used by MD Engines. @@ -721,25 +790,28 @@ def toString(self, engine, perturbation_type=None): turned on when the perturbation type is "restraint", but for which the permanent distance restraint is always active if the perturbation type is "release_restraint" (or any other perturbation type). + restraint_lambda : str, optional, default=False + Whether to use restraint_lambda in Gromacs, this would move the dihedral restraints + from [ dihedrals ], which is controlled by the bonded-lambda to + [ dihedral_restraints ], which is controlled by restraint-lambda. """ - to_str_functions = { - "boresch": {"gromacs": self._gromacs_boresch, "somd": self._somd_boresch}, - "multiple_distance": { - "gromacs": self._gromacs_multiple_distance, - "somd": self._somd_multiple_distance, - }, - } - engine = engine.strip().lower() - try: - str_fn = to_str_functions[self._restraint_type][engine] - except KeyError: - raise NotImplementedError( - f"Restraint type {self._restraint_type} not implemented " - f"yet for {engine}." - ) - - return str_fn(perturbation_type) + match (self._restraint_type, engine): + case "boresch", "gromacs": + return self._gromacs_boresch( + perturbation_type, restraint_lambda=restraint_lambda + ) + case "boresch", "somd": + return self._somd_boresch(perturbation_type) + case "multiple_distance", "gromacs": + return self._gromacs_multiple_distance(perturbation_type) + case "multiple_distance", "somd": + return self._somd_multiple_distance(perturbation_type) + case _: + raise NotImplementedError( + f"Restraint type {self._restraint_type} not implemented " + f"yet for {engine}." + ) def getCorrection(self, method="analytical"): """ diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 6d0bf4278..2586be104 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -407,6 +407,8 @@ def _write_system(self, system, coord_file=None, topol_file=None, ref_file=None) self._restraint.toString( engine="GROMACS", perturbation_type=self._protocol.getPerturbationType(), + restraint_lambda="restraint" + in self._protocol.getLambda(type="series"), ) ) diff --git a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py index e5d7ce24d..6c88cb050 100644 --- a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py +++ b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py @@ -171,7 +171,9 @@ def test_dihedral(self, Topology): assert aj == "2" assert ak == "1" assert al == "1496" + assert type == "2" assert phiA == "148.396" + assert kA == "0.00" assert phiB == "148.396" assert kB == "41.84" ai, aj, ak, al, type, phiA, kA, phiB, kB = Topology[11].split() @@ -186,6 +188,30 @@ def test_dihedral(self, Topology): assert al == "1498" +class TestGromacsOutputBoreschRestraintLambda(TestGromacsOutputBoresch): + @staticmethod + @pytest.fixture(scope="class") + def Topology(boresch_restraint): + return boresch_restraint.toString( + engine="Gromacs", restraint_lambda=True + ).split("\n") + + def test_dihedral(self, Topology): + assert "dihedral_restraints" in Topology[8] + ai, aj, ak, al, type, phiA, dphiA, kA, phiB, dphiB, kB = Topology[10].split() + assert ai == "3" + assert aj == "2" + assert ak == "1" + assert al == "1496" + assert type == "1" + assert phiA == "148.396" + assert dphiA == "0.00" + assert dphiB == "0.00" + assert kA == "0.00" + assert phiB == "148.396" + assert kB == "41.84" + + class TestSomdOutputBoresch: @staticmethod @pytest.fixture(scope="class") diff --git a/tests/Sandpit/Exscientia/Process/test_gromacs.py b/tests/Sandpit/Exscientia/Process/test_gromacs.py index ec3b9b7f7..09c3e3564 100644 --- a/tests/Sandpit/Exscientia/Process/test_gromacs.py +++ b/tests/Sandpit/Exscientia/Process/test_gromacs.py @@ -167,57 +167,87 @@ def test_restraints(perturbable_system, restraint): has_gromacs is False or has_pyarrow is False, reason="Requires GROMACS and pyarrow to be installed.", ) -def test_write_restraint(system, tmp_path): - """Test if the restraint has been written in a way that could be processed - correctly. - """ - ligand = ligand = BSS.IO.readMolecules( - [f"{url}/ligand01.prm7.bz2", f"{url}/ligand01.rst7.bz2"] - ).getMolecule(0) - decoupled_ligand = decouple(ligand) - l1 = decoupled_ligand.getAtoms()[0] - l2 = decoupled_ligand.getAtoms()[1] - l3 = decoupled_ligand.getAtoms()[2] - ligand_2 = BSS.IO.readMolecules( - [f"{url}/ligand04.prm7.bz2", f"{url}/ligand04.rst7.bz2"] - ).getMolecule(0) - r1 = ligand_2.getAtoms()[0] - r2 = ligand_2.getAtoms()[1] - r3 = ligand_2.getAtoms()[2] - system = (decoupled_ligand + ligand_2).toSystem() - - restraint_dict = { - "anchor_points": {"r1": r1, "r2": r2, "r3": r3, "l1": l1, "l2": l2, "l3": l3}, - "equilibrium_values": { - "r0": 7.84 * angstrom, - "thetaA0": 0.81 * radian, - "thetaB0": 1.74 * radian, - "phiA0": 2.59 * radian, - "phiB0": -1.20 * radian, - "phiC0": 2.63 * radian, - }, - "force_constants": { - "kr": 10 * kcal_per_mol / angstrom**2, - "kthetaA": 10 * kcal_per_mol / (radian * radian), - "kthetaB": 10 * kcal_per_mol / (radian * radian), - "kphiA": 10 * kcal_per_mol / (radian * radian), - "kphiB": 10 * kcal_per_mol / (radian * radian), - "kphiC": 10 * kcal_per_mol / (radian * radian), - }, - } - restraint = Restraint( - system, restraint_dict, 300 * kelvin, restraint_type="Boresch" - ) +class TestRestraint: + @pytest.fixture(scope="class") + def setup(self): + ligand = BSS.IO.readMolecules( + [f"{url}/ligand01.prm7.bz2", f"{url}/ligand01.rst7.bz2"] + ).getMolecule(0) + decoupled_ligand = decouple(ligand) + l1 = decoupled_ligand.getAtoms()[0] + l2 = decoupled_ligand.getAtoms()[1] + l3 = decoupled_ligand.getAtoms()[2] + ligand_2 = BSS.IO.readMolecules( + [f"{url}/ligand04.prm7.bz2", f"{url}/ligand04.rst7.bz2"] + ).getMolecule(0) + r1 = ligand_2.getAtoms()[0] + r2 = ligand_2.getAtoms()[1] + r3 = ligand_2.getAtoms()[2] + system = (decoupled_ligand + ligand_2).toSystem() + restraint_dict = { + "anchor_points": { + "r1": r1, + "r2": r2, + "r3": r3, + "l1": l1, + "l2": l2, + "l3": l3, + }, + "equilibrium_values": { + "r0": 7.84 * angstrom, + "thetaA0": 0.81 * radian, + "thetaB0": 1.74 * radian, + "phiA0": 2.59 * radian, + "phiB0": -1.20 * radian, + "phiC0": 2.63 * radian, + }, + "force_constants": { + "kr": 10 * kcal_per_mol / angstrom**2, + "kthetaA": 10 * kcal_per_mol / (radian * radian), + "kthetaB": 10 * kcal_per_mol / (radian * radian), + "kphiA": 10 * kcal_per_mol / (radian * radian), + "kphiB": 10 * kcal_per_mol / (radian * radian), + "kphiC": 10 * kcal_per_mol / (radian * radian), + }, + } + restraint = Restraint( + system, restraint_dict, 300 * kelvin, restraint_type="Boresch" + ) + return system, restraint + + def test_regular_protocol(self, setup, tmp_path_factory): + """Test if the restraint has been written in a way that could be processed + correctly. + """ + tmp_path = tmp_path_factory.mktemp("out") + system, restraint = setup + # Create a short production protocol. + protocol = BSS.Protocol.FreeEnergy( + runtime=BSS.Types.Time(0.0001, "nanoseconds"), perturbation_type="full" + ) - # Create a short production protocol. - protocol = BSS.Protocol.FreeEnergy( - runtime=BSS.Types.Time(0.0001, "nanoseconds"), perturbation_type="full" - ) + # Run the process and check that it finishes without error. + run_process(system, protocol, restraint=restraint, work_dir=str(tmp_path)) + with open(tmp_path / "test.top", "r") as f: + assert "intermolecular_interactions" in f.read() + + def test_restraint_lambda(self, setup, tmp_path_factory): + """Test if the restraint has been written correctly when restraint lambda is evoked.""" + tmp_path = tmp_path_factory.mktemp("out") + system, restraint = setup + # Create a short production protocol. + protocol = BSS.Protocol.FreeEnergy( + runtime=BSS.Types.Time(0.0001, "nanoseconds"), + lam=pd.Series(data={"bonded": 0.0, "restraint": 0.0}), + lam_vals=pd.DataFrame(data={"bonded": [0.0, 1.0], "restraint": [0.0, 1.0]}), + ) - # Run the process and check that it finishes without error. - run_process(system, protocol, restraint=restraint, work_dir=str(tmp_path)) - with open(tmp_path / "test.top", "r") as f: - assert "intermolecular_interactions" in f.read() + # Run the process and check that it finishes without error. + run_process(system, protocol, restraint=restraint, work_dir=str(tmp_path)) + with open(tmp_path / "test.top", "r") as f: + assert "dihedral_restraints" in f.read() + with open(tmp_path / "test.mdp", "r") as f: + assert "restraint-lambdas" in f.read() def run_process(system, protocol, **kwargs): From baf0aefba6943ef315cb44a79edfa9fe359af5eb Mon Sep 17 00:00:00 2001 From: Zhiyi Wu Date: Tue, 12 Mar 2024 20:34:29 +0000 Subject: [PATCH 09/32] =?UTF-8?q?Implement=20the=20Schr=C3=B6dinger's=20de?= =?UTF-8?q?rivation=20of=20the=20analytical=20correction=20for=20Boresch?= =?UTF-8?q?=20restraint=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Exscientia/FreeEnergy/_restraint.py | 250 +++++++++++++----- .../Exscientia/FreeEnergy/test_restraint.py | 13 +- 2 files changed, 189 insertions(+), 74 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py index 74d9652ee..5aaa857db 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py @@ -22,29 +22,54 @@ """ A class for holding restraints. """ -from math import e -from scipy import integrate as _integrate -import numpy as _np +import math as _math import warnings as _warnings +from typing import Literal -from sire.legacy.Units import angstrom3 as _Sire_angstrom3 -from sire.legacy.Units import k_boltz as _k_boltz -from sire.legacy.Units import meter3 as _Sire_meter3 -from sire.legacy.Units import mole as _Sire_mole - -from .._SireWrappers import Atom as _Atom -from .._SireWrappers import System as _System -from ..Types import Angle as _Angle -from ..Types import Length as _Length -from ..Types import Temperature as _Temperature -from ..Units.Angle import degree as _degree -from ..Units.Angle import radian as _radian +import numpy as _np +from scipy import integrate as _integrate +from scipy.special import erf as _erf +from sire.legacy.Units import ( + angstrom3 as _Sire_angstrom3, + k_boltz as _k_boltz, + meter3 as _Sire_meter3, + mole as _Sire_mole, +) +from sire.units import GeneralUnit as _sire_GeneralUnit + +from BioSimSpace.Sandpit.Exscientia.Types._general_unit import ( + GeneralUnit as _GeneralUnit, +) +from ..Types import Angle as _Angle, Length as _Length, Temperature as _Temperature +from ..Units.Angle import degree as _degree, radian as _radian from ..Units.Area import angstrom2 as _angstrom2 -from ..Units.Energy import kcal_per_mol as _kcal_per_mol -from ..Units.Energy import kj_per_mol as _kj_per_mol -from ..Units.Length import angstrom as _angstrom -from ..Units.Length import nanometer as _nanometer +from ..Units.Energy import kcal_per_mol as _kcal_per_mol, kj_per_mol as _kj_per_mol +from ..Units.Length import angstrom as _angstrom, nanometer as _nanometer from ..Units.Temperature import kelvin as _kelvin +from ..Units.Volume import angstrom3 as _angstrom3 +from .._SireWrappers import Atom as _Atom, System as _System + + +def sqrt(u): + dims = u._sire_unit.dimensions() + for dim in dims: + if dim % 2 != 0: + raise ValueError( + "Square root not possible on dimension that is not divisible by 2!" + ) + return _GeneralUnit( + _sire_GeneralUnit(_math.sqrt(u.value()), [int(0.5 * dim) for dim in dims]) + ) + + +def exp(u): + dims = u._sire_unit.dimensions() + return _GeneralUnit(_sire_GeneralUnit(_math.exp(u.value()), dims)) + + +def erf(u): + dims = u._sire_unit.dimensions() + return _GeneralUnit(_sire_GeneralUnit(_erf(u.value()), dims)) class Restraint: @@ -813,7 +838,11 @@ def toString(self, engine, perturbation_type=None, restraint_lambda=False): f"yet for {engine}." ) - def getCorrection(self, method="analytical"): + def getCorrection( + self, + method="analytical", + flavour: Literal["boresch", "schrodinger"] = "boresch", + ): """ Calculate the free energy of releasing the restraint to the standard state volume. @@ -827,6 +856,11 @@ def getCorrection(self, method="analytical"): correction can introduce errors when the restraints are weak, restrained angles are close to 0 or pi radians, or the restrained distance is close to 0. + flavour : str + When analytical correction is used, one could either use + Boresch's derivation or Schrodinger's derivation. Both of + them usually agrees quite well with each other to the extent + of 0.2 kcal/mol. Returns ---------- @@ -975,59 +1009,10 @@ def numerical_dihedral_integrand(phi, phi0, kphi): return dg elif method == "analytical": - # Only need three equilibrium values for the analytical correction - r0 = ( - self._restraint_dict["equilibrium_values"]["r0"] / _angstrom - ) # Distance in A - thetaA0 = ( - self._restraint_dict["equilibrium_values"]["thetaA0"] / _radian - ) # Angle in radians - thetaB0 = ( - self._restraint_dict["equilibrium_values"]["thetaB0"] / _radian - ) # Angle in radians - - force_constants = [] - - # Loop through and correct for force constants of zero, - # which break the analytical correction. To account for this, - # divide the prefactor accordingly. Note that setting - # certain force constants to zero while others are non-zero - # will result in unstable restraints, but this will be checked when - # the restraint object is created - for k, val in self._restraint_dict["force_constants"].items(): - if val.value() == 0: - if k == "kr": - raise ValueError("The force constant kr must not be zero") - if k == "kthetaA": - prefactor /= 2 / _np.sin(thetaA0) - if k == "kthetaB": - prefactor /= 2 / _np.sin(thetaB0) - if k[:4] == "kphi": - prefactor /= 2 * _np.pi - else: - if k == "kr": - force_constants.append(val / (_kcal_per_mol / _angstrom2)) - else: - force_constants.append( - val / (_kcal_per_mol / (_radian * _radian)) - ) - - # Calculation - n_nonzero_k = len(force_constants) - prod_force_constants = _np.prod(force_constants) - numerator = prefactor * _np.sqrt(prod_force_constants) - denominator = ( - (r0**2) - * _np.sin(thetaA0) - * _np.sin(thetaB0) - * (2 * _np.pi * R * T) ** (n_nonzero_k / 2) - ) - - # Compute dg and attach unit - dg = -R * T * _np.log(numerator / denominator) - dg *= _kcal_per_mol - - return dg + if flavour.lower() == "schrodinger": + return self._schrodinger_analytical_correction() + elif flavour.lower() == "boresch": + return self._boresch_analytical_correction() else: raise ValueError( @@ -1127,6 +1112,125 @@ def _get_correction(r0, r_fb, kr): ) return _get_correction(r0, r_fb, kr) + def _schrodinger_analytical_correction(self): + # Adapted from DOI: 10.1021/acs.jcim.3c00013 + k_boltz = _GeneralUnit(_k_boltz) + beta = 1 / (k_boltz * self.T) + V = 1660 * _angstrom3 + + r = self._restraint_dict["equilibrium_values"]["r0"] + # Schrodinger uses k(b-b0)**2 + kr = self._restraint_dict["force_constants"]["kr"] / 2 + + Z_dist = r / (2 * beta * kr) * _np.exp(-beta * kr * r**2) + _np.sqrt( + _np.pi + ) / (4 * beta * kr * sqrt(beta * kr)) * (1 + 2 * beta * kr * r**2) * ( + 1 + _erf(sqrt(beta * kr) * r) + ) + + Z_angles = [] + for angle in ["A", "B"]: + theta = ( + self._restraint_dict["equilibrium_values"][f"theta{angle}0"] / _radian + ) # Angle in radians + # Schrodinger uses k instead of k/2 + ktheta = self._restraint_dict["force_constants"][f"ktheta{angle}"] / 2 + Z_angle = ( + sqrt(_np.pi / (beta * ktheta)) + * exp(-1 / (4 * beta * ktheta)) + * _np.sin(theta) + ) + Z_angle /= _radian**3 + Z_angles.append(Z_angle) + + Z_dihedrals = [] + for dihedral in ["A", "B", "C"]: + # Schrodinger uses k instead of k/2 + kphi = self._restraint_dict["force_constants"][f"kphi{dihedral}"] / 2 + Z_dihedral = sqrt(_np.pi / (beta * kphi)) * erf(_np.pi * sqrt(beta * kphi)) + Z_dihedrals.append(Z_dihedral) + + dG = ( + k_boltz + * self.T + * _np.log( + Z_angles[0] + * Z_angles[1] + * Z_dist + * Z_dihedrals[0] + * Z_dihedrals[1] + * Z_dihedrals[2] + / (8 * _np.pi**2 * V) + ) + ) + return dG + + def _boresch_analytical_correction(self): + R = ( + _k_boltz.value() * _kcal_per_mol / _kelvin + ).value() # molar gas constant in kcal mol-1 K-1 + + # Parameters + T = self.T / _kelvin # Temperature in Kelvin + v0 = ( + ((_Sire_meter3 / 1000) / _Sire_mole) / _Sire_angstrom3 + ).value() # standard state volume in A^3 + prefactor = ( + 8 * (_np.pi**2) * v0 + ) # In A^3. Divide this to account for force constants of 0 in the + # analytical correction + # Only need three equilibrium values for the analytical correction + r0 = ( + self._restraint_dict["equilibrium_values"]["r0"] / _angstrom + ) # Distance in A + thetaA0 = ( + self._restraint_dict["equilibrium_values"]["thetaA0"] / _radian + ) # Angle in radians + thetaB0 = ( + self._restraint_dict["equilibrium_values"]["thetaB0"] / _radian + ) # Angle in radians + + force_constants = [] + + # Loop through and correct for force constants of zero, + # which break the analytical correction. To account for this, + # divide the prefactor accordingly. Note that setting + # certain force constants to zero while others are non-zero + # will result in unstable restraints, but this will be checked when + # the restraint object is created + for k, val in self._restraint_dict["force_constants"].items(): + if val.value() == 0: + if k == "kr": + raise ValueError("The force constant kr must not be zero") + if k == "kthetaA": + prefactor /= 2 / _np.sin(thetaA0) + if k == "kthetaB": + prefactor /= 2 / _np.sin(thetaB0) + if k[:4] == "kphi": + prefactor /= 2 * _np.pi + else: + if k == "kr": + force_constants.append(val / (_kcal_per_mol / _angstrom2)) + else: + force_constants.append(val / (_kcal_per_mol / (_radian * _radian))) + + # Calculation + n_nonzero_k = len(force_constants) + prod_force_constants = _np.prod(force_constants) + numerator = prefactor * _np.sqrt(prod_force_constants) + denominator = ( + (r0**2) + * _np.sin(thetaA0) + * _np.sin(thetaB0) + * (2 * _np.pi * R * T) ** (n_nonzero_k / 2) + ) + + # Compute dg and attach unit + dg = -R * T * _np.log(numerator / denominator) + dg *= _kcal_per_mol + + return dg + @property def correction(self): """Give the free energy of removing the restraint.""" diff --git a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py index 6c88cb050..0149a64e3 100644 --- a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py +++ b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint.py @@ -97,11 +97,22 @@ def test_numerical_correction_boresch(boresch_restraint): def test_analytical_correction_boresch(boresch_restraint): - dG = boresch_restraint.getCorrection(method="analytical") / kcal_per_mol + dG = ( + boresch_restraint.getCorrection(method="analytical", flavour="boresch") + / kcal_per_mol + ) assert np.isclose(-7.2, dG, atol=0.1) assert isinstance(boresch_restraint, Restraint) +def test_analytical_schrodinger_correction_boresch(boresch_restraint): + dG = ( + boresch_restraint.getCorrection(method="analytical", flavour="schrodinger") + / kcal_per_mol + ) + assert np.isclose(-7.2, dG, atol=0.1) + + test_force_constants_boresch = [ ({"kr": 0}, ValueError), ({"kthetaA": 0}, ValueError), From 18b95cb3e22e1d829832c8321f0c837b61a6e3cb Mon Sep 17 00:00:00 2001 From: Zhiyi Wu Date: Wed, 13 Mar 2024 13:38:36 +0000 Subject: [PATCH 10/32] Have the position restraint deals with the alchemical ion (#39) --- .../Sandpit/Exscientia/Align/_alch_ion.py | 22 ++- .../Sandpit/Exscientia/Process/_amber.py | 14 +- .../Sandpit/Exscientia/Process/_gromacs.py | 58 +++++-- .../Sandpit/Exscientia/Protocol/_config.py | 153 ++++++++++-------- .../Exscientia/_SireWrappers/_molecule.py | 38 +++-- .../Exscientia/Align/test_alchemical_ion.py | 13 +- .../Sandpit/Exscientia/Process/test_amber.py | 4 +- .../Exscientia/Process/test_gromacs.py | 13 +- .../Process/test_position_restraint.py | 127 ++++++++++++++- 9 files changed, 327 insertions(+), 115 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Align/_alch_ion.py b/python/BioSimSpace/Sandpit/Exscientia/Align/_alch_ion.py index 14773adf6..cd75db2a3 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Align/_alch_ion.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Align/_alch_ion.py @@ -1,6 +1,6 @@ import warnings -from .._SireWrappers import Molecule as _Molecule +from .._SireWrappers import Molecule as _Molecule, System as _System def _mark_alchemical_ion(molecule): @@ -50,3 +50,23 @@ def _mark_alchemical_ion(molecule): mol._sire_object = mol_edit.commit() return mol + + +def _get_protein_com_idx(system: _System) -> int: + """return the index of the atom that is closest to the center of + mass of the biggest molecule in the system. + + Args: + system: The input system. + + Returns: + atom_index + """ + biggest_mol_idx = max(range(system.nMolecules()), key=lambda x: system[x].nAtoms()) + + atom_offset = 0 + for i, mol in enumerate(system): + if biggest_mol_idx == i: + return atom_offset + mol.getCOMIdx() + else: + atom_offset += mol.nAtoms() diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py index d84e7eaa5..f70f4f13b 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_amber.py @@ -44,7 +44,6 @@ from sire.legacy import Base as _SireBase from sire.legacy import IO as _SireIO from sire.legacy import Mol as _SireMol -import pandas as pd from .._Utils import _assert_imported, _have_imported, _try_import @@ -236,8 +235,12 @@ def _setup(self): ) # Create the reference file - if self._ref_system is not None and self._protocol.getRestraint() is not None: - self._write_system(self._ref_system, ref_file=self._ref_file) + if self._ref_system is not None: + if ( + self._system.getAlchemicalIon() + or self._protocol.getRestraint() is not None + ): + self._write_system(self._ref_system, ref_file=self._ref_file) else: _shutil.copy(self._rst_file, self._ref_file) @@ -543,7 +546,10 @@ def _generate_args(self): if not isinstance(self._protocol, _Protocol.Custom): # Append a reference file if this a restrained simulation. if isinstance(self._protocol, _Protocol._PositionRestraintMixin): - if self._protocol.getRestraint() is not None: + if ( + self._protocol.getRestraint() is not None + or self._system.getAlchemicalIon() + ): self.setArg("-ref", "%s_ref.rst7" % self._name) # Append a trajectory file if this anything other than a minimisation. diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 2586be104..5953781fc 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -28,9 +28,6 @@ import glob as _glob import os as _os -import warnings as _warnings - -import pandas as pd from .._Utils import _try_import @@ -261,8 +258,12 @@ def _setup(self): ) # Create the reference file - if self._ref_system is not None and self._protocol.getRestraint() is not None: - self._write_system(self._ref_system, ref_file=self._ref_file) + if self._ref_system is not None: + if ( + self._system.getAlchemicalIon() + or self._protocol.getRestraint() is not None + ): + self._write_system(self._ref_system, ref_file=self._ref_file) else: _shutil.copy(self._gro_file, self._ref_file) @@ -2080,7 +2081,7 @@ def _add_position_restraints(self, config_options): # Get the restraint type. restraint = self._protocol.getRestraint() - if restraint is not None: + if restraint is not None or self._system.getAlchemicalIon(): # Get the force constant in units of kJ_per_mol/nanometer**2 force_constant = self._protocol.getForceConstant()._sire_unit force_constant = force_constant.to( @@ -2140,8 +2141,15 @@ def _add_position_restraints(self, config_options): moltypes_sys_idx[mol_type].append(idx) sys_idx_moltypes[idx] = mol_type + if self._system.getAlchemicalIon(): + biggest_mol_idx = max( + range(system.nMolecules()), key=lambda x: system[x].nAtoms() + ) + else: + biggest_mol_idx = -1 + # A keyword restraint. - if isinstance(restraint, str): + if isinstance(restraint, str) or self._system.getAlchemicalIon(): # The number of restraint files. num_restraint = 1 @@ -2158,12 +2166,36 @@ def _add_position_restraints(self, config_options): for idx, mol_idx in enumerate(mol_idxs): # Get the indices of any restrained atoms in this molecule, # making sure that indices are relative to the molecule. - atom_idxs = self._system.getRestraintAtoms( - restraint, - mol_index=mol_idx, - is_absolute=False, - allow_zero_matches=True, - ) + if restraint is not None: + atom_idxs = self._system.getRestraintAtoms( + restraint, + mol_index=mol_idx, + is_absolute=False, + allow_zero_matches=True, + ) + else: + atom_idxs = [] + + if self._system.getMolecule(mol_idx).isAlchemicalIon(): + alch_ion = self._system.getMolecule(mol_idx).getAtoms() + alch_idx = alch_ion[0].index() + if alch_idx != 0 or len(alch_ion) != 1: + # The alchemical ions should only contain 1 atom + # and the relative index should thus be 0. + raise ValueError( + f"{self._system.getMolecule(mol_idx)} is marked as an alchemical ion but has more than 1 atom." + ) + else: + atom_idxs.append(alch_idx) + + if mol_idx == biggest_mol_idx: + # Only triggered when there is alchemical ion present. + # The biggest_mol_idx is -1 when there is no alchemical ion. + protein_com_idx = self._system.getMolecule( + mol_idx + ).getCOMIdx() + if protein_com_idx not in atom_idxs: + atom_idxs.append(protein_com_idx) # Store the atom index if it hasn't already been recorded. for atom_idx in atom_idxs: diff --git a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py index a0319ea2b..99446e14b 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_config.py @@ -1,16 +1,15 @@ +import math as _math import warnings as _warnings -import math as _math from sire.legacy import Units as _SireUnits -from ..Units.Time import nanosecond as _nanosecond -from .. import Protocol as _Protocol -from .. import _gmx_version -from .._Exceptions import IncompatibleError as _IncompatibleError +from .. import Protocol as _Protocol, _gmx_version +from ..Align._alch_ion import _get_protein_com_idx from ..Align._squash import _amber_mask_from_indices, _squashed_atom_mapping from ..FreeEnergy._restraint import Restraint as _Restraint from ..Units.Energy import kj_per_mol as _kj_per_mol from ..Units.Length import nanometer as _nanometer +from .._Exceptions import IncompatibleError as _IncompatibleError class ConfigFactory: @@ -217,9 +216,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: @@ -248,6 +247,13 @@ def generateAmberConfig(self, extra_options=None, extra_lines=None): # Restrain the backbone. restraint = self.protocol.getRestraint() + if self.system.getAlchemicalIon(): + alchem_ion_idx = self.system.getAlchemicalIonIdx() + protein_com_idx = _get_protein_com_idx(self.system) + alchemical_ion_mask = f"@{alchem_ion_idx} | @{protein_com_idx}" + else: + alchemical_ion_mask = None + if restraint is not None: # Get the indices of the atoms that are restrained. if type(restraint) is str: @@ -293,9 +299,9 @@ def generateAmberConfig(self, extra_options=None, extra_lines=None): ] restraint_mask = "@" + ",".join(restraint_atom_names) elif restraint == "heavy": - restraint_mask = "!:WAT & !@H=" + restraint_mask = "!:WAT & !@%NA,CL & !@H=" elif restraint == "all": - restraint_mask = "!:WAT" + restraint_mask = "!:WAT & !@%NA,CL" # We can't do anything about a custom restraint, since we don't # know anything about the atoms. @@ -304,13 +310,22 @@ def generateAmberConfig(self, extra_options=None, extra_lines=None): "AMBER atom 'restraintmask' exceeds 256 character limit!" ) - protocol_dict["ntr"] = 1 - force_constant = self.protocol.getForceConstant()._sire_unit - force_constant = force_constant.to( - _SireUnits.kcal_per_mol / _SireUnits.angstrom2 - ) - protocol_dict["restraint_wt"] = force_constant - protocol_dict["restraintmask"] = f'"{restraint_mask}"' + else: + restraint_mask = None + + if restraint_mask or alchemical_ion_mask: + if restraint_mask and alchemical_ion_mask: + restraint_mask = f"{restraint_mask} | {alchemical_ion_mask}" + elif alchemical_ion_mask: + restraint_mask = alchemical_ion_mask + + protocol_dict["ntr"] = 1 + force_constant = self.protocol.getForceConstant()._sire_unit + force_constant = force_constant.to( + _SireUnits.kcal_per_mol / _SireUnits.angstrom2 + ) + protocol_dict["restraint_wt"] = force_constant + protocol_dict["restraintmask"] = f'"{restraint_mask}"' # Pressure control. if not isinstance(self.protocol, _Protocol.Minimisation): @@ -318,9 +333,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 +481,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 +518,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" @@ -521,9 +536,9 @@ def generateGromacsConfig( else: 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). @@ -538,12 +553,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 @@ -613,20 +628,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" @@ -833,18 +848,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 = ( @@ -861,9 +876,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/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 7bebe86ee..7c25c30f7 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -32,20 +32,22 @@ from math import isclose as _isclose from warnings import warn as _warn -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 Maths as _SireMaths -from sire.legacy import Mol as _SireMol -from sire.legacy import System as _SireSystem -from sire.legacy import Units as _SireUnits +import numpy as _np +from sire.legacy import ( + Base as _SireBase, + IO as _SireIO, + MM as _SireMM, + Maths as _SireMaths, + Mol as _SireMol, + System as _SireSystem, + Units as _SireUnits, +) +from ._sire_wrapper import SireWrapper as _SireWrapper from .. import _isVerbose +from ..Types import Coordinate as _Coordinate, Length as _Length, Vector as _BSSVector +from ..Units.Length import angstrom as _angstrom from .._Exceptions import IncompatibleError as _IncompatibleError -from ..Types import Coordinate as _Coordinate -from ..Types import Length as _Length - -from ._sire_wrapper import SireWrapper as _SireWrapper class Molecule(_SireWrapper): @@ -1879,6 +1881,20 @@ def _getPerturbationIndices(self): return idxs + def getCOMIdx(self): + """Get the index of the atom that closest to the center of mass.""" + if self.isPerturbable(): + property_map = {"coordinates": "coordinates0", "mass": "mass0"} + else: + property_map = {"coordinates": "coordinates", "mass": "mass"} + coords = self.coordinates(property_map=property_map) + com = self._getCenterOfMass(property_map=property_map) + com = _BSSVector(*[e / _angstrom for e in com]) + diffs = [coord.toVector() - com for coord in coords] + sq_distances = [diff.dot(diff) for diff in diffs] + idx = int(_np.argmin(sq_distances)) + return idx + # Import at bottom of module to avoid circular dependency. from ._atom import Atom as _Atom diff --git a/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py b/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py index fa2a0aeb6..5caee6425 100644 --- a/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py +++ b/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py @@ -1,10 +1,12 @@ import pytest import BioSimSpace.Sandpit.Exscientia as BSS -from BioSimSpace.Sandpit.Exscientia.Align._alch_ion import _mark_alchemical_ion +from BioSimSpace.Sandpit.Exscientia.Align._alch_ion import ( + _get_protein_com_idx, + _mark_alchemical_ion, +) from BioSimSpace.Sandpit.Exscientia._SireWrappers import Molecule - -from tests.conftest import root_fp, has_gromacs +from tests.conftest import has_gromacs, root_fp @pytest.fixture @@ -50,3 +52,8 @@ def test_getAlchemicalIon(input_system, isalchem, request): def test_getAlchemicalIonIdx(alchemical_ion_system): index = alchemical_ion_system.getAlchemicalIonIdx() assert index == 680 + + +def test_get_protein_com_idx(alchemical_ion_system): + index = _get_protein_com_idx(alchemical_ion_system) + assert index == 8 diff --git a/tests/Sandpit/Exscientia/Process/test_amber.py b/tests/Sandpit/Exscientia/Process/test_amber.py index 73866d01a..1a6daded5 100644 --- a/tests/Sandpit/Exscientia/Process/test_amber.py +++ b/tests/Sandpit/Exscientia/Process/test_amber.py @@ -5,11 +5,9 @@ import numpy as np import pandas as pd import pytest -import shutil import BioSimSpace.Sandpit.Exscientia as BSS - -from tests.Sandpit.Exscientia.conftest import url, has_amber, has_pyarrow +from tests.Sandpit.Exscientia.conftest import has_amber, has_pyarrow from tests.conftest import root_fp diff --git a/tests/Sandpit/Exscientia/Process/test_gromacs.py b/tests/Sandpit/Exscientia/Process/test_gromacs.py index 09c3e3564..9a1886e97 100644 --- a/tests/Sandpit/Exscientia/Process/test_gromacs.py +++ b/tests/Sandpit/Exscientia/Process/test_gromacs.py @@ -1,13 +1,12 @@ import math -import numpy as np -import pytest -import pandas as pd import shutil - from pathlib import Path -import BioSimSpace.Sandpit.Exscientia as BSS +import numpy as np +import pandas as pd +import pytest +import BioSimSpace.Sandpit.Exscientia as BSS from BioSimSpace.Sandpit.Exscientia.Align import decouple from BioSimSpace.Sandpit.Exscientia.FreeEnergy import Restraint from BioSimSpace.Sandpit.Exscientia.Units.Angle import radian @@ -17,15 +16,13 @@ from BioSimSpace.Sandpit.Exscientia.Units.Temperature import kelvin from BioSimSpace.Sandpit.Exscientia.Units.Time import picosecond from BioSimSpace.Sandpit.Exscientia.Units.Volume import nanometer3 - from tests.Sandpit.Exscientia.conftest import ( - url, - has_alchemlyb, has_alchemtest, has_amber, has_gromacs, has_openff, has_pyarrow, + url, ) from tests.conftest import root_fp diff --git a/tests/Sandpit/Exscientia/Process/test_position_restraint.py b/tests/Sandpit/Exscientia/Process/test_position_restraint.py index 7355adc70..7c16427c1 100644 --- a/tests/Sandpit/Exscientia/Process/test_position_restraint.py +++ b/tests/Sandpit/Exscientia/Process/test_position_restraint.py @@ -1,17 +1,20 @@ -from difflib import unified_diff - import itertools import os +from difflib import unified_diff import pandas as pd import pytest +import sire as sr +from sire.legacy import Units as SireUnits from sire.legacy.IO import AmberRst import BioSimSpace.Sandpit.Exscientia as BSS +from BioSimSpace.Sandpit.Exscientia.Align._alch_ion import _mark_alchemical_ion from BioSimSpace.Sandpit.Exscientia.Units.Energy import kj_per_mol from BioSimSpace.Sandpit.Exscientia.Units.Length import angstrom - +from BioSimSpace.Sandpit.Exscientia._SireWrappers import Molecule from tests.Sandpit.Exscientia.conftest import has_amber, has_gromacs, has_openff +from tests.conftest import root_fp @pytest.fixture @@ -22,6 +25,41 @@ def system(): return BSS.Align.merge(mol0, mol1).toSystem() +@pytest.fixture(scope="session") +def alchemical_ion_system(): + """A large protein system for re-use.""" + system = BSS.IO.readMolecules( + [f"{root_fp}/input/ala.top", f"{root_fp}/input/ala.crd"] + ) + solvated = BSS.Solvent.tip3p( + system, box=[4 * BSS.Units.Length.nanometer] * 3, ion_conc=0.15 + ) + ion = solvated.getMolecule(-1) + pert_ion = BSS.Align.merge(ion, ion, mapping={0: 0}) + pert_ion._sire_object = ( + pert_ion.getAtoms()[0] + ._sire_object.edit() + .setProperty("charge1", 0 * SireUnits.mod_electron) + .molecule() + ) + alchemcial_ion = _mark_alchemical_ion(pert_ion) + solvated.updateMolecule(solvated.getIndex(ion), alchemcial_ion) + return solvated + + +@pytest.fixture(scope="session") +def alchemical_ion_system_psores(alchemical_ion_system): + # Create a reference system with different coordinate + system = alchemical_ion_system.copy() + mol = system.getMolecule(0) + sire_mol = mol._sire_object + atoms = sire_mol.cursor().atoms() + atoms[0]["coordinates"] = sr.maths.Vector(0, 0, 0) + new_mol = atoms.commit() + system.updateMolecule(0, Molecule(new_mol)) + return system + + @pytest.fixture def ref_system(system): mol = system[0] @@ -146,3 +184,86 @@ def test_amber(protocol, system, ref_system, tmp_path): # We are pointing the reference to the correct file assert f"{proc._work_dir}/{proc.getArgs()['-ref']}" == proc._ref_file + + +@pytest.mark.parametrize( + "restraint", + ["backbone", "heavy", "all", "none"], +) +def test_gromacs(alchemical_ion_system, restraint, alchemical_ion_system_psores): + protocol = BSS.Protocol.FreeEnergy(restraint=restraint) + process = BSS.Process.Gromacs( + alchemical_ion_system, + protocol, + name="test", + reference_system=alchemical_ion_system_psores, + ) + + # Test the position restraint for protein center + with open(f"{process.workDir()}/posre_0001.itp", "r") as f: + posres = f.read().split("\n") + posres = [tuple(line.split()) for line in posres] + + assert ("9", "1", "4184.0", "4184.0", "4184.0") in posres + + # Test the position restraint for alchemical ion + with open(f"{process.workDir()}/test.top", "r") as f: + top = f.read() + lines = top[top.index("Merged_Molecule") :].split("\n") + assert lines[6] == '#include "posre_0002.itp"' + + with open(f"{process.workDir()}/posre_0002.itp", "r") as f: + posres = f.read().split("\n") + + assert posres[2].split() == ["1", "1", "4184.0", "4184.0", "4184.0"] + + # Test if the original coordinate is correct + with open(f"{process.workDir()}/test.gro", "r") as f: + gro = f.read().splitlines() + assert gro[2].split() == ["1ACE", "HH31", "1", "1.791", "1.610", "2.058"] + + # Test if the reference coordinate is passed + with open(f"{process.workDir()}/test_ref.gro", "r") as f: + gro = f.read().splitlines() + assert gro[2].split() == ["1ACE", "HH31", "1", "0.000", "0.000", "0.000"] + + +@pytest.mark.parametrize( + ("restraint", "target"), + [ + ("backbone", "@5-7,9,15-17 | @2148 | @8"), + ("heavy", "@2,5-7,9,11,15-17,19 | @2148 | @8"), + ("all", "@1-22 | @2148 | @8"), + ("none", "@2148 | @8"), + ], +) +def test_amber(alchemical_ion_system, restraint, target, alchemical_ion_system_psores): + # Create an equilibration protocol with backbone restraints. + protocol = BSS.Protocol.Equilibration(restraint=restraint) + + # Create the process object. + process = BSS.Process.Amber( + alchemical_ion_system, + protocol, + name="test", + reference_system=alchemical_ion_system_psores, + ) + + # Check that the correct restraint mask is in the config. + config = " ".join(process.getConfig()) + assert target in config + # Check is the reference file is passed to the cmd + assert "-ref test_ref.rst7" in process.getArgString() + + # Test if the original coordinate is correct + original = BSS.IO.readMolecules( + [f"{process.workDir()}/test.prm7", f"{process.workDir()}/test.rst7"] + ) + original_crd = original.getMolecule(0).coordinates()[0] + assert str(original_crd) == "(17.9138 A, 16.0981 A, 20.5786 A)" + # Test if the reference coordinate is passed + ref = BSS.IO.readMolecules( + [f"{process.workDir()}/test.prm7", f"{process.workDir()}/test_ref.rst7"] + ) + ref_crd = ref.getMolecule(0).coordinates()[0] + assert str(ref_crd) == "(0.0000e+00 A, 0.0000e+00 A, 0.0000e+00 A)" From 70f309c328380e8192d01b64c3b0604b412b0b5e Mon Sep 17 00:00:00 2001 From: Miroslav Suruzhon <36005076+msuruzhon@users.noreply.github.com> Date: Thu, 21 Mar 2024 10:05:05 +0000 Subject: [PATCH 11/32] Merge remote-tracking branch 'obs/main' into feat_main (#41) --- .github/workflows/Sandpit_exs.yml | 2 +- doc/source/changelog.rst | 13 ++ python/BioSimSpace/Process/_gromacs.py | 6 +- .../Protocol/_position_restraint_mixin.py | 4 +- .../Exscientia/FreeEnergy/_restraint.py | 10 +- .../FreeEnergy/_restraint_search.py | 4 +- .../Sandpit/Exscientia/Process/_gromacs.py | 6 +- .../Protocol/_position_restraint.py | 2 +- .../Sandpit/Exscientia/Types/_angle.py | 18 +- .../Sandpit/Exscientia/Types/_area.py | 18 +- .../Sandpit/Exscientia/Types/_charge.py | 16 +- .../Sandpit/Exscientia/Types/_energy.py | 16 +- .../Sandpit/Exscientia/Types/_general_unit.py | 170 ++++++++++-------- .../Sandpit/Exscientia/Types/_length.py | 41 +---- .../Sandpit/Exscientia/Types/_pressure.py | 16 +- .../Sandpit/Exscientia/Types/_temperature.py | 33 ++-- .../Sandpit/Exscientia/Types/_time.py | 24 +-- .../Sandpit/Exscientia/Types/_type.py | 110 ++++++------ .../Sandpit/Exscientia/Types/_volume.py | 18 +- .../Exscientia/_SireWrappers/_molecule.py | 15 +- python/BioSimSpace/Types/_angle.py | 18 +- python/BioSimSpace/Types/_area.py | 18 +- python/BioSimSpace/Types/_charge.py | 16 +- python/BioSimSpace/Types/_energy.py | 16 +- python/BioSimSpace/Types/_general_unit.py | 170 ++++++++++-------- python/BioSimSpace/Types/_length.py | 41 +---- python/BioSimSpace/Types/_pressure.py | 16 +- python/BioSimSpace/Types/_temperature.py | 28 +-- python/BioSimSpace/Types/_time.py | 24 +-- python/BioSimSpace/Types/_type.py | 110 ++++++------ python/BioSimSpace/Types/_volume.py | 18 +- python/BioSimSpace/_Config/_amber.py | 6 +- python/BioSimSpace/_SireWrappers/_molecule.py | 15 +- recipes/biosimspace/template.yaml | 11 +- requirements.txt | 2 +- .../FreeEnergy/test_restraint_search.py | 10 +- .../Process/test_position_restraint.py | 1 + .../Exscientia/Types/test_general_unit.py | 76 +++++++- tests/Types/test_general_unit.py | 76 +++++++- 39 files changed, 676 insertions(+), 538 deletions(-) diff --git a/.github/workflows/Sandpit_exs.yml b/.github/workflows/Sandpit_exs.yml index 76b9a3242..f5b0fc0d3 100644 --- a/.github/workflows/Sandpit_exs.yml +++ b/.github/workflows/Sandpit_exs.yml @@ -35,7 +35,7 @@ jobs: - name: Install dependency run: | - mamba install -c conda-forge -c openbiosim/label/main biosimspace python=3.10 ambertools gromacs "sire=2023.4" "alchemlyb>=2.1" pytest openff-interchange pint=0.21 rdkit "jaxlib>0.3.7" tqdm + mamba install -c conda-forge -c openbiosim/label/main biosimspace python=3.10 ambertools gromacs "sire=2023.5" "alchemlyb>=2.1" pytest openff-interchange pint=0.21 rdkit "jaxlib>0.3.7" tqdm python -m pip install git+https://github.com/Exscientia/MDRestraintsGenerator.git # For the testing of BSS.FreeEnergy.AlchemicalFreeEnergy.analysis python -m pip install https://github.com/alchemistry/alchemtest/archive/master.zip diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 5420e438a..0e8765d6c 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -9,6 +9,19 @@ company supporting open-source development of fostering academic/industrial coll within the biomolecular simulation community. Our software is hosted via the `OpenBioSim` `GitHub `__ organisation. +`2023.5.1 `_ - Mar 20 2024 +------------------------------------------------------------------------------------------------- + +* Fixed path to user links file in the :func:`generateNetwork ` function (`#233 `__). +* Fixed redirection of stderr (`#233 `__). +* Switched to using ``AtomCoordMatcher`` to map parameterised molecules back to their original topology. This resolves issues where atoms moved between residues following parameterisation (`#235 `__). +* Make the GROMACS ``_generate_binary_run_file`` function static so that it can be used when initialising free energy simulations in setup-only mode (`#237 `__). +* Improve error handling and message when attempting to extract an all dummy atom selection (`#251 `__). +* Don't set SOMD specific end-state properties when decoupling a molecule (`#253 `__). +* Only convert to a end-state system when not running a free energy protocol with GROMACS so that hybrid topology isn't lost when using position restraints (`#257 `__). +* Exclude standard free ions from the AMBER position restraint mask (`#260 `__). +* Update the ``BioSimSpace.Types._GeneralUnit.__pow__`` operator to support fractional exponents (`#260 `__). + `2023.5.0 `_ - Dec 16 2023 ------------------------------------------------------------------------------------------------- diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index e4531dad3..166910e05 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -1995,8 +1995,10 @@ def _add_position_restraints(self): # Create a copy of the system. system = self._system.copy() - # Convert to the lambda = 0 state if this is a perturbable system. - system = self._checkPerturbable(system) + # Convert to the lambda = 0 state if this is a perturbable system and this + # isn't a free energy protocol. + if not isinstance(self._protocol, _FreeEnergyMixin): + system = self._checkPerturbable(system) # Convert the water model topology so that it matches the GROMACS naming convention. system._set_water_topology("GROMACS") diff --git a/python/BioSimSpace/Protocol/_position_restraint_mixin.py b/python/BioSimSpace/Protocol/_position_restraint_mixin.py index 346f7f6e3..8374bf8ad 100644 --- a/python/BioSimSpace/Protocol/_position_restraint_mixin.py +++ b/python/BioSimSpace/Protocol/_position_restraint_mixin.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -214,7 +214,7 @@ def setForceConstant(self, force_constant): ) # Validate the dimensions. - if force_constant.dimensions() != (0, 0, 0, 1, -1, 0, -2): + if force_constant.dimensions() != (1, 0, -2, 0, 0, -1, 0): raise ValueError( "'force_constant' has invalid dimensions! " f"Expected dimensions are 'M Q-1 T-2', found '{force_constant.unit()}'" diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py index 5aaa857db..54421eed9 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -199,7 +199,7 @@ def __init__(self, system, restraint_dict, temperature, restraint_type="Boresch" for key in ["kthetaA", "kthetaB", "kphiA", "kphiB", "kphiC"]: if restraint_dict["force_constants"][key] != 0: dim = restraint_dict["force_constants"][key].dimensions() - if dim != (-2, 0, 2, 1, -1, 0, -2): + if dim != (1, 2, -2, 0, 0, -1, -2): raise ValueError( f"restraint_dict['force_constants']['{key}'] must be of type " f"'BioSimSpace.Types.Energy'/'BioSimSpace.Types.Angle^2'" @@ -227,7 +227,7 @@ def __init__(self, system, restraint_dict, temperature, restraint_type="Boresch" # Test if the force constant of the bond r1-l1 is the correct unit # Such as kcal/mol/angstrom^2 dim = restraint_dict["force_constants"]["kr"].dimensions() - if dim != (0, 0, 0, 1, -1, 0, -2): + if dim != (1, 0, -2, 0, 0, -1, 0): raise ValueError( "restraint_dict['force_constants']['kr'] must be of type " "'BioSimSpace.Types.Energy'/'BioSimSpace.Types.Length^2'" @@ -315,13 +315,13 @@ def __init__(self, system, restraint_dict, temperature, restraint_type="Boresch" "'BioSimSpace.Types.Length'" ) if not single_restraint_dict["kr"].dimensions() == ( + 1, 0, + -2, 0, 0, - 1, -1, 0, - -2, ): raise ValueError( "distance_restraint_dict['kr'] must be of type " diff --git a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py index 30c4b0af4..f7ec62dd4 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py +++ b/python/BioSimSpace/Sandpit/Exscientia/FreeEnergy/_restraint_search.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -641,7 +641,7 @@ def analyse( if force_constant: dim = force_constant.dimensions() - if dim != (0, 0, 0, 1, -1, 0, -2): + if dim != (1, 0, -2, 0, 0, -1, 0): raise ValueError( "force_constant must be of type " "'BioSimSpace.Types.Energy'/'BioSimSpace.Types.Length^2'" diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py index 5953781fc..788450c84 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_gromacs.py @@ -2102,8 +2102,10 @@ def _add_position_restraints(self, config_options): # Create a copy of the system. system = self._system.copy() - # Convert to the lambda = 0 state if this is a perturbable system. - system = self._checkPerturbable(system) + # Convert to the lambda = 0 state if this is a perturbable system and this + # isn't a free energy protocol. + if not isinstance(self._protocol, _Protocol._FreeEnergyMixin): + system = self._checkPerturbable(system) # Convert the water model topology so that it matches the GROMACS naming convention. system._set_water_topology("GROMACS") diff --git a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_position_restraint.py b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_position_restraint.py index ae3578870..38a82266d 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Protocol/_position_restraint.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Protocol/_position_restraint.py @@ -185,7 +185,7 @@ def setForceConstant(self, force_constant): ) # Validate the dimensions. - if force_constant.dimensions() != (0, 0, 0, 1, -1, 0, -2): + if force_constant.dimensions() != (1, 0, -2, 0, 0, -1, 0): raise ValueError( "'force_constant' has invalid dimensions! " f"Expected dimensions are 'M Q-1 T-2', found '{force_constant.unit()}'" diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_angle.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_angle.py index 759a71724..cef19c862 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_angle.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_angle.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -52,9 +52,8 @@ class Angle(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "RADIAN" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (1, 0, 0, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -188,7 +187,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -210,13 +210,13 @@ def _validate_unit(self, unit): unit = unit.replace("AD", "") # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_area.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_area.py index ec8484cca..2763618a9 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_area.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_area.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -72,9 +72,8 @@ class Area(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM2" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 2, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -330,7 +329,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit is supported.""" # Strip whitespace and convert to upper case. @@ -360,13 +360,13 @@ def _validate_unit(self, unit): unit = unit[0:index] + unit[index + 1 :] + "2" # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_charge.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_charge.py index 7717f481e..a65d54cd3 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_charge.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_charge.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -58,9 +58,8 @@ class Charge(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ELECTRON CHARGE" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 1, 0, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -182,7 +181,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -213,11 +213,11 @@ def _validate_unit(self, unit): unit = unit.replace("COUL", "C") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_energy.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_energy.py index bb293a17a..af9aa2894 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_energy.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_energy.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -68,9 +68,8 @@ class Energy(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "KILO CALORIES PER MOL" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 2, 1, -1, 0, -2) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -213,7 +212,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -235,11 +235,11 @@ def _validate_unit(self, unit): unit = unit.replace("JOULES", "J") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_general_unit.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_general_unit.py index deca60800..7097e616e 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_general_unit.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_general_unit.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -38,16 +38,16 @@ class GeneralUnit(_Type): """A general unit type.""" _dimension_chars = [ - "A", # Angle - "C", # Charge - "L", # Length "M", # Mass - "Q", # Quantity + "L", # Length + "T", # Time + "C", # Charge "t", # Temperature - "T", # Tme + "Q", # Quantity + "A", # Angle ] - def __new__(cls, *args): + def __new__(cls, *args, no_cast=False): """ Constructor. @@ -65,6 +65,9 @@ def __new__(cls, *args): string : str A string representation of the unit type. + + no_cast: bool + Whether to disable casting to a specific type. """ # This operator may be called when unpickling an object. Catch empty @@ -96,7 +99,7 @@ def __new__(cls, *args): if isinstance(_args[0], _GeneralUnit): general_unit = _args[0] - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(_args[0], str): # Extract the string. string = _args[0] @@ -128,15 +131,7 @@ def __new__(cls, *args): general_unit = value * general_unit # Store the dimension mask. - dimensions = ( - general_unit.ANGLE(), - general_unit.CHARGE(), - general_unit.LENGTH(), - general_unit.MASS(), - general_unit.QUANTITY(), - general_unit.TEMPERATURE(), - general_unit.TIME(), - ) + dimensions = tuple(general_unit.dimensions()) # This is a dimensionless quantity, return the value as a float. if all(x == 0 for x in dimensions): @@ -144,13 +139,13 @@ def __new__(cls, *args): # Check to see if the dimensions correspond to a supported type. # If so, return an object of that type. - if dimensions in _base_dimensions: + if not no_cast and dimensions in _base_dimensions: return _base_dimensions[dimensions](general_unit) # Otherwise, call __init__() else: return super(GeneralUnit, cls).__new__(cls) - def __init__(self, *args): + def __init__(self, *args, no_cast=False): """ Constructor. @@ -168,6 +163,9 @@ def __init__(self, *args): string : str A string representation of the unit type. + + no_cast: bool + Whether to disable casting to a specific type. """ value = 1 @@ -194,7 +192,7 @@ def __init__(self, *args): if isinstance(_args[0], _GeneralUnit): general_unit = _args[0] - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(_args[0], str): # Extract the string. string = _args[0] @@ -222,15 +220,7 @@ def __init__(self, *args): self._value = self._sire_unit.value() # Store the dimension mask. - self._dimensions = ( - general_unit.ANGLE(), - general_unit.CHARGE(), - general_unit.LENGTH(), - general_unit.MASS(), - general_unit.QUANTITY(), - general_unit.TEMPERATURE(), - general_unit.TIME(), - ) + self._dimensions = tuple(general_unit.dimensions()) # Create the unit string. self._unit = "" @@ -271,12 +261,22 @@ def __add__(self, other): temp = self._from_string(other) return self + temp + # Addition of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for +: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __radd__(self, other): + """Addition operator.""" + + # Addition is commutative: a+b = b+a + return self.__add__(other) + def __sub__(self, other): """Subtraction operator.""" @@ -285,17 +285,27 @@ def __sub__(self, other): temp = self._sire_unit - other._to_sire_unit() return GeneralUnit(temp) - # Addition of a string. + # Subtraction of a string. elif isinstance(other, str): temp = self._from_string(other) return self - temp + # Subtraction of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for -: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __rsub__(self, other): + """Subtraction operator.""" + + # Subtraction is not commutative: a-b != b-a + return -self.__sub__(other) + def __mul__(self, other): """Multiplication operator.""" @@ -312,16 +322,8 @@ def __mul__(self, other): # Multipy the Sire unit objects. temp = self._sire_unit * other._to_sire_unit() - # Create the dimension mask. - dimensions = ( - temp.ANGLE(), - temp.CHARGE(), - temp.LENGTH(), - temp.MASS(), - temp.QUANTITY(), - temp.TEMPERATURE(), - temp.TIME(), - ) + # Get the dimension mask. + dimensions = temp.dimensions() # Return as an existing type if the dimensions match. try: @@ -432,7 +434,7 @@ def __rtruediv__(self, other): def __pow__(self, other): """Power operator.""" - if type(other) is not int: + if not isinstance(other, (int, float)): raise TypeError( "unsupported operand type(s) for ^: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) @@ -441,15 +443,29 @@ def __pow__(self, other): if other == 0: return GeneralUnit(self._sire_unit / self._sire_unit) - # Multiply the Sire GeneralUnit 'other' times. - temp = self._sire_unit - for x in range(0, abs(other) - 1): - temp = temp * self._sire_unit + # Convert to float. + other = float(other) - if other > 0: - return GeneralUnit(temp) - else: - return GeneralUnit(1 / temp) + # Get the existing unit dimensions. + dims = self.dimensions() + + # Compute the new dimensions, rounding floats to 16 decimal places. + new_dims = [round(dim * other, 16) for dim in dims] + + # Make sure the new dimensions are integers. + if not all(dim.is_integer() for dim in new_dims): + raise ValueError( + "The exponent must be a factor of all the unit dimensions." + ) + + # Convert to integers. + new_dims = [int(dim) for dim in new_dims] + + # Compute the new value. + value = self.value() ** other + + # Return a new GeneralUnit object. + return GeneralUnit(_GeneralUnit(value, new_dims)) def __lt__(self, other): """Less than operator.""" @@ -606,87 +622,87 @@ def dimensions(self): """ return self._dimensions - def angle(self): + def mass(self): """ - Return the power of this general unit in the 'angle' dimension. + Return the power of this general unit in the 'mass' dimension. Returns ------- - angle : int - The power of the general unit in the 'angle' dimension. + mass : int + The power of the general unit in the 'mass' dimension. """ return self._dimensions[0] - def charge(self): + def length(self): """ - Return the power of this general unit in the 'charge' dimension. + Return the power of this general unit in the 'length' dimension. Returns ------- - charge : int - The power of the general unit in the 'charge' dimension. + length : int + The power of the general unit in the 'length' dimension. """ return self._dimensions[1] - def length(self): + def time(self): """ - Return the power of this general unit in the 'length' dimension. + Return the power of this general unit in the 'time' dimension. Returns ------- - length : int - The power of the general unit in the 'length' dimension. + time : int + The power of the general unit in the 'time' dimension. """ return self._dimensions[2] - def mass(self): + def charge(self): """ - Return the power of this general unit in the 'mass' dimension. + Return the power of this general unit in the 'charge' dimension. Returns ------- - mass : int - The power of the general unit in the 'mass' dimension. + charge : int + The power of the general unit in the 'charge' dimension. """ return self._dimensions[3] - def quantity(self): + def temperature(self): """ - Return the power of this general unit in the 'quantity' dimension. + Return the power of this general unit in the 'temperature' dimension. Returns ------- - quantity : int - The power of the general unit in the 'quantity' dimension. + temperature : int + The power of the general unit in the 'temperature' dimension. """ return self._dimensions[4] - def temperature(self): + def quantity(self): """ - Return the power of this general unit in the 'temperature' dimension. + Return the power of this general unit in the 'quantity' dimension. Returns ------- - temperature : int - The power of the general unit in the 'temperature' dimension. + quantity : int + The power of the general unit in the 'quantity' dimension. """ return self._dimensions[5] - def time(self): + def angle(self): """ - Return the power of this general unit in the 'time' dimension. + Return the power of this general unit in the 'angle' dimension. Returns ------- - time : int - The power of the general unit in the 'time' dimension. + angle : int + The power of the general unit in the 'angle' dimension. """ return self._dimensions[6] diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_length.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_length.py index 5eb10fb07..67f163af8 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_length.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_length.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -87,9 +87,8 @@ class Length(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 1, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -195,29 +194,6 @@ def __rmul__(self, other): # Multiplication is commutative: a*b = b*a return self.__mul__(other) - def __pow__(self, other): - """Power operator.""" - - if not isinstance(other, int): - raise ValueError("We can only raise to the power of integer values.") - - # No change. - if other == 1: - return self - - # Area. - if other == 2: - mag = self.angstroms().value() ** 2 - return _Area(mag, "A2") - - # Volume. - if other == 3: - mag = self.angstroms().value() ** 3 - return _Volume(mag, "A3") - - else: - return super().__pow__(other) - def meters(self): """ Return the length in meters. @@ -362,7 +338,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -376,13 +353,13 @@ def _validate_unit(self, unit): unit = "ANGS" + unit[3:] # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_pressure.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_pressure.py index 699d9d5f2..fbe6da782 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_pressure.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_pressure.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -55,9 +55,8 @@ class Pressure(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ATMOSPHERE" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, -1, 1, 0, 0, -2) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -177,7 +176,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -196,11 +196,11 @@ def _validate_unit(self, unit): unit = unit.replace("S", "") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_temperature.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_temperature.py index d11d70f0c..a7010dd7f 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_temperature.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_temperature.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -60,9 +60,8 @@ class Temperature(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "KELVIN" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 0, 0, 0, 1, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -392,7 +391,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -405,13 +405,16 @@ def _validate_unit(self, unit): unit = unit.replace("DEG", "") # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] + elif len(unit) == 0: + raise ValueError(f"Unit is not given. You must supply the unit.") else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Unsupported unit '%s'. Supported units are: '%s'" + % (unit, list(cls._supported_units.keys())) ) def _to_sire_unit(self): @@ -441,13 +444,13 @@ def _from_sire_unit(cls, sire_unit): if isinstance(sire_unit, _SireUnits.GeneralUnit): # Create a mask for the dimensions of the object. dimensions = ( - sire_unit.ANGLE(), - sire_unit.CHARGE(), - sire_unit.LENGTH(), sire_unit.MASS(), - sire_unit.QUANTITY(), - sire_unit.TEMPERATURE(), + sire_unit.LENGTH(), sire_unit.TIME(), + sire_unit.CHARGE(), + sire_unit.TEMPERATURE(), + sire_unit.QUANTITY(), + sire_unit.ANGLE(), ) # Make sure the dimensions match. @@ -470,7 +473,7 @@ def _from_sire_unit(cls, sire_unit): else: raise TypeError( "'sire_unit' must be of type 'sire.units.GeneralUnit', " - "'Sire.Units.Celsius', or 'sire.units.Fahrenheit'" + "'sire.units.Celsius', or 'sire.units.Fahrenheit'" ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_time.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_time.py index 19fc10401..0f8dda977 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_time.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_time.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -96,9 +96,8 @@ class Time(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "NANOSECOND" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 0, 0, 0, 0, 1) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -337,24 +336,25 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. unit = unit.replace(" ", "").upper() # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit[:-1] in self._supported_units: + elif unit[:-1] in cls._supported_units: return unit[:-1] - elif unit in self._abbreviations: - return self._abbreviations[unit] - elif unit[:-1] in self._abbreviations: - return self._abbreviations[unit[:-1]] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] + elif unit[:-1] in cls._abbreviations: + return cls._abbreviations[unit[:-1]] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_type.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_type.py index f01635631..75d7b7e0c 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_type.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_type.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -103,7 +103,7 @@ def __init__(self, *args): self._value = temp._value self._unit = temp._unit - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(args[0], str): # Convert the string to an object of this type. obj = self._from_string(args[0]) @@ -168,12 +168,22 @@ def __add__(self, other): temp = self._from_string(other) return self + temp + # Addition of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for +: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __radd__(self, other): + """Addition operator.""" + + # Addition is commutative: a+b = b+a + return self.__add__(other) + def __sub__(self, other): """Subtraction operator.""" @@ -185,22 +195,32 @@ def __sub__(self, other): # Return a new object of the same type with the original unit. return self._to_default_unit(val)._convert_to(self._unit) - # Addition of a different type with the same dimensions. + # Subtraction of a different type with the same dimensions. elif isinstance(other, Type) and self._dimensions == other.dimensions: # Negate other and add. return -other + self - # Addition of a string. + # Subtraction of a string. elif isinstance(other, str): temp = self._from_string(other) return self - temp + # Subtraction of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for -: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __rsub__(self, other): + """Subtraction operator.""" + + # Subtraction is not commutative: a-b != b-a + return -self.__sub__(other) + def __mul__(self, other): """Multiplication operator.""" @@ -244,19 +264,9 @@ def __rmul__(self, other): def __pow__(self, other): """Power operator.""" - if not isinstance(other, int): - raise ValueError("We can only raise to the power of integer values.") - from ._general_unit import GeneralUnit as _GeneralUnit - default_unit = self._to_default_unit() - mag = default_unit.value() ** other - unit = default_unit.unit().lower() - pow_to_mul = "*".join(abs(other) * [unit]) - if other > 0: - return _GeneralUnit(f"{mag}*{pow_to_mul}") - else: - return _GeneralUnit(f"{mag}/({pow_to_mul})") + return _GeneralUnit(self._to_sire_unit(), no_cast=True) ** other def __truediv__(self, other): """Division operator.""" @@ -486,99 +496,99 @@ def dimensions(cls): containing the power in each dimension. Returns : (int, int, int, int, int, int) - The power in each dimension: 'angle', 'charge', 'length', - 'mass', 'quantity', 'temperature', and 'time'. + The power in each dimension: 'mass', 'length', 'temperature', + 'charge', 'time', 'quantity', and 'angle'. """ return cls._dimensions @classmethod - def angle(cls): + def mass(cls): """ - Return the power in the 'angle' dimension. + Return the power in the 'mass' dimension. Returns ------- - angle : int - The power in the 'angle' dimension. + mass : int + The power in the 'mass' dimension. """ return cls._dimensions[0] @classmethod - def charge(cls): + def length(cls): """ - Return the power in the 'charge' dimension. + Return the power in the 'length' dimension. Returns ------- - charge : int - The power in the 'charge' dimension. + length : int + The power in the 'length' dimension. """ return cls._dimensions[1] @classmethod - def length(cls): + def time(cls): """ - Return the power in the 'length' dimension. + Return the power in the 'time' dimension. Returns ------- - length : int - The power in the 'length' dimension. + time : int + The power the 'time' dimension. """ return cls._dimensions[2] @classmethod - def mass(cls): + def charge(cls): """ - Return the power in the 'mass' dimension. + Return the power in the 'charge' dimension. Returns ------- - mass : int - The power in the 'mass' dimension. + charge : int + The power in the 'charge' dimension. """ return cls._dimensions[3] @classmethod - def quantity(cls): + def temperature(cls): """ - Return the power in the 'quantity' dimension. + Return the power in the 'temperature' dimension. Returns ------- - quantity : int - The power in the 'quantity' dimension. + temperature : int + The power in the 'temperature' dimension. """ return cls._dimensions[4] @classmethod - def temperature(cls): + def quantity(cls): """ - Return the power in the 'temperature' dimension. + Return the power in the 'quantity' dimension. Returns ------- - temperature : int - The power in the 'temperature' dimension. + quantity : int + The power in the 'quantity' dimension. """ return cls._dimensions[5] @classmethod - def time(cls): + def angle(cls): """ - Return the power in the 'time' dimension. + Return the power in the 'angle' dimension. Returns ------- - time : int - The power the 'time' dimension. + angle : int + The power in the 'angle' dimension. """ return cls._dimensions[6] @@ -662,15 +672,7 @@ def _from_sire_unit(cls, sire_unit): raise TypeError("'sire_unit' must be of type 'sire.units.GeneralUnit'") # Create a mask for the dimensions of the object. - dimensions = ( - sire_unit.ANGLE(), - sire_unit.CHARGE(), - sire_unit.LENGTH(), - sire_unit.MASS(), - sire_unit.QUANTITY(), - sire_unit.TEMPERATURE(), - sire_unit.TIME(), - ) + dimensions = tuple(sire_unit.dimensions()) # Make sure that this isn't zero. if hasattr(sire_unit, "is_zero"): diff --git a/python/BioSimSpace/Sandpit/Exscientia/Types/_volume.py b/python/BioSimSpace/Sandpit/Exscientia/Types/_volume.py index 4b255e01a..4dad85642 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Types/_volume.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Types/_volume.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -72,9 +72,8 @@ class Volume(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM3" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 3, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -287,7 +286,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -317,13 +317,13 @@ def _validate_unit(self, unit): unit = unit[0:index] + unit[index + 1 :] + "3" # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py index 7c25c30f7..dc662f217 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_molecule.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -792,9 +792,8 @@ def makeCompatibleWith( # Have we matched all of the atoms? if len(matches) < num_atoms0: - # Atom names might have changed. Try to match by residue index - # and coordinates. - matcher = _SireMol.ResIdxAtomCoordMatcher() + # Atom names or order might have changed. Try to match by coordinates. + matcher = _SireMol.AtomCoordMatcher() matches = matcher.match(mol0, mol1) # We need to rename the atoms. @@ -994,9 +993,6 @@ def makeCompatibleWith( # Tally counter for the total number of matches. num_matches = 0 - # Initialise the offset. - offset = 0 - # Get the molecule numbers in the system. mol_nums = mol1.molNums() @@ -1006,16 +1002,13 @@ def makeCompatibleWith( mol = mol1[num] # Initialise the matcher. - matcher = _SireMol.ResIdxAtomCoordMatcher(_SireMol.ResIdx(offset)) + matcher = _SireMol.AtomCoordMatcher() # Get the matches for this molecule and append to the list. match = matcher.match(mol0, mol) matches.append(match) num_matches += len(match) - # Increment the offset. - offset += mol.nResidues() - # Have we matched all of the atoms? if num_matches < num_atoms0: raise _IncompatibleError("Failed to match all atoms!") diff --git a/python/BioSimSpace/Types/_angle.py b/python/BioSimSpace/Types/_angle.py index 759a71724..cef19c862 100644 --- a/python/BioSimSpace/Types/_angle.py +++ b/python/BioSimSpace/Types/_angle.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -52,9 +52,8 @@ class Angle(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "RADIAN" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (1, 0, 0, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -188,7 +187,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -210,13 +210,13 @@ def _validate_unit(self, unit): unit = unit.replace("AD", "") # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_area.py b/python/BioSimSpace/Types/_area.py index ec8484cca..2763618a9 100644 --- a/python/BioSimSpace/Types/_area.py +++ b/python/BioSimSpace/Types/_area.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -72,9 +72,8 @@ class Area(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM2" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 2, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -330,7 +329,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit is supported.""" # Strip whitespace and convert to upper case. @@ -360,13 +360,13 @@ def _validate_unit(self, unit): unit = unit[0:index] + unit[index + 1 :] + "2" # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_charge.py b/python/BioSimSpace/Types/_charge.py index 7717f481e..a65d54cd3 100644 --- a/python/BioSimSpace/Types/_charge.py +++ b/python/BioSimSpace/Types/_charge.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -58,9 +58,8 @@ class Charge(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ELECTRON CHARGE" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 1, 0, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -182,7 +181,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -213,11 +213,11 @@ def _validate_unit(self, unit): unit = unit.replace("COUL", "C") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_energy.py b/python/BioSimSpace/Types/_energy.py index bb293a17a..af9aa2894 100644 --- a/python/BioSimSpace/Types/_energy.py +++ b/python/BioSimSpace/Types/_energy.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -68,9 +68,8 @@ class Energy(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "KILO CALORIES PER MOL" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 2, 1, -1, 0, -2) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -213,7 +212,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -235,11 +235,11 @@ def _validate_unit(self, unit): unit = unit.replace("JOULES", "J") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_general_unit.py b/python/BioSimSpace/Types/_general_unit.py index deca60800..7097e616e 100644 --- a/python/BioSimSpace/Types/_general_unit.py +++ b/python/BioSimSpace/Types/_general_unit.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -38,16 +38,16 @@ class GeneralUnit(_Type): """A general unit type.""" _dimension_chars = [ - "A", # Angle - "C", # Charge - "L", # Length "M", # Mass - "Q", # Quantity + "L", # Length + "T", # Time + "C", # Charge "t", # Temperature - "T", # Tme + "Q", # Quantity + "A", # Angle ] - def __new__(cls, *args): + def __new__(cls, *args, no_cast=False): """ Constructor. @@ -65,6 +65,9 @@ def __new__(cls, *args): string : str A string representation of the unit type. + + no_cast: bool + Whether to disable casting to a specific type. """ # This operator may be called when unpickling an object. Catch empty @@ -96,7 +99,7 @@ def __new__(cls, *args): if isinstance(_args[0], _GeneralUnit): general_unit = _args[0] - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(_args[0], str): # Extract the string. string = _args[0] @@ -128,15 +131,7 @@ def __new__(cls, *args): general_unit = value * general_unit # Store the dimension mask. - dimensions = ( - general_unit.ANGLE(), - general_unit.CHARGE(), - general_unit.LENGTH(), - general_unit.MASS(), - general_unit.QUANTITY(), - general_unit.TEMPERATURE(), - general_unit.TIME(), - ) + dimensions = tuple(general_unit.dimensions()) # This is a dimensionless quantity, return the value as a float. if all(x == 0 for x in dimensions): @@ -144,13 +139,13 @@ def __new__(cls, *args): # Check to see if the dimensions correspond to a supported type. # If so, return an object of that type. - if dimensions in _base_dimensions: + if not no_cast and dimensions in _base_dimensions: return _base_dimensions[dimensions](general_unit) # Otherwise, call __init__() else: return super(GeneralUnit, cls).__new__(cls) - def __init__(self, *args): + def __init__(self, *args, no_cast=False): """ Constructor. @@ -168,6 +163,9 @@ def __init__(self, *args): string : str A string representation of the unit type. + + no_cast: bool + Whether to disable casting to a specific type. """ value = 1 @@ -194,7 +192,7 @@ def __init__(self, *args): if isinstance(_args[0], _GeneralUnit): general_unit = _args[0] - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(_args[0], str): # Extract the string. string = _args[0] @@ -222,15 +220,7 @@ def __init__(self, *args): self._value = self._sire_unit.value() # Store the dimension mask. - self._dimensions = ( - general_unit.ANGLE(), - general_unit.CHARGE(), - general_unit.LENGTH(), - general_unit.MASS(), - general_unit.QUANTITY(), - general_unit.TEMPERATURE(), - general_unit.TIME(), - ) + self._dimensions = tuple(general_unit.dimensions()) # Create the unit string. self._unit = "" @@ -271,12 +261,22 @@ def __add__(self, other): temp = self._from_string(other) return self + temp + # Addition of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for +: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __radd__(self, other): + """Addition operator.""" + + # Addition is commutative: a+b = b+a + return self.__add__(other) + def __sub__(self, other): """Subtraction operator.""" @@ -285,17 +285,27 @@ def __sub__(self, other): temp = self._sire_unit - other._to_sire_unit() return GeneralUnit(temp) - # Addition of a string. + # Subtraction of a string. elif isinstance(other, str): temp = self._from_string(other) return self - temp + # Subtraction of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for -: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __rsub__(self, other): + """Subtraction operator.""" + + # Subtraction is not commutative: a-b != b-a + return -self.__sub__(other) + def __mul__(self, other): """Multiplication operator.""" @@ -312,16 +322,8 @@ def __mul__(self, other): # Multipy the Sire unit objects. temp = self._sire_unit * other._to_sire_unit() - # Create the dimension mask. - dimensions = ( - temp.ANGLE(), - temp.CHARGE(), - temp.LENGTH(), - temp.MASS(), - temp.QUANTITY(), - temp.TEMPERATURE(), - temp.TIME(), - ) + # Get the dimension mask. + dimensions = temp.dimensions() # Return as an existing type if the dimensions match. try: @@ -432,7 +434,7 @@ def __rtruediv__(self, other): def __pow__(self, other): """Power operator.""" - if type(other) is not int: + if not isinstance(other, (int, float)): raise TypeError( "unsupported operand type(s) for ^: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) @@ -441,15 +443,29 @@ def __pow__(self, other): if other == 0: return GeneralUnit(self._sire_unit / self._sire_unit) - # Multiply the Sire GeneralUnit 'other' times. - temp = self._sire_unit - for x in range(0, abs(other) - 1): - temp = temp * self._sire_unit + # Convert to float. + other = float(other) - if other > 0: - return GeneralUnit(temp) - else: - return GeneralUnit(1 / temp) + # Get the existing unit dimensions. + dims = self.dimensions() + + # Compute the new dimensions, rounding floats to 16 decimal places. + new_dims = [round(dim * other, 16) for dim in dims] + + # Make sure the new dimensions are integers. + if not all(dim.is_integer() for dim in new_dims): + raise ValueError( + "The exponent must be a factor of all the unit dimensions." + ) + + # Convert to integers. + new_dims = [int(dim) for dim in new_dims] + + # Compute the new value. + value = self.value() ** other + + # Return a new GeneralUnit object. + return GeneralUnit(_GeneralUnit(value, new_dims)) def __lt__(self, other): """Less than operator.""" @@ -606,87 +622,87 @@ def dimensions(self): """ return self._dimensions - def angle(self): + def mass(self): """ - Return the power of this general unit in the 'angle' dimension. + Return the power of this general unit in the 'mass' dimension. Returns ------- - angle : int - The power of the general unit in the 'angle' dimension. + mass : int + The power of the general unit in the 'mass' dimension. """ return self._dimensions[0] - def charge(self): + def length(self): """ - Return the power of this general unit in the 'charge' dimension. + Return the power of this general unit in the 'length' dimension. Returns ------- - charge : int - The power of the general unit in the 'charge' dimension. + length : int + The power of the general unit in the 'length' dimension. """ return self._dimensions[1] - def length(self): + def time(self): """ - Return the power of this general unit in the 'length' dimension. + Return the power of this general unit in the 'time' dimension. Returns ------- - length : int - The power of the general unit in the 'length' dimension. + time : int + The power of the general unit in the 'time' dimension. """ return self._dimensions[2] - def mass(self): + def charge(self): """ - Return the power of this general unit in the 'mass' dimension. + Return the power of this general unit in the 'charge' dimension. Returns ------- - mass : int - The power of the general unit in the 'mass' dimension. + charge : int + The power of the general unit in the 'charge' dimension. """ return self._dimensions[3] - def quantity(self): + def temperature(self): """ - Return the power of this general unit in the 'quantity' dimension. + Return the power of this general unit in the 'temperature' dimension. Returns ------- - quantity : int - The power of the general unit in the 'quantity' dimension. + temperature : int + The power of the general unit in the 'temperature' dimension. """ return self._dimensions[4] - def temperature(self): + def quantity(self): """ - Return the power of this general unit in the 'temperature' dimension. + Return the power of this general unit in the 'quantity' dimension. Returns ------- - temperature : int - The power of the general unit in the 'temperature' dimension. + quantity : int + The power of the general unit in the 'quantity' dimension. """ return self._dimensions[5] - def time(self): + def angle(self): """ - Return the power of this general unit in the 'time' dimension. + Return the power of this general unit in the 'angle' dimension. Returns ------- - time : int - The power of the general unit in the 'time' dimension. + angle : int + The power of the general unit in the 'angle' dimension. """ return self._dimensions[6] diff --git a/python/BioSimSpace/Types/_length.py b/python/BioSimSpace/Types/_length.py index 5eb10fb07..67f163af8 100644 --- a/python/BioSimSpace/Types/_length.py +++ b/python/BioSimSpace/Types/_length.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -87,9 +87,8 @@ class Length(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 1, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -195,29 +194,6 @@ def __rmul__(self, other): # Multiplication is commutative: a*b = b*a return self.__mul__(other) - def __pow__(self, other): - """Power operator.""" - - if not isinstance(other, int): - raise ValueError("We can only raise to the power of integer values.") - - # No change. - if other == 1: - return self - - # Area. - if other == 2: - mag = self.angstroms().value() ** 2 - return _Area(mag, "A2") - - # Volume. - if other == 3: - mag = self.angstroms().value() ** 3 - return _Volume(mag, "A3") - - else: - return super().__pow__(other) - def meters(self): """ Return the length in meters. @@ -362,7 +338,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -376,13 +353,13 @@ def _validate_unit(self, unit): unit = "ANGS" + unit[3:] # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_pressure.py b/python/BioSimSpace/Types/_pressure.py index 699d9d5f2..fbe6da782 100644 --- a/python/BioSimSpace/Types/_pressure.py +++ b/python/BioSimSpace/Types/_pressure.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -55,9 +55,8 @@ class Pressure(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ATMOSPHERE" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, -1, 1, 0, 0, -2) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -177,7 +176,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -196,11 +196,11 @@ def _validate_unit(self, unit): unit = unit.replace("S", "") # Check that the unit is supported. - if unit in self._abbreviations: - return self._abbreviations[unit] + if unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_temperature.py b/python/BioSimSpace/Types/_temperature.py index f97d2b956..a7010dd7f 100644 --- a/python/BioSimSpace/Types/_temperature.py +++ b/python/BioSimSpace/Types/_temperature.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -60,9 +60,8 @@ class Temperature(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "KELVIN" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 0, 0, 0, 1, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -392,7 +391,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -405,16 +405,16 @@ def _validate_unit(self, unit): unit = unit.replace("DEG", "") # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] elif len(unit) == 0: raise ValueError(f"Unit is not given. You must supply the unit.") else: raise ValueError( "Unsupported unit '%s'. Supported units are: '%s'" - % (unit, list(self._supported_units.keys())) + % (unit, list(cls._supported_units.keys())) ) def _to_sire_unit(self): @@ -444,13 +444,13 @@ def _from_sire_unit(cls, sire_unit): if isinstance(sire_unit, _SireUnits.GeneralUnit): # Create a mask for the dimensions of the object. dimensions = ( - sire_unit.ANGLE(), - sire_unit.CHARGE(), - sire_unit.LENGTH(), sire_unit.MASS(), - sire_unit.QUANTITY(), - sire_unit.TEMPERATURE(), + sire_unit.LENGTH(), sire_unit.TIME(), + sire_unit.CHARGE(), + sire_unit.TEMPERATURE(), + sire_unit.QUANTITY(), + sire_unit.ANGLE(), ) # Make sure the dimensions match. diff --git a/python/BioSimSpace/Types/_time.py b/python/BioSimSpace/Types/_time.py index 19fc10401..0f8dda977 100644 --- a/python/BioSimSpace/Types/_time.py +++ b/python/BioSimSpace/Types/_time.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -96,9 +96,8 @@ class Time(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "NANOSECOND" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 0, 0, 0, 0, 1) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -337,24 +336,25 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. unit = unit.replace(" ", "").upper() # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit[:-1] in self._supported_units: + elif unit[:-1] in cls._supported_units: return unit[:-1] - elif unit in self._abbreviations: - return self._abbreviations[unit] - elif unit[:-1] in self._abbreviations: - return self._abbreviations[unit[:-1]] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] + elif unit[:-1] in cls._abbreviations: + return cls._abbreviations[unit[:-1]] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/Types/_type.py b/python/BioSimSpace/Types/_type.py index f01635631..75d7b7e0c 100644 --- a/python/BioSimSpace/Types/_type.py +++ b/python/BioSimSpace/Types/_type.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -103,7 +103,7 @@ def __init__(self, *args): self._value = temp._value self._unit = temp._unit - # The user has passed a string representation of the temperature. + # The user has passed a string representation of the type. elif isinstance(args[0], str): # Convert the string to an object of this type. obj = self._from_string(args[0]) @@ -168,12 +168,22 @@ def __add__(self, other): temp = self._from_string(other) return self + temp + # Addition of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for +: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __radd__(self, other): + """Addition operator.""" + + # Addition is commutative: a+b = b+a + return self.__add__(other) + def __sub__(self, other): """Subtraction operator.""" @@ -185,22 +195,32 @@ def __sub__(self, other): # Return a new object of the same type with the original unit. return self._to_default_unit(val)._convert_to(self._unit) - # Addition of a different type with the same dimensions. + # Subtraction of a different type with the same dimensions. elif isinstance(other, Type) and self._dimensions == other.dimensions: # Negate other and add. return -other + self - # Addition of a string. + # Subtraction of a string. elif isinstance(other, str): temp = self._from_string(other) return self - temp + # Subtraction of a zero-valued integer or float. + elif isinstance(other, (int, float)) and other == 0: + return self + else: raise TypeError( "unsupported operand type(s) for -: '%s' and '%s'" % (self.__class__.__qualname__, other.__class__.__qualname__) ) + def __rsub__(self, other): + """Subtraction operator.""" + + # Subtraction is not commutative: a-b != b-a + return -self.__sub__(other) + def __mul__(self, other): """Multiplication operator.""" @@ -244,19 +264,9 @@ def __rmul__(self, other): def __pow__(self, other): """Power operator.""" - if not isinstance(other, int): - raise ValueError("We can only raise to the power of integer values.") - from ._general_unit import GeneralUnit as _GeneralUnit - default_unit = self._to_default_unit() - mag = default_unit.value() ** other - unit = default_unit.unit().lower() - pow_to_mul = "*".join(abs(other) * [unit]) - if other > 0: - return _GeneralUnit(f"{mag}*{pow_to_mul}") - else: - return _GeneralUnit(f"{mag}/({pow_to_mul})") + return _GeneralUnit(self._to_sire_unit(), no_cast=True) ** other def __truediv__(self, other): """Division operator.""" @@ -486,99 +496,99 @@ def dimensions(cls): containing the power in each dimension. Returns : (int, int, int, int, int, int) - The power in each dimension: 'angle', 'charge', 'length', - 'mass', 'quantity', 'temperature', and 'time'. + The power in each dimension: 'mass', 'length', 'temperature', + 'charge', 'time', 'quantity', and 'angle'. """ return cls._dimensions @classmethod - def angle(cls): + def mass(cls): """ - Return the power in the 'angle' dimension. + Return the power in the 'mass' dimension. Returns ------- - angle : int - The power in the 'angle' dimension. + mass : int + The power in the 'mass' dimension. """ return cls._dimensions[0] @classmethod - def charge(cls): + def length(cls): """ - Return the power in the 'charge' dimension. + Return the power in the 'length' dimension. Returns ------- - charge : int - The power in the 'charge' dimension. + length : int + The power in the 'length' dimension. """ return cls._dimensions[1] @classmethod - def length(cls): + def time(cls): """ - Return the power in the 'length' dimension. + Return the power in the 'time' dimension. Returns ------- - length : int - The power in the 'length' dimension. + time : int + The power the 'time' dimension. """ return cls._dimensions[2] @classmethod - def mass(cls): + def charge(cls): """ - Return the power in the 'mass' dimension. + Return the power in the 'charge' dimension. Returns ------- - mass : int - The power in the 'mass' dimension. + charge : int + The power in the 'charge' dimension. """ return cls._dimensions[3] @classmethod - def quantity(cls): + def temperature(cls): """ - Return the power in the 'quantity' dimension. + Return the power in the 'temperature' dimension. Returns ------- - quantity : int - The power in the 'quantity' dimension. + temperature : int + The power in the 'temperature' dimension. """ return cls._dimensions[4] @classmethod - def temperature(cls): + def quantity(cls): """ - Return the power in the 'temperature' dimension. + Return the power in the 'quantity' dimension. Returns ------- - temperature : int - The power in the 'temperature' dimension. + quantity : int + The power in the 'quantity' dimension. """ return cls._dimensions[5] @classmethod - def time(cls): + def angle(cls): """ - Return the power in the 'time' dimension. + Return the power in the 'angle' dimension. Returns ------- - time : int - The power the 'time' dimension. + angle : int + The power in the 'angle' dimension. """ return cls._dimensions[6] @@ -662,15 +672,7 @@ def _from_sire_unit(cls, sire_unit): raise TypeError("'sire_unit' must be of type 'sire.units.GeneralUnit'") # Create a mask for the dimensions of the object. - dimensions = ( - sire_unit.ANGLE(), - sire_unit.CHARGE(), - sire_unit.LENGTH(), - sire_unit.MASS(), - sire_unit.QUANTITY(), - sire_unit.TEMPERATURE(), - sire_unit.TIME(), - ) + dimensions = tuple(sire_unit.dimensions()) # Make sure that this isn't zero. if hasattr(sire_unit, "is_zero"): diff --git a/python/BioSimSpace/Types/_volume.py b/python/BioSimSpace/Types/_volume.py index 4b255e01a..4dad85642 100644 --- a/python/BioSimSpace/Types/_volume.py +++ b/python/BioSimSpace/Types/_volume.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -72,9 +72,8 @@ class Volume(_Type): # Null type unit for avoiding issue printing configargparse help. _default_unit = "ANGSTROM3" - # The dimension mask: - # Angle, Charge, Length, Mass, Quantity, Temperature, Time - _dimensions = (0, 0, 3, 0, 0, 0, 0) + # The dimension mask. + _dimensions = tuple(list(_supported_units.values())[0].dimensions()) def __init__(self, *args): """ @@ -287,7 +286,8 @@ def _convert_to(self, unit): "Supported units are: '%s'" % list(self._supported_units.keys()) ) - def _validate_unit(self, unit): + @classmethod + def _validate_unit(cls, unit): """Validate that the unit are supported.""" # Strip whitespace and convert to upper case. @@ -317,13 +317,13 @@ def _validate_unit(self, unit): unit = unit[0:index] + unit[index + 1 :] + "3" # Check that the unit is supported. - if unit in self._supported_units: + if unit in cls._supported_units: return unit - elif unit in self._abbreviations: - return self._abbreviations[unit] + elif unit in cls._abbreviations: + return cls._abbreviations[unit] else: raise ValueError( - "Supported units are: '%s'" % list(self._supported_units.keys()) + "Supported units are: '%s'" % list(cls._supported_units.keys()) ) @staticmethod diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index ddd51ed41..2a93e52a8 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -224,9 +224,9 @@ def createConfig( ] restraint_mask = "@" + ",".join(restraint_atom_names) elif restraint == "heavy": - restraint_mask = "!:WAT & !@H=" + restraint_mask = "!:WAT & !@%NA,CL & !@H=" elif restraint == "all": - restraint_mask = "!:WAT" + restraint_mask = "!:WAT & !@%NA,CL" # We can't do anything about a custom restraint, since we don't # know anything about the atoms. diff --git a/python/BioSimSpace/_SireWrappers/_molecule.py b/python/BioSimSpace/_SireWrappers/_molecule.py index 9a3a3d70e..089c88146 100644 --- a/python/BioSimSpace/_SireWrappers/_molecule.py +++ b/python/BioSimSpace/_SireWrappers/_molecule.py @@ -1,7 +1,7 @@ ###################################################################### # BioSimSpace: Making biomolecular simulation a breeze! # -# Copyright: 2017-2023 +# Copyright: 2017-2024 # # Authors: Lester Hedges # @@ -746,9 +746,8 @@ def makeCompatibleWith( # Have we matched all of the atoms? if len(matches) < num_atoms0: - # Atom names might have changed. Try to match by residue index - # and coordinates. - matcher = _SireMol.ResIdxAtomCoordMatcher() + # Atom names or order might have changed. Try to match by coordinates. + matcher = _SireMol.AtomCoordMatcher() matches = matcher.match(mol0, mol1) # We need to rename the atoms. @@ -948,9 +947,6 @@ def makeCompatibleWith( # Tally counter for the total number of matches. num_matches = 0 - # Initialise the offset. - offset = 0 - # Get the molecule numbers in the system. mol_nums = mol1.molNums() @@ -960,16 +956,13 @@ def makeCompatibleWith( mol = mol1[num] # Initialise the matcher. - matcher = _SireMol.ResIdxAtomCoordMatcher(_SireMol.ResIdx(offset)) + matcher = _SireMol.AtomCoordMatcher() # Get the matches for this molecule and append to the list. match = matcher.match(mol0, mol) matches.append(match) num_matches += len(match) - # Increment the offset. - offset += mol.nResidues() - # Have we matched all of the atoms? if num_matches < num_atoms0: raise _IncompatibleError("Failed to match all atoms!") diff --git a/recipes/biosimspace/template.yaml b/recipes/biosimspace/template.yaml index 50b670ded..efcb2c141 100644 --- a/recipes/biosimspace/template.yaml +++ b/recipes/biosimspace/template.yaml @@ -26,18 +26,19 @@ test: - SIRE_DONT_PHONEHOME - SIRE_SILENT_PHONEHOME requires: - - pytest - - black 23 # [linux and x86_64 and py==39] - - pytest-black # [linux and x86_64 and py==39] + - pytest <8 + - black 23 # [linux and x86_64 and py==311] + - pytest-black # [linux and x86_64 and py==311] - ambertools # [linux and x86_64] - gromacs # [linux and x86_64] + - requests imports: - BioSimSpace source_files: - - python/BioSimSpace # [linux and x86_64 and py==39] + - python/BioSimSpace # [linux and x86_64 and py==311] - tests commands: - - pytest -vvv --color=yes --black python/BioSimSpace # [linux and x86_64 and py==39] + - pytest -vvv --color=yes --black python/BioSimSpace # [linux and x86_64 and py==311] - pytest -vvv --color=yes --import-mode=importlib tests about: diff --git a/requirements.txt b/requirements.txt index f7bb52604..79c862907 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # BioSimSpace runtime requirements. # main -sire~=2023.5.0 +sire~=2023.5.2 # devel #sire==2023.5.0.dev diff --git a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint_search.py b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint_search.py index c7489f8d2..b34496bb0 100644 --- a/tests/Sandpit/Exscientia/FreeEnergy/test_restraint_search.py +++ b/tests/Sandpit/Exscientia/FreeEnergy/test_restraint_search.py @@ -291,7 +291,15 @@ def test_dict_mdr(self, multiple_distance_restraint): assert restr_dict["permanent_distance_restraint"][ "r0" ].value() == pytest.approx(8.9019, abs=1e-4) - assert restr_dict["permanent_distance_restraint"]["kr"].unit() == "M Q-1 T-2" + assert restr_dict["permanent_distance_restraint"]["kr"].dimensions() == ( + 1, + 0, + -2, + 0, + 0, + -1, + 0, + ) assert restr_dict["permanent_distance_restraint"]["kr"].value() == 40.0 assert restr_dict["permanent_distance_restraint"]["r_fb"].unit() == "ANGSTROM" assert restr_dict["permanent_distance_restraint"][ diff --git a/tests/Sandpit/Exscientia/Process/test_position_restraint.py b/tests/Sandpit/Exscientia/Process/test_position_restraint.py index 7c16427c1..f67cb267e 100644 --- a/tests/Sandpit/Exscientia/Process/test_position_restraint.py +++ b/tests/Sandpit/Exscientia/Process/test_position_restraint.py @@ -197,6 +197,7 @@ def test_gromacs(alchemical_ion_system, restraint, alchemical_ion_system_psores) protocol, name="test", reference_system=alchemical_ion_system_psores, + ignore_warnings=True, ) # Test the position restraint for protein center diff --git a/tests/Sandpit/Exscientia/Types/test_general_unit.py b/tests/Sandpit/Exscientia/Types/test_general_unit.py index 3862c2a47..efc3cd4e1 100644 --- a/tests/Sandpit/Exscientia/Types/test_general_unit.py +++ b/tests/Sandpit/Exscientia/Types/test_general_unit.py @@ -3,15 +3,26 @@ import BioSimSpace.Sandpit.Exscientia.Types as Types import BioSimSpace.Sandpit.Exscientia.Units as Units +import sire as sr + @pytest.mark.parametrize( "string, dimensions", [ - ("kilo Cal oriEs per Mole / angstrom **2", (0, 0, 0, 1, -1, 0, -2)), - ("k Cal_per _mOl / nm^2", (0, 0, 0, 1, -1, 0, -2)), - ("kj p eR moles / pico METERs2", (0, 0, 0, 1, -1, 0, -2)), - ("coul oMbs / secs * ATm os phereS", (0, 1, -1, 1, 0, 0, -3)), - ("pm**3 * rads * de grEE", (2, 0, 3, 0, 0, 0, 0)), + ( + "kilo Cal oriEs per Mole / angstrom **2", + tuple(sr.u("kcal_per_mol / angstrom**2").dimensions()), + ), + ("k Cal_per _mOl / nm^2", tuple(sr.u("kcal_per_mol / nm**2").dimensions())), + ( + "kj p eR moles / pico METERs2", + tuple(sr.u("kJ_per_mol / pm**2").dimensions()), + ), + ( + "coul oMbs / secs * ATm os phereS", + tuple(sr.u("coulombs / second / atm").dimensions()), + ), + ("pm**3 * rads * de grEE", tuple(sr.u("pm**3 * rad * degree").dimensions())), ], ) def test_supported_units(string, dimensions): @@ -140,6 +151,61 @@ def test_neg_pow(unit_type): assert d1 == -d0 +def test_frac_pow(): + """Test that unit-based types can be raised to fractional powers.""" + + # Create a base unit type. + unit_type = 2 * Units.Length.angstrom + + # Store the original value and dimensions. + value = unit_type.value() + dimensions = unit_type.dimensions() + + # Square the type. + unit_type = unit_type**2 + + # Assert that we can't take the cube root. + with pytest.raises(ValueError): + unit_type = unit_type ** (1 / 3) + + # Now take the square root. + unit_type = unit_type ** (1 / 2) + + # The value should be the same. + assert unit_type.value() == value + + # The dimensions should be the same. + assert unit_type.dimensions() == dimensions + + # Cube the type. + unit_type = unit_type**3 + + # Assert that we can't take the square root. + with pytest.raises(ValueError): + unit_type = unit_type ** (1 / 2) + + # Now take the cube root. + unit_type = unit_type ** (1 / 3) + + # The value should be the same. + assert unit_type.value() == value + + # The dimensions should be the same. + assert unit_type.dimensions() == dimensions + + # Square the type again. + unit_type = unit_type**2 + + # Now take the negative square root. + unit_type = unit_type ** (-1 / 2) + + # The value should be inverted. + assert unit_type.value() == 1 / value + + # The dimensions should be negated. + assert unit_type.dimensions() == tuple(-d for d in dimensions) + + @pytest.mark.parametrize( "string", [ diff --git a/tests/Types/test_general_unit.py b/tests/Types/test_general_unit.py index d97acaf36..ec630d060 100644 --- a/tests/Types/test_general_unit.py +++ b/tests/Types/test_general_unit.py @@ -3,15 +3,26 @@ import BioSimSpace.Types as Types import BioSimSpace.Units as Units +import sire as sr + @pytest.mark.parametrize( "string, dimensions", [ - ("kilo Cal oriEs per Mole / angstrom **2", (0, 0, 0, 1, -1, 0, -2)), - ("k Cal_per _mOl / nm^2", (0, 0, 0, 1, -1, 0, -2)), - ("kj p eR moles / pico METERs2", (0, 0, 0, 1, -1, 0, -2)), - ("coul oMbs / secs * ATm os phereS", (0, 1, -1, 1, 0, 0, -3)), - ("pm**3 * rads * de grEE", (2, 0, 3, 0, 0, 0, 0)), + ( + "kilo Cal oriEs per Mole / angstrom **2", + tuple(sr.u("kcal_per_mol / angstrom**2").dimensions()), + ), + ("k Cal_per _mOl / nm^2", tuple(sr.u("kcal_per_mol / nm**2").dimensions())), + ( + "kj p eR moles / pico METERs2", + tuple(sr.u("kJ_per_mol / pm**2").dimensions()), + ), + ( + "coul oMbs / secs * ATm os phereS", + tuple(sr.u("coulombs / second / atm").dimensions()), + ), + ("pm**3 * rads * de grEE", tuple(sr.u("pm**3 * rad * degree").dimensions())), ], ) def test_supported_units(string, dimensions): @@ -140,6 +151,61 @@ def test_neg_pow(unit_type): assert d1 == -d0 +def test_frac_pow(): + """Test that unit-based types can be raised to fractional powers.""" + + # Create a base unit type. + unit_type = 2 * Units.Length.angstrom + + # Store the original value and dimensions. + value = unit_type.value() + dimensions = unit_type.dimensions() + + # Square the type. + unit_type = unit_type**2 + + # Assert that we can't take the cube root. + with pytest.raises(ValueError): + unit_type = unit_type ** (1 / 3) + + # Now take the square root. + unit_type = unit_type ** (1 / 2) + + # The value should be the same. + assert unit_type.value() == value + + # The dimensions should be the same. + assert unit_type.dimensions() == dimensions + + # Cube the type. + unit_type = unit_type**3 + + # Assert that we can't take the square root. + with pytest.raises(ValueError): + unit_type = unit_type ** (1 / 2) + + # Now take the cube root. + unit_type = unit_type ** (1 / 3) + + # The value should be the same. + assert unit_type.value() == value + + # The dimensions should be the same. + assert unit_type.dimensions() == dimensions + + # Square the type again. + unit_type = unit_type**2 + + # Now take the negative square root. + unit_type = unit_type ** (-1 / 2) + + # The value should be inverted. + assert unit_type.value() == 1 / value + + # The dimensions should be negated. + assert unit_type.dimensions() == tuple(-d for d in dimensions) + + @pytest.mark.parametrize( "string", [ From 4d6f6a8e8d2843d7d66ce2eb1a46279d7eac6452 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 09:34:30 +0100 Subject: [PATCH 12/32] Switch to using os.path.join. --- python/BioSimSpace/FreeEnergy/_relative.py | 60 ++++++------ .../Parameters/_Protocol/_amber.py | 94 +++++++++---------- .../Parameters/_Protocol/_openforcefield.py | 26 ++--- python/BioSimSpace/Process/_amber.py | 16 ++-- python/BioSimSpace/Process/_gromacs.py | 37 +++++--- python/BioSimSpace/Process/_namd.py | 41 ++++---- python/BioSimSpace/Process/_openmm.py | 30 +++--- python/BioSimSpace/Process/_plumed.py | 29 +++--- python/BioSimSpace/Process/_process.py | 14 +-- python/BioSimSpace/Process/_process_runner.py | 4 +- python/BioSimSpace/Process/_somd.py | 24 ++--- python/BioSimSpace/Trajectory/_trajectory.py | 24 +++-- 12 files changed, 221 insertions(+), 178 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index a54a6d707..4e197f1dc 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -406,7 +406,7 @@ def getData(self, name="data", file_link=False, work_dir=None): ) # Write to the zip file. - with _zipfile.ZipFile(cwd + f"/{zipname}", "w") as zip: + with _zipfile.Zipfile(_os.join(cwd, zipname), "w") as zip: for file in files: zip.write(file) @@ -2074,15 +2074,15 @@ def _initialise_runner(self, system): process._system = first_process._system.copy() process._protocol = self._protocol process._work_dir = new_dir - process._std_out_file = new_dir + "/somd.out" - process._std_err_file = new_dir + "/somd.err" - process._rst_file = new_dir + "/somd.rst7" - process._top_file = new_dir + "/somd.prm7" - process._traj_file = new_dir + "/traj000000001.dcd" - process._restart_file = new_dir + "/latest.rst" - process._config_file = new_dir + "/somd.cfg" - process._pert_file = new_dir + "/somd.pert" - process._gradients_file = new_dir + "/gradients.dat" + process._std_out_file = _os.path.join(new_dir, "somd.out") + process._std_err_file = _os.path.join(new_dir, "somd.err") + process._rst_file = _os.path.join(new_dir, "somd.rst7") + process._top_file = _os.path.join(new_dir, "somd.prm7") + process._traj_file = _os.path.join(new_dir, "traj000000001.dcd") + process._restart_file = _os.path.join(new_dir, "latest.rst") + process._config_file = _os.path.join(new_dir, "somd.cfg") + process._pert_file = _os.path.join(new_dir, "somd.pert") + process._gradients_file = _os.path.join(new_dir, "gradients.dat") process._input_files = [ process._config_file, process._rst_file, @@ -2106,10 +2106,10 @@ def _initialise_runner(self, system): for line in new_config: f.write(line) - mdp = new_dir + "/gromacs.mdp" - gro = new_dir + "/gromacs.gro" - top = new_dir + "/gromacs.top" - tpr = new_dir + "/gromacs.tpr" + mdp = _os.path.join(new_dir, "gromacs.mdp") + gro = _os.path.join(new_dir, "gromacs.gro") + top = _os.path.join(new_dir, "gromacs.top") + tpr = _os.path.join(new_dir, "gromacs.tpr") # Use grompp to generate the portable binary run input file. _Process.Gromacs._generate_binary_run_file( @@ -2129,14 +2129,14 @@ def _initialise_runner(self, system): process._system = first_process._system.copy() process._protocol = self._protocol process._work_dir = new_dir - process._std_out_file = new_dir + "/gromacs.out" - 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" + process._std_out_file = _os.path.join(new_dir, "gromacs.out") + process._std_err_file = _os.path.join(new_dir, "gromacs.err") + process._gro_file = _os.path.join(new_dir, "gromacs.gro") + process._top_file = _os.path.join(new_dir, "gromacs.top") + process._ref_file = _os.path.join(new_dir, "gromacs_ref.gro") + process._traj_file = _os.path.join(new_dir, "gromacs.trr") + process._config_file = _os.path.join(new_dir, "gromacs.mdp") + process._tpr_file = _os.path.join(new_dir, "gromacs.tpr") process._input_files = [ process._config_file, process._gro_file, @@ -2165,14 +2165,14 @@ def _initialise_runner(self, system): 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._std_out_file = _os.path.join(new_dir, "amber.out") + process._std_err_file = _os.path.join(new_dir, "amber.err") + process._rst_file = _os.path.join(new_dir, "amber.rst7") + process._top_file = _os.path.join(new_dir, "amber.prm7") + process._ref_file = _os.path.join(new_dir, "amber_ref.rst7") + process._traj_file = _os.path.join(new_dir, "amber.nc") + process._config_file = _os.path.join(new_dir, "amber.cfg") + process._nrg_file = _os.path.join(new_dir, "amber.nrg") process._input_files = [ process._config_file, process._rst_file, diff --git a/python/BioSimSpace/Parameters/_Protocol/_amber.py b/python/BioSimSpace/Parameters/_Protocol/_amber.py index 69816c1e4..5857da3ff 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_amber.py +++ b/python/BioSimSpace/Parameters/_Protocol/_amber.py @@ -316,9 +316,6 @@ def run(self, molecule, work_dir=None, queue=None): else: is_smiles = False - # Create the file prefix. - prefix = work_dir + "/" - if not is_smiles: # Create a copy of the molecule. new_mol = molecule.copy() @@ -352,7 +349,10 @@ def run(self, molecule, work_dir=None, queue=None): ) # Prepend the working directory to the output file names. - output = [prefix + output[0], prefix + output[1]] + output = [ + _os.path.join(str(work_dir), output[0]), + _os.path.join(str(work_dir), output[1]), + ] try: # Load the parameterised molecule. (This could be a system of molecules.) @@ -443,9 +443,6 @@ def _run_tleap(self, molecule, work_dir): else: _molecule = molecule - # Create the file prefix. - prefix = work_dir + "/" - # Write the system to a PDB file. try: # LEaP expects residue numbering to be ascending and continuous. @@ -454,7 +451,7 @@ def _run_tleap(self, molecule, work_dir): )[0] renumbered_molecule = _Molecule(renumbered_molecule) _IO.saveMolecules( - prefix + "leap", + _os.path.join(str(work_dir), "leap"), renumbered_molecule, "pdb", property_map=self._property_map, @@ -500,7 +497,7 @@ def _run_tleap(self, molecule, work_dir): pruned_bond_records.append(bond) # Write the LEaP input file. - with open(prefix + "leap.txt", "w") as file: + with open(_os.path.join(str(work_dir), "leap.txt"), "w") as file: file.write("source %s\n" % ff) if self._water_model is not None: if self._water_model in ["tip4p", "tip5p"]: @@ -528,14 +525,14 @@ def _run_tleap(self, molecule, work_dir): # Generate the tLEaP command. command = "%s -f leap.txt" % _tleap_exe - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# tLEaP was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "leap.out", "w") - stderr = open(prefix + "leap.err", "w") + stdout = open(_os.path.join(str(work_dir), "leap.out"), "w") + stderr = open(_os.path.join(str(work_dir), "leap.err"), "w") # Run tLEaP as a subprocess. proc = _subprocess.run( @@ -550,12 +547,12 @@ def _run_tleap(self, molecule, work_dir): # tLEaP doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "leap.top") and _os.path.isfile( - prefix + "leap.crd" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "leap.top") + ) and _os.path.isfile(_os.path.join(str(work_dir), "leap.crd")): # Check the output of tLEaP for missing atoms. if self._ensure_compatible: - if _has_missing_atoms(prefix + "leap.out"): + if _has_missing_atoms(_os.path.join(str(work_dir), "leap.top")): raise _ParameterisationError( "tLEaP added missing atoms. The topology is now " "inconsistent with the original molecule. Please " @@ -604,13 +601,13 @@ def _run_pdb2gmx(self, molecule, work_dir): else: _molecule = molecule - # Create the file prefix. - prefix = work_dir + "/" - # Write the system to a PDB file. try: _IO.saveMolecules( - prefix + "leap", _molecule, "pdb", property_map=self._property_map + _os.path.join(str(work_dir), "input"), + _molecule, + "pdb", + property_map=self._property_map, ) except Exception as e: msg = "Failed to write system to 'PDB' format." @@ -626,14 +623,14 @@ def _run_pdb2gmx(self, molecule, work_dir): % (_gmx_exe, supported_ff[self._forcefield]) ) - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# pdb2gmx was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "pdb2gmx.out", "w") - stderr = open(prefix + "pdb2gmx.err", "w") + stdout = open(_os.path.join(str(work_dir), "pdb2gmx.out"), "w") + stderr = open(_os.path.join(str(work_dir), "pdb2gmx.err"), "w") # Run pdb2gmx as a subprocess. proc = _subprocess.run( @@ -647,9 +644,9 @@ def _run_pdb2gmx(self, molecule, work_dir): stderr.close() # Check for the expected output. - if _os.path.isfile(prefix + "output.gro") and _os.path.isfile( - prefix + "output.top" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "output.gro") + ) and _os.path.isfile(_os.path.join(str(work_dir), "output.top")): return ["output.gro", "output.top"] else: raise _ParameterisationError("pdb2gmx failed!") @@ -1010,9 +1007,6 @@ def run(self, molecule, work_dir=None, queue=None): if work_dir is None: work_dir = _os.getcwd() - # Create the file prefix. - prefix = work_dir + "/" - # Convert SMILES to a molecule. if isinstance(molecule, str): is_smiles = True @@ -1092,7 +1086,10 @@ def run(self, molecule, work_dir=None, queue=None): # Write the system to a PDB file. try: _IO.saveMolecules( - prefix + "antechamber", new_mol, "pdb", property_map=self._property_map + _os.path.join(str(work_dir), "antechamber"), + new_mol, + "pdb", + property_map=self._property_map, ) except Exception as e: msg = "Failed to write system to 'PDB' format." @@ -1108,14 +1105,14 @@ def run(self, molecule, work_dir=None, queue=None): + "-o antechamber.mol2 -fo mol2 -c %s -s 2 -nc %d" ) % (_antechamber_exe, self._version, self._charge_method.lower(), charge) - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# Antechamber was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "antechamber.out", "w") - stderr = open(prefix + "antechamber.err", "w") + stdout = open(_os.path.join(str(work_dir), "antechamber.out"), "w") + stderr = open(_os.path.join(str(work_dir), "antechamber.err"), "w") # Run Antechamber as a subprocess. proc = _subprocess.run( @@ -1130,20 +1127,20 @@ def run(self, molecule, work_dir=None, queue=None): # Antechamber doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "antechamber.mol2"): + if _os.path.isfile(_os.path.join(str(work_dir), "antechamber.mol2")): # Run parmchk to check for missing parameters. command = ( "%s -s %d -i antechamber.mol2 -f mol2 " + "-o antechamber.frcmod" ) % (_parmchk_exe, self._version) - with open(prefix + "README.txt", "a") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "a") as file: # Write the command to file. file.write("\n# ParmChk was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "parmchk.out", "w") - stderr = open(prefix + "parmchk.err", "w") + stdout = open(_os.path.join(str(work_dir), "parmchk.out"), "w") + stderr = open(_os.path.join(str(work_dir), "parmchk.err"), "w") # Run parmchk as a subprocess. proc = _subprocess.run( @@ -1157,7 +1154,7 @@ def run(self, molecule, work_dir=None, queue=None): stderr.close() # The frcmod file was created. - if _os.path.isfile(prefix + "antechamber.frcmod"): + if _os.path.isfile(_os.path.join(str(work_dir), "antechamber.frcmod")): # Now call tLEaP using the partially parameterised molecule and the frcmod file. # tLEap will run in the same working directory, using the Mol2 file generated by # Antechamber. @@ -1169,7 +1166,7 @@ def run(self, molecule, work_dir=None, queue=None): ff = _find_force_field("gaff2") # Write the LEaP input file. - with open(prefix + "leap.txt", "w") as file: + with open(_os.path.join(str(work_dir), "leap.txt"), "w") as file: file.write("source %s\n" % ff) file.write("mol = loadMol2 antechamber.mol2\n") file.write("loadAmberParams antechamber.frcmod\n") @@ -1179,14 +1176,14 @@ def run(self, molecule, work_dir=None, queue=None): # Generate the tLEaP command. command = "%s -f leap.txt" % _tleap_exe - with open(prefix + "README.txt", "a") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "a") as file: # Write the command to file. file.write("\n# tLEaP was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "leap.out", "w") - stderr = open(prefix + "leap.err", "w") + stdout = open(_os.path.join(str(work_dir), "leap.out"), "w") + stderr = open(_os.path.join(str(work_dir), "leap.err"), "w") # Run tLEaP as a subprocess. proc = _subprocess.run( @@ -1201,12 +1198,12 @@ def run(self, molecule, work_dir=None, queue=None): # tLEaP doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "leap.top") and _os.path.isfile( - prefix + "leap.crd" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "leap.top") + ) and _os.path.isfile(_os.path.join(str(work_dir), "leap.crd")): # Check the output of tLEaP for missing atoms. if self._ensure_compatible: - if _has_missing_atoms(prefix + "leap.out"): + if _has_missing_atoms(_os.path.join(str(work_dir), "leap.out")): raise _ParameterisationError( "tLEaP added missing atoms. The topology is now " "inconsistent with the original molecule. Please " @@ -1217,7 +1214,10 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "leap.top", prefix + "leap.crd"] + [ + _os.path.join(str(work_dir), "leap.top"), + _os.path.join(str(work_dir), "leap.crd"), + ], ) # Extract single molecules. if par_mol.nMolecules() == 1: diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index 018f43e4e..f6bc49605 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -214,9 +214,6 @@ def run(self, molecule, work_dir=None, queue=None): if work_dir is None: work_dir = _os.getcwd() - # Create the file prefix. - prefix = work_dir + "/" - # Flag whether the molecule is a SMILES string. if isinstance(molecule, str): is_smiles = True @@ -256,7 +253,7 @@ def run(self, molecule, work_dir=None, queue=None): # Write the molecule to SDF format. try: _IO.saveMolecules( - prefix + "molecule", + _os.path.join(str(work_dir), "molecule"), molecule, "sdf", property_map=self._property_map, @@ -275,7 +272,7 @@ def run(self, molecule, work_dir=None, queue=None): # Write the molecule to a PDB file. try: _IO.saveMolecules( - prefix + "molecule", + _os.path.join(str(work_dir), "molecule"), molecule, "pdb", property_map=self._property_map, @@ -291,7 +288,7 @@ def run(self, molecule, work_dir=None, queue=None): # Create an RDKit molecule from the PDB file. try: rdmol = _Chem.MolFromPDBFile( - prefix + "molecule.pdb", removeHs=False + _os.path.join(str(work_dir), "molecule.pdb"), removeHs=False ) except Exception as e: msg = "RDKit was unable to read the molecular PDB file!" @@ -303,7 +300,9 @@ def run(self, molecule, work_dir=None, queue=None): # Use RDKit to write back to SDF format. try: - writer = _Chem.SDWriter(prefix + "molecule.sdf") + writer = _Chem.SDWriter( + _os.path.join(str(work_dir), "molecule.sdf") + ) writer.write(rdmol) writer.close() except Exception as e: @@ -317,7 +316,9 @@ def run(self, molecule, work_dir=None, queue=None): # Create the Open Forcefield Molecule from the intermediate SDF file, # as recommended by @j-wags and @mattwthompson. try: - off_molecule = _OpenFFMolecule.from_file(prefix + "molecule.sdf") + off_molecule = _OpenFFMolecule.from_file( + _os.path.join(str(work_dir), "molecule.sdf") + ) except Exception as e: msg = "Unable to create OpenFF Molecule!" if _isVerbose(): @@ -383,8 +384,8 @@ def run(self, molecule, work_dir=None, queue=None): # Export AMBER format files. try: - interchange.to_prmtop(prefix + "interchange.prm7") - interchange.to_inpcrd(prefix + "interchange.rst7") + interchange.to_prmtop(_os.path.join(str(work_dir), "interchange.prmtop")) + interchange.to_inpcrd(_os.path.join(str(work_dir), "interchange.inpcrd")) except Exception as e: msg = "Unable to write Interchange object to AMBER format!" if _isVerbose(): @@ -396,7 +397,10 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "interchange.prm7", prefix + "interchange.rst7"] + [ + _os.path.join(str(work_dir), "interchange.prmtop"), + _os.path.join(str(work_dir), "interchange.inpcrd"), + ], ) # Extract single molecules. if par_mol.nMolecules() == 1: diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 466216e87..8140729a4 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -235,15 +235,15 @@ def __init__( self._is_header = False # 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) + self._rst_file = _os.path.join(str(self._work_dir), f"{name}.rst7") + self._top_file = _os.path.join(str(self._work_dir), f"{name}.prm7") + self._ref_file = _os.path.join(str(self._work_dir), f"{name}_ref.rst7") # The name of the trajectory file. - self._traj_file = "%s/%s.nc" % (self._work_dir, name) + self._traj_file = _os.path.join(str(self._work_dir), f"{name}.nc") # Set the path for the AMBER configuration file. - self._config_file = "%s/%s.cfg" % (self._work_dir, name) + self._config_file = _os.path.join(str(self._work_dir), f"{name}.cfg") # Create the list of input files. self._input_files = [self._config_file, self._rst_file, self._top_file] @@ -400,7 +400,9 @@ def _generate_config(self): if auxiliary_files is not None: for file in auxiliary_files: file_name = _os.path.basename(file) - _shutil.copyfile(file, self._work_dir + f"/{file_name}") + _shutil.copyfile( + file, _os.path.join(str(self._work_dir), file_name) + ) self._input_files.append(self._plumed_config_file) # Expose the PLUMED specific member functions. @@ -534,7 +536,7 @@ def getSystem(self, block="AUTO"): _warnings.warn("The process exited with an error!") # Create the name of the restart CRD file. - restart = "%s/%s.crd" % (self._work_dir, self._name) + restart = _os.path.join(str(self._work_dir), "%s.crd" % self._name) # Check that the file exists. if _os.path.isfile(restart): diff --git a/python/BioSimSpace/Process/_gromacs.py b/python/BioSimSpace/Process/_gromacs.py index a35fc3531..7de88ac88 100644 --- a/python/BioSimSpace/Process/_gromacs.py +++ b/python/BioSimSpace/Process/_gromacs.py @@ -208,18 +208,18 @@ def __init__( self._energy_file = "%s/%s.edr" % (self._work_dir, name) # 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) + self._gro_file = _os.path.join(str(self._work_dir), f"{name}.gro") + self._top_file = _os.path.join(str(self._work_dir), f"{name}.top") + self._ref_file = _os.path.join(str(self._work_dir), f"{name}_ref.gro") # The name of the trajectory file. - self._traj_file = "%s/%s.trr" % (self._work_dir, name) + self._traj_file = _os.path.join(str(self._work_dir), f"{name}.trr") # The name of the output coordinate file. - self._crd_file = "%s/%s_out.gro" % (self._work_dir, name) + self._crd_file = _os.path.join(str(self._work_dir), f"{name}_out.gro") # Set the path for the GROMACS configuration file. - self._config_file = "%s/%s.mdp" % (self._work_dir, name) + self._config_file = _os.path.join(str(self._work_dir), f"{name}.mdp") # Create the list of input files. self._input_files = [self._config_file, self._gro_file, self._top_file] @@ -314,7 +314,7 @@ def _setup(self, **kwargs): ) # Create the binary input file name. - self._tpr_file = "%s/%s.tpr" % (self._work_dir, self._name) + self._tpr_file = _os.path.join(str(self._work_dir), f"{self._name}.tpr") self._input_files.append(self._tpr_file) # Generate the GROMACS configuration file. @@ -397,7 +397,9 @@ def _generate_config(self): if auxiliary_files is not None: for file in auxiliary_files: file_name = _os.path.basename(file) - _shutil.copyfile(file, self._work_dir + f"/{file_name}") + _shutil.copyfile( + file, _os.path.join(str(self._work_dir), file_name) + ) self._input_files.append(self._plumed_config_file) # Expose the PLUMED specific member functions. @@ -423,7 +425,9 @@ def _generate_config(self): if auxiliary_files is not None: for file in auxiliary_files: file_name = _os.path.basename(file) - _shutil.copyfile(file, self._work_dir + f"/{file_name}") + _shutil.copyfile( + file, _os.path.join(str(self._work_dir), file_name) + ) self._input_files.append(self._plumed_config_file) # Expose the PLUMED specific member functions. @@ -2122,7 +2126,9 @@ def _add_position_restraints(self): if len(restrained_atoms) > 0: # Create the file names. include_file = "posre_%04d.itp" % num_restraint - restraint_file = "%s/%s" % (self._work_dir, include_file) + restraint_file = _os.path.join( + str(self._work_dir), include_file + ) with open(restraint_file, "w") as file: # Write the header. @@ -2206,7 +2212,9 @@ def _add_position_restraints(self): if len(atom_idxs) > 0: # Create the file names. include_file = "posre_%04d.itp" % num_restraint - restraint_file = "%s/%s" % (self._work_dir, include_file) + restraint_file = _os.path.join( + str(self._work_dir), include_file + ) with open(restraint_file, "w") as file: # Write the header. @@ -2735,11 +2743,12 @@ def _getFrame(self, time): return old_system except: + raise _warnings.warn( "Failed to extract trajectory frame with trjconv. " "Try running 'getSystem' again." ) - frame = "%s/frame.gro" % self._work_dir + frame = _os.path.join(str(self._work_dir), "frame.gro") if _os.path.isfile(frame): _os.remove(frame) return None @@ -2759,7 +2768,7 @@ def _find_trajectory_file(self): # Check that the current trajectory file is found. if not _os.path.isfile(self._traj_file): # If not, first check for any trr extension. - traj_file = _glob.glob("%s/*.trr" % self._work_dir) + traj_file = _glob.glob(_os.path.join(str(self._work_dir), "*.trr")) # Store the number of trr files. num_trr = len(traj_file) @@ -2769,7 +2778,7 @@ def _find_trajectory_file(self): return traj_file[0] else: # Now check for any xtc files. - traj_file = _glob.glob("%s/*.xtc" % self._work_dir) + traj_file = _glob.glob(_os.path.join(str(self._work_dir), "*.xtc")) if len(traj_file) == 1: return traj_file[0] diff --git a/python/BioSimSpace/Process/_namd.py b/python/BioSimSpace/Process/_namd.py index 9811d26ab..787452905 100644 --- a/python/BioSimSpace/Process/_namd.py +++ b/python/BioSimSpace/Process/_namd.py @@ -148,17 +148,17 @@ def __init__( self._stdout_title = None # The names of the input files. - self._psf_file = "%s/%s.psf" % (self._work_dir, name) - self._top_file = "%s/%s.pdb" % (self._work_dir, name) - self._param_file = "%s/%s.params" % (self._work_dir, name) + self._psf_file = _os.path.join(str(self._work_dir), f"{name}.psf") + self._top_file = _os.path.join(str(self._work_dir), f"{name}.pdb") + self._param_file = _os.path.join(str(self._work_dir), f"{name}.params") self._velocity_file = None self._restraint_file = None # The name of the trajectory file. - self._traj_file = "%s/%s_out.dcd" % (self._work_dir, name) + self._traj_file = _os.path.join(str(self._work_dir), f"{name}_out.dcd") # Set the path for the NAMD configuration file. - self._config_file = "%s/%s.cfg" % (self._work_dir, name) + self._config_file = _os.path.join(str(self._work_dir), f"{name}.cfg") # Create the list of input files. self._input_files = [ @@ -443,9 +443,8 @@ def _generate_config(self): p = _SireIO.PDB2(restrained._sire_object, {prop: "restrained"}) # File name for the restraint file. - self._restraint_file = "%s/%s.restrained" % ( - self._work_dir, - self._name, + self._restraint_file = _os.path.join( + str(self._work_dir), f"{self._name}.restrained" ) # Write the PDB file. @@ -733,13 +732,19 @@ def getSystem(self, block="AUTO"): has_coor = False # First check for final configuration. - if _os.path.isfile("%s/%s_out.coor" % (self._work_dir, self._name)): - coor_file = "%s/%s_out.coor" % (self._work_dir, self._name) + if _os.path.isfile( + _os.path.join(str(self._work_dir), f"{self._name}_out.coor") + ): + coor_file = _os.path.join(str(self._work_dir), f"{self._name}_out.coor") has_coor = True # Otherwise check for a restart file. - elif _os.path.isfile("%s/%s_out.restart.coor" % (self._work_dir, self._name)): - coor_file = "%s/%s_out.restart.coor" % (self._work_dir, self._name) + elif _os.path.isfile( + _os.path.join(str(self._work_dir), f"{self._name}_out.restart.coor") + ): + coor_file = _os.path.join( + str(self._work_dir), f"{self._name}_out.restart.coor" + ) has_coor = True # Try to find an XSC file. @@ -747,13 +752,17 @@ def getSystem(self, block="AUTO"): has_xsc = False # First check for final XSC file. - if _os.path.isfile("%s/%s_out.xsc" % (self._work_dir, self._name)): - xsc_file = "%s/%s_out.xsc" % (self._work_dir, self._name) + if _os.path.isfile(_os.path.join(str(self._work_dir), f"{self._name}_out.xsc")): + xsc_file = _os.path.join(str(self._work_dir), f"{self._name}_out.xsc") has_xsc = True # Otherwise check for a restart XSC file. - elif _os.path.isfile("%s/%s_out.restart.xsc" % (self._work_dir, self._name)): - xsc_file = "%s/%s_out.restart.xsc" % (self._work_dir, self._name) + elif _os.path.isfile( + _os.path.join(str(self._work_dir), f"{self._name}_out.restart.xsc") + ): + xsc_file = _os.path.join( + str(self._work_dir), f"{self._name}_out.restart.xsc" + ) has_xsc = True # We found a coordinate file. diff --git a/python/BioSimSpace/Process/_openmm.py b/python/BioSimSpace/Process/_openmm.py index d11974d22..e19cc1f40 100644 --- a/python/BioSimSpace/Process/_openmm.py +++ b/python/BioSimSpace/Process/_openmm.py @@ -174,7 +174,7 @@ def __init__( self._stdout_dict = _process._MultiDict() # Store the name of the OpenMM log file. - self._log_file = "%s/%s.log" % (self._work_dir, name) + self._log_file = _os.path.join(str(self._work_dir), f"{name}.log") # Initialise the log file separator. self._record_separator = None @@ -184,16 +184,16 @@ def __init__( # The names of the input files. We choose to use AMBER files since they # 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) + self._rst_file = _os.path.join(str(self._work_dir), f"{name}.rst7") + self._top_file = _os.path.join(str(self._work_dir), f"{name}.prm7") + self._ref_file = _os.path.join(str(self._work_dir), f"{name}_ref.rst7") # The name of the trajectory file. - self._traj_file = "%s/%s.dcd" % (self._work_dir, name) + self._traj_file = _os.path.join(str(self._work_dir), f"{name}.dcd") # Set the path for the OpenMM Python script. (We use the concept of a # config file for consistency with other Process classes.) - self._config_file = "%s/%s_script.py" % (self._work_dir, name) + self._config_file = _os.path.join(str(self._work_dir), f"{name}_script.py") # Create the list of input files. self._input_files = [self._config_file, self._rst_file, self._top_file] @@ -772,7 +772,7 @@ def _generate_config(self): ) # Copy the file into the working directory. - _shutil.copyfile(path, self._work_dir + f"/{aux_file}") + _shutil.copyfile(path, _os.path.join(str(self._work_dir), aux_file)) # The following OpenMM native implementation of the funnel metadynamics protocol # is adapted from funnel_maker.py by Dominykas Lukauskis. @@ -970,9 +970,13 @@ def _generate_config(self): # Get the number of steps to date. step = 0 - if _os.path.isfile(f"{self._work_dir}/{self._name}.xml"): - if _os.path.isfile(f"{self._work_dir}/{self._name}.log"): - with open(f"{self._work_dir}/{self._name}.log", "r") as f: + if _os.path.isfile(_os.path.join(str(self._work_dir), f"{self._name}.xml")): + if _os.path.isfile( + _os.path.join(str(self._work_dir), f"{self._name}.log") + ): + with open( + _os.path.join(str(self._work_dir), f"{self._name}.log"), "r" + ) as f: lines = f.readlines() last_line = lines[-1].split() try: @@ -2031,8 +2035,10 @@ def _add_config_restart(self): self.addToConfig("else:") self.addToConfig(" is_restart = False") - if _os.path.isfile(f"{self._work_dir}/{self._name}.xml"): - with open(f"{self._work_dir}/{self._name}.log", "r") as f: + if _os.path.isfile(_os.path.join(str(self._work_dir), f"{self._name}.xml")): + with open( + _os.path.join(str(self._work_dir), f"{self._name}.log"), "r" + ) as f: lines = f.readlines() last_line = lines[-1].split() step = int(last_line[0]) diff --git a/python/BioSimSpace/Process/_plumed.py b/python/BioSimSpace/Process/_plumed.py index aad824087..7ee712696 100644 --- a/python/BioSimSpace/Process/_plumed.py +++ b/python/BioSimSpace/Process/_plumed.py @@ -117,8 +117,8 @@ def __init__(self, work_dir): self._work_dir = work_dir # Set the location of the HILLS and COLVAR files. - self._hills_file = "%s/HILLS" % self._work_dir - self._colvar_file = "%s/COLVAR" % self._work_dir + self._hills_file = _os.path.join(str(self._work_dir), "HILLS") + self._colvar_file = _os.path.join(str(self._work_dir), "COLVAR") # The number of collective variables and total number of components. self._num_colvar = 0 @@ -250,11 +250,11 @@ def _createMetadynamicsConfig(self, system, protocol, property_map={}): # Always remove pygtail offset files. try: - _os.remove("%s/COLVAR.offset" % self._work_dir) + _os.remove(_os.path.join(str(self._work_dir), "COLVAR.offset")) except: pass try: - _os.remove("%s/HILLS.offset" % self._work_dir) + _os.remove(_os.path.join(str(self._work_dir), "HILLS.offset")) except: pass @@ -627,7 +627,9 @@ def _createMetadynamicsConfig(self, system, protocol, property_map={}): colvar_string += " TYPE=%s" % colvar.getAlignmentType().upper() # Write the reference PDB file. - with open("%s/reference.pdb" % self._work_dir, "w") as file: + with open( + _os.path.join(str(self._work_dir), "reference.pdb"), "w" + ) as file: for line in colvar.getReferencePDB(): file.write(line + "\n") @@ -870,7 +872,9 @@ def _createMetadynamicsConfig(self, system, protocol, property_map={}): metad_string += ( " GRID_WFILE=GRID GRID_WSTRIDE=%s" % protocol.getHillFrequency() ) - if is_restart and _os.path.isfile(f"{self._work_dir}/GRID"): + if is_restart and _os.path.isfile( + _os.path.join(str(self._work_dir), "GRID") + ): metad_string += " GRID_RFILE=GRID" metad_string += " CALC_RCT" @@ -940,7 +944,7 @@ def _createSteeringConfig(self, system, protocol, property_map={}): # Always remove pygtail offset files. try: - _os.remove("%s/COLVAR.offset" % self._work_dir) + _os.remove(_os.path.join(str(self._work_dir), "COLVAR.offset")) except: pass @@ -1225,7 +1229,8 @@ def _createSteeringConfig(self, system, protocol, property_map={}): # Write the reference PDB file. with open( - "%s/reference_%i.pdb" % (self._work_dir, num_rmsd), "w" + _os.path.join(str(self._work_dir), "reference_%i.pdb" % num_rmsd), + "w", ) as file: for line in colvar.getReferencePDB(): file.write(line + "\n") @@ -1449,8 +1454,8 @@ def getFreeEnergy(self, index=None, stride=None, kt=_Types.Energy(1.0, "kt")): raise ValueError("'kt' must have value > 0") # Delete any existing FES directotry and create a new one. - _shutil.rmtree(f"{self._work_dir}/fes", ignore_errors=True) - _os.makedirs(f"{self._work_dir}/fes") + _shutil.rmtree(_os.path.join(str(self._work_dir), "fes"), ignore_errors=True) + _os.makedirs(_os.path.join(str(self._work_dir), "fes")) # Create the command string. command = "%s sum_hills --hills ../HILLS --mintozero" % self._exe @@ -1466,7 +1471,7 @@ def getFreeEnergy(self, index=None, stride=None, kt=_Types.Energy(1.0, "kt")): free_energies = [] # Move to the working directory. - with _Utils.cd(self._work_dir + "/fes"): + with _Utils.cd(_os.path.join(str(self._work_dir), "fes")): # Run the sum_hills command as a background process. proc = _subprocess.run( _Utils.command_split(command), @@ -1556,7 +1561,7 @@ def getFreeEnergy(self, index=None, stride=None, kt=_Types.Energy(1.0, "kt")): _os.remove(fes) # Remove the FES output directory. - _shutil.rmtree(f"{self._work_dir}/fes", ignore_errors=True) + _shutil.rmtree(_os.path.join(str(self._work_dir), "fes"), ignore_errors=True) return tuple(free_energies) diff --git a/python/BioSimSpace/Process/_process.py b/python/BioSimSpace/Process/_process.py index a4ef0ebf2..3457e492c 100644 --- a/python/BioSimSpace/Process/_process.py +++ b/python/BioSimSpace/Process/_process.py @@ -281,11 +281,11 @@ def __init__( self._work_dir = _Utils.WorkDir(work_dir) # Files for redirection of stdout and stderr. - self._stdout_file = "%s/%s.out" % (self._work_dir, name) - self._stderr_file = "%s/%s.err" % (self._work_dir, name) + self._stdout_file = _os.path.join(str(self._work_dir), f"{name}.out") + self._stderr_file = _os.path.join(str(self._work_dir), f"{name}.err") # Files for metadynamics simulation with PLUMED. - self._plumed_config_file = "%s/plumed.dat" % self._work_dir + self._plumed_config_file = _os.path.join(str(self._work_dir), "plumed.dat") self._plumed_config = None # Initialise the configuration file string list. @@ -349,13 +349,13 @@ def _clear_output(self): self._stderr = [] # Clean up any existing offset files. - offset_files = _glob.glob("%s/*.offset" % self._work_dir) + offset_files = _glob.glob(_os.path.join(str(self._work_dir), "*.offset")) # Remove any HILLS or COLVAR files from the list. These will be dealt # with by the PLUMED interface. try: - offset_files.remove("%s/COLVAR.offset" % self._work_dir) - offset_files.remove("%s/HILLS.offset" % self._work_dir) + offset_files.remove(_os.path.join(str(self._work_dir), "COLVAR.offset")) + offset_files.remove(_os.path.join(str(self._work_dir), "HILLS.offset")) except: pass @@ -1240,7 +1240,7 @@ def getOutput(self, name=None, block="AUTO", file_link=False): zipname = "%s.zip" % name # Glob all of the output files. - output = _glob.glob("%s/*" % self._work_dir) + output = _glob.glob(_os.path.join(str(self._work_dir), "*")) with _zipfile.ZipFile(zipname, "w") as zip: # Loop over all of the file outputs. diff --git a/python/BioSimSpace/Process/_process_runner.py b/python/BioSimSpace/Process/_process_runner.py index 5c45c4f04..fa1f08cee 100644 --- a/python/BioSimSpace/Process/_process_runner.py +++ b/python/BioSimSpace/Process/_process_runner.py @@ -843,7 +843,9 @@ def _nest_directories(self, processes): # Loop over each process. for process in processes: # Create the new working directory name. - new_dir = "%s/%s" % (self._work_dir, _os.path.basename(process._work_dir)) + new_dir = _os.path.join( + self._work_dir, _os.path.basename(process._work_dir) + ) # Create a new process object using the nested directory. if process._package_name == "SOMD": diff --git a/python/BioSimSpace/Process/_somd.py b/python/BioSimSpace/Process/_somd.py index 1f3094daf..4b66b8646 100644 --- a/python/BioSimSpace/Process/_somd.py +++ b/python/BioSimSpace/Process/_somd.py @@ -227,23 +227,23 @@ def __init__( raise IOError("SOMD executable doesn't exist: '%s'" % exe) # 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._rst_file = _os.path.join(str(self._work_dir), f"{name}.rst7") + self._top_file = _os.path.join(str(self._work_dir), f"{name}.prm7") # The name of the trajectory file. - self._traj_file = "%s/traj000000001.dcd" % self._work_dir + self._traj_file = _os.path.join(str(self._work_dir), "traj000000001.dcd") # The name of the restart file. - self._restart_file = "%s/latest.pdb" % self._work_dir + self._restart_file = _os.path.join(str(self._work_dir), "latest.pdb") # Set the path for the SOMD configuration file. - self._config_file = "%s/%s.cfg" % (self._work_dir, name) + self._config_file = _os.path.join(str(self._work_dir), f"{name}.cfg") # Set the path for the perturbation file. - self._pert_file = "%s/%s.pert" % (self._work_dir, name) + self._pert_file = _os.path.join(str(self._work_dir), f"{name}.pert") # Set the path for the gradient file and create the gradient list. - self._gradient_file = "%s/gradients.dat" % self._work_dir + self._gradient_file = _os.path.join(str(self._work_dir), "gradients.dat") self._gradients = [] # Create the list of input files. @@ -871,26 +871,26 @@ def _clear_output(self): # Delete any restart and trajectory files in the working directory. - file = "%s/sim_restart.s3" % self._work_dir + file = _os.path.join(str(self._work_dir), "sim_restart.s3") if _os.path.isfile(file): _os.remove(file) - file = "%s/SYSTEM.s3" % self._work_dir + file = _os.path.join(str(self._work_dir), "SYSTEM.s3") if _os.path.isfile(file): _os.remove(file) - files = _glob.glob("%s/traj*.dcd" % self._work_dir) + files = _glob.glob(_os.path.join(str(self._work_dir), "traj*.dcd")) for file in files: if _os.path.isfile(file): _os.remove(file) # Additional files for free energy simulations. if isinstance(self._protocol, _Protocol.FreeEnergy): - file = "%s/gradients.dat" % self._work_dir + file = _os.path.join(str(self._work_dir), "gradients.dat") if _os.path.isfile(file): _os.remove(file) - file = "%s/simfile.dat" % self._work_dir + file = _os.path.join(str(self._work_dir), "simfile.dat") if _os.path.isfile(file): _os.remove(file) diff --git a/python/BioSimSpace/Trajectory/_trajectory.py b/python/BioSimSpace/Trajectory/_trajectory.py index 7b5f9d45e..1b7ac59ed 100644 --- a/python/BioSimSpace/Trajectory/_trajectory.py +++ b/python/BioSimSpace/Trajectory/_trajectory.py @@ -157,7 +157,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): errors = [] is_sire = False is_mdanalysis = False - pdb_file = work_dir + f"/{str(_uuid.uuid4())}.pdb" + pdb_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.pdb") try: frame = _sire_load( [trajectory, topology], @@ -169,7 +169,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): except Exception as e: errors.append(f"Sire: {str(e)}") try: - frame_file = work_dir + f"/{str(_uuid.uuid4())}.rst7" + frame_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.rst7") frame = _mdtraj.load_frame(trajectory, index, top=topology) frame.save(frame_file, force_overwrite=True) frame.save(pdb_file, force_overwrite=True) @@ -178,7 +178,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): errors.append(f"MDTraj: {str(e)}") # Try to load the frame with MDAnalysis. try: - frame_file = work_dir + f"/{str(_uuid.uuid4())}.gro" + frame_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.gro") universe = _mdanalysis.Universe(topology, trajectory) universe.trajectory.trajectory[index] with _warnings.catch_warnings(): @@ -615,7 +615,9 @@ def getTrajectory(self, format="auto"): # If this is a PRM7 file, copy to PARM7. if extension == ".prm7": # Set the path to the temporary topology file. - top_file = self._work_dir + f"/{str(_uuid.uuid4())}.parm7" + top_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.parm7" + ) # Copy the topology to a file with the correct extension. _shutil.copyfile(self._top_file, top_file) @@ -761,16 +763,20 @@ def getFrames(self, indices=None): # Write the current frame to file. - pdb_file = self._work_dir + f"/{str(_uuid.uuid4())}.pdb" + pdb_file = _os.path.join(str(self._work_dir), f"{str(_uuid.uuid4())}.pdb") if self._backend == "SIRE": frame = self._trajectory[x] elif self._backend == "MDTRAJ": - frame_file = self._work_dir + f"/{str(_uuid.uuid4())}.rst7" + frame_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.rst7" + ) self._trajectory[x].save(frame_file, force_overwrite=True) self._trajectory[x].save(pdb_file, force_overwrite=True) elif self._backend == "MDANALYSIS": - frame_file = self._work_dir + f"/{str(_uuid.uuid4())}.gro" + frame_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.gro" + ) self._trajectory.trajectory[x] with _warnings.catch_warnings(): _warnings.simplefilter("ignore") @@ -1110,8 +1116,8 @@ def _split_molecules(frame, pdb, reference, work_dir, property_map={}): formats = reference.fileFormat() # Write the frame coordinates/velocities to file. - coord_file = work_dir + f"/{str(_uuid.uuid4())}.coords" - top_file = work_dir + f"/{str(_uuid.uuid4())}.top" + coord_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.coords") + top_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.top") frame.writeToFile(coord_file) # Whether we've parsed as a PDB file. From bd60625d01322ff1ef127d5f06885ef9bb61ce5a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:22:38 +0100 Subject: [PATCH 13/32] Preserve fileformat property when creating a system from a molecule. [closes #273] --- python/BioSimSpace/_SireWrappers/_system.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/BioSimSpace/_SireWrappers/_system.py b/python/BioSimSpace/_SireWrappers/_system.py index 2d458890b..dbb8db8fc 100644 --- a/python/BioSimSpace/_SireWrappers/_system.py +++ b/python/BioSimSpace/_SireWrappers/_system.py @@ -88,12 +88,20 @@ def __init__(self, system): sire_object = _SireSystem.System("BioSimSpace_System.") super().__init__(sire_object) self.addMolecules(_Molecule(system)) + if "fileformat" in system.propertyKeys(): + self._sire_object.setProperty( + "fileformat", system.property("fileformat") + ) # A BioSimSpace Molecule object. elif isinstance(system, _Molecule): sire_object = _SireSystem.System("BioSimSpace_System.") super().__init__(sire_object) self.addMolecules(system) + if "fileformat" in system._sire_object.propertyKeys(): + self._sire_object.setProperty( + "fileformat", system._sire_object.property("fileformat") + ) # A BioSimSpace Molecules object. elif isinstance(system, _Molecules): From a42aa2ea2accbf0e3c70ca7c5bc168087b3faf44 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:23:26 +0100 Subject: [PATCH 14/32] Make sure base protocol returns None for getRestraint. [closes #274] --- .../Protocol/_position_restraint_mixin.py | 5 +++-- python/BioSimSpace/Protocol/_protocol.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Protocol/_position_restraint_mixin.py b/python/BioSimSpace/Protocol/_position_restraint_mixin.py index 8374bf8ad..3211558d1 100644 --- a/python/BioSimSpace/Protocol/_position_restraint_mixin.py +++ b/python/BioSimSpace/Protocol/_position_restraint_mixin.py @@ -110,13 +110,14 @@ def __eq__(self, other): ) def getRestraint(self): - """Return the type of restraint.. + """ + Return the type of restraint. Returns ------- restraint : str, [int] - The type of restraint. + The type of restraint, either a keyword or a list of atom indices. """ return self._restraint diff --git a/python/BioSimSpace/Protocol/_protocol.py b/python/BioSimSpace/Protocol/_protocol.py index 9d37e004f..ad52a55e1 100644 --- a/python/BioSimSpace/Protocol/_protocol.py +++ b/python/BioSimSpace/Protocol/_protocol.py @@ -40,6 +40,23 @@ def __init__(self): # Flag that the protocol hasn't been customised. self._is_customised = False + def getRestraint(self): + """ + Return the type of restraint. + + Returns + ------- + + restraint : str, [int] + The type of restraint, either a keyword or a list of atom indices. + """ + from ._position_restraint_mixin import _PositionRestraintMixin + + if isinstance(self, _PositionRestraintMixin): + return self._restraint + else: + return None + def _setCustomised(self, is_customised): """ Internal function to flag whether a protocol has been customised. From 65115b28983754c5afb792a176c794898326bc0b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:23:56 +0100 Subject: [PATCH 15/32] Add missing thermostat_time_constant to metadynamics protocol. [closes #275] --- python/BioSimSpace/Protocol/_metadynamics.py | 43 ++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/python/BioSimSpace/Protocol/_metadynamics.py b/python/BioSimSpace/Protocol/_metadynamics.py index 99b49320e..8e6b25e82 100644 --- a/python/BioSimSpace/Protocol/_metadynamics.py +++ b/python/BioSimSpace/Protocol/_metadynamics.py @@ -48,6 +48,7 @@ def __init__( runtime=_Types.Time(1, "nanosecond"), temperature=_Types.Temperature(300, "kelvin"), pressure=_Types.Pressure(1, "atmosphere"), + thermostat_time_constant=_Types.Time(1, "picosecond"), hill_height=_Types.Energy(1, "kj per mol"), hill_frequency=1000, report_interval=1000, @@ -76,6 +77,9 @@ def __init__( pressure : :class:`Pressure ` The pressure. Pass pressure=None to use the NVT ensemble. + thermostat_time_constant : :class:`Time ` + Time constant for thermostat coupling. + hill_height : :class:`Energy ` The height of the Gaussian hills. @@ -123,6 +127,9 @@ def __init__( else: self._pressure = None + # Set the thermostat time constant. + self.setThermostatTimeConstant(thermostat_time_constant) + # Set the hill parameters: height, frequency. self.setHillHeight(hill_height) self.setHillFrequency(hill_frequency) @@ -384,6 +391,42 @@ def setPressure(self, pressure): "'pressure' must be of type 'str' or 'BioSimSpace.Types.Pressure'" ) + def getThermostatTimeConstant(self): + """ + Return the time constant for the thermostat. + + Returns + ------- + + runtime : :class:`Time ` + The time constant for the thermostat. + """ + return self._thermostat_time_constant + + def setThermostatTimeConstant(self, thermostat_time_constant): + """ + Set the time constant for the thermostat. + + Parameters + ---------- + + thermostat_time_constant : str, :class:`Time ` + The time constant for the thermostat. + """ + if isinstance(thermostat_time_constant, str): + try: + self._thermostat_time_constant = _Types.Time(thermostat_time_constant) + except: + raise ValueError( + "Unable to parse 'thermostat_time_constant' string." + ) from None + elif isinstance(thermostat_time_constant, _Types.Time): + self._thermostat_time_constant = thermostat_time_constant + else: + raise TypeError( + "'thermostat_time_constant' must be of type 'str' or 'BioSimSpace.Types.Time'" + ) + def getHillHeight(self): """ Return the height of the Gaussian hills. From 557bbacb29b5fffdbcf44e763727d5399c5d3e57 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:28:56 +0100 Subject: [PATCH 16/32] Add temperature control to Metadynamics and Steering protocols. --- python/BioSimSpace/_Config/_amber.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 861fa5d72..34d6ad84d 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -321,10 +321,7 @@ def createConfig( ) # Temperature control. - if not isinstance( - self._protocol, - (_Protocol.Metadynamics, _Protocol.Steering, _Protocol.Minimisation), - ): + if not isinstance(self._protocol, _Protocol.Minimisation): # Langevin dynamics. protocol_dict["ntt"] = 3 # Collision frequency (1 / ps). From fed23af4894f704688c24985d9ceb3d154fd5221 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:44:32 +0100 Subject: [PATCH 17/32] Refactor fixtures. --- tests/Convert/test_convert.py | 5 ---- tests/FreeEnergy/test_relative.py | 11 -------- tests/Process/test_amber.py | 28 -------------------- tests/Process/test_gromacs.py | 17 ------------- tests/Process/test_namd.py | 12 --------- tests/Process/test_openmm.py | 6 ----- tests/Process/test_single_point_energy.py | 22 +++++++++------- tests/Process/test_somd.py | 17 ------------- tests/Protocol/test_protocol.py | 6 ----- tests/Solvent/test_solvent.py | 8 +++--- tests/Stream/test_stream.py | 5 ---- tests/Trajectory/test_trajectory.py | 6 ----- tests/_SireWrappers/test_molecule.py | 5 ---- tests/_SireWrappers/test_search_result.py | 6 ----- tests/_SireWrappers/test_system.py | 6 ----- tests/conftest.py | 31 +++++++++++++++++++++++ 16 files changed, 48 insertions(+), 143 deletions(-) diff --git a/tests/Convert/test_convert.py b/tests/Convert/test_convert.py index f2f4efbb8..8823e4ef9 100644 --- a/tests/Convert/test_convert.py +++ b/tests/Convert/test_convert.py @@ -5,11 +5,6 @@ import pytest -@pytest.fixture(scope="session") -def system(): - return BSS.IO.readMolecules(["tests/input/ala.crd", "tests/input/ala.top"]) - - def test_system(system): """ Check that system conversions work as expected. diff --git a/tests/FreeEnergy/test_relative.py b/tests/FreeEnergy/test_relative.py index 6d01fe81f..6808b0ed7 100644 --- a/tests/FreeEnergy/test_relative.py +++ b/tests/FreeEnergy/test_relative.py @@ -9,17 +9,6 @@ from tests.conftest import url, has_alchemlyb, has_gromacs -@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.fixture(scope="module") def fep_output(): """Path to a temporary directory containing FEP output.""" diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index a6626047e..735f2af46 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -13,12 +13,6 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.fixture(scope="session") def rna_system(): """An RNA system for re-use.""" @@ -35,28 +29,6 @@ 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.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): diff --git a/tests/Process/test_gromacs.py b/tests/Process/test_gromacs.py index a321f033d..5de10a95a 100644 --- a/tests/Process/test_gromacs.py +++ b/tests/Process/test_gromacs.py @@ -19,23 +19,6 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - -@pytest.fixture(scope="session") -def perturbable_system(): - """Re-use the same perturbable system for each test.""" - return BSS.IO.readPerturbableSystem( - f"{url}/complex_vac0.prm7.bz2", - f"{url}/complex_vac0.rst7.bz2", - f"{url}/complex_vac1.prm7.bz2", - f"{url}/complex_vac1.rst7.bz2", - ) - - @pytest.mark.skipif(has_gromacs is False, reason="Requires GROMACS to be installed.") @pytest.mark.parametrize("restraint", restraints) def test_minimise(system, restraint): diff --git a/tests/Process/test_namd.py b/tests/Process/test_namd.py index 03c550d46..990ff1473 100644 --- a/tests/Process/test_namd.py +++ b/tests/Process/test_namd.py @@ -8,18 +8,6 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules( - [ - "tests/input/alanin.psf", - f"tests/input/alanin.pdb", - f"tests/input/alanin.params", - ] - ) - - @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") @pytest.mark.parametrize("restraint", restraints) def test_minimise(system, restraint): diff --git a/tests/Process/test_openmm.py b/tests/Process/test_openmm.py index 7925bd91b..a7968173a 100644 --- a/tests/Process/test_openmm.py +++ b/tests/Process/test_openmm.py @@ -8,12 +8,6 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.mark.parametrize("restraint", restraints) def test_minimise(system, restraint): """Test a minimisation protocol.""" diff --git a/tests/Process/test_single_point_energy.py b/tests/Process/test_single_point_energy.py index e252cfb99..8fc3a604e 100644 --- a/tests/Process/test_single_point_energy.py +++ b/tests/Process/test_single_point_energy.py @@ -5,8 +5,8 @@ from tests.conftest import url, has_amber, has_gromacs -@pytest.fixture(scope="session") -def system(): +@pytest.fixture(scope="module") +def ubiquitin_system(): """Re-use the same molecuar system for each test.""" return BSS.IO.readMolecules( [f"{url}/ubiquitin.prm7.bz2", f"{url}/ubiquitin.rst7.bz2"] @@ -17,17 +17,19 @@ def system(): has_amber is False or has_gromacs is False, reason="Requires that both AMBER and GROMACS are installed.", ) -def test_amber_gromacs(system): +def test_amber_gromacs(ubiquitin_system): """Single point energy comparison between AMBER and GROMACS.""" # Create a single-step minimisation protocol. protocol = BSS.Protocol.Minimisation(steps=1) # Create a process to run with AMBER. - process_amb = BSS.Process.Amber(system, protocol) + process_amb = BSS.Process.Amber(ubiquitin_system, protocol) # Create a process to run with GROMACS. - process_gmx = BSS.Process.Gromacs(system, protocol, extra_options={"nsteps": 0}) + process_gmx = BSS.Process.Gromacs( + ubiquitin_system, protocol, extra_options={"nsteps": 0} + ) # Run the AMBER process and wait for it to finish. process_amb.start() @@ -57,23 +59,25 @@ def test_amber_gromacs(system): has_amber is False or has_gromacs is False, reason="Requires that both AMBER and GROMACS are installed.", ) -def test_amber_gromacs_triclinic(system): +def test_amber_gromacs_triclinic(ubiquitin_system): """Single point energy comparison between AMBER and GROMACS in a triclinic box.""" # Swap the space for a triclinic cell (truncated octahedron). from sire.legacy.Vol import TriclinicBox triclinic_box = TriclinicBox.truncatedOctahedron(50) - system._sire_object.setProperty("space", triclinic_box) + ubiquitin_system._sire_object.setProperty("space", triclinic_box) # Create a single-step minimisation protocol. protocol = BSS.Protocol.Minimisation(steps=1) # Create a process to run with AMBER. - process_amb = BSS.Process.Amber(system, protocol) + process_amb = BSS.Process.Amber(ubiquitin_system, protocol) # Create a process to run with GROMACS. - process_gmx = BSS.Process.Gromacs(system, protocol, extra_options={"nsteps": 0}) + process_gmx = BSS.Process.Gromacs( + ubiquitin_system, protocol, extra_options={"nsteps": 0} + ) # Run the AMBER process and wait for it to finish. process_amb.start() diff --git a/tests/Process/test_somd.py b/tests/Process/test_somd.py index 2220e589e..f7eedc52c 100644 --- a/tests/Process/test_somd.py +++ b/tests/Process/test_somd.py @@ -9,23 +9,6 @@ url = BSS.tutorialUrl() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - -@pytest.fixture(scope="session") -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", - ) - - def test_minimise(system): """Test a minimisation protocol.""" diff --git a/tests/Protocol/test_protocol.py b/tests/Protocol/test_protocol.py index c38e8e553..b0614b6fa 100644 --- a/tests/Protocol/test_protocol.py +++ b/tests/Protocol/test_protocol.py @@ -6,12 +6,6 @@ # using strings of unit-based types. -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - def test_equilibration(): # Instantiate from types. p0 = BSS.Protocol.Equilibration( diff --git a/tests/Solvent/test_solvent.py b/tests/Solvent/test_solvent.py index 185bb6373..02afe1f06 100644 --- a/tests/Solvent/test_solvent.py +++ b/tests/Solvent/test_solvent.py @@ -11,7 +11,7 @@ @pytest.fixture(scope="module") -def system(): +def kigaki_system(): return BSS.IO.readMolecules( BSS.IO.expand( BSS.tutorialUrl(), ["kigaki_xtal_water.gro", "kigaki_xtal_water.top"] @@ -25,7 +25,7 @@ def system(): [partial(BSS.Solvent.solvate, "tip3p"), BSS.Solvent.tip3p], ) @pytest.mark.skipif(not has_gromacs, reason="Requires GROMACS to be installed") -def test_crystal_water(system, match_water, function): +def test_crystal_water(kigaki_system, match_water, function): """ Test that user defined crystal waters can be preserved during solvation and on write to GroTop format. @@ -35,13 +35,13 @@ def test_crystal_water(system, match_water, function): if match_water: num_matches = 0 else: - num_matches = len(system.search("resname COF").molecules()) + num_matches = len(kigaki_system.search("resname COF").molecules()) # Create the box parameters. box, angles = BSS.Box.cubic(5.5 * BSS.Units.Length.nanometer) # Create the solvated system. - solvated = function(system, box, angles, match_water=match_water) + solvated = function(kigaki_system, box, angles, match_water=match_water) # Search for the crystal waters in the solvated system. try: diff --git a/tests/Stream/test_stream.py b/tests/Stream/test_stream.py index 12f169dec..73788bd7e 100644 --- a/tests/Stream/test_stream.py +++ b/tests/Stream/test_stream.py @@ -4,11 +4,6 @@ import BioSimSpace as BSS -@pytest.fixture -def system(scope="session"): - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.fixture(autouse=True) def run_around_tests(): yield diff --git a/tests/Trajectory/test_trajectory.py b/tests/Trajectory/test_trajectory.py index b03a554ed..08457529d 100644 --- a/tests/Trajectory/test_trajectory.py +++ b/tests/Trajectory/test_trajectory.py @@ -13,12 +13,6 @@ def wrap(arg): from tests.conftest import url, has_mdanalysis, has_mdtraj -@pytest.fixture(scope="session") -def system(): - """A system object with the same topology as the trajectories.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.fixture(scope="session") def traj_sire(system): """A trajectory object using the Sire backend.""" diff --git a/tests/_SireWrappers/test_molecule.py b/tests/_SireWrappers/test_molecule.py index 28b6c28df..8152c038a 100644 --- a/tests/_SireWrappers/test_molecule.py +++ b/tests/_SireWrappers/test_molecule.py @@ -5,11 +5,6 @@ from tests.conftest import url, has_amber -@pytest.fixture(scope="session") -def system(): - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.mark.skipif(has_amber is False, reason="Requires AMBER to be installed.") def test_makeCompatibleWith(): # Load the original PDB file. In this representation the system contains diff --git a/tests/_SireWrappers/test_search_result.py b/tests/_SireWrappers/test_search_result.py index b38cb40c2..55aeda098 100644 --- a/tests/_SireWrappers/test_search_result.py +++ b/tests/_SireWrappers/test_search_result.py @@ -6,12 +6,6 @@ url = BSS.tutorialUrl() -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.fixture(scope="session") def molecule(system): """Re-use the same molecule for each test.""" diff --git a/tests/_SireWrappers/test_system.py b/tests/_SireWrappers/test_system.py index 81ff55a5f..2073f4b26 100644 --- a/tests/_SireWrappers/test_system.py +++ b/tests/_SireWrappers/test_system.py @@ -8,12 +8,6 @@ from tests.conftest import url, has_amber, has_openff -@pytest.fixture(scope="session") -def system(): - """Re-use the same molecuar system for each test.""" - return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) - - @pytest.fixture(scope="session") def rna_system(): """An RNA system for re-use.""" diff --git a/tests/conftest.py b/tests/conftest.py index 2ef298ea0..46be2d10a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ collect_ignore_glob = ["*/out_test*.py"] import os +import pytest from pathlib import Path @@ -55,3 +56,33 @@ # Allow tests to be run from any directory. root_fp = Path(__file__).parent.resolve() + +# Fixtures for tests. + + +@pytest.fixture(scope="session") +def system(): + """Solvated alanine dipeptide system.""" + return BSS.IO.readMolecules(["tests/input/ala.top", "tests/input/ala.crd"]) + + +@pytest.fixture(scope="module") +def perturbable_system(): + """A vacuum perturbable system.""" + 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.fixture(scope="module") +def solvated_perturbable_system(): + """A solvated perturbable system.""" + 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", + ) From 75ab3bd5cc32ce4c46d4a88b83153cfd267b3199 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 10:51:51 +0100 Subject: [PATCH 18/32] Add local test for metadynamics simulations. --- tests/Metadynamics/test_metadynamics.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/Metadynamics/test_metadynamics.py diff --git a/tests/Metadynamics/test_metadynamics.py b/tests/Metadynamics/test_metadynamics.py new file mode 100644 index 000000000..835012659 --- /dev/null +++ b/tests/Metadynamics/test_metadynamics.py @@ -0,0 +1,40 @@ +import pytest +import socket + +import BioSimSpace as BSS + + +@pytest.mark.skipif( + socket.gethostname() != "porridge", + reason="Local test requiring PLUMED patched GROMACS.", +) +def test_metadynamics(system): + # Search for the first molecule containing ALA. + molecule = system.search("resname ALA").molecules()[0] + + # Store the torsion indices. + phi_idx = [4, 6, 8, 14] + psi_idx = [6, 8, 14, 16] + + # Create the collective variables. + phi = BSS.Metadynamics.CollectiveVariable.Torsion(atoms=phi_idx) + psi = BSS.Metadynamics.CollectiveVariable.Torsion(atoms=psi_idx) + + # Create the metadynamics protocol. + protocol = BSS.Protocol.Metadynamics( + collective_variable=[phi, psi], runtime=100 * BSS.Units.Time.picosecond + ) + + # Run the metadynamics simulation. + process = BSS.Metadynamics.run(molecule.toSystem(), protocol, gpu_support=True) + + # Wait for the process to finish. + process.wait() + + # Check if the process has finished successfully. + assert not process.isError() + + free_nrg = process.getFreeEnergy(kt=BSS.Units.Energy.kt) + + # Check if the free energy is not None. + assert free_nrg is not None From 861f5d388949911fdb22c29d4391db384b47073a Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 11:12:55 +0100 Subject: [PATCH 19/32] Use NAMD specific system in tests so we validate parsing too. --- tests/Process/test_namd.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/Process/test_namd.py b/tests/Process/test_namd.py index 990ff1473..7c9eb19b7 100644 --- a/tests/Process/test_namd.py +++ b/tests/Process/test_namd.py @@ -8,21 +8,33 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() +@pytest.fixture(scope="module") +def namd_system(): + """Re-use the same molecuar system for each test.""" + return BSS.IO.readMolecules( + [ + "tests/input/alanin.psf", + f"tests/input/alanin.pdb", + f"tests/input/alanin.params", + ] + ) + + @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") @pytest.mark.parametrize("restraint", restraints) -def test_minimise(system, restraint): +def test_minimise(namd_system, restraint): """Test a minimisation protocol.""" # Create a short minimisation protocol. protocol = BSS.Protocol.Minimisation(steps=100, restraint=restraint) # Run the process, check that it finished without error, and returns a system. - run_process(system, protocol) + run_process(namd_system, protocol) @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") @pytest.mark.parametrize("restraint", restraints) -def test_equilibrate(system, restraint): +def test_equilibrate(namd_system, restraint): """Test an equilibration protocol.""" # Create a short equilibration protocol. @@ -31,11 +43,11 @@ def test_equilibrate(system, restraint): ) # Run the process, check that it finished without error, and returns a system. - run_process(system, protocol) + run_process(namd_system, protocol) @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") -def test_heat(system): +def test_heat(namd_system): """Test a heating protocol.""" # Create a short heating protocol. @@ -46,11 +58,11 @@ def test_heat(system): ) # Run the process, check that it finished without error, and returns a system. - run_process(system, protocol) + run_process(namd_system, protocol) @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") -def test_cool(system): +def test_cool(namd_system): """Test a cooling protocol.""" # Create a short heating protocol. @@ -61,12 +73,12 @@ def test_cool(system): ) # Run the process, check that it finished without error, and returns a system. - run_process(system, protocol) + run_process(namd_system, protocol) @pytest.mark.skipif(has_namd is False, reason="Requires NAMD to be installed.") @pytest.mark.parametrize("restraint", restraints) -def test_production(system, restraint): +def test_production(namd_system, restraint): """Test a production protocol.""" # Create a short production protocol. @@ -75,14 +87,14 @@ def test_production(system, restraint): ) # Run the process, check that it finished without error, and returns a system. - run_process(system, protocol) + run_process(namd_system, protocol) -def run_process(system, protocol): +def run_process(namd_system, protocol): """Helper function to run various simulation protocols.""" # Initialise the NAMD process. - process = BSS.Process.Namd(system, protocol, name="test") + process = BSS.Process.Namd(namd_system, protocol, name="test") # Start the NAMD simulation. process.start() From b36da43656ad0b8f1cc485c33bf1d750d75dc5a9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 11:25:24 +0100 Subject: [PATCH 20/32] Make fixtures module specific. --- tests/Process/test_amber.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Process/test_amber.py b/tests/Process/test_amber.py index 735f2af46..18a23df2a 100644 --- a/tests/Process/test_amber.py +++ b/tests/Process/test_amber.py @@ -13,7 +13,7 @@ restraints = BSS.Protocol._position_restraint_mixin._PositionRestraintMixin.restraints() -@pytest.fixture(scope="session") +@pytest.fixture(scope="module") def rna_system(): """An RNA system for re-use.""" return BSS.IO.readMolecules( @@ -21,7 +21,7 @@ def rna_system(): ) -@pytest.fixture(scope="session") +@pytest.fixture(scope="module") def large_protein_system(): """A large protein system for re-use.""" return BSS.IO.readMolecules( From 631f1a44d1a452349f7c6c651f4bf4bf35ac8cb9 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 12:38:21 +0100 Subject: [PATCH 21/32] Add tests for steered MD and funnel metadynamics. --- tests/Metadynamics/test_metadynamics.py | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/Metadynamics/test_metadynamics.py b/tests/Metadynamics/test_metadynamics.py index 835012659..418c9d88a 100644 --- a/tests/Metadynamics/test_metadynamics.py +++ b/tests/Metadynamics/test_metadynamics.py @@ -38,3 +38,120 @@ def test_metadynamics(system): # Check if the free energy is not None. assert free_nrg is not None + + +@pytest.mark.skipif( + socket.gethostname() != "porridge", + reason="Local test requiring PLUMED patched GROMACS.", +) +def test_steering(system): + # Find the indices of the atoms in the ALA residue. + rmsd_idx = [x.index() for x in system.search("resname ALA").atoms()] + + # Create the collective variable. + cv = BSS.Metadynamics.CollectiveVariable.RMSD(system, system[0], rmsd_idx) + + # Add some stages. + start = 0 * BSS.Units.Time.nanosecond + apply_force = 4 * BSS.Units.Time.picosecond + steer = 50 * BSS.Units.Time.picosecond + relax = 100 * BSS.Units.Time.picosecond + + # Create some restraints. + nm = BSS.Units.Length.nanometer + restraint_1 = BSS.Metadynamics.Restraint(cv.getInitialValue(), 0) + restraint_2 = BSS.Metadynamics.Restraint(cv.getInitialValue(), 3500) + restraint_3 = BSS.Metadynamics.Restraint(0 * nm, 3500) + restraint_4 = BSS.Metadynamics.Restraint(0 * nm, 0) + + # Create the steering protocol. + protocol = BSS.Protocol.Steering( + cv, + [start, apply_force, steer, relax], + [restraint_1, restraint_2, restraint_3, restraint_4], + runtime=100 * BSS.Units.Time.picosecond, + ) + + # Create the steering process. + process = BSS.Process.Gromacs(system, protocol, extra_args={"-ntmpi": 1}) + + # Start the process and wait for it to finish. + process.start() + process.wait() + + # Check if the process has finished successfully. + assert not process.isError() + + +def test_funnel_metadynamics(): + # Load the protein-ligand system. + system = BSS.IO.readMolecules( + BSS.IO.expand(BSS.tutorialUrl(), ["funnel_system.rst7", "funnel_system.prm7"]) + ) + + # Get the p0 and p1 points for defining the funnel. + p0, p1 = BSS.Metadynamics.CollectiveVariable.makeFunnel(system) + + # Expected p0 and p1 points. + expected_p0 = [1017, 1031, 1050, 1186, 1205, 1219, 1238, 2585, 2607, 2623] + expected_p1 = [ + 519, + 534, + 553, + 572, + 583, + 597, + 608, + 619, + 631, + 641, + 1238, + 1254, + 1280, + 1287, + 1306, + 1313, + 1454, + 1473, + 1480, + 1863, + 1879, + 1886, + 1906, + 2081, + 2116, + 2564, + 2571, + 2585, + 2607, + ] + + # Make sure the p0 and p1 points are as expected. + assert p0 == expected_p0 + assert p1 == expected_p1 + + # Set the upper bound for the funnel collective variable. + upper_bound = BSS.Metadynamics.Bound(value=3.5 * BSS.Units.Length.nanometer) + + # Create the funnel collective variable. + cv = BSS.Metadynamics.CollectiveVariable.Funnel(p0, p1, upper_bound=upper_bound) + + # Create the metadynamics protocol. + protocol = BSS.Protocol.Metadynamics( + cv, + runtime=1 * BSS.Units.Time.picosecond, + hill_height=1.5 * BSS.Units.Energy.kj_per_mol, + hill_frequency=500, + restart_interval=1000, + bias_factor=10, + ) + + # Create the metadynamics process. + process = BSS.Process.OpenMM(system, protocol) + + # Start the process and wait for it to finish. + process.start() + process.wait() + + # Check if the process has finished successfully. + assert not process.isError() From 4dcf175bf0d3e6b22e6365eb17df0ffef6974919 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 12:59:02 +0100 Subject: [PATCH 22/32] Revert copilot update to interchange file extensions. --- .../BioSimSpace/Parameters/_Protocol/_openforcefield.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py index f6bc49605..c94e7d349 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Parameters/_Protocol/_openforcefield.py @@ -384,8 +384,8 @@ def run(self, molecule, work_dir=None, queue=None): # Export AMBER format files. try: - interchange.to_prmtop(_os.path.join(str(work_dir), "interchange.prmtop")) - interchange.to_inpcrd(_os.path.join(str(work_dir), "interchange.inpcrd")) + interchange.to_prmtop(_os.path.join(str(work_dir), "interchange.prm7")) + interchange.to_inpcrd(_os.path.join(str(work_dir), "interchange.rst7")) except Exception as e: msg = "Unable to write Interchange object to AMBER format!" if _isVerbose(): @@ -398,8 +398,8 @@ def run(self, molecule, work_dir=None, queue=None): try: par_mol = _IO.readMolecules( [ - _os.path.join(str(work_dir), "interchange.prmtop"), - _os.path.join(str(work_dir), "interchange.inpcrd"), + _os.path.join(str(work_dir), "interchange.prm7"), + _os.path.join(str(work_dir), "interchange.rst7"), ], ) # Extract single molecules. From 2c0b1e6b1f671cbf74d00e98a3269b5f654804bb Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 13:00:36 +0100 Subject: [PATCH 23/32] Revert another copilot typo. --- python/BioSimSpace/Parameters/_Protocol/_amber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/BioSimSpace/Parameters/_Protocol/_amber.py b/python/BioSimSpace/Parameters/_Protocol/_amber.py index 5857da3ff..2ce0b15f9 100644 --- a/python/BioSimSpace/Parameters/_Protocol/_amber.py +++ b/python/BioSimSpace/Parameters/_Protocol/_amber.py @@ -552,7 +552,7 @@ def _run_tleap(self, molecule, work_dir): ) and _os.path.isfile(_os.path.join(str(work_dir), "leap.crd")): # Check the output of tLEaP for missing atoms. if self._ensure_compatible: - if _has_missing_atoms(_os.path.join(str(work_dir), "leap.top")): + if _has_missing_atoms(_os.path.join(str(work_dir), "leap.out")): raise _ParameterisationError( "tLEaP added missing atoms. The topology is now " "inconsistent with the original molecule. Please " From 5bd3545cfc92f9635e2ea72b18f0ebd785b63b56 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 13:55:35 +0100 Subject: [PATCH 24/32] Only run funnel metadynamics test locally. --- tests/Metadynamics/test_metadynamics.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Metadynamics/test_metadynamics.py b/tests/Metadynamics/test_metadynamics.py index 418c9d88a..50af89415 100644 --- a/tests/Metadynamics/test_metadynamics.py +++ b/tests/Metadynamics/test_metadynamics.py @@ -83,6 +83,10 @@ def test_steering(system): assert not process.isError() +@pytest.mark.skipif( + socket.gethostname() != "porridge", + reason="Local test requiring PLUMED patched GROMACS.", +) def test_funnel_metadynamics(): # Load the protein-ligand system. system = BSS.IO.readMolecules( From 1f7a46b3fb800aa0ed2e98c6d32efbb4a2c53b00 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 13:58:11 +0100 Subject: [PATCH 25/32] Port fileformat fix to sandpit. --- .../Sandpit/Exscientia/_SireWrappers/_system.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py index 7dc882868..64426775e 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py @@ -88,12 +88,20 @@ def __init__(self, system): sire_object = _SireSystem.System("BioSimSpace_System.") super().__init__(sire_object) self.addMolecules(_Molecule(system)) + if "fileformat" in system.propertyKeys(): + self._sire_object.setProperty( + "fileformat", system.property("fileformat") + ) # A BioSimSpace Molecule object. elif isinstance(system, _Molecule): sire_object = _SireSystem.System("BioSimSpace_System.") super().__init__(sire_object) self.addMolecules(system) + if "fileformat" in system._sire_object.propertyKeys(): + self._sire_object.setProperty( + "fileformat", system._sire_object.property("fileformat") + ) # A BioSimSpace Molecules object. elif isinstance(system, _Molecules): From c1cb3636cd4f9f6320cc1c916ad959e0cdd686dd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 14:01:56 +0100 Subject: [PATCH 26/32] Update test description. --- tests/Metadynamics/test_metadynamics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Metadynamics/test_metadynamics.py b/tests/Metadynamics/test_metadynamics.py index 50af89415..5266e1c5d 100644 --- a/tests/Metadynamics/test_metadynamics.py +++ b/tests/Metadynamics/test_metadynamics.py @@ -85,7 +85,7 @@ def test_steering(system): @pytest.mark.skipif( socket.gethostname() != "porridge", - reason="Local test requiring PLUMED patched GROMACS.", + reason="Local test requiring PLUMED.", ) def test_funnel_metadynamics(): # Load the protein-ligand system. From a1ec8afe77cf6888022a3926c863708a9537d8b8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 14:45:10 +0100 Subject: [PATCH 27/32] Fix duplicate tests and add missing guards. --- .../Process/test_position_restraint.py | 16 ++++++++++++++-- tests/Sandpit/Exscientia/Protocol/test_config.py | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/Sandpit/Exscientia/Process/test_position_restraint.py b/tests/Sandpit/Exscientia/Process/test_position_restraint.py index f67cb267e..29a7aa35c 100644 --- a/tests/Sandpit/Exscientia/Process/test_position_restraint.py +++ b/tests/Sandpit/Exscientia/Process/test_position_restraint.py @@ -186,11 +186,17 @@ def test_amber(protocol, system, ref_system, tmp_path): assert f"{proc._work_dir}/{proc.getArgs()['-ref']}" == proc._ref_file +@pytest.mark.skipif( + has_gromacs is False or has_openff is False, + reason="Requires GROMACS and openff to be installed", +) @pytest.mark.parametrize( "restraint", ["backbone", "heavy", "all", "none"], ) -def test_gromacs(alchemical_ion_system, restraint, alchemical_ion_system_psores): +def test_gromacs_alchemical_ion( + alchemical_ion_system, restraint, alchemical_ion_system_psores +): protocol = BSS.Protocol.FreeEnergy(restraint=restraint) process = BSS.Process.Gromacs( alchemical_ion_system, @@ -229,6 +235,10 @@ def test_gromacs(alchemical_ion_system, restraint, alchemical_ion_system_psores) assert gro[2].split() == ["1ACE", "HH31", "1", "0.000", "0.000", "0.000"] +@pytest.mark.skipif( + has_amber is False or has_openff is False, + reason="Requires AMBER and openff to be installed", +) @pytest.mark.parametrize( ("restraint", "target"), [ @@ -238,7 +248,9 @@ def test_gromacs(alchemical_ion_system, restraint, alchemical_ion_system_psores) ("none", "@2148 | @8"), ], ) -def test_amber(alchemical_ion_system, restraint, target, alchemical_ion_system_psores): +def test_amber_alchemical_ion( + alchemical_ion_system, restraint, target, alchemical_ion_system_psores +): # Create an equilibration protocol with backbone restraints. protocol = BSS.Protocol.Equilibration(restraint=restraint) diff --git a/tests/Sandpit/Exscientia/Protocol/test_config.py b/tests/Sandpit/Exscientia/Protocol/test_config.py index 1924e919c..32446dcda 100644 --- a/tests/Sandpit/Exscientia/Protocol/test_config.py +++ b/tests/Sandpit/Exscientia/Protocol/test_config.py @@ -312,6 +312,9 @@ def test_decouple_vdw_q(self, system): assert "couple-lambda1 = none" in mdp_text assert "couple-intramol = yes" in mdp_text + @pytest.mark.skipif( + has_gromacs is False, reason="Requires GROMACS to be installed." + ) def test_decouple_perturbable(self, system): m, protocol = system mol = decouple(m) From d8d5740190b0642b644e50a54ce7253a28a37bf8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 15:00:51 +0100 Subject: [PATCH 28/32] Remove references to redundant "future" branch. [ci skip] --- doc/source/contributing/packaging.rst | 28 +++------------------------ doc/source/contributing/roadmap.rst | 2 -- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/doc/source/contributing/packaging.rst b/doc/source/contributing/packaging.rst index b98bbbf06..5c1425fde 100644 --- a/doc/source/contributing/packaging.rst +++ b/doc/source/contributing/packaging.rst @@ -4,12 +4,11 @@ Development process =================== -:mod:`BioSimSpace` uses a ``main``, ``devel`` and ``future`` development process, +:mod:`BioSimSpace` uses a ``main`` and ``devel`` development process, using feature branches for all code development. * ``main`` - this always contains the latest official release. * ``devel`` - this always contains the latest development release, which will become the next official release. -* ``future`` - this contains pull requests that have been accepted, but which are targetted for a future release (i.e. not the next official release) Code should be developed on a fork or in a feature branch called ``feature_{feature}``. When your feature is ready, please submit a pull request against ``devel``. This @@ -27,29 +26,8 @@ tests, examples and/or tutorial instructions. the tutorials or writing a detailed description for the website. Assuming the CI completes successfully, then one of the release team will -conduct a code review. The outcome of the review will be one of the following; - -1. This feature is ready, and should be part of the next official release. The pull request - will be accepted into ``devel``. This will trigger our CI/CD process, building the new dev - package and uploading it to `anaconda.org `__ - for everyone to use. - -2. This feature is good, but it is not yet ready to be part of the next offical release. This - could be because the feature is part of a series, and all of the series need to be finished - before release. Or because we are in a feature freeze period. Or because you want more time - for people to explore and play with the feature before it is officially released (and would - then need to be supported, and backwards compatibility maintained). If this is the case (or - it is your request) then the pull request will be redirected into the ``future`` branch. - Once it (and features that depend on it) are ready, you can then issue a pull request for - all of the features at once into ``devel``. It will be noted that each of the individual - parts have already been code reviewed, so the process to accept the combination - into ``devel`` should be more straightforward. - -3. This feature is good, but more work is needed before it can be accepted. This could be - because some of the unit tests haven't passed, or the latest version of ``devel`` hasn't - been merged. Or there may be changes that are requested that would make the code easier - to maintain or to preserve backwards compatibility. If this is the case, then we - will engage in conversation with you and will work together to rectify any issues. +conduct a code review, with the code being merged into ``devel`` if it is +approved. Bug fixes or issue fixes are developed on fix branches, called ``fix_{number}`` (again in either the main repository or forks). If no `issue thread `__ diff --git a/doc/source/contributing/roadmap.rst b/doc/source/contributing/roadmap.rst index 808af2c3e..5a13a2b17 100644 --- a/doc/source/contributing/roadmap.rst +++ b/doc/source/contributing/roadmap.rst @@ -76,8 +76,6 @@ You can keep up with what we are working on in several ways; * Keep an eye on the various ``feature_X`` branches as they appear in the repository. Feel free to initiate a conversation on GitHub with the developer who is working on that branch if you want to learn more, or want to make suggestions or offer a helping hand. -* Clone and build your own copy of the ``future`` branch. This is the bleeding edge, and things may change and break. - But it is the earliest way to use the future version of :mod:`BioSimSpace`. Wishlists / suggestions ======================= From cfb428489886b3b65292e32facc5845b14714918 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 12 Apr 2024 15:26:48 +0100 Subject: [PATCH 29/32] Remove references to mamba since libmamba is now default solver. [ci skip] --- README.rst | 18 ++++++------- doc/source/install.rst | 58 +++++++++++++++--------------------------- 2 files changed, 30 insertions(+), 46 deletions(-) diff --git a/README.rst b/README.rst index 562af1769..b1753915e 100644 --- a/README.rst +++ b/README.rst @@ -63,35 +63,35 @@ Conda package The easiest way to install BioSimSpace is using our `conda channel `__. BioSimSpace is built using dependencies from `conda-forge `__, so please ensure that the channel takes strict priority. We recommend using -`Mambaforge `__. +`Miniforge `__. To create a new environment: .. code-block:: bash - mamba create -n openbiosim -c conda-forge -c openbiosim biosimspace - mamba activate openbiosim + conda create -n openbiosim -c conda-forge -c openbiosim biosimspace + conda activate openbiosim To install the latest development version you can use: .. code-block:: bash - mamba create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace - mamba activate openbiosim-dev + conda create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace + conda activate openbiosim-dev When updating the development version it is generally advised to update `Sire `_ at the same time: .. code-block:: bash - mamba update -c conda-forge -c openbiosim/label/dev biosimspace sire + conda update -c conda-forge -c openbiosim/label/dev biosimspace sire Unless you add the required channels to your Conda configuration, then you'll need to add them when updating, e.g., for the development package: .. code-block:: bash - mamba update -c conda-forge -c openbiosim/label/dev biosimspace + conda update -c conda-forge -c openbiosim/label/dev biosimspace Installing from source ^^^^^^^^^^^^^^^^^^^^^^ @@ -146,8 +146,8 @@ latest development code into that. .. code-block:: bash - mamba create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace --only-deps - mamba activate openbiosim-dev + conda create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace --only-deps + conda activate openbiosim-dev git clone https://github.com/openbiosim/biosimspace cd biosimspace/python BSS_SKIP_DEPENDENCIES=1 python setup.py develop diff --git a/doc/source/install.rst b/doc/source/install.rst index 87a91a704..728491e61 100644 --- a/doc/source/install.rst +++ b/doc/source/install.rst @@ -46,27 +46,25 @@ The easiest way to install :mod:`BioSimSpace` is in a new `conda environment `__. You can use any conda environment or installation. We recommend using -`mambaforge `__, -as this is pre-configured to use `conda-forge `__, -and bundles `mamba `__, which -is a fast drop-in replacement for `conda `__. +`Miniforge `__, +as this is pre-configured to use `conda-forge `__. -.. _Install_Mambaforge: -Either... Install a new copy of ``mambaforge`` +.. _Install_Miniforge: +Either... Install a new copy of ``Miniforge`` ---------------------------------------------- To install a new copy of -`mambaforge `__, -first download a ``Mambaforge`` from -`this page `__ that +`Miniforge `__, +first download a ``Miniforge`` from +`this page `__ that matches your operating system and processor. -Install ``Mambaforge`` following the +Install ``Miniforge`` following the `instructions here `__. -Once installed, you should be able to run the ``mamba`` command to -install other packages (e.g. ``mamba -h`` will print out help on -how to use the ``mamba`` command). +Once installed, you should be able to run the ``conda`` command to +install other packages (e.g. ``conda -h`` will print out help on +how to use the ``conda`` command). Or... Use an existing anaconda/miniconda install ------------------------------------------------ @@ -80,21 +78,7 @@ the full path to your anaconda or miniconda installation. You should now be able to run the ``conda`` command to install other packages (e.g. ``conda -h`` will print out help on how to use the -``conda`` command). We highly recommend that you use ``mamba`` as a -drop-in replacement for ``conda``, so first install ``mamba``. - -.. code-block:: bash - - $ conda install -c conda-forge mamba - -This should install mamba. If this fails, then your anaconda or miniconda -environment is likely quite full, or else it is outdated. We recommend -going back and following `the instructions <_Install_Mambaforge>` -to install a new copy of ``mambaforge``. - -If this works, then you should now be able to run the ``mamba`` command -to install other packages (e.g. ``mamba -h`` will print out help -on how to use the ``mamba`` command). +``conda`` command). And then... Install BioSimSpace into a new environment ------------------------------------------------------ @@ -107,7 +91,7 @@ by creating a Python 3.9 environment that we will call ``openbiosim``. .. code-block:: bash - $ mamba create -n openbiosim "python<3.10" + $ conda create -n openbiosim "python<3.10" .. note:: @@ -118,27 +102,27 @@ We can now install :mod:`BioSimSpace` into that environment by typing .. code-block:: bash - $ mamba install -n openbiosim -c openbiosim biosimspace + $ conda install -n openbiosim -c openbiosim biosimspace .. note:: - The option ``-n openbiosim`` tells ``mamba`` to install :mod:`BioSimSpace` + The option ``-n openbiosim`` tells ``conda`` to install :mod:`BioSimSpace` into the ``openbiosim`` environment. The option ``-c openbiosim`` - tells ``mamba`` to install :mod:`BioSimSpace` from the ``openbiosim`` + tells ``conda`` to install :mod:`BioSimSpace` from the ``openbiosim`` conda channel. If you want the latest development release, then install by typing .. code-block:: bash - $ mamba install -n openbiosim -c "openbiosim/label/dev" biosimspace + $ conda install -n openbiosim -c "openbiosim/label/dev" biosimspace To install the latest development version you can use: .. code-block:: bash - mamba create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace - mamba activate openbiosim-dev + conda create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace + conda activate openbiosim-dev To run :mod:`BioSimSpace`, you must now activate the ``openbiosim`` environment. You can do this by typing @@ -268,8 +252,8 @@ latest development code into that. .. code-block:: bash - mamba create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace --only-deps - mamba activate openbiosim-dev + conda create -n openbiosim-dev -c conda-forge -c openbiosim/label/dev biosimspace --only-deps + conda activate openbiosim-dev git clone https://github.com/openbiosim/biosimspace cd biosimspace/python BSS_SKIP_DEPENDENCIES=1 python setup.py develop From fc3897db518cca00ec06c8ab455f57a14158a8f0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Apr 2024 08:35:58 +0100 Subject: [PATCH 30/32] Synchronise files untouched by Exs with core. --- .../Exscientia/Parameters/_Protocol/_amber.py | 94 +++++++++---------- .../Parameters/_Protocol/_openforcefield.py | 26 ++--- .../Exscientia/Trajectory/_trajectory.py | 24 +++-- 3 files changed, 77 insertions(+), 67 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py index 69816c1e4..2ce0b15f9 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_amber.py @@ -316,9 +316,6 @@ def run(self, molecule, work_dir=None, queue=None): else: is_smiles = False - # Create the file prefix. - prefix = work_dir + "/" - if not is_smiles: # Create a copy of the molecule. new_mol = molecule.copy() @@ -352,7 +349,10 @@ def run(self, molecule, work_dir=None, queue=None): ) # Prepend the working directory to the output file names. - output = [prefix + output[0], prefix + output[1]] + output = [ + _os.path.join(str(work_dir), output[0]), + _os.path.join(str(work_dir), output[1]), + ] try: # Load the parameterised molecule. (This could be a system of molecules.) @@ -443,9 +443,6 @@ def _run_tleap(self, molecule, work_dir): else: _molecule = molecule - # Create the file prefix. - prefix = work_dir + "/" - # Write the system to a PDB file. try: # LEaP expects residue numbering to be ascending and continuous. @@ -454,7 +451,7 @@ def _run_tleap(self, molecule, work_dir): )[0] renumbered_molecule = _Molecule(renumbered_molecule) _IO.saveMolecules( - prefix + "leap", + _os.path.join(str(work_dir), "leap"), renumbered_molecule, "pdb", property_map=self._property_map, @@ -500,7 +497,7 @@ def _run_tleap(self, molecule, work_dir): pruned_bond_records.append(bond) # Write the LEaP input file. - with open(prefix + "leap.txt", "w") as file: + with open(_os.path.join(str(work_dir), "leap.txt"), "w") as file: file.write("source %s\n" % ff) if self._water_model is not None: if self._water_model in ["tip4p", "tip5p"]: @@ -528,14 +525,14 @@ def _run_tleap(self, molecule, work_dir): # Generate the tLEaP command. command = "%s -f leap.txt" % _tleap_exe - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# tLEaP was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "leap.out", "w") - stderr = open(prefix + "leap.err", "w") + stdout = open(_os.path.join(str(work_dir), "leap.out"), "w") + stderr = open(_os.path.join(str(work_dir), "leap.err"), "w") # Run tLEaP as a subprocess. proc = _subprocess.run( @@ -550,12 +547,12 @@ def _run_tleap(self, molecule, work_dir): # tLEaP doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "leap.top") and _os.path.isfile( - prefix + "leap.crd" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "leap.top") + ) and _os.path.isfile(_os.path.join(str(work_dir), "leap.crd")): # Check the output of tLEaP for missing atoms. if self._ensure_compatible: - if _has_missing_atoms(prefix + "leap.out"): + if _has_missing_atoms(_os.path.join(str(work_dir), "leap.out")): raise _ParameterisationError( "tLEaP added missing atoms. The topology is now " "inconsistent with the original molecule. Please " @@ -604,13 +601,13 @@ def _run_pdb2gmx(self, molecule, work_dir): else: _molecule = molecule - # Create the file prefix. - prefix = work_dir + "/" - # Write the system to a PDB file. try: _IO.saveMolecules( - prefix + "leap", _molecule, "pdb", property_map=self._property_map + _os.path.join(str(work_dir), "input"), + _molecule, + "pdb", + property_map=self._property_map, ) except Exception as e: msg = "Failed to write system to 'PDB' format." @@ -626,14 +623,14 @@ def _run_pdb2gmx(self, molecule, work_dir): % (_gmx_exe, supported_ff[self._forcefield]) ) - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# pdb2gmx was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "pdb2gmx.out", "w") - stderr = open(prefix + "pdb2gmx.err", "w") + stdout = open(_os.path.join(str(work_dir), "pdb2gmx.out"), "w") + stderr = open(_os.path.join(str(work_dir), "pdb2gmx.err"), "w") # Run pdb2gmx as a subprocess. proc = _subprocess.run( @@ -647,9 +644,9 @@ def _run_pdb2gmx(self, molecule, work_dir): stderr.close() # Check for the expected output. - if _os.path.isfile(prefix + "output.gro") and _os.path.isfile( - prefix + "output.top" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "output.gro") + ) and _os.path.isfile(_os.path.join(str(work_dir), "output.top")): return ["output.gro", "output.top"] else: raise _ParameterisationError("pdb2gmx failed!") @@ -1010,9 +1007,6 @@ def run(self, molecule, work_dir=None, queue=None): if work_dir is None: work_dir = _os.getcwd() - # Create the file prefix. - prefix = work_dir + "/" - # Convert SMILES to a molecule. if isinstance(molecule, str): is_smiles = True @@ -1092,7 +1086,10 @@ def run(self, molecule, work_dir=None, queue=None): # Write the system to a PDB file. try: _IO.saveMolecules( - prefix + "antechamber", new_mol, "pdb", property_map=self._property_map + _os.path.join(str(work_dir), "antechamber"), + new_mol, + "pdb", + property_map=self._property_map, ) except Exception as e: msg = "Failed to write system to 'PDB' format." @@ -1108,14 +1105,14 @@ def run(self, molecule, work_dir=None, queue=None): + "-o antechamber.mol2 -fo mol2 -c %s -s 2 -nc %d" ) % (_antechamber_exe, self._version, self._charge_method.lower(), charge) - with open(prefix + "README.txt", "w") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "w") as file: # Write the command to file. file.write("# Antechamber was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "antechamber.out", "w") - stderr = open(prefix + "antechamber.err", "w") + stdout = open(_os.path.join(str(work_dir), "antechamber.out"), "w") + stderr = open(_os.path.join(str(work_dir), "antechamber.err"), "w") # Run Antechamber as a subprocess. proc = _subprocess.run( @@ -1130,20 +1127,20 @@ def run(self, molecule, work_dir=None, queue=None): # Antechamber doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "antechamber.mol2"): + if _os.path.isfile(_os.path.join(str(work_dir), "antechamber.mol2")): # Run parmchk to check for missing parameters. command = ( "%s -s %d -i antechamber.mol2 -f mol2 " + "-o antechamber.frcmod" ) % (_parmchk_exe, self._version) - with open(prefix + "README.txt", "a") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "a") as file: # Write the command to file. file.write("\n# ParmChk was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "parmchk.out", "w") - stderr = open(prefix + "parmchk.err", "w") + stdout = open(_os.path.join(str(work_dir), "parmchk.out"), "w") + stderr = open(_os.path.join(str(work_dir), "parmchk.err"), "w") # Run parmchk as a subprocess. proc = _subprocess.run( @@ -1157,7 +1154,7 @@ def run(self, molecule, work_dir=None, queue=None): stderr.close() # The frcmod file was created. - if _os.path.isfile(prefix + "antechamber.frcmod"): + if _os.path.isfile(_os.path.join(str(work_dir), "antechamber.frcmod")): # Now call tLEaP using the partially parameterised molecule and the frcmod file. # tLEap will run in the same working directory, using the Mol2 file generated by # Antechamber. @@ -1169,7 +1166,7 @@ def run(self, molecule, work_dir=None, queue=None): ff = _find_force_field("gaff2") # Write the LEaP input file. - with open(prefix + "leap.txt", "w") as file: + with open(_os.path.join(str(work_dir), "leap.txt"), "w") as file: file.write("source %s\n" % ff) file.write("mol = loadMol2 antechamber.mol2\n") file.write("loadAmberParams antechamber.frcmod\n") @@ -1179,14 +1176,14 @@ def run(self, molecule, work_dir=None, queue=None): # Generate the tLEaP command. command = "%s -f leap.txt" % _tleap_exe - with open(prefix + "README.txt", "a") as file: + with open(_os.path.join(str(work_dir), "README.txt"), "a") as file: # Write the command to file. file.write("\n# tLEaP was run with the following command:\n") file.write("%s\n" % command) # Create files for stdout/stderr. - stdout = open(prefix + "leap.out", "w") - stderr = open(prefix + "leap.err", "w") + stdout = open(_os.path.join(str(work_dir), "leap.out"), "w") + stderr = open(_os.path.join(str(work_dir), "leap.err"), "w") # Run tLEaP as a subprocess. proc = _subprocess.run( @@ -1201,12 +1198,12 @@ def run(self, molecule, work_dir=None, queue=None): # tLEaP doesn't return sensible error codes, so we need to check that # the expected output was generated. - if _os.path.isfile(prefix + "leap.top") and _os.path.isfile( - prefix + "leap.crd" - ): + if _os.path.isfile( + _os.path.join(str(work_dir), "leap.top") + ) and _os.path.isfile(_os.path.join(str(work_dir), "leap.crd")): # Check the output of tLEaP for missing atoms. if self._ensure_compatible: - if _has_missing_atoms(prefix + "leap.out"): + if _has_missing_atoms(_os.path.join(str(work_dir), "leap.out")): raise _ParameterisationError( "tLEaP added missing atoms. The topology is now " "inconsistent with the original molecule. Please " @@ -1217,7 +1214,10 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "leap.top", prefix + "leap.crd"] + [ + _os.path.join(str(work_dir), "leap.top"), + _os.path.join(str(work_dir), "leap.crd"), + ], ) # Extract single molecules. if par_mol.nMolecules() == 1: diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py index 018f43e4e..c94e7d349 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_Protocol/_openforcefield.py @@ -214,9 +214,6 @@ def run(self, molecule, work_dir=None, queue=None): if work_dir is None: work_dir = _os.getcwd() - # Create the file prefix. - prefix = work_dir + "/" - # Flag whether the molecule is a SMILES string. if isinstance(molecule, str): is_smiles = True @@ -256,7 +253,7 @@ def run(self, molecule, work_dir=None, queue=None): # Write the molecule to SDF format. try: _IO.saveMolecules( - prefix + "molecule", + _os.path.join(str(work_dir), "molecule"), molecule, "sdf", property_map=self._property_map, @@ -275,7 +272,7 @@ def run(self, molecule, work_dir=None, queue=None): # Write the molecule to a PDB file. try: _IO.saveMolecules( - prefix + "molecule", + _os.path.join(str(work_dir), "molecule"), molecule, "pdb", property_map=self._property_map, @@ -291,7 +288,7 @@ def run(self, molecule, work_dir=None, queue=None): # Create an RDKit molecule from the PDB file. try: rdmol = _Chem.MolFromPDBFile( - prefix + "molecule.pdb", removeHs=False + _os.path.join(str(work_dir), "molecule.pdb"), removeHs=False ) except Exception as e: msg = "RDKit was unable to read the molecular PDB file!" @@ -303,7 +300,9 @@ def run(self, molecule, work_dir=None, queue=None): # Use RDKit to write back to SDF format. try: - writer = _Chem.SDWriter(prefix + "molecule.sdf") + writer = _Chem.SDWriter( + _os.path.join(str(work_dir), "molecule.sdf") + ) writer.write(rdmol) writer.close() except Exception as e: @@ -317,7 +316,9 @@ def run(self, molecule, work_dir=None, queue=None): # Create the Open Forcefield Molecule from the intermediate SDF file, # as recommended by @j-wags and @mattwthompson. try: - off_molecule = _OpenFFMolecule.from_file(prefix + "molecule.sdf") + off_molecule = _OpenFFMolecule.from_file( + _os.path.join(str(work_dir), "molecule.sdf") + ) except Exception as e: msg = "Unable to create OpenFF Molecule!" if _isVerbose(): @@ -383,8 +384,8 @@ def run(self, molecule, work_dir=None, queue=None): # Export AMBER format files. try: - interchange.to_prmtop(prefix + "interchange.prm7") - interchange.to_inpcrd(prefix + "interchange.rst7") + interchange.to_prmtop(_os.path.join(str(work_dir), "interchange.prm7")) + interchange.to_inpcrd(_os.path.join(str(work_dir), "interchange.rst7")) except Exception as e: msg = "Unable to write Interchange object to AMBER format!" if _isVerbose(): @@ -396,7 +397,10 @@ def run(self, molecule, work_dir=None, queue=None): # Load the parameterised molecule. (This could be a system of molecules.) try: par_mol = _IO.readMolecules( - [prefix + "interchange.prm7", prefix + "interchange.rst7"] + [ + _os.path.join(str(work_dir), "interchange.prm7"), + _os.path.join(str(work_dir), "interchange.rst7"), + ], ) # Extract single molecules. if par_mol.nMolecules() == 1: diff --git a/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py b/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py index 7b5f9d45e..1b7ac59ed 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Trajectory/_trajectory.py @@ -157,7 +157,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): errors = [] is_sire = False is_mdanalysis = False - pdb_file = work_dir + f"/{str(_uuid.uuid4())}.pdb" + pdb_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.pdb") try: frame = _sire_load( [trajectory, topology], @@ -169,7 +169,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): except Exception as e: errors.append(f"Sire: {str(e)}") try: - frame_file = work_dir + f"/{str(_uuid.uuid4())}.rst7" + frame_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.rst7") frame = _mdtraj.load_frame(trajectory, index, top=topology) frame.save(frame_file, force_overwrite=True) frame.save(pdb_file, force_overwrite=True) @@ -178,7 +178,7 @@ def getFrame(trajectory, topology, index, system=None, property_map={}): errors.append(f"MDTraj: {str(e)}") # Try to load the frame with MDAnalysis. try: - frame_file = work_dir + f"/{str(_uuid.uuid4())}.gro" + frame_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.gro") universe = _mdanalysis.Universe(topology, trajectory) universe.trajectory.trajectory[index] with _warnings.catch_warnings(): @@ -615,7 +615,9 @@ def getTrajectory(self, format="auto"): # If this is a PRM7 file, copy to PARM7. if extension == ".prm7": # Set the path to the temporary topology file. - top_file = self._work_dir + f"/{str(_uuid.uuid4())}.parm7" + top_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.parm7" + ) # Copy the topology to a file with the correct extension. _shutil.copyfile(self._top_file, top_file) @@ -761,16 +763,20 @@ def getFrames(self, indices=None): # Write the current frame to file. - pdb_file = self._work_dir + f"/{str(_uuid.uuid4())}.pdb" + pdb_file = _os.path.join(str(self._work_dir), f"{str(_uuid.uuid4())}.pdb") if self._backend == "SIRE": frame = self._trajectory[x] elif self._backend == "MDTRAJ": - frame_file = self._work_dir + f"/{str(_uuid.uuid4())}.rst7" + frame_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.rst7" + ) self._trajectory[x].save(frame_file, force_overwrite=True) self._trajectory[x].save(pdb_file, force_overwrite=True) elif self._backend == "MDANALYSIS": - frame_file = self._work_dir + f"/{str(_uuid.uuid4())}.gro" + frame_file = _os.path.join( + str(self._work_dir), f"{str(_uuid.uuid4())}.gro" + ) self._trajectory.trajectory[x] with _warnings.catch_warnings(): _warnings.simplefilter("ignore") @@ -1110,8 +1116,8 @@ def _split_molecules(frame, pdb, reference, work_dir, property_map={}): formats = reference.fileFormat() # Write the frame coordinates/velocities to file. - coord_file = work_dir + f"/{str(_uuid.uuid4())}.coords" - top_file = work_dir + f"/{str(_uuid.uuid4())}.top" + coord_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.coords") + top_file = _os.path.join(str(work_dir), f"{str(_uuid.uuid4())}.top") frame.writeToFile(coord_file) # Whether we've parsed as a PDB file. From 958eaf63d27cf2e390559620c96cb087c07039da Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Apr 2024 09:27:17 +0100 Subject: [PATCH 31/32] Add missing skipif decorator. --- tests/Sandpit/Exscientia/Align/test_alchemical_ion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py b/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py index 5caee6425..a3320e5da 100644 --- a/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py +++ b/tests/Sandpit/Exscientia/Align/test_alchemical_ion.py @@ -54,6 +54,7 @@ def test_getAlchemicalIonIdx(alchemical_ion_system): assert index == 680 +@pytest.mark.skipif(has_gromacs is False, reason="Requires GROMACS to be installed.") def test_get_protein_com_idx(alchemical_ion_system): index = _get_protein_com_idx(alchemical_ion_system) assert index == 8 From d79961e71b7656cdc5900a56ec29045196bad277 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 15 Apr 2024 10:58:54 +0100 Subject: [PATCH 32/32] Guard test against missing GROMACS package. --- tests/Sandpit/Exscientia/Process/test_position_restraint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Sandpit/Exscientia/Process/test_position_restraint.py b/tests/Sandpit/Exscientia/Process/test_position_restraint.py index 29a7aa35c..b82ec3c1f 100644 --- a/tests/Sandpit/Exscientia/Process/test_position_restraint.py +++ b/tests/Sandpit/Exscientia/Process/test_position_restraint.py @@ -236,8 +236,8 @@ def test_gromacs_alchemical_ion( @pytest.mark.skipif( - has_amber is False or has_openff is False, - reason="Requires AMBER and openff to be installed", + has_amber is False or has_gromacs is False or has_openff is False, + reason="Requires AMBER, GROMACS and OpenFF to be installed", ) @pytest.mark.parametrize( ("restraint", "target"),