From f56b6bc144368a2b1535325d7b92a8b3f8d6a9fd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 14 Mar 2025 10:10:05 +0000 Subject: [PATCH 01/19] Add support for ff19SB. --- python/BioSimSpace/Parameters/_parameters.py | 6 ++---- .../Sandpit/Exscientia/Parameters/_parameters.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/python/BioSimSpace/Parameters/_parameters.py b/python/BioSimSpace/Parameters/_parameters.py index 9e2bc5d8..39092c55 100644 --- a/python/BioSimSpace/Parameters/_parameters.py +++ b/python/BioSimSpace/Parameters/_parameters.py @@ -37,16 +37,14 @@ # A dictionary mapping AMBER protein force field names to their pdb2gmx # compatibility. Note that the names specified below will be used for the -# parameterisation functions, so they should be suitably formatted. Once we -# have CMAP support we should be able to determine the available force fields -# by scanning the AmberTools installation directory, as we do for those from -# OpenFF. +# parameterisation functions, so they should be suitably formatted. _amber_protein_forcefields = { "ff03": True, "ff99": True, "ff99SB": False, "ff99SBildn": False, "ff14SB": False, + "ff19SB": False, } from .. import _amber_home, _gmx_exe, _gmx_path, _isVerbose diff --git a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_parameters.py b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_parameters.py index 9e2bc5d8..39092c55 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Parameters/_parameters.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Parameters/_parameters.py @@ -37,16 +37,14 @@ # A dictionary mapping AMBER protein force field names to their pdb2gmx # compatibility. Note that the names specified below will be used for the -# parameterisation functions, so they should be suitably formatted. Once we -# have CMAP support we should be able to determine the available force fields -# by scanning the AmberTools installation directory, as we do for those from -# OpenFF. +# parameterisation functions, so they should be suitably formatted. _amber_protein_forcefields = { "ff03": True, "ff99": True, "ff99SB": False, "ff99SBildn": False, "ff14SB": False, + "ff19SB": False, } from .. import _amber_home, _gmx_exe, _gmx_path, _isVerbose From b1cb6ba3b43756339437dd6c712bf3f0c1f8c416 Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 21 Mar 2025 14:50:17 +0000 Subject: [PATCH 02/19] Fix for issue #397, also adds utility function to get name of node directory --- python/BioSimSpace/Node/_node.py | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/python/BioSimSpace/Node/_node.py b/python/BioSimSpace/Node/_node.py index 2e047df2..d19d8476 100644 --- a/python/BioSimSpace/Node/_node.py +++ b/python/BioSimSpace/Node/_node.py @@ -36,7 +36,7 @@ # Set the default node directory. _node_dir = _os.path.dirname(__file__) + "/_nodes" -__all__ = ["list", "help", "run", "setNodeDirectory"] +__all__ = ["list", "help", "run", "setNodeDirectory", "getNodeDirectory"] def list(): @@ -123,20 +123,36 @@ def run(name, args={}): if not _os.path.isfile(full_name + ".py"): raise ValueError( "Cannot find node: '%s'. " % name + + "in directory '%s'. " % _node_dir + "Run 'Node.list()' to see available nodes!" ) else: full_name += ".py" + # get pid so that we can associate a unique directory with this run + import uuid + + input_unique_id = str(uuid.uuid4()) + input_name = f"input_{input_unique_id}.yaml" + + # Special case argument for file_prefix, allows users to associate + # a unique identifier with the output files. + if "file_prefix" in args: + output_unique_id = args["file_prefix"] + out_name = f"{output_unique_id}.yaml" + else: + out_name = "output.yaml" + # Write a YAML configuration file for the BioSimSpace node. if len(args) > 0: - with open("input.yaml", "w") as file: + with open(input_name, "w") as file: _yaml.dump(args, file, default_flow_style=False) # Create the command. - command = "%s/python %s --config input.yaml" % ( + command = "%s/python %s --config %s" % ( _SireBase.getBinDir(), full_name, + input_name, ) # No arguments. @@ -150,13 +166,11 @@ def run(name, args={}): if proc.returncode == 0: # Read the output YAML file into a dictionary. - with open("output.yaml", "r") as file: + with open(out_name, "r") as file: output = _yaml.safe_load(file) - # Delete the redundant YAML files. - _os.remove("input.yaml") - _os.remove("output.yaml") - + _os.remove(input_name) + _os.remove(out_name) return output else: @@ -180,3 +194,16 @@ def setNodeDirectory(dir): global _node_dir _node_dir = dir + + +def getNodeDirectory(): + """ + Get the directory of the node library. + + Returns + ------- + + dir : str + The path to the node library. + """ + return _node_dir From d32e415197163cc3320a8c002e61a7807f23beaf Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Tue, 27 May 2025 09:52:10 +0100 Subject: [PATCH 03/19] Allow pert file without modifications to dummy bonded terms. --- python/BioSimSpace/Process/_somd.py | 131 ++++++++++++++++------------ 1 file changed, 75 insertions(+), 56 deletions(-) diff --git a/python/BioSimSpace/Process/_somd.py b/python/BioSimSpace/Process/_somd.py index cababe0f..b606042b 100644 --- a/python/BioSimSpace/Process/_somd.py +++ b/python/BioSimSpace/Process/_somd.py @@ -238,6 +238,9 @@ def __init__( self._zero_dummy_impropers = kwargs.get("zero_dummy_impropers", False) if not isinstance(self._zero_dummy_impropers, bool): self._zero_dummy_impropers = False + self._no_dummy_modifications = kwargs.get("no_dummy_modifications", False) + if not isinstance(self._no_dummy_modifications, bool): + self._no_dummy_modifications = False # The names of the input files. self._rst_file = _os.path.join(str(self._work_dir), f"{name}.rst7") @@ -340,6 +343,7 @@ def _setup(self): zero_dummy_impropers=self._zero_dummy_impropers, property_map=self._property_map, perturbation_type=self._protocol.getPerturbationType(), + no_dummy_modifications=self._no_dummy_modifications, ) self._input_files.append(self._pert_file) @@ -922,6 +926,7 @@ def _to_pert_file( print_all_atoms=False, property_map={}, perturbation_type="full", + no_dummy_modifications=False, ): """ Write a perturbation file for a perturbable molecule. @@ -961,6 +966,10 @@ def _to_pert_file( "grow_soft" : Perturb all growing soft atom LJ terms (i.e. 0.0->value). "charge_soft" : Perturb all charging soft atom LJ terms (i.e. 0.0->value). + no_dummy_modifications : bool + Whether to skip modifications to dummy atoms. This is useful when + modifications to dummy atoms have already been applied. + Returns ------- @@ -997,6 +1006,9 @@ def _to_pert_file( if not isinstance(perturbation_type, str): raise TypeError("'perturbation_type' must be of type 'str'") + if not isinstance(no_dummy_modifications, bool): + raise TypeError("'no_dummy_modifications' must be of type 'bool'") + # Convert to lower case and strip whitespace. perturbation_type = perturbation_type.lower().replace(" ", "") @@ -1973,19 +1985,22 @@ def sort_angles(angles, idx): initial_dummy = _has_dummy(mol, [idx0, idx1, idx2]) final_dummy = _has_dummy(mol, [idx0, idx1, idx2], True) - # Set the angle parameters of the dummy state to those of the non-dummy end state. - if initial_dummy and final_dummy: - has_dummy = True - amber_angle0 = _SireMM.AmberAngle() - amber_angle1 = _SireMM.AmberAngle() - elif initial_dummy or final_dummy: - has_dummy = True - if initial_dummy: - amber_angle0 = amber_angle1 + # Modifications to dummy states. + if not no_dummy_modifications: + # Set the angle parameters of the dummy state to those of + # the non-dummy end state. + if initial_dummy and final_dummy: + has_dummy = True + amber_angle0 = _SireMM.AmberAngle() + amber_angle1 = _SireMM.AmberAngle() + elif initial_dummy or final_dummy: + has_dummy = True + if initial_dummy: + amber_angle0 = amber_angle1 + else: + amber_angle1 = amber_angle0 else: - amber_angle1 = amber_angle0 - else: - has_dummy = False + has_dummy = False # Only write record if the angle parameters change. if has_dummy or amber_angle0 != amber_angle1: @@ -2313,28 +2328,30 @@ def sort_dihedrals(dihedrals, idx): all_dummy_initial = all(_is_dummy(mol, [idx0, idx1, idx2, idx3])) all_dummy_final = all(_is_dummy(mol, [idx0, idx1, idx2, idx3], True)) - # Dummies are present in both end states, use null potentials. - if has_dummy_initial and has_dummy_final: - amber_dihedral0 = _SireMM.AmberDihedral() - amber_dihedral1 = _SireMM.AmberDihedral() - - # Dummies in the initial state. - elif has_dummy_initial: - if all_dummy_initial and not zero_dummy_dihedrals: - # Use the potential at lambda = 1 and write to the pert file. - amber_dihedral0 = amber_dihedral1 - force_write = True - else: - zero_k = True - - # Dummies in the final state. - elif has_dummy_final: - if all_dummy_final and not zero_dummy_dihedrals: - # Use the potential at lambda = 0 and write to the pert file. - amber_dihedral1 = amber_dihedral0 - force_write = True - else: - zero_k = True + # Perform dummy modifications if required. + if not no_dummy_modifications: + # Dummies are present in both end states, use null potentials. + if has_dummy_initial and has_dummy_final: + amber_dihedral0 = _SireMM.AmberDihedral() + amber_dihedral1 = _SireMM.AmberDihedral() + + # Dummies in the initial state. + elif has_dummy_initial: + if all_dummy_initial and not zero_dummy_dihedrals: + # Use the potential at lambda = 1 and write to the pert file. + amber_dihedral0 = amber_dihedral1 + force_write = True + else: + zero_k = True + + # Dummies in the final state. + elif has_dummy_final: + if all_dummy_final and not zero_dummy_dihedrals: + # Use the potential at lambda = 0 and write to the pert file. + amber_dihedral1 = amber_dihedral0 + force_write = True + else: + zero_k = True # Only write record if the dihedral parameters change. if zero_k or force_write or amber_dihedral0 != amber_dihedral1: @@ -2711,28 +2728,30 @@ def sort_dihedrals(dihedrals, idx): all_dummy_initial = all(_is_dummy(mol, [idx0, idx1, idx2, idx3])) all_dummy_final = all(_is_dummy(mol, [idx0, idx1, idx2, idx3], True)) - # Dummies are present in both end states, use null potentials. - if has_dummy_initial and has_dummy_final: - amber_dihedral0 = _SireMM.AmberDihedral() - amber_dihedral1 = _SireMM.AmberDihedral() - - # Dummies in the initial state. - elif has_dummy_initial: - if all_dummy_initial and not zero_dummy_impropers: - # Use the potential at lambda = 1 and write to the pert file. - amber_dihedral0 = amber_dihedral1 - force_write = True - else: - zero_k = True - - # Dummies in the final state. - elif has_dummy_final: - if all_dummy_final and not zero_dummy_impropers: - # Use the potential at lambda = 0 and write to the pert file. - amber_dihedral1 = amber_dihedral0 - force_write = True - else: - zero_k = True + # Perform dummy modifications if required. + if not no_dummy_modifications: + # Dummies are present in both end states, use null potentials. + if has_dummy_initial and has_dummy_final: + amber_dihedral0 = _SireMM.AmberDihedral() + amber_dihedral1 = _SireMM.AmberDihedral() + + # Dummies in the initial state. + elif has_dummy_initial: + if all_dummy_initial and not zero_dummy_impropers: + # Use the potential at lambda = 1 and write to the pert file. + amber_dihedral0 = amber_dihedral1 + force_write = True + else: + zero_k = True + + # Dummies in the final state. + elif has_dummy_final: + if all_dummy_final and not zero_dummy_impropers: + # Use the potential at lambda = 0 and write to the pert file. + amber_dihedral1 = amber_dihedral0 + force_write = True + else: + zero_k = True # Only write record if the improper parameters change. if zero_k or force_write or amber_dihedral0 != amber_dihedral1: From 41534e618a612987f07241af9c8d6cf72918ebbf Mon Sep 17 00:00:00 2001 From: Matthew Date: Fri, 30 May 2025 13:52:42 +0100 Subject: [PATCH 04/19] Fix for is_lambda1 in Somd1 processes --- python/BioSimSpace/Process/_process.py | 2 +- python/BioSimSpace/Sandpit/Exscientia/Process/_process.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Process/_process.py b/python/BioSimSpace/Process/_process.py index 020558e0..28437edd 100644 --- a/python/BioSimSpace/Process/_process.py +++ b/python/BioSimSpace/Process/_process.py @@ -731,7 +731,7 @@ def _checkPerturbable(self, system): "in the 'property_map' argument." ) else: - is_lambda1 = self._property_map["is_lambda1"].value() + is_lambda1 = self._property_map["is_lambda1"] self._property_map.pop("is_lambda1") # Loop over all perturbable molecules in the system and replace them diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py index fdcca27c..47a09a82 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py @@ -736,7 +736,7 @@ def _checkPerturbable(self, system): "in the 'property_map' argument." ) else: - is_lambda1 = self._property_map["is_lambda1"].value() + is_lambda1 = self._property_map["is_lambda1"] self._property_map.pop("is_lambda1") # Loop over all perturbable molecules in the system and replace them From db03a53130b748c715fa8c3ceb6321e1a2a0ef86 Mon Sep 17 00:00:00 2001 From: Matthew Date: Mon, 2 Jun 2025 12:04:20 +0100 Subject: [PATCH 05/19] Import proper errors in somd config --- python/BioSimSpace/_Config/_somd.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/BioSimSpace/_Config/_somd.py b/python/BioSimSpace/_Config/_somd.py index 308c8d69..417b70f6 100644 --- a/python/BioSimSpace/_Config/_somd.py +++ b/python/BioSimSpace/_Config/_somd.py @@ -32,6 +32,7 @@ from .. import Protocol as _Protocol from ..Protocol._free_energy_mixin import _FreeEnergyMixin from ..Protocol._position_restraint_mixin import _PositionRestraintMixin +from .._Exceptions import IncompatibleError as _IncompatibleError from ._config import Config as _Config From 5d748ce9a25bcd51623363b3cdd0705ef36b84d0 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 2 Jun 2025 17:08:32 +0100 Subject: [PATCH 06/19] Typo. [ci skip] --- python/BioSimSpace/FreeEnergy/_relative.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index 1ba69e79..b852a1a5 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1085,7 +1085,7 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): df = table.to_pandas() if is_mbar: - # Extract the columns correspodning to the lambda array. + # Extract the columns corresponding to the lambda array. df = df[[x for x in lambda_array]] # Subtract the potential at the simulated lambda. From 44eeb7c643c582e66dc74d3bb0481174e2d29bca Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 3 Jun 2025 11:29:52 +0100 Subject: [PATCH 07/19] Amber config typo fix [ci skip] --- python/BioSimSpace/_Config/_amber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/BioSimSpace/_Config/_amber.py b/python/BioSimSpace/_Config/_amber.py index 9c64d359..1d34d4ba 100644 --- a/python/BioSimSpace/_Config/_amber.py +++ b/python/BioSimSpace/_Config/_amber.py @@ -240,7 +240,7 @@ def createConfig( # Convert to a squashed representation, if needed if isinstance(self._protocol, _FreeEnergyMixin): atom_mapping0 = _squashed_atom_mapping( - self.system, is_lambda1=False + self._system, is_lambda1=False ) atom_mapping1 = _squashed_atom_mapping( self._system, is_lambda1=True From 580c91b3be4d7cfa6cf77a28bb4af0b1fbd3317b Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 3 Jun 2025 15:10:36 +0100 Subject: [PATCH 08/19] Fix for AMBER runs on perturbable systems with restraints --- python/BioSimSpace/Process/_amber.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index c585c670..6f5a35c8 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -172,6 +172,13 @@ def __init__( # Flag to indicate whether the original system has a box. self._has_box = _AmberConfig.hasBox(self._system, self._property_map) + # Take note of whether the original reference system was None + # This will be used later to avoid duplication + if reference_system is not None: + self._is_real_reference = True + else: + self._is_real_reference = False + # If the path to the executable wasn't specified, then search # for it in AMBERHOME and the PATH. if exe is None: @@ -333,7 +340,6 @@ def _setup(self, **kwargs): else: # Check for perturbable molecules and convert to the chosen end state. system = self._checkPerturbable(system) - reference_system = self._checkPerturbable(reference_system) # RST file (coordinates). try: @@ -348,6 +354,17 @@ def _setup(self, **kwargs): # Reference file for position restraints. try: + if self._is_real_reference: + reference_system, _ = _squash( + system, explicit_dummies=self._explicit_dummies + ) + reference_system = self._checkPerturbable(reference_system) + file = _os.path.splitext(self._ref_file)[0] + _IO.saveMolecules( + file, reference_system, "rst7", property_map=self._property_map + ) + else: + _shutil.copy(self._rst_file, self._ref_file) file = _os.path.splitext(self._ref_file)[0] _IO.saveMolecules( file, reference_system, "rst7", property_map=self._property_map From 8fe807ad16b77356b7764fe10fe31f593835e50e Mon Sep 17 00:00:00 2001 From: Matthew Date: Tue, 3 Jun 2025 15:56:29 +0100 Subject: [PATCH 09/19] Fix for previous commit - removes old file save [ci skip] --- python/BioSimSpace/Process/_amber.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/BioSimSpace/Process/_amber.py b/python/BioSimSpace/Process/_amber.py index 6f5a35c8..44f20929 100644 --- a/python/BioSimSpace/Process/_amber.py +++ b/python/BioSimSpace/Process/_amber.py @@ -365,10 +365,6 @@ def _setup(self, **kwargs): ) else: _shutil.copy(self._rst_file, self._ref_file) - file = _os.path.splitext(self._ref_file)[0] - _IO.saveMolecules( - file, reference_system, "rst7", property_map=self._property_map - ) except Exception as e: msg = "Failed to write reference system to 'RST7' format." if _isVerbose(): From 754d7f07392772983852d3b8985e7a753c2912cd Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Jun 2025 13:26:06 +0100 Subject: [PATCH 10/19] Return full data frame, not just lambda array. --- python/BioSimSpace/FreeEnergy/_relative.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index 1ba69e79..f4952b88 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1066,6 +1066,10 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): raise ValueError("Parquet metadata does not contain 'lambda'.") try: lambda_array = metadata["lambda_array"] + try: + lambda_grad = metadata["lambda_grad"] + except: + lambda_grad = [] except: raise ValueError("Parquet metadata does not contain 'lambda array'") if not is_mbar: @@ -1085,8 +1089,8 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): df = table.to_pandas() if is_mbar: - # Extract the columns correspodning to the lambda array. - df = df[[x for x in lambda_array]] + # Extract all columns other than those used for the gradient. + df = df[[x for x in df.columns if x not in lambda_grad]] # Subtract the potential at the simulated lambda. df = df.subtract(df[lam], axis=0) From 93fc038f0d7bd6765c902b65b663947811973e1f Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 13 Jun 2025 13:55:16 +0100 Subject: [PATCH 11/19] Parquet lambda_array metadata is no longer needed. --- python/BioSimSpace/FreeEnergy/_relative.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index f4952b88..fe667f15 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1064,19 +1064,16 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): lam = float(metadata["lambda"]) except: raise ValueError("Parquet metadata does not contain 'lambda'.") - try: - lambda_array = metadata["lambda_array"] + if not is_mbar: try: lambda_grad = metadata["lambda_grad"] except: - lambda_grad = [] - except: - raise ValueError("Parquet metadata does not contain 'lambda array'") - if not is_mbar: + raise ValueError("Parquet metadata does not contain 'lambda grad'") + else: try: lambda_grad = metadata["lambda_grad"] except: - raise ValueError("Parquet metadata does not contain 'lambda grad'") + lambda_grad = [] # Make sure that the temperature is correct. if not T == temperature: From 2e5f4fad8ab156fb7173ebd65441cb3d2d98f875 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 20 Jun 2025 14:07:42 +0100 Subject: [PATCH 12/19] Test that we can parameterise with ff19SB, which includes CMAP terms. --- tests/Parameters/test_parameters.py | 15 +++++++++++++++ .../Exscientia/Parameters/test_parameters.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/Parameters/test_parameters.py b/tests/Parameters/test_parameters.py index 9b2d68ed..8e159519 100644 --- a/tests/Parameters/test_parameters.py +++ b/tests/Parameters/test_parameters.py @@ -208,3 +208,18 @@ def test_broken_sdf_formal_charge(): from math import isclose assert isclose(charge.value(), 0.0, abs_tol=1e-6) + + +def test_ff19SB(): + """ + Test that the ff19SB force field can be used to parameterise a molecule. + """ + + # Load the molecule. + mol = BSS.IO.readMolecules(f"{url}/4LYT_Fixed.pdb.bz2")[0] + + # Parameterise the molecule with ff19SB. + mol = BSS.Parameters.ff19SB(mol).getMolecule() + + # Make sure the molecule has CMAP terms. + assert mol._sire_object.hasProperty("cmap") diff --git a/tests/Sandpit/Exscientia/Parameters/test_parameters.py b/tests/Sandpit/Exscientia/Parameters/test_parameters.py index e23f7f4e..dc95d428 100644 --- a/tests/Sandpit/Exscientia/Parameters/test_parameters.py +++ b/tests/Sandpit/Exscientia/Parameters/test_parameters.py @@ -213,3 +213,18 @@ def test_broken_sdf_formal_charge(): from math import isclose assert isclose(charge.value(), 0.0, abs_tol=1e-6) + + +def test_ff19SB(): + """ + Test that the ff19SB force field can be used to parameterise a molecule. + """ + + # Load the molecule. + mol = BSS.IO.readMolecules(f"{url}/4LYT_Fixed.pdb.bz2")[0] + + # Parameterise the molecule with ff19SB. + mol = BSS.Parameters.ff19SB(mol).getMolecule() + + # Make sure the molecule has CMAP terms. + assert mol._sire_object.hasProperty("cmap") From 71145d4b5dc6ecdc3657489dafe15ea966984b38 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Sat, 21 Jun 2025 20:45:47 +0100 Subject: [PATCH 13/19] Simplify finite difference calculation. [ci skip] --- python/BioSimSpace/FreeEnergy/_relative.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index 1ba69e79..9c08e0ad 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1103,13 +1103,13 @@ def _somd2_extract(parquet_file, T=None, estimator="MBAR"): # Forward difference. if lam_delta > lam: - double_incr = (lam_delta - lam) * 2 - grad = (df[lam_delta] - df[lam]) * 2 / double_incr + incr = lam_delta - lam + grad = (df[lam_delta] - df[lam]) / incr # Backward difference. else: - double_incr = (lam - lam_delta) * 2 - grad = (df[lam] - df[lam_delta]) * 2 / double_incr + incr = lam - lam_delta + grad = (df[lam] - df[lam_delta]) / incr # Central difference. else: From e657eab4e146344fb73d3035d028addd7d966f62 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Jun 2025 13:19:05 +0100 Subject: [PATCH 14/19] Add support for OPC water model. [ci skip] --- .../Sandpit/Exscientia/Solvent/_solvent.py | 134 +++++++++++++++++- python/BioSimSpace/Solvent/_solvent.py | 134 +++++++++++++++++- 2 files changed, 260 insertions(+), 8 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py b/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py index 7464f352..478de29d 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py @@ -24,12 +24,13 @@ __author__ = "Lester Hedges" __email__ = "lester.hedges@gmail.com" -__all__ = ["solvate", "spc", "spce", "tip3p", "tip4p", "tip5p", "waterModels"] +__all__ = ["solvate", "spc", "spce", "tip3p", "tip4p", "tip5p", "opc", "waterModels"] import os as _os import re as _re import subprocess as _subprocess import shlex as _shlex +import shutil as _shutil import sys as _sys import warnings as _warnings @@ -658,6 +659,108 @@ def tip5p( ) +def opc( + molecule=None, + box=None, + angles=3 * [_Angle(90, "degrees")], + shell=None, + ion_conc=0, + is_neutral=True, + is_aligned=False, + match_water=True, + work_dir=None, + property_map={}, +): + """ + Add OPC solvent. + + Parameters + ---------- + + molecule : :class:`Molecule `, \ + :class:`Molecule `, \ + :class:`System ` + A molecule, or container/system of molecules. + + box : [:class:`Length `] + A list containing the box size in each dimension. + + angles : [:class:`Angle `] + A list containing the angles between the box vectors: yz, xz, and xy. + + shell : :class:`Length` ` + Thickness of the water shell around the solute. Note that the + base length of the resulting box must be at least twice as large + as the cutoff used by the chosen molecular dynamics engine. As such, + the shell option is often unsuitable for small molecules. + + ion_conc : float + The ion concentration in (mol per litre). + + is_neutral : bool + Whether to neutralise the system. + + is_aligned : bool + Whether to align the principal axes of the molecule to those of the + solvent box. + + match_water : bool + Whether to update the naming of existing water molecules to match the + expected convention for GROMACS, which is used as the solvation engine. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. This allows the user to refer to properties with their + own naming scheme, e.g. { "charge" : "my-charge" } + + Returns + ------- + + system : :class:`System ` + The solvated molecular system. + """ + + if _gmx_exe is None: + raise _MissingSoftwareError( + "'BioSimSpace.Solvent.opc' is not supported. " + "Please install GROMACS (http://www.gromacs.org)." + ) + + # Validate arguments. + molecule, box, angles, shell, work_dir, property_map = _validate_input( + "opc", + molecule, + box, + angles, + shell, + ion_conc, + is_neutral, + is_aligned, + match_water, + work_dir, + property_map, + ) + + # Return the solvated system. + return _solvate( + molecule, + box, + angles, + shell, + "opc", + 4, + ion_conc, + is_neutral, + is_aligned, + match_water, + work_dir=work_dir, + property_map=property_map, + ) + + def _validate_input( model, molecule, @@ -1094,12 +1197,22 @@ def _solvate( mod = model command = "%s solvate -cs %s" % (_gmx_exe, mod) + # OPC is a special case using a local structure file. + if mod == "opc": + command += ".gro" + # Add the shell information. if molecule is not None and shell is not None: command += " -shell %f" % shell.nanometers().value() command += " -cp box.gro -o output.gro" + if mod == "opc": + template = _SireBase.getShareDir() + "/templates/water/opc" + _shutil.copyfile(template + ".itp", "opc.top") + _shutil.copyfile(template + "_solvate.gro", "opc.gro") + command += " -p opc.top" + with open("README.txt", "a") as file: # Write the command to file. file.write("\n# gmx solvate was run with the following command:\n") @@ -1151,14 +1264,27 @@ def _solvate( "the 'box' size or 'shell' thickness." ) + # Delete the final line from the OPC topology file. + if model == "opc": + with open("opc.top", "r") as file: + lines = file.readlines() + with open("opc.top", "w") as file: + for line in lines[:-1]: + file.write(line) + # Create a TOP file for the water model. By default we use the Amber03 # force field to generate a dummy topology for the water model. with open("water_ions.top", "w") as file: file.write("#define FLEXIBLE 1\n\n") - file.write("; Include AmberO3 force field\n") - file.write('#include "amber03.ff/forcefield.itp"\n\n') + if model != "opc": + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') + # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) - file.write('#include "amber03.ff/%s.itp"\n\n' % model) + if model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % model) file.write("; Include ions\n") file.write('#include "amber03.ff/ions.itp"\n\n') file.write("[ system ] \n") diff --git a/python/BioSimSpace/Solvent/_solvent.py b/python/BioSimSpace/Solvent/_solvent.py index 7464f352..478de29d 100644 --- a/python/BioSimSpace/Solvent/_solvent.py +++ b/python/BioSimSpace/Solvent/_solvent.py @@ -24,12 +24,13 @@ __author__ = "Lester Hedges" __email__ = "lester.hedges@gmail.com" -__all__ = ["solvate", "spc", "spce", "tip3p", "tip4p", "tip5p", "waterModels"] +__all__ = ["solvate", "spc", "spce", "tip3p", "tip4p", "tip5p", "opc", "waterModels"] import os as _os import re as _re import subprocess as _subprocess import shlex as _shlex +import shutil as _shutil import sys as _sys import warnings as _warnings @@ -658,6 +659,108 @@ def tip5p( ) +def opc( + molecule=None, + box=None, + angles=3 * [_Angle(90, "degrees")], + shell=None, + ion_conc=0, + is_neutral=True, + is_aligned=False, + match_water=True, + work_dir=None, + property_map={}, +): + """ + Add OPC solvent. + + Parameters + ---------- + + molecule : :class:`Molecule `, \ + :class:`Molecule `, \ + :class:`System ` + A molecule, or container/system of molecules. + + box : [:class:`Length `] + A list containing the box size in each dimension. + + angles : [:class:`Angle `] + A list containing the angles between the box vectors: yz, xz, and xy. + + shell : :class:`Length` ` + Thickness of the water shell around the solute. Note that the + base length of the resulting box must be at least twice as large + as the cutoff used by the chosen molecular dynamics engine. As such, + the shell option is often unsuitable for small molecules. + + ion_conc : float + The ion concentration in (mol per litre). + + is_neutral : bool + Whether to neutralise the system. + + is_aligned : bool + Whether to align the principal axes of the molecule to those of the + solvent box. + + match_water : bool + Whether to update the naming of existing water molecules to match the + expected convention for GROMACS, which is used as the solvation engine. + + work_dir : str + The working directory for the process. + + property_map : dict + A dictionary that maps system "properties" to their user defined + values. This allows the user to refer to properties with their + own naming scheme, e.g. { "charge" : "my-charge" } + + Returns + ------- + + system : :class:`System ` + The solvated molecular system. + """ + + if _gmx_exe is None: + raise _MissingSoftwareError( + "'BioSimSpace.Solvent.opc' is not supported. " + "Please install GROMACS (http://www.gromacs.org)." + ) + + # Validate arguments. + molecule, box, angles, shell, work_dir, property_map = _validate_input( + "opc", + molecule, + box, + angles, + shell, + ion_conc, + is_neutral, + is_aligned, + match_water, + work_dir, + property_map, + ) + + # Return the solvated system. + return _solvate( + molecule, + box, + angles, + shell, + "opc", + 4, + ion_conc, + is_neutral, + is_aligned, + match_water, + work_dir=work_dir, + property_map=property_map, + ) + + def _validate_input( model, molecule, @@ -1094,12 +1197,22 @@ def _solvate( mod = model command = "%s solvate -cs %s" % (_gmx_exe, mod) + # OPC is a special case using a local structure file. + if mod == "opc": + command += ".gro" + # Add the shell information. if molecule is not None and shell is not None: command += " -shell %f" % shell.nanometers().value() command += " -cp box.gro -o output.gro" + if mod == "opc": + template = _SireBase.getShareDir() + "/templates/water/opc" + _shutil.copyfile(template + ".itp", "opc.top") + _shutil.copyfile(template + "_solvate.gro", "opc.gro") + command += " -p opc.top" + with open("README.txt", "a") as file: # Write the command to file. file.write("\n# gmx solvate was run with the following command:\n") @@ -1151,14 +1264,27 @@ def _solvate( "the 'box' size or 'shell' thickness." ) + # Delete the final line from the OPC topology file. + if model == "opc": + with open("opc.top", "r") as file: + lines = file.readlines() + with open("opc.top", "w") as file: + for line in lines[:-1]: + file.write(line) + # Create a TOP file for the water model. By default we use the Amber03 # force field to generate a dummy topology for the water model. with open("water_ions.top", "w") as file: file.write("#define FLEXIBLE 1\n\n") - file.write("; Include AmberO3 force field\n") - file.write('#include "amber03.ff/forcefield.itp"\n\n') + if model != "opc": + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') + # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) - file.write('#include "amber03.ff/%s.itp"\n\n' % model) + if model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % model) file.write("; Include ions\n") file.write('#include "amber03.ff/ions.itp"\n\n') file.write("[ system ] \n") From 59b512f27e5299015ed73a5679035b1815b325c6 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Wed, 25 Jun 2025 14:36:47 +0100 Subject: [PATCH 15/19] Don't duplicate the defaults directive. [ci skip] --- .../Sandpit/Exscientia/Solvent/_solvent.py | 15 +++++++++------ python/BioSimSpace/Solvent/_solvent.py | 15 +++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py b/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py index 478de29d..56f5a931 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Solvent/_solvent.py @@ -1264,21 +1264,20 @@ def _solvate( "the 'box' size or 'shell' thickness." ) - # Delete the final line from the OPC topology file. + # Delete the defaults section and final line from the OPC topology file. if model == "opc": with open("opc.top", "r") as file: lines = file.readlines() with open("opc.top", "w") as file: - for line in lines[:-1]: + for line in lines[6:-1]: file.write(line) # Create a TOP file for the water model. By default we use the Amber03 # force field to generate a dummy topology for the water model. with open("water_ions.top", "w") as file: file.write("#define FLEXIBLE 1\n\n") - if model != "opc": - file.write("; Include AmberO3 force field\n") - file.write('#include "amber03.ff/forcefield.itp"\n\n') + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) if model == "opc": @@ -1515,8 +1514,12 @@ def _solvate( file.write("#define FLEXIBLE 1\n\n") file.write("; Include AmberO3 force field\n") file.write('#include "amber03.ff/forcefield.itp"\n\n') + # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) - file.write('#include "amber03.ff/%s.itp"\n\n' % model) + if model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % model) file.write("; Include ions\n") file.write('#include "amber03.ff/ions.itp"\n\n') file.write("[ system ] \n") diff --git a/python/BioSimSpace/Solvent/_solvent.py b/python/BioSimSpace/Solvent/_solvent.py index 478de29d..56f5a931 100644 --- a/python/BioSimSpace/Solvent/_solvent.py +++ b/python/BioSimSpace/Solvent/_solvent.py @@ -1264,21 +1264,20 @@ def _solvate( "the 'box' size or 'shell' thickness." ) - # Delete the final line from the OPC topology file. + # Delete the defaults section and final line from the OPC topology file. if model == "opc": with open("opc.top", "r") as file: lines = file.readlines() with open("opc.top", "w") as file: - for line in lines[:-1]: + for line in lines[6:-1]: file.write(line) # Create a TOP file for the water model. By default we use the Amber03 # force field to generate a dummy topology for the water model. with open("water_ions.top", "w") as file: file.write("#define FLEXIBLE 1\n\n") - if model != "opc": - file.write("; Include AmberO3 force field\n") - file.write('#include "amber03.ff/forcefield.itp"\n\n') + file.write("; Include AmberO3 force field\n") + file.write('#include "amber03.ff/forcefield.itp"\n\n') # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) if model == "opc": @@ -1515,8 +1514,12 @@ def _solvate( file.write("#define FLEXIBLE 1\n\n") file.write("; Include AmberO3 force field\n") file.write('#include "amber03.ff/forcefield.itp"\n\n') + # Special handling for OPC, which uses a local topology file. file.write("; Include %s water topology\n" % model.upper()) - file.write('#include "amber03.ff/%s.itp"\n\n' % model) + if model == "opc": + file.write('#include "opc.top"\n\n') + else: + file.write('#include "amber03.ff/%s.itp"\n\n' % model) file.write("; Include ions\n") file.write('#include "amber03.ff/ions.itp"\n\n') file.write("[ system ] \n") From 10187b31a88477e84e8cc092ae167e0e824f03f8 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 26 Jun 2025 11:44:33 +0100 Subject: [PATCH 16/19] Allow user to disable data preprocessing. --- python/BioSimSpace/FreeEnergy/_relative.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/python/BioSimSpace/FreeEnergy/_relative.py b/python/BioSimSpace/FreeEnergy/_relative.py index fe667f15..218803cf 100644 --- a/python/BioSimSpace/FreeEnergy/_relative.py +++ b/python/BioSimSpace/FreeEnergy/_relative.py @@ -1312,10 +1312,21 @@ def _analyse_internal(files, temperatures, lambdas, engine, estimator, **kwargs) # Preprocess the data. try: - processed_data = Relative._preprocess_data(data, estimator, **kwargs) - processed_data = _alchemlyb.concat(processed_data) - except: - _warnings.warn("Could not preprocess the data!") + preprocess = kwargs.pop("preprocess", True) + except KeyError: + preprocess = True + + if not isinstance(preprocess, bool): + raise TypeError("'preprocess' must be of type 'bool'.") + + if preprocess: + try: + processed_data = Relative._preprocess_data(data, estimator, **kwargs) + processed_data = _alchemlyb.concat(processed_data) + except: + _warnings.warn("Could not preprocess the data!") + processed_data = _alchemlyb.concat(data) + else: processed_data = _alchemlyb.concat(data) mbar_method = None From 98b26d3909f140ab77f0f13ccb4708d008ff1916 Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Thu, 26 Jun 2025 14:49:43 +0100 Subject: [PATCH 17/19] Distinguish OPC and TIP4P water. [ci skip] --- .../Sandpit/Exscientia/_SireWrappers/_system.py | 12 +++++++++++- python/BioSimSpace/_SireWrappers/_system.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py index e3733017..fc5dc4ca 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py +++ b/python/BioSimSpace/Sandpit/Exscientia/_SireWrappers/_system.py @@ -2522,7 +2522,17 @@ def _set_water_topology(self, format, is_crystal=False, property_map={}): # TODO: Assume TIP3P. Not sure how to detect SPC/E at present. water_model = "TIP3P" elif num_point == 4: - water_model = "TIP4P" + # Check for OPC water. + try: + if ( + waters[0].search("element Xx").atoms()[0].charge().value() + < -1.1 + ): + water_model = "OPC" + else: + water_model = "TIP4P" + except: + water_model = "TIP4P" elif num_point == 5: water_model = "TIP5P" diff --git a/python/BioSimSpace/_SireWrappers/_system.py b/python/BioSimSpace/_SireWrappers/_system.py index 3b8d2194..b548e126 100644 --- a/python/BioSimSpace/_SireWrappers/_system.py +++ b/python/BioSimSpace/_SireWrappers/_system.py @@ -2438,7 +2438,17 @@ def _set_water_topology(self, format, is_crystal=False, property_map={}): # TODO: Assume TIP3P. Not sure how to detect SPC/E at present. water_model = "TIP3P" elif num_point == 4: - water_model = "TIP4P" + # Check for OPC water. + try: + if ( + waters[0].search("element Xx").atoms()[0].charge().value() + < -1.1 + ): + water_model = "OPC" + else: + water_model = "TIP4P" + except: + water_model = "TIP4P" elif num_point == 5: water_model = "TIP5P" From 9ad668cc6d8c559f204cac40f14e188383dd4d3e Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Fri, 27 Jun 2025 12:45:16 +0100 Subject: [PATCH 18/19] Allow node to be run in a specified working directory. --- python/BioSimSpace/Node/_node.py | 86 ++++++++--------- .../Sandpit/Exscientia/Node/_node.py | 92 +++++++++++++------ 2 files changed, 107 insertions(+), 71 deletions(-) diff --git a/python/BioSimSpace/Node/_node.py b/python/BioSimSpace/Node/_node.py index d19d8476..3d63cfda 100644 --- a/python/BioSimSpace/Node/_node.py +++ b/python/BioSimSpace/Node/_node.py @@ -90,7 +90,7 @@ def help(name): print(proc.stdout) -def run(name, args={}): +def run(name, args={}, work_dir=None): """ Run a node. @@ -103,6 +103,10 @@ def run(name, args={}): args : dict A dictionary of arguments to be passed to the node. + work_dir : str, optional + The working directory in which to run the node. If not specified, + the current working directory is used. + Returns ------- @@ -112,9 +116,18 @@ def run(name, args={}): # Validate the input. + if not isinstance(name, str): + raise TypeError("'name' must be of type 'str'.") + if not isinstance(args, dict): raise TypeError("'args' must be of type 'dict'.") + if work_dir is not None: + if not isinstance(work_dir, str): + raise TypeError("'work_dir' must be of type 'str'.") + else: + work_dir = _os.getcwd() + # Apped the node directory name. full_name = _node_dir + "/" + name @@ -129,53 +142,44 @@ def run(name, args={}): else: full_name += ".py" - # get pid so that we can associate a unique directory with this run - import uuid + with _Utils.chdir(work_dir): + # Write a YAML configuration file for the BioSimSpace node. + if len(args) > 0: + with open("input.yaml", "w") as file: + _yaml.dump(args, file, default_flow_style=False) - input_unique_id = str(uuid.uuid4()) - input_name = f"input_{input_unique_id}.yaml" + # Create the command. + command = "%s/python %s --config input.yaml" % ( + _SireBase.getBinDir(), + full_name, + ) - # Special case argument for file_prefix, allows users to associate - # a unique identifier with the output files. - if "file_prefix" in args: - output_unique_id = args["file_prefix"] - out_name = f"{output_unique_id}.yaml" - else: - out_name = "output.yaml" - - # Write a YAML configuration file for the BioSimSpace node. - if len(args) > 0: - with open(input_name, "w") as file: - _yaml.dump(args, file, default_flow_style=False) - - # Create the command. - command = "%s/python %s --config %s" % ( - _SireBase.getBinDir(), - full_name, - input_name, + # No arguments. + else: + command = "%s/python %s" % (_SireBase.getBinDir(), full_name) + + # Run the node as a subprocess. + proc = _subprocess.run( + _Utils.command_split(command), + shell=False, + text=True, + stderr=_subprocess.PIPE, ) - # No arguments. - else: - command = "%s/python %s" % (_SireBase.getBinDir(), full_name) + if proc.returncode == 0: + # Read the output YAML file into a dictionary. + with open("output.yaml", "r") as file: + output = _yaml.safe_load(file) - # Run the node as a subprocess. - proc = _subprocess.run( - _Utils.command_split(command), shell=False, text=True, stderr=_subprocess.PIPE - ) + # Delete the redundant YAML files. + _os.remove("input.yaml") + _os.remove("output.yaml") - if proc.returncode == 0: - # Read the output YAML file into a dictionary. - with open(out_name, "r") as file: - output = _yaml.safe_load(file) - # Delete the redundant YAML files. - _os.remove(input_name) - _os.remove(out_name) - return output + return output - else: - # Print the standard error, decoded as UTF-8. - print(proc.stderr) + else: + # Print the standard error, decoded as UTF-8. + print(proc.stderr) def setNodeDirectory(dir): diff --git a/python/BioSimSpace/Sandpit/Exscientia/Node/_node.py b/python/BioSimSpace/Sandpit/Exscientia/Node/_node.py index 0a244da0..3d63cfda 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Node/_node.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Node/_node.py @@ -22,6 +22,7 @@ from glob import glob as _glob from .._Utils import _try_import + from .. import _Utils import os as _os @@ -35,7 +36,7 @@ # Set the default node directory. _node_dir = _os.path.dirname(__file__) + "/_nodes" -__all__ = ["list", "help", "run", "setNodeDirectory"] +__all__ = ["list", "help", "run", "setNodeDirectory", "getNodeDirectory"] def list(): @@ -89,7 +90,7 @@ def help(name): print(proc.stdout) -def run(name, args={}): +def run(name, args={}, work_dir=None): """ Run a node. @@ -102,6 +103,10 @@ def run(name, args={}): args : dict A dictionary of arguments to be passed to the node. + work_dir : str, optional + The working directory in which to run the node. If not specified, + the current working directory is used. + Returns ------- @@ -111,9 +116,18 @@ def run(name, args={}): # Validate the input. + if not isinstance(name, str): + raise TypeError("'name' must be of type 'str'.") + if not isinstance(args, dict): raise TypeError("'args' must be of type 'dict'.") + if work_dir is not None: + if not isinstance(work_dir, str): + raise TypeError("'work_dir' must be of type 'str'.") + else: + work_dir = _os.getcwd() + # Apped the node directory name. full_name = _node_dir + "/" + name @@ -122,45 +136,50 @@ def run(name, args={}): if not _os.path.isfile(full_name + ".py"): raise ValueError( "Cannot find node: '%s'. " % name + + "in directory '%s'. " % _node_dir + "Run 'Node.list()' to see available nodes!" ) else: full_name += ".py" - # Write a YAML configuration file for the BioSimSpace node. - if len(args) > 0: - with open("input.yaml", "w") as file: - _yaml.dump(args, file, default_flow_style=False) + with _Utils.chdir(work_dir): + # Write a YAML configuration file for the BioSimSpace node. + if len(args) > 0: + with open("input.yaml", "w") as file: + _yaml.dump(args, file, default_flow_style=False) - # Create the command. - command = "%s/python %s --config input.yaml" % ( - _SireBase.getBinDir(), - full_name, - ) - - # No arguments. - else: - command = "%s/python %s" % (_SireBase.getBinDir(), full_name) + # Create the command. + command = "%s/python %s --config input.yaml" % ( + _SireBase.getBinDir(), + full_name, + ) - # Run the node as a subprocess. - proc = _subprocess.run( - _Utils.command_split(command), shell=False, text=True, stderr=_subprocess.PIPE - ) + # No arguments. + else: + command = "%s/python %s" % (_SireBase.getBinDir(), full_name) + + # Run the node as a subprocess. + proc = _subprocess.run( + _Utils.command_split(command), + shell=False, + text=True, + stderr=_subprocess.PIPE, + ) - if proc.returncode == 0: - # Read the output YAML file into a dictionary. - with open("output.yaml", "r") as file: - output = _yaml.safe_load(file) + if proc.returncode == 0: + # Read the output YAML file into a dictionary. + with open("output.yaml", "r") as file: + output = _yaml.safe_load(file) - # Delete the redundant YAML files. - _os.remove("input.yaml") - _os.remove("output.yaml") + # Delete the redundant YAML files. + _os.remove("input.yaml") + _os.remove("output.yaml") - return output + return output - else: - # Print the standard error, decoded as UTF-8. - print(proc.stderr) + else: + # Print the standard error, decoded as UTF-8. + print(proc.stderr) def setNodeDirectory(dir): @@ -179,3 +198,16 @@ def setNodeDirectory(dir): global _node_dir _node_dir = dir + + +def getNodeDirectory(): + """ + Get the directory of the node library. + + Returns + ------- + + dir : str + The path to the node library. + """ + return _node_dir From 5b0c5650fe081b93b6b7cb76a2509538e57fd71b Mon Sep 17 00:00:00 2001 From: Lester Hedges Date: Mon, 30 Jun 2025 15:47:15 +0100 Subject: [PATCH 19/19] Handle different boolean property types. --- python/BioSimSpace/Process/_process.py | 5 ++++- python/BioSimSpace/Sandpit/Exscientia/Process/_process.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/python/BioSimSpace/Process/_process.py b/python/BioSimSpace/Process/_process.py index 28437edd..ca57b898 100644 --- a/python/BioSimSpace/Process/_process.py +++ b/python/BioSimSpace/Process/_process.py @@ -731,7 +731,10 @@ def _checkPerturbable(self, system): "in the 'property_map' argument." ) else: - is_lambda1 = self._property_map["is_lambda1"] + try: + is_lambda1 = self._property_map["is_lambda1"].value() + except: + is_lambda1 = self._property_map["is_lambda1"] self._property_map.pop("is_lambda1") # Loop over all perturbable molecules in the system and replace them diff --git a/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py b/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py index 47a09a82..cce3479d 100644 --- a/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py +++ b/python/BioSimSpace/Sandpit/Exscientia/Process/_process.py @@ -736,7 +736,10 @@ def _checkPerturbable(self, system): "in the 'property_map' argument." ) else: - is_lambda1 = self._property_map["is_lambda1"] + try: + is_lambda1 = self._property_map["is_lambda1"].value() + except: + is_lambda1 = self._property_map["is_lambda1"] self._property_map.pop("is_lambda1") # Loop over all perturbable molecules in the system and replace them