From 71648228ce14a53a8d1c58eebd23acf6e2e12a62 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 21 Jul 2025 11:00:49 +0100 Subject: [PATCH 01/32] Add hybridtop settings --- src/pontibus/protocols/relative/__init__.py | 0 src/pontibus/protocols/relative/settings.py | 36 +++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/pontibus/protocols/relative/__init__.py create mode 100644 src/pontibus/protocols/relative/settings.py diff --git a/src/pontibus/protocols/relative/__init__.py b/src/pontibus/protocols/relative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pontibus/protocols/relative/settings.py b/src/pontibus/protocols/relative/settings.py new file mode 100644 index 0000000..e4bc42e --- /dev/null +++ b/src/pontibus/protocols/relative/settings.py @@ -0,0 +1,36 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +"""Settings classes for RFE Protocols using OpenMM + OpenMMTools + +This module implements the necessary settings necessary to run the following: + * HybridTopProtocol + +See Also +-------- +pontibus.protocols.relative.HybridTopProtocol +""" +from openfe.protocols.openmm_rfe.equil_rfe_settings import ( + RelativeHybridTopologyProtocolSettings, +) +from pontibus.utils.settings import ( + InterchangeFFSettings, + PackmolSolvationSettings, +) + + +class HybridTopProtocolSettings(RelativeHybridTopologyProtocolSettings): + """ + Configuration object for ``HybridTopologyRFEProtocol``. + + See Also + -------- + pontibus.protocols.relative.HybridTopologyRFEProtocol + """ + + # Inherited things + forcefield_settings: InterchangeFFSettings + """Parameters to set up in the force field""" + + solvation_settings: PackmolSolvationSettings + """Settings for solvating the system.""" From 3ab6cca876fde013aec7434bf814f75eae2a7c50 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 21 Jul 2025 11:05:24 +0100 Subject: [PATCH 02/32] Add hybridtop results --- .../protocols/relative/hybridtop_protocol.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/pontibus/protocols/relative/hybridtop_protocol.py diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py new file mode 100644 index 0000000..9f93c0d --- /dev/null +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -0,0 +1,29 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +"""Equilibrium Relative Free Energy methods using OpenMM and OpenMMTools in a +Perses-like manner. +This module implements the necessary methodology toolking to run calculate a +ligand relative free energy transformation using OpenMM tools and one of the +following methods: + - Hamiltonian Replica Exchange + - Self-adjusted mixture sampling + - Independent window sampling + +Acknowledgements +---------------- +This Protocol is a subclass of the OpenFE RelativeHybridTopologyProtocol. +This Protocol is based on, and leverages components originating from +the Perses toolkit (https://github.com/choderalab/perses). +""" + +from openfe.protocols.openmm_rfe.equil_rfe_methods import( + RelativeHybridTopologyProtocolResults, +) + + +class HybridTopologyProtocolResult(RelativeHybridTopologyProtocolResult): + """ + Results class for the HybridTopologyProtocol class. + Inherits from + :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyProtocolResult`. + """ From 470bf77323828123fe00823b0b71e44794923b8f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 21 Jul 2025 11:05:45 +0100 Subject: [PATCH 03/32] rename --- src/pontibus/protocols/relative/hybridtop_protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index 9f93c0d..477af9e 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -21,7 +21,7 @@ ) -class HybridTopologyProtocolResult(RelativeHybridTopologyProtocolResult): +class HybridTopProtocolResult(RelativeHybridTopologyProtocolResult): """ Results class for the HybridTopologyProtocol class. Inherits from From 74e616ee7b5677bee72d3c2d814fb40c7e4a2f83 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 21 Jul 2025 11:33:45 +0100 Subject: [PATCH 04/32] Add HybridTopProtocol and results --- .../protocols/relative/hybridtop_protocol.py | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index 477af9e..ec07e57 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -16,9 +16,38 @@ the Perses toolkit (https://github.com/choderalab/perses). """ -from openfe.protocols.openmm_rfe.equil_rfe_methods import( - RelativeHybridTopologyProtocolResults, +import uuid +from openff.units import unit +from gufe import ( + ChemicalSystem, + ComponentMapping, + ProtocolDAGResult, + ProtocolUnit ) +from gufe.settings import ThermoSettings +from openfe.protocols.openmm_utils.omm_settings import ( + IntegratorSettings, + MultiStateSimulationSettings, + MultiStateOutputSettings, + OpenMMEngineSettings, + OpenFFPartialChargeSettings, +) +from openfe.protocols.openmm_utils import system_validation +from openfe.protocols.openmm_rfe.equil_rfe_settings import ( + LambdaSettings, + AlchemicalSettings, +) +from openfe.protocols.openmm_rfe.equil_rfe_methods import ( + RelativeHybridTopologyProtocol, + RelativeHybridTopologyProtocolResult, + _validate_alchemical_components, +) +from pontibus.protocols.relative.settings import HybridTopProtocolSettings +from pontibus.utils.settings import ( + InterchangeFFSettings, + PackmolSolvationSettings +) +from pontibus.protocols.relative.hybridtop_units import HybridTopProtocolUnit class HybridTopProtocolResult(RelativeHybridTopologyProtocolResult): @@ -27,3 +56,92 @@ class HybridTopProtocolResult(RelativeHybridTopologyProtocolResult): Inherits from :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyProtocolResult`. """ + + +class HybridTopProtocol(RelativeHybridTopologyProtocol): + """ + Relative Free Energy calculations using OpenMM and OpenMMTools. + + Based on `Perses `_ + + See Also + -------- + :mod:`openfe.protocols` + :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyProtocol` + :class:`pontibus.protocols.relative.HybridTopSettings` + :class:`pontibus.protocols.relative.HybridTopResult` + :class:`pontibus.protocols.relative.HybridTopProtocolUnit` + """ + results_cls = HybridTopProtocolResult + _settings_cls = HybridTopProtocolSettings + _settings: HybridTopProtocolSettings + + @classmethod + def _default_settings(cls): + """A dictionary of initial settings for this creating this Protocol + These settings are intended as a suitable starting point for creating + an instance of this protocol. It is recommended, however that care is + taken to inspect and customize these before performing a Protocol. + Returns + ------- + Settings + a set of default settings + """ + return HybridTopProtocolSettings( + protocol_repeats=3, + forcefield_settings=InterchangeFFSettings(), + thermo_settings=ThermoSettings( + temperature=298.15 * unit.kelvin, + pressure=1 * unit.bar, + ), + partial_charge_settings=OpenFFPartialChargeSettings(), + solvation_settings=PackmolSolvationSettings(), + alchemical_settings=AlchemicalSettings(softcore_LJ='gapsys'), + lambda_settings=LambdaSettings(), + simulation_settings=MultiStateSimulationSettings( + equilibration_length=1.0 * unit.nanosecond, + production_length=5.0 * unit.nanosecond, + ), + engine_settings=OpenMMEngineSettings(), + integrator_settings=IntegratorSettings(), + output_settings=MultiStateOutputSettings(), + ) + + def _create( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: ComponentMapping | list[ComponentMapping] | None, + extends: ProtocolDAGResult | None, + ) -> list[ProtocolUnit]: + if extends: + raise NotImplementedError("Can't extend simulations yet") + + # Get alchemical components & validate them + mapping + alchem_comps = system_validation.get_alchemical_components( + stateA, stateB + ) + _validate_alchemical_components(alchem_comps, mapping) + ligandmapping = mapping[0] if isinstance(mapping, list) else mapping # type: ignore + + # Validate solvent component + nonbond = self.settings.forcefield_settings.nonbonded_method + system_validation.validate_solvent(stateA, nonbond) + + # Validate protein component + system_validation.validate_protein(stateA) + + # actually create and return Units + Anames = ','.join(c.name for c in alchem_comps['stateA']) + Bnames = ','.join(c.name for c in alchem_comps['stateB']) + # our DAG has no dependencies, so just list units + n_repeats = self.settings.protocol_repeats + units = [HybridTopProtocolUnit( + protocol=self, + stateA=stateA, stateB=stateB, + ligandmapping=ligandmapping, # type: ignore + generation=0, repeat_id=int(uuid.uuid4()), + name=f'{Anames} to {Bnames} repeat {i} generation 0') + for i in range(n_repeats)] + + return units From 9162fa3fbde2c8453b5dfe6b26d13981a5c01331 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 10:35:56 +0000 Subject: [PATCH 05/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../protocols/relative/hybridtop_protocol.py | 70 +++++++++---------- src/pontibus/protocols/relative/settings.py | 2 + 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index ec07e57..a728422 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -17,37 +17,31 @@ """ import uuid -from openff.units import unit -from gufe import ( - ChemicalSystem, - ComponentMapping, - ProtocolDAGResult, - ProtocolUnit -) + +from gufe import ChemicalSystem, ComponentMapping, ProtocolDAGResult, ProtocolUnit from gufe.settings import ThermoSettings -from openfe.protocols.openmm_utils.omm_settings import ( - IntegratorSettings, - MultiStateSimulationSettings, - MultiStateOutputSettings, - OpenMMEngineSettings, - OpenFFPartialChargeSettings, -) -from openfe.protocols.openmm_utils import system_validation -from openfe.protocols.openmm_rfe.equil_rfe_settings import ( - LambdaSettings, - AlchemicalSettings, -) from openfe.protocols.openmm_rfe.equil_rfe_methods import ( RelativeHybridTopologyProtocol, RelativeHybridTopologyProtocolResult, _validate_alchemical_components, ) -from pontibus.protocols.relative.settings import HybridTopProtocolSettings -from pontibus.utils.settings import ( - InterchangeFFSettings, - PackmolSolvationSettings +from openfe.protocols.openmm_rfe.equil_rfe_settings import ( + AlchemicalSettings, + LambdaSettings, +) +from openfe.protocols.openmm_utils import system_validation +from openfe.protocols.openmm_utils.omm_settings import ( + IntegratorSettings, + MultiStateOutputSettings, + MultiStateSimulationSettings, + OpenFFPartialChargeSettings, + OpenMMEngineSettings, ) +from openff.units import unit + from pontibus.protocols.relative.hybridtop_units import HybridTopProtocolUnit +from pontibus.protocols.relative.settings import HybridTopProtocolSettings +from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings class HybridTopProtocolResult(RelativeHybridTopologyProtocolResult): @@ -72,6 +66,7 @@ class HybridTopProtocol(RelativeHybridTopologyProtocol): :class:`pontibus.protocols.relative.HybridTopResult` :class:`pontibus.protocols.relative.HybridTopProtocolUnit` """ + results_cls = HybridTopProtocolResult _settings_cls = HybridTopProtocolSettings _settings: HybridTopProtocolSettings @@ -96,7 +91,7 @@ def _default_settings(cls): ), partial_charge_settings=OpenFFPartialChargeSettings(), solvation_settings=PackmolSolvationSettings(), - alchemical_settings=AlchemicalSettings(softcore_LJ='gapsys'), + alchemical_settings=AlchemicalSettings(softcore_LJ="gapsys"), lambda_settings=LambdaSettings(), simulation_settings=MultiStateSimulationSettings( equilibration_length=1.0 * unit.nanosecond, @@ -118,9 +113,7 @@ def _create( raise NotImplementedError("Can't extend simulations yet") # Get alchemical components & validate them + mapping - alchem_comps = system_validation.get_alchemical_components( - stateA, stateB - ) + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) _validate_alchemical_components(alchem_comps, mapping) ligandmapping = mapping[0] if isinstance(mapping, list) else mapping # type: ignore @@ -132,16 +125,21 @@ def _create( system_validation.validate_protein(stateA) # actually create and return Units - Anames = ','.join(c.name for c in alchem_comps['stateA']) - Bnames = ','.join(c.name for c in alchem_comps['stateB']) + Anames = ",".join(c.name for c in alchem_comps["stateA"]) + Bnames = ",".join(c.name for c in alchem_comps["stateB"]) # our DAG has no dependencies, so just list units n_repeats = self.settings.protocol_repeats - units = [HybridTopProtocolUnit( - protocol=self, - stateA=stateA, stateB=stateB, - ligandmapping=ligandmapping, # type: ignore - generation=0, repeat_id=int(uuid.uuid4()), - name=f'{Anames} to {Bnames} repeat {i} generation 0') - for i in range(n_repeats)] + units = [ + HybridTopProtocolUnit( + protocol=self, + stateA=stateA, + stateB=stateB, + ligandmapping=ligandmapping, # type: ignore + generation=0, + repeat_id=int(uuid.uuid4()), + name=f"{Anames} to {Bnames} repeat {i} generation 0", + ) + for i in range(n_repeats) + ] return units diff --git a/src/pontibus/protocols/relative/settings.py b/src/pontibus/protocols/relative/settings.py index e4bc42e..2775f99 100644 --- a/src/pontibus/protocols/relative/settings.py +++ b/src/pontibus/protocols/relative/settings.py @@ -10,9 +10,11 @@ -------- pontibus.protocols.relative.HybridTopProtocol """ + from openfe.protocols.openmm_rfe.equil_rfe_settings import ( RelativeHybridTopologyProtocolSettings, ) + from pontibus.utils.settings import ( InterchangeFFSettings, PackmolSolvationSettings, From c450faa5b909cfaea8101a15ae4c46890d32d259 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 21 Jul 2025 16:59:19 +0100 Subject: [PATCH 06/32] Progress towards RHFEs --- src/pontibus/protocols/relative/__init__.py | 19 + .../protocols/relative/hybridtop_units.py | 571 ++++++++++++++++++ src/pontibus/protocols/solvation/__init__.py | 1 - src/pontibus/protocols/solvation/base.py | 60 +- src/pontibus/tests/conftest.py | 76 +++ .../tests/protocols/relative/__init__.py | 0 .../tests/protocols/relative/test_protocol.py | 360 +++++++++++ src/pontibus/utils/protocol_utils.py | 65 ++ src/pontibus/utils/system_creation.py | 2 +- 9 files changed, 1094 insertions(+), 60 deletions(-) create mode 100644 src/pontibus/protocols/relative/hybridtop_units.py create mode 100644 src/pontibus/tests/protocols/relative/__init__.py create mode 100644 src/pontibus/tests/protocols/relative/test_protocol.py create mode 100644 src/pontibus/utils/protocol_utils.py diff --git a/src/pontibus/protocols/relative/__init__.py b/src/pontibus/protocols/relative/__init__.py index e69de29..f365ecb 100644 --- a/src/pontibus/protocols/relative/__init__.py +++ b/src/pontibus/protocols/relative/__init__.py @@ -0,0 +1,19 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +Run relative free energy calculations using OpenMM and OpenMMTools. +""" + +from .hybridtop_protocol import ( + HybridTopProtocol, + HybridTopProtocolResult, +) +from .settings import HybridTopProtocolSettings +from .hybridtop_units import HybridTopProtocolUnit + +__all__ = [ + "HybridTopProtocol", + "HybridTopProtocolSettings", + "HybridTopProtocolResult", + "HybridTopProtocolUnit", +] diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py new file mode 100644 index 0000000..2d0cd18 --- /dev/null +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -0,0 +1,571 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +ProtocolUnit implementations for the HybridTopProtocol. +""" + +from typing import Any +import pathlib +import numpy as np +import openmmtools +from openmmtools import multistate +import os +from openff.toolkit import Molecule as OFFMolecule +from openff.toolkit import Topology +from openff.units import unit +from openff.units.openmm import to_openmm, from_openmm +from openff.interchange.interop.openmm import to_openmm_positions +from gufe.settings import ThermoSettings +from gufe import SmallMoleculeComponent +from openfe.protocols.openmm_utils import ( + settings_validation, + system_validation, + multistate_analysis, + omm_compute, +) +import mdtraj +from openfe.utils import without_oechem_backend +from openfe.protocols.openmm_rfe.equil_rfe_methods import ( + RelativeHybridTopologyProtocolUnit, + _get_alchemical_charge_difference, +) +from openfe.protocols.openmm_rfe import _rfe_utils +from openfe.protocols.openmm_utils.omm_settings import ( + IntegratorSettings, + MultiStateOutputSettings, + MultiStateSimulationSettings, + BasePartialChargeSettings, +) +from openfe.protocols.openmm_rfe.equil_rfe_settings import ( + AlchemicalSettings, + LambdaSettings, +) +from pontibus.protocols.relative.settings import HybridTopProtocolSettings +from pontibus.utils.system_creation import ( + interchange_packmol_creation, + _get_force_field, + _check_and_deduplicate_charged_mols, +) +from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings +from itertools import chain +from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol + + +class HybridTopProtocolUnit(RelativeHybridTopologyProtocolUnit): + @staticmethod + def _get_interchanges( + small_mols, + protein_component, + solvent_component, + forcefield_settings, + solvation_settings, + charge_settings, + ): + # Create an smc comp dictionary for stateA + stateA_smc_comps = dict(chain(small_mols["stateA"], small_mols["both"])) + + # Get solvent offmol if necessary + if solvent_component is not None: + solvent_offmol = _get_and_charge_solvent_offmol( + solvent_component, + solvation_settings, + charge_settings, + ) + else: + solvent_offmol = None + + # Get the stateA interchange + with without_oechem_backend(): + interA, comp_residsA = interchange_packmol_creation( + ffsettings=forcefield_settings, + solvation_settings=solvation_settings, + smc_components=stateA_smc_comps, + protein_component=protein_component, + solvent_component=solvent_component, + solvent_offmol=solvent_offmol, + ) + + # We assume a single alchemical component, which this Protocol asserts + molA = small_mols["stateA"][0][1] + molB = small_mols["stateB"][0][1] + + # Get the molA idx + molA_idx = None + for idx, mol in enumerate(interA.topology._molecules): + if mol.is_isomorphic_with(molA): + if np.allclose(mol.conformers[0], molA.conformers[0]): + if molA_idx is not None: + raise ValueError("equality clash with molA") + molA_idx = idx + molA_resnum = mol.atoms[0].metadata["residue_number"] + + if molA_idx is None: + raise ValueError("Could not find alchemical molecule A in system creation") + + # Set molB residue number to molA + for atom in molB.atoms: + atom.metadata["residue_number"] = molA_resnum + + top_mols = [m for m in interA.topology.molecules] + top_mols.pop(molA_idx) + top_mols.append(molB) + + ff = _get_force_field(forcefield_settings) + charged_mols = _check_and_deduplicate_charged_mols( + molecules=[m for m in top_mols if m.partial_charges is not None] + ) + + interB = ff.create_interchange( + topology=Topology.from_molecules(top_mols), + charge_from_molecules=charged_mols, + ) + + # Fetch the alchemical resids for each state from the comp_resids + alchem_resids = { + "stateA": comp_residsA[small_mols["stateA"][0][0]], + "stateB": np.array([len(interB.topology._molecules) - 1], dtype=int), + } + + return interA, interB, alchem_resids + + def run( + self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None + ) -> dict[str, Any]: + """Run the relative free energy calculation. + + Parameters + ---------- + dry : bool + Do a dry run of the calculation, creating all necessary hybrid + system components (topology, system, sampler, etc...) but without + running the simulation. + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + scratch_basepath: Pathlike, optional + Where to store temporary files, defaults to current working directory + shared_basepath : Pathlike, optional + Where to run the calculation, defaults to current working directory + + Returns + ------- + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. + + Raises + ------ + error + Exception if anything failed + """ + if verbose: + self.logger.info("Preparing the hybrid topology simulation") + if scratch_basepath is None: + scratch_basepath = pathlib.Path(".") + if shared_basepath is None: + # use cwd + shared_basepath = pathlib.Path(".") + + # 0. General setup and settings dependency resolution step + + # Extract relevant settings + protocol_settings: HybridTopProtocolSettings = self._inputs["protocol"].settings + stateA = self._inputs["stateA"] + stateB = self._inputs["stateB"] + mapping = self._inputs["ligandmapping"] + + forcefield_settings: InterchangeFFSettings = protocol_settings.forcefield_settings + thermo_settings: ThermoSettings = protocol_settings.thermo_settings + alchem_settings: AlchemicalSettings = protocol_settings.alchemical_settings + lambda_settings: LambdaSettings = protocol_settings.lambda_settings + charge_settings: BasePartialChargeSettings = protocol_settings.partial_charge_settings + solvation_settings: PackmolSolvationSettings = protocol_settings.solvation_settings + sampler_settings: MultiStateSimulationSettings = protocol_settings.simulation_settings + output_settings: MultiStateOutputSettings = protocol_settings.output_settings + integrator_settings: IntegratorSettings = protocol_settings.integrator_settings + + # is the timestep good for the mass? + settings_validation.validate_timestep( + forcefield_settings.hydrogen_mass, integrator_settings.timestep + ) + # TODO: Also validate various conversions? + # Convert various time based inputs to steps/iterations + steps_per_iteration = settings_validation.convert_steps_per_iteration( + simulation_settings=sampler_settings, + integrator_settings=integrator_settings, + ) + + equil_steps = settings_validation.get_simsteps( + sim_length=sampler_settings.equilibration_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + prod_steps = settings_validation.get_simsteps( + sim_length=sampler_settings.production_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + solvent_comp, protein_comp, small_mols = system_validation.get_components(stateA) + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + # We already do this in the Protocol but check the number of alchemical comps + for state in alchem_comps.values(): + assert len(state) == 1, "too many alchemical components found in one state" + + # Get the change difference between the end states + # and check if the charge correction used is appropriate + charge_difference = _get_alchemical_charge_difference( + mapping, + forcefield_settings.nonbonded_method, + alchem_settings.explicit_charge_correction, + solvent_comp, + ) + + # 1. Create stateA system + self.logger.info("Parameterizing molecules") + + # a. create (SMC, offmol) dictionaries and assign partial charges + # calculate partial charges manually if not already given + # convert to OpenFF here, + # and keep the molecule around to maintain the partial charges + off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] + off_small_mols = { + "stateA": [(alchem_comps["stateA"][0], alchem_comps["stateA"][0].to_openff())], + "stateB": [(alchem_comps["stateB"][0], alchem_comps["stateB"][0].to_openff())], + "both": [ + (m, m.to_openff()) + for m in small_mols + if (m != alchem_comps["stateA"][0] and m != alchem_comps["stateB"][0]) + ], + } + + self._assign_partial_charges(charge_settings, off_small_mols) + + # Get stateA and stateB intercahnges + stateA_interchange, stateB_interchange, alchem_resids = self._get_interchanges( + off_small_mols, + protein_comp, + solvent_comp, + forcefield_settings, + solvation_settings, + charge_settings, + ) + + # get topology & positions + stateA_topology = stateA_interchange.to_openmm_topology(collate=True) + stateA_positions = to_openmm_positions( + stateA_interchange, + include_virtual_sites=True, + ) + stateA_system = stateA_interchange.to_openmm_system( + hydrogen_mass=forcefield_settings.hydrogen_mass + ) + stateB_topology = stateB_interchange.to_openmm_topology(collate=True) + stateB_positions = to_openmm_positions( + stateB_interchange, + include_virtual_sites=True, + ) + stateB_system = stateB_interchange.to_openmm_system( + hydrogen_mass=forcefield_settings.hydrogen_mass + ) + + # c. Define correspondence mappings between the two systems + ligand_mappings = _rfe_utils.topologyhelpers.get_system_mappings( + mapping.componentA_to_componentB, + stateA_system, + stateA_topology, + alchem_resids["stateA"], + stateB_system, + stateB_topology, + alchem_resids["stateB"], + # These are non-optional settings for this method + fix_constraints=True, + ) + + # d. if a charge correction is necessary, select alchemical waters + # and transform them + if alchem_settings.explicit_charge_correction: + alchem_water_resids = _rfe_utils.topologyhelpers.get_alchemical_waters( + stateA_topology, + from_openmm(stateA_positions).m, + charge_difference, + alchem_settings.explicit_charge_correction_cutoff, + ) + _rfe_utils.topologyhelpers.handle_alchemical_waters( + alchem_water_resids, + stateB_topology, + stateB_system, + ligand_mappings, + charge_difference, + solvent_comp, + ) + + # e. Finally get the positions + # stateB_positions = _rfe_utils.topologyhelpers.set_and_check_new_positions( + # ligand_mappings, + # stateA_topology, + # stateB_topology, + # old_positions=ensure_quantity(stateA_positions, 'openmm'), + # insert_positions=ensure_quantity(off_small_mols['stateB'][0][1].conformers[0], 'openmm'), + # ) + + # 3. Create the hybrid topology + hybrid_factory = _rfe_utils.relative.HybridTopologyFactory( + stateA_system, + stateA_positions, + stateA_topology, + stateB_system, + stateB_positions, + stateB_topology, + old_to_new_atom_map=ligand_mappings["old_to_new_atom_map"], + old_to_new_core_atom_map=ligand_mappings["old_to_new_core_atom_map"], + use_dispersion_correction=alchem_settings.use_dispersion_correction, + softcore_alpha=alchem_settings.softcore_alpha, + softcore_LJ_v2=alchem_settings.softcore_LJ.lower() == "gapsys", + softcore_LJ_v2_alpha=alchem_settings.softcore_alpha, + interpolate_old_and_new_14s=alchem_settings.turn_off_core_unique_exceptions, + ) + + # 4. Create lambda schedule + lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( + functions=lambda_settings.lambda_functions, windows=lambda_settings.lambda_windows + ) + + # pin lambda schedule spacing to n_replicas + n_replicas = sampler_settings.n_replicas + if n_replicas != len(lambdas.lambda_schedule): + errmsg = ( + f"Number of replicas {n_replicas} " + f"does not equal the number of lambda windows " + f"{len(lambdas.lambda_schedule)}" + ) + raise ValueError(errmsg) + + # 9. Create the multistate reporter + # Get the sub selection of the system to print coords for + selection_indices = hybrid_factory.hybrid_topology.select(output_settings.output_indices) + + # a. Create the multistate reporter + # convert checkpoint_interval from time to iterations + chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=sampler_settings.time_per_iteration, + ) + + nc = shared_basepath / output_settings.output_filename + chk = output_settings.checkpoint_storage_filename + + if output_settings.positions_write_frequency is not None: + pos_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.positions_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' position_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + else: + pos_interval = 0 + + if output_settings.velocities_write_frequency is not None: + vel_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.velocities_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' velocity_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + else: + vel_interval = 0 + + reporter = multistate.MultiStateReporter( + storage=nc, + analysis_particle_indices=selection_indices, + checkpoint_interval=chk_intervals, + checkpoint_storage=chk, + position_interval=pos_interval, + velocity_interval=vel_interval, + ) + + # b. Write out a PDB containing the subsampled hybrid state + bfactors = np.zeros_like(selection_indices, dtype=float) # solvent + bfactors[ + np.in1d(selection_indices, list(hybrid_factory._atom_classes["unique_old_atoms"])) + ] = 0.25 # lig A + bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes["core_atoms"]))] = ( + 0.50 # core + ) + bfactors[ + np.in1d(selection_indices, list(hybrid_factory._atom_classes["unique_new_atoms"])) + ] = 0.75 # lig B + + if len(selection_indices) > 0: + traj = mdtraj.Trajectory( + hybrid_factory.hybrid_positions[selection_indices, :], + hybrid_factory.hybrid_topology.subset(selection_indices), + ).save_pdb( + shared_basepath / output_settings.output_structure, + bfactors=bfactors, + ) + + # 10. Get compute platform + # restrict to a single CPU if running vacuum + restrict_cpu = forcefield_settings.nonbonded_method.lower() == "nocutoff" + platform = omm_compute.get_openmm_platform( + platform_name=protocol_settings.engine_settings.compute_platform, + gpu_device_index=protocol_settings.engine_settings.gpu_device_index, + restrict_cpu_count=restrict_cpu, + ) + + # 11. Set the integrator + # a. Validate integrator settings for current system + # Virtual sites sanity check - ensure we restart velocities when + # there are virtual sites in the system + if hybrid_factory.has_virtual_sites: + if not integrator_settings.reassign_velocities: + errmsg = ( + "Simulations with virtual sites without velocity " + "reassignments are unstable in openmmtools" + ) + raise ValueError(errmsg) + + # b. create langevin integrator + integrator = openmmtools.mcmc.LangevinDynamicsMove( + timestep=to_openmm(integrator_settings.timestep), + collision_rate=to_openmm(integrator_settings.langevin_collision_rate), + n_steps=steps_per_iteration, + reassign_velocities=integrator_settings.reassign_velocities, + n_restart_attempts=integrator_settings.n_restart_attempts, + constraint_tolerance=integrator_settings.constraint_tolerance, + ) + + # 12. Create sampler + self.logger.info("Creating and setting up the sampler") + rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( + simulation_settings=sampler_settings, + ) + # convert early_termination_target_error from kcal/mol to kT + early_termination_target_error = ( + settings_validation.convert_target_error_from_kcal_per_mole_to_kT( + thermo_settings.temperature, + sampler_settings.early_termination_target_error, + ) + ) + + if sampler_settings.sampler_method.lower() == "repex": + sampler = _rfe_utils.multistate.HybridRepexSampler( + mcmc_moves=integrator, + hybrid_factory=hybrid_factory, + online_analysis_interval=rta_its, + online_analysis_target_error=early_termination_target_error, + online_analysis_minimum_iterations=rta_min_its, + ) + elif sampler_settings.sampler_method.lower() == "sams": + sampler = _rfe_utils.multistate.HybridSAMSSampler( + mcmc_moves=integrator, + hybrid_factory=hybrid_factory, + online_analysis_interval=rta_its, + online_analysis_minimum_iterations=rta_min_its, + flatness_criteria=sampler_settings.sams_flatness_criteria, + gamma0=sampler_settings.sams_gamma0, + ) + elif sampler_settings.sampler_method.lower() == "independent": + sampler = _rfe_utils.multistate.HybridMultiStateSampler( + mcmc_moves=integrator, + hybrid_factory=hybrid_factory, + online_analysis_interval=rta_its, + online_analysis_target_error=early_termination_target_error, + online_analysis_minimum_iterations=rta_min_its, + ) + + else: + raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") + + sampler.setup( + n_replicas=sampler_settings.n_replicas, + reporter=reporter, + lambda_protocol=lambdas, + temperature=to_openmm(thermo_settings.temperature), + endstates=alchem_settings.endstate_dispersion_correction, + minimization_platform=platform.getName(), + ) + + try: + # Create context caches (energy + sampler) + energy_context_cache = openmmtools.cache.ContextCache( + capacity=None, + time_to_live=None, + platform=platform, + ) + + sampler_context_cache = openmmtools.cache.ContextCache( + capacity=None, + time_to_live=None, + platform=platform, + ) + + sampler.energy_context_cache = energy_context_cache + sampler.sampler_context_cache = sampler_context_cache + + if not dry: # pragma: no-cover + # minimize + if verbose: + self.logger.info("Running minimization") + + sampler.minimize(max_iterations=sampler_settings.minimization_steps) + + # equilibrate + if verbose: + self.logger.info("Running equilibration phase") + + sampler.equilibrate(int(equil_steps / steps_per_iteration)) + + # production + if verbose: + self.logger.info("Running production phase") + + sampler.extend(int(prod_steps / steps_per_iteration)) + + self.logger.info("Production phase complete") + + self.logger.info("Post-simulation analysis of results") + # calculate relevant analyses of the free energies & sampling + # First close & reload the reporter to avoid netcdf clashes + analyzer = multistate_analysis.MultistateEquilFEAnalysis( + reporter, + sampling_method=sampler_settings.sampler_method.lower(), + result_units=unit.kilocalorie_per_mole, + ) + analyzer.plot(filepath=shared_basepath, filename_prefix="") + analyzer.close() + + else: + # clean up the reporter file + fns = [ + shared_basepath / output_settings.output_filename, + shared_basepath / output_settings.checkpoint_storage_filename, + ] + for fn in fns: + os.remove(fn) + finally: + # close reporter when you're done, prevent + # file handle clashes + reporter.close() + + # clear GPU contexts + # TODO: use cache.empty() calls when openmmtools #690 is resolved + # replace with above + for context in list(energy_context_cache._lru._data.keys()): + del energy_context_cache._lru._data[context] + for context in list(sampler_context_cache._lru._data.keys()): + del sampler_context_cache._lru._data[context] + # cautiously clear out the global context cache too + for context in list(openmmtools.cache.global_context_cache._lru._data.keys()): + del openmmtools.cache.global_context_cache._lru._data[context] + + del sampler_context_cache, energy_context_cache + + if not dry: + del integrator, sampler + + if not dry: # pragma: no-cover + return {"nc": nc, "last_checkpoint": chk, **analyzer.unit_results_dict} + else: + return {"debug": {"sampler": sampler}} diff --git a/src/pontibus/protocols/solvation/__init__.py b/src/pontibus/protocols/solvation/__init__.py index b98b8ac..541b58e 100644 --- a/src/pontibus/protocols/solvation/__init__.py +++ b/src/pontibus/protocols/solvation/__init__.py @@ -2,7 +2,6 @@ # For details, see https://github.com/OpenFreeEnergy/openfe """ Run absolute solvation free energy calculations using OpenMM and OpenMMTools. - """ from .asfe_protocol import ( diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index e92629f..4c02536 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -15,10 +15,8 @@ ) from gufe.settings import SettingsBaseModel from openfe.protocols.openmm_afe.base import BaseAbsoluteUnit -from openfe.protocols.openmm_utils import charge_generation from openfe.protocols.openmm_utils.omm_settings import ( IntegratorSettings, - OpenFFPartialChargeSettings, ) from openfe.utils import log_system_probe, without_oechem_backend from openff.interchange.interop.openmm import to_openmm_positions @@ -28,13 +26,11 @@ AbsoluteAlchemicalFactory, AlchemicalRegion, ) - -from pontibus.components import ExtendedSolventComponent -from pontibus.protocols.solvation.settings import PackmolSolvationSettings from pontibus.utils.experimental_absolute_factory import ( ExperimentalAbsoluteAlchemicalFactory, ) from pontibus.utils.system_creation import interchange_packmol_creation +from pontibus.utils.protocol_utils import _get_and_charge_solvent_offmol logger = logging.getLogger(__name__) @@ -42,58 +38,6 @@ class BaseASFEUnit(BaseAbsoluteUnit): _simtype: str - @staticmethod - def _get_and_charge_solvent_offmol( - solvent_component: SolventComponent | ExtendedSolventComponent, - solvation_settings: PackmolSolvationSettings, - partial_charge_settings: OpenFFPartialChargeSettings, - ) -> OFFMolecule: - """ - Helper method to fetch the solvent offmol either - from an existing solvent_smcs, or from smiles. - - Parameters - ---------- - solvent_component : SolventComponent - smiles for the solvent molecule - solvation_settings : PackmolSolvationSettings - Settings defining how the system will be solvated - partial_charge_settings : OpenFFPartialChargeSettigns - Settings defining how partial charges are applied - - Returns - ------- - offmol : openff.toolkit.Molecule - - Notes - ----- - * If created from a smiles, the solvent will be assigned - a single conformer through `Molecule.generate_conformers`. - """ - # Get the solvent offmol - if isinstance(solvent_component, ExtendedSolventComponent): - solvent_offmol = solvent_component.solvent_molecule.to_openff() # type: ignore[union-attr] - else: - # If not, we create the solvent from smiles - # We generate a single conformer to avoid packing issues - solvent_offmol = OFFMolecule.from_smiles(solvent_component.smiles) - solvent_offmol.generate_conformers(n_conformers=1) - - # In-place assign solvent offmol charges if necessary - # Note: we don't enforce partial charge assignment to avoid - # cases where we want to rely on library charges instead. - if solvation_settings.assign_solvent_charges: - charge_generation.assign_offmol_partial_charges( - offmol=solvent_offmol, - overwrite=False, - method=partial_charge_settings.partial_charge_method, - toolkit_backend=partial_charge_settings.off_toolkit_backend, - generate_n_conformers=partial_charge_settings.number_of_conformers, - nagl_model=partial_charge_settings.nagl_model, - ) - - return solvent_offmol - @staticmethod def _validate_vsites(system: openmm.System, integrator_settings: IntegratorSettings) -> None: """ @@ -172,7 +116,7 @@ def _get_omm_objects( # Get solvent offmol if necessary if solvent_component is not None: - solvent_offmol = self._get_and_charge_solvent_offmol( + solvent_offmol = _get_and_charge_solvent_offmol( solvent_component, settings["solvation_settings"], settings["charge_settings"], diff --git a/src/pontibus/tests/conftest.py b/src/pontibus/tests/conftest.py index 8a5c8b5..0031a1c 100644 --- a/src/pontibus/tests/conftest.py +++ b/src/pontibus/tests/conftest.py @@ -6,9 +6,11 @@ from importlib import resources import gufe +import openfe import pytest from gufe import SmallMoleculeComponent from rdkit import Chem +from openff.units import unit class SlowTests: @@ -167,3 +169,77 @@ def T4_protein_component(): comp = gufe.ProteinComponent.from_pdb_file(fn, name="T4_protein") return comp + + +@pytest.fixture +def benzene_vacuum_system(benzene_modifications): + return openfe.ChemicalSystem( + {"ligand": benzene_modifications["benzene"]}, + ) + + +@pytest.fixture(scope="session") +def benzene_system(benzene_modifications): + return openfe.ChemicalSystem( + { + "ligand": benzene_modifications["benzene"], + "solvent": openfe.SolventComponent( + positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar + ), + }, + ) + + +@pytest.fixture +def benzene_complex_system(benzene_modifications, T4_protein_component): + return openfe.ChemicalSystem( + { + "ligand": benzene_modifications["benzene"], + "solvent": openfe.SolventComponent( + positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar + ), + "protein": T4_protein_component, + } + ) + + +@pytest.fixture +def toluene_vacuum_system(benzene_modifications): + return openfe.ChemicalSystem( + {"ligand": benzene_modifications["toluene"]}, + ) + + +@pytest.fixture(scope="session") +def toluene_system(benzene_modifications): + return openfe.ChemicalSystem( + { + "ligand": benzene_modifications["toluene"], + "solvent": openfe.SolventComponent( + positive_ion="Na", negative_ion="Cl", ion_concentration=0 * unit.molar + ), + }, + ) + + +@pytest.fixture +def toluene_complex_system(benzene_modifications, T4_protein_component): + return openfe.ChemicalSystem( + { + "ligand": benzene_modifications["toluene"], + "solvent": openfe.SolventComponent( + positive_ion="Na", negative_ion="Cl", ion_concentration=0 * unit.molar + ), + "protein": T4_protein_component, + } + ) + + +@pytest.fixture(scope="session") +def benzene_to_toluene_mapping(benzene_modifications): + mapper = openfe.setup.LomapAtomMapper(element_change=False) + + molA = benzene_modifications["benzene"] + molB = benzene_modifications["toluene"] + + return next(mapper.suggest_mappings(molA, molB)) diff --git a/src/pontibus/tests/protocols/relative/__init__.py b/src/pontibus/tests/protocols/relative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py new file mode 100644 index 0000000..bbc27f0 --- /dev/null +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -0,0 +1,360 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +import pytest +import mdtraj as mdt +import numpy as np +from openff.units import unit +from openff.units.openmm import ensure_quantity +from rdkit import Chem +import openfe +from openmmtools.multistate import MultiStateSampler +from pontibus.protocols.relative import HybridTopProtocol +from openmm import NonbondedForce +from openmm import unit as omm_unit + + +def test_create_default_settings(): + settings = HybridTopProtocol.default_settings() + + assert settings + + +def test_create_default_protocol(): + protocol = HybridTopProtocol(settings=HybridTopProtocol.default_settings()) + + assert protocol + + +def test_serialize_protocol(): + protocol = HybridTopProtocol( + settings=HybridTopProtocol.default_settings(), + ) + + ser = protocol.to_dict() + + ret = HybridTopProtocol.from_dict(ser) + + assert protocol == ret + + +@pytest.mark.parametrize("method", ["repex", "sams", "independent", "InDePeNdENT"]) +def test_dry_run_default_vacuum( + benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, method, tmpdir +): + vac_settings = HybridTopProtocol.default_settings() + vac_settings.forcefield_settings.nonbonded_method = "nocutoff" + vac_settings.simulation_settings.sampler_method = method + vac_settings.protocol_repeats = 1 + + protocol = HybridTopProtocol( + settings=vac_settings, + ) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + dag_unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + sampler = dag_unit.run(dry=True)["debug"]["sampler"] + assert isinstance(sampler, MultiStateSampler) + assert not sampler.is_periodic + assert sampler._thermodynamic_states[0].barostat is None + + # Check hybrid OMM and MDTtraj Topologies + htf = sampler._hybrid_factory + # 16 atoms: + # 11 common atoms, 1 extra hydrogen in benzene, 4 extra in toluene + # 12 bonds in benzene + 4 extra toluene bonds + assert len(list(htf.hybrid_topology.atoms)) == 16 + assert len(list(htf.omm_hybrid_topology.atoms())) == 16 + assert len(list(htf.hybrid_topology.bonds)) == 16 + assert len(list(htf.omm_hybrid_topology.bonds())) == 16 + + # smoke test - can convert back the mdtraj topology + ret_top = mdt.Topology.to_openmm(htf.hybrid_topology) + assert len(list(ret_top.atoms())) == 16 + assert len(list(ret_top.bonds())) == 16 + + # check that our PDB has the right number of atoms + pdb = mdt.load_pdb("hybrid_system.pdb") + assert pdb.n_atoms == 16 + + +BENZ = """\ +benzene + PyMOL2.5 3D 0 + + 12 12 0 0 0 0 0 0 0 0999 V2000 + 1.4045 -0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7022 1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7023 1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.4045 -0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7023 -1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7023 -1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.5079 -0.0000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.2540 2.1720 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2540 2.1720 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -2.5079 -0.0000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2540 -2.1719 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.2540 -2.1720 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 2 0 0 0 0 + 1 6 1 0 0 0 0 + 1 7 1 0 0 0 0 + 2 3 1 0 0 0 0 + 2 8 1 0 0 0 0 + 3 4 2 0 0 0 0 + 3 9 1 0 0 0 0 + 4 5 1 0 0 0 0 + 4 10 1 0 0 0 0 + 5 6 2 0 0 0 0 + 5 11 1 0 0 0 0 + 6 12 1 0 0 0 0 +M END +$$$$ +""" + + +PYRIDINE = """\ +pyridine + PyMOL2.5 3D 0 + + 11 11 0 0 0 0 0 0 0 0999 V2000 + 1.4045 -0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7023 1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -1.4045 -0.0000 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + -0.7023 -1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7023 -1.2164 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0 + 2.4940 -0.0325 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1.2473 -2.1604 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2473 -2.1604 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -2.4945 -0.0000 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + -1.2753 2.1437 0.0000 H 0 0 0 0 0 0 0 0 0 0 0 0 + 0.7525 1.3034 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0 + 1 5 1 0 0 0 0 + 1 6 1 0 0 0 0 + 1 11 2 0 0 0 0 + 2 3 2 0 0 0 0 + 2 10 1 0 0 0 0 + 3 4 1 0 0 0 0 + 3 9 1 0 0 0 0 + 4 5 2 0 0 0 0 + 4 8 1 0 0 0 0 + 5 7 1 0 0 0 0 + 2 11 1 0 0 0 0 +M END +$$$$ +""" + + +def test_dry_core_element_change(tmpdir): + benz = openfe.SmallMoleculeComponent(Chem.MolFromMolBlock(BENZ, removeHs=False)) + pyr = openfe.SmallMoleculeComponent(Chem.MolFromMolBlock(PYRIDINE, removeHs=False)) + + mapping = openfe.LigandAtomMapping( + benz, pyr, {0: 0, 1: 10, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 8: 9, 9: 8, 10: 7, 11: 6} + ) + + settings = HybridTopProtocol.default_settings() + settings.forcefield_settings.nonbonded_method = "nocutoff" + + protocol = HybridTopProtocol( + settings=settings, + ) + + dag = protocol.create( + stateA=openfe.ChemicalSystem( + { + "ligand": benz, + } + ), + stateB=openfe.ChemicalSystem( + { + "ligand": pyr, + } + ), + mapping=mapping, + ) + + dag_unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + sampler = dag_unit.run(dry=True)["debug"]["sampler"] + system = sampler._hybrid_factory.hybrid_system + assert system.getNumParticles() == 12 + # Average mass between nitrogen and carbon + assert pytest.approx(system.getParticleMass(1)._value) == 12.0008030 + + # Get out the CustomNonbondedForce + cnf = [f for f in system.getForces() if f.__class__.__name__ == "CustomNonbondedForce"][0] + # there should be no new unique atoms + assert cnf.getInteractionGroupParameters(6) == [(), ()] + # there should be one old unique atom (spare hydrogen from the benzene) + assert cnf.getInteractionGroupParameters(7) == [(7,), (7,)] + + +@pytest.mark.parametrize("method", ["repex", "sams", "independent"]) +def test_dry_run_ligand(benzene_system, toluene_system, benzene_to_toluene_mapping, method, tmpdir): + # this might be a bit time consuming + settings = HybridTopProtocol.default_settings() + settings.simulation_settings.sampler_method = method + settings.protocol_repeats = 1 + settings.output_settings.output_indices = "resname UNK" + + protocol = HybridTopProtocol( + settings=settings, + ) + dag = protocol.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping, + ) + dag_unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + sampler = dag_unit.run(dry=True)["debug"]["sampler"] + assert isinstance(sampler, MultiStateSampler) + assert sampler.is_periodic + assert isinstance(sampler._thermodynamic_states[0].barostat, MonteCarloBarostat) + assert sampler._thermodynamic_states[1].pressure == 1 * omm_unit.bar + + # Check we have the right number of atoms in the PDB + pdb = mdt.load_pdb("hybrid_system.pdb") + assert pdb.n_atoms == 16 + + +def test_dry_run_user_charges(benzene_modifications, tmpdir): + """ + Create a hybrid system with a set of fictitious user supplied charges + and ensure that they are properly passed through to the constructed + hybrid topology. + """ + vac_settings = HybridTopProtocol.default_settings() + vac_settings.forcefield_settings.nonbonded_method = "nocutoff" + vac_settings.protocol_repeats = 1 + + protocol = HybridTopProtocol( + settings=vac_settings, + ) + + def assign_fictitious_charges(offmol): + """ + Get a random array of fake partial charges (ints because why not) + that sums up to 0. Note that OpenFF will complain if you try to + create a molecule that has a total charge that is different from + the expected formal charge, hence we enforce a zero charge here. + """ + rand_arr = np.random.randint(1, 10, size=offmol.n_atoms) / 100 + rand_arr[-1] = -sum(rand_arr[:-1]) + return rand_arr * unit.elementary_charge + + def check_propchgs(smc, charge_array): + """ + Check that the partial charges we assigned to our offmol from which + the smc was constructed are present and the right ones. + """ + prop_chgs = smc.to_dict()["molprops"]["atom.dprop.PartialCharge"] + prop_chgs = np.array(prop_chgs.split(), dtype=float) + np.testing.assert_allclose(prop_chgs, charge_array.m) + + # Create new smc with overriden charges + benzene_offmol = benzene_modifications["benzene"].to_openff() + toluene_offmol = benzene_modifications["toluene"].to_openff() + benzene_rand_chg = assign_fictitious_charges(benzene_offmol) + toluene_rand_chg = assign_fictitious_charges(toluene_offmol) + benzene_offmol.partial_charges = benzene_rand_chg + toluene_offmol.partial_charges = toluene_rand_chg + benzene_smc = openfe.SmallMoleculeComponent.from_openff(benzene_offmol) + toluene_smc = openfe.SmallMoleculeComponent.from_openff(toluene_offmol) + + # Check that the new smcs have the new overriden charges + check_propchgs(benzene_smc, benzene_rand_chg) + check_propchgs(toluene_smc, toluene_rand_chg) + + # Create new mapping + mapper = openfe.setup.LomapAtomMapper(element_change=False) + mapping = next(mapper.suggest_mappings(benzene_smc, toluene_smc)) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=openfe.ChemicalSystem( + { + "l": benzene_smc, + } + ), + stateB=openfe.ChemicalSystem( + { + "l": toluene_smc, + } + ), + mapping=mapping, + ) + dag_unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + sampler = dag_unit.run(dry=True)["debug"]["sampler"] + htf = sampler._factory + hybrid_system = htf.hybrid_system + + # get the standard nonbonded force + nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] + assert len(nonbond) == 1 + + # get the particle parameter offsets + c_offsets = {} + for i in range(nonbond[0].getNumParticleParameterOffsets()): + offset = nonbond[0].getParticleParameterOffset(i) + c_offsets[offset[1]] = ensure_quantity(offset[2], "openff") + + # Here is a bit of exposition on what we're doing + # HTF creates two sets of nonbonded forces, a standard one (for the + # PME) and a custom one (for sterics). + # Here we specifically check charges, so we only concentrate on the + # standard NonbondedForce. + # The way the NonbondedForce is constructed is as follows: + # - unique old atoms: + # * The particle charge is set to the input molA particle charge + # * The chargeScale offset is set to the negative value of the molA + # particle charge (such that by scaling you effectively zero out + # the charge. + # - unique new atoms: + # * The particle charge is set to zero (doesn't exist in the starting + # end state). + # * The chargeScale offset is set to the value of the molB particle + # charge (such that by scaling you effectively go from 0 to molB + # charge). + # - core atoms: + # * The particle charge is set to the input molA particle charge + # (i.e. we start from a system that has molA charges). + # * The particle charge offset is set to the difference between + # the molB particle charge and the molA particle charge (i.e. + # we scale by that difference to get to the value of the molB + # particle charge). + for i in range(hybrid_system.getNumParticles()): + c, s, e = nonbond[0].getParticleParameters(i) + # get the particle charge (c) + c = ensure_quantity(c, "openff") + # particle charge (c) is equal to molA particle charge + # offset (c_offsets) is equal to -(molA particle charge) + if i in htf._atom_classes["unique_old_atoms"]: + idx = htf._hybrid_to_old_map[i] + np.testing.assert_allclose(c, benzene_rand_chg[idx]) + np.testing.assert_allclose(c_offsets[i], -benzene_rand_chg[idx]) + # particle charge (c) is equal to 0 + # offset (c_offsets) is equal to molB particle charge + elif i in htf._atom_classes["unique_new_atoms"]: + idx = htf._hybrid_to_new_map[i] + np.testing.assert_allclose(c, 0 * unit.elementary_charge) + np.testing.assert_allclose(c_offsets[i], toluene_rand_chg[idx]) + # particle charge (c) is equal to molA particle charge + # offset (c_offsets) is equal to difference between molB and molA + elif i in htf._atom_classes["core_atoms"]: + old_i = htf._hybrid_to_old_map[i] + new_i = htf._hybrid_to_new_map[i] + c_exp = toluene_rand_chg[new_i] - benzene_rand_chg[old_i] + np.testing.assert_allclose(c, benzene_rand_chg[old_i]) + np.testing.assert_allclose(c_offsets[i], c_exp) diff --git a/src/pontibus/utils/protocol_utils.py b/src/pontibus/utils/protocol_utils.py new file mode 100644 index 0000000..e0dad93 --- /dev/null +++ b/src/pontibus/utils/protocol_utils.py @@ -0,0 +1,65 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +""" +Reusable methods for pontibus Protocols. +""" + +from openff.toolkit import Molecule as OFFMolecule +from openfe import SolventComponent +from openfe.protocols.openmm_utils import charge_generation +from openfe.protocols.openmm_utils.omm_settings import OpenFFPartialChargeSettings +from pontibus.components.extended_solvent_component import ExtendedSolventComponent +from pontibus.utils.settings import PackmolSolvationSettings + + +def _get_and_charge_solvent_offmol( + solvent_component: SolventComponent | ExtendedSolventComponent, + solvation_settings: PackmolSolvationSettings, + partial_charge_settings: OpenFFPartialChargeSettings, +) -> OFFMolecule: + """ + Helper method to fetch the solvent offmol either + from an existing solvent_smcs, or from smiles. + + Parameters + ---------- + solvent_component : SolventComponent + smiles for the solvent molecule + solvation_settings : PackmolSolvationSettings + Settings defining how the system will be solvated + partial_charge_settings : OpenFFPartialChargeSettigns + Settings defining how partial charges are applied + + Returns + ------- + offmol : openff.toolkit.Molecule + + Notes + ----- + * If created from a smiles, the solvent will be assigned + a single conformer through `Molecule.generate_conformers`. + """ + # Get the solvent offmol + if isinstance(solvent_component, ExtendedSolventComponent): + solvent_offmol = solvent_component.solvent_molecule.to_openff() # type: ignore[union-attr] + else: + # If not, we create the solvent from smiles + # We generate a single conformer to avoid packing issues + solvent_offmol = OFFMolecule.from_smiles(solvent_component.smiles) + solvent_offmol.generate_conformers(n_conformers=1) + + # In-place assign solvent offmol charges if necessary + # Note: we don't enforce partial charge assignment to avoid + # cases where we want to rely on library charges instead. + if solvation_settings.assign_solvent_charges: + charge_generation.assign_offmol_partial_charges( + offmol=solvent_offmol, + overwrite=False, + method=partial_charge_settings.partial_charge_method, + toolkit_backend=partial_charge_settings.off_toolkit_backend, + generate_n_conformers=partial_charge_settings.number_of_conformers, + nagl_model=partial_charge_settings.nagl_model, + ) + + return solvent_offmol diff --git a/src/pontibus/utils/system_creation.py b/src/pontibus/utils/system_creation.py index ccb1ecd..f07ab6d 100644 --- a/src/pontibus/utils/system_creation.py +++ b/src/pontibus/utils/system_creation.py @@ -19,7 +19,7 @@ from openff.toolkit import Molecule as OFFMolecule from openff.units import unit as offunit -from pontibus.protocols.solvation.settings import ( +from pontibus.utils.settings import ( InterchangeFFSettings, PackmolSolvationSettings, ) From 19e181174c9f9db8586c773bb743029810219460 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:00:10 +0000 Subject: [PATCH 07/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pontibus/protocols/relative/__init__.py | 2 +- .../protocols/relative/hybridtop_units.py | 58 ++++++++++--------- src/pontibus/protocols/solvation/base.py | 3 +- src/pontibus/tests/conftest.py | 2 +- .../tests/protocols/relative/test_protocol.py | 11 ++-- src/pontibus/utils/protocol_utils.py | 3 +- 6 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/pontibus/protocols/relative/__init__.py b/src/pontibus/protocols/relative/__init__.py index f365ecb..4817b4f 100644 --- a/src/pontibus/protocols/relative/__init__.py +++ b/src/pontibus/protocols/relative/__init__.py @@ -8,8 +8,8 @@ HybridTopProtocol, HybridTopProtocolResult, ) -from .settings import HybridTopProtocolSettings from .hybridtop_units import HybridTopProtocolUnit +from .settings import HybridTopProtocolSettings __all__ = [ "HybridTopProtocol", diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index 2d0cd18..bcb4cb2 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -4,51 +4,53 @@ ProtocolUnit implementations for the HybridTopProtocol. """ -from typing import Any +import os import pathlib +from itertools import chain +from typing import Any + +import mdtraj import numpy as np import openmmtools -from openmmtools import multistate -import os -from openff.toolkit import Molecule as OFFMolecule -from openff.toolkit import Topology -from openff.units import unit -from openff.units.openmm import to_openmm, from_openmm -from openff.interchange.interop.openmm import to_openmm_positions -from gufe.settings import ThermoSettings from gufe import SmallMoleculeComponent -from openfe.protocols.openmm_utils import ( - settings_validation, - system_validation, - multistate_analysis, - omm_compute, -) -import mdtraj -from openfe.utils import without_oechem_backend +from gufe.settings import ThermoSettings +from openfe.protocols.openmm_rfe import _rfe_utils from openfe.protocols.openmm_rfe.equil_rfe_methods import ( RelativeHybridTopologyProtocolUnit, _get_alchemical_charge_difference, ) -from openfe.protocols.openmm_rfe import _rfe_utils +from openfe.protocols.openmm_rfe.equil_rfe_settings import ( + AlchemicalSettings, + LambdaSettings, +) +from openfe.protocols.openmm_utils import ( + multistate_analysis, + omm_compute, + settings_validation, + system_validation, +) from openfe.protocols.openmm_utils.omm_settings import ( + BasePartialChargeSettings, IntegratorSettings, MultiStateOutputSettings, MultiStateSimulationSettings, - BasePartialChargeSettings, -) -from openfe.protocols.openmm_rfe.equil_rfe_settings import ( - AlchemicalSettings, - LambdaSettings, ) +from openfe.utils import without_oechem_backend +from openff.interchange.interop.openmm import to_openmm_positions +from openff.toolkit import Molecule as OFFMolecule +from openff.toolkit import Topology +from openff.units import unit +from openff.units.openmm import from_openmm, to_openmm +from openmmtools import multistate + from pontibus.protocols.relative.settings import HybridTopProtocolSettings +from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol +from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings from pontibus.utils.system_creation import ( - interchange_packmol_creation, - _get_force_field, _check_and_deduplicate_charged_mols, + _get_force_field, + interchange_packmol_creation, ) -from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings -from itertools import chain -from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol class HybridTopProtocolUnit(RelativeHybridTopologyProtocolUnit): diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index 4c02536..8eb37aa 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -26,11 +26,12 @@ AbsoluteAlchemicalFactory, AlchemicalRegion, ) + from pontibus.utils.experimental_absolute_factory import ( ExperimentalAbsoluteAlchemicalFactory, ) -from pontibus.utils.system_creation import interchange_packmol_creation from pontibus.utils.protocol_utils import _get_and_charge_solvent_offmol +from pontibus.utils.system_creation import interchange_packmol_creation logger = logging.getLogger(__name__) diff --git a/src/pontibus/tests/conftest.py b/src/pontibus/tests/conftest.py index 0031a1c..e4bf014 100644 --- a/src/pontibus/tests/conftest.py +++ b/src/pontibus/tests/conftest.py @@ -9,8 +9,8 @@ import openfe import pytest from gufe import SmallMoleculeComponent -from rdkit import Chem from openff.units import unit +from rdkit import Chem class SlowTests: diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index bbc27f0..8deb3df 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -1,17 +1,18 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -import pytest import mdtraj as mdt import numpy as np +import openfe +import pytest from openff.units import unit from openff.units.openmm import ensure_quantity -from rdkit import Chem -import openfe -from openmmtools.multistate import MultiStateSampler -from pontibus.protocols.relative import HybridTopProtocol from openmm import NonbondedForce from openmm import unit as omm_unit +from openmmtools.multistate import MultiStateSampler +from rdkit import Chem + +from pontibus.protocols.relative import HybridTopProtocol def test_create_default_settings(): diff --git a/src/pontibus/utils/protocol_utils.py b/src/pontibus/utils/protocol_utils.py index e0dad93..fac1b0f 100644 --- a/src/pontibus/utils/protocol_utils.py +++ b/src/pontibus/utils/protocol_utils.py @@ -5,10 +5,11 @@ Reusable methods for pontibus Protocols. """ -from openff.toolkit import Molecule as OFFMolecule from openfe import SolventComponent from openfe.protocols.openmm_utils import charge_generation from openfe.protocols.openmm_utils.omm_settings import OpenFFPartialChargeSettings +from openff.toolkit import Molecule as OFFMolecule + from pontibus.components.extended_solvent_component import ExtendedSolventComponent from pontibus.utils.settings import PackmolSolvationSettings From 2db8c43ff69da1729655d21e8977e2a6f7016018 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 10:29:36 +0100 Subject: [PATCH 08/32] use sltcap branch --- environment.yml | 1 + src/pontibus/tests/utils/test_interchange_packmol.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 3fd3d39..ffc6571 100644 --- a/environment.yml +++ b/environment.yml @@ -36,3 +36,4 @@ dependencies: - pip: - git+https://github.com/OpenFreeEnergy/openfe@main - git+https://github.com/OpenFreeEnergy/ofe-sphinx-theme@a45f3edd5bc3e973c1a01b577c71efa1b62a65d6 + - git+https://https://github.com/openforcefield/openff-interchange@sltcap diff --git a/src/pontibus/tests/utils/test_interchange_packmol.py b/src/pontibus/tests/utils/test_interchange_packmol.py index 1cc5088..9f5c0a0 100644 --- a/src/pontibus/tests/utils/test_interchange_packmol.py +++ b/src/pontibus/tests/utils/test_interchange_packmol.py @@ -1358,7 +1358,6 @@ def test_solvent_nonbond_parameters(self, nonbonds, num_particles, num_waters): assert from_openmm(e).m == pytest.approx(2.68724395648) assert from_openmm(s).m_as(unit.angstroms) == pytest.approx(4.108824888) - @pytest.mark.xfail def test_system_total_charge(self, nonbonds, omm_system): total_charge = 0.0 for i in range(omm_system.getNumParticles()): From 2674e5edbef5f9883765b34e0c0a61915cf81486 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 10:33:21 +0100 Subject: [PATCH 09/32] fix env typo --- .vscode/settings.json | 5 +++++ environment.yml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a8c2003 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/environment.yml b/environment.yml index ffc6571..b7022a0 100644 --- a/environment.yml +++ b/environment.yml @@ -36,4 +36,4 @@ dependencies: - pip: - git+https://github.com/OpenFreeEnergy/openfe@main - git+https://github.com/OpenFreeEnergy/ofe-sphinx-theme@a45f3edd5bc3e973c1a01b577c71efa1b62a65d6 - - git+https://https://github.com/openforcefield/openff-interchange@sltcap + - git+https://github.com/openforcefield/openff-interchange@sltcap From 49f98d37f7531d950105d84751ae6299988f962d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 10:34:00 +0100 Subject: [PATCH 10/32] Ignore vscode things --- .gitignore | 3 +++ .vscode/settings.json | 5 ----- 2 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index 120764b..183bb88 100644 --- a/.gitignore +++ b/.gitignore @@ -166,3 +166,6 @@ cython_debug/ # Rever rever/ + +# VSCode +.vscode diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index a8c2003..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "python-envs.defaultEnvManager": "ms-python.python:conda", - "python-envs.defaultPackageManager": "ms-python.python:conda", - "python-envs.pythonProjects": [] -} \ No newline at end of file From 0518a28ed6698d03cdf9ffe2b569c059ba9efaf8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 11:48:56 +0100 Subject: [PATCH 11/32] Add system maniupation tooling --- .../protocols/relative/hybridtop_protocol.py | 4 +- .../protocols/relative/hybridtop_units.py | 52 +++---- .../tests/utils/test_interchange_packmol.py | 8 +- src/pontibus/utils/molecule_utils.py | 131 ++++++++++++++++++ src/pontibus/utils/system_manipulation.py | 87 ++++++++++++ 5 files changed, 242 insertions(+), 40 deletions(-) create mode 100644 src/pontibus/utils/molecule_utils.py create mode 100644 src/pontibus/utils/system_manipulation.py diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index a728422..a084ba9 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -115,7 +115,7 @@ def _create( # Get alchemical components & validate them + mapping alchem_comps = system_validation.get_alchemical_components(stateA, stateB) _validate_alchemical_components(alchem_comps, mapping) - ligandmapping = mapping[0] if isinstance(mapping, list) else mapping # type: ignore + ligandmapping = mapping[0] if isinstance(mapping, list) else mapping # Validate solvent component nonbond = self.settings.forcefield_settings.nonbonded_method @@ -134,7 +134,7 @@ def _create( protocol=self, stateA=stateA, stateB=stateB, - ligandmapping=ligandmapping, # type: ignore + ligandmapping=ligandmapping, generation=0, repeat_id=int(uuid.uuid4()), name=f"{Anames} to {Bnames} repeat {i} generation 0", diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index bcb4cb2..d6b1139 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -47,10 +47,10 @@ from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings from pontibus.utils.system_creation import ( - _check_and_deduplicate_charged_mols, _get_force_field, interchange_packmol_creation, ) +from pontibus.utils.system_manipulation import copy_interchange_with_replacement class HybridTopProtocolUnit(RelativeHybridTopologyProtocolUnit): @@ -87,45 +87,27 @@ def _get_interchanges( solvent_offmol=solvent_offmol, ) - # We assume a single alchemical component, which this Protocol asserts - molA = small_mols["stateA"][0][1] - molB = small_mols["stateB"][0][1] - - # Get the molA idx - molA_idx = None - for idx, mol in enumerate(interA.topology._molecules): - if mol.is_isomorphic_with(molA): - if np.allclose(mol.conformers[0], molA.conformers[0]): - if molA_idx is not None: - raise ValueError("equality clash with molA") - molA_idx = idx - molA_resnum = mol.atoms[0].metadata["residue_number"] - - if molA_idx is None: - raise ValueError("Could not find alchemical molecule A in system creation") - - # Set molB residue number to molA - for atom in molB.atoms: - atom.metadata["residue_number"] = molA_resnum - - top_mols = [m for m in interA.topology.molecules] - top_mols.pop(molA_idx) - top_mols.append(molB) - - ff = _get_force_field(forcefield_settings) - charged_mols = _check_and_deduplicate_charged_mols( - molecules=[m for m in top_mols if m.partial_charges is not None] - ) - - interB = ff.create_interchange( - topology=Topology.from_molecules(top_mols), - charge_from_molecules=charged_mols, + # Get a list of the charged molecules to create stateB + stateB_charged_mols = [ + pair[1] + for pair in chain(small_mols["stateB"], small_mols["both"]) + ] + if solvent_component is not None and solvation_settings.assign_solvent_charges: + stateB_charged_mols.append(solvent_offmol) + + # Set the stateB interchange + interB = copy_interchange_with_replacement( + interchange=interA, + del_mol=small_mols["stateA"][0][1], + insert_mol=small_mols["stateB"][0][1], + force_field=_get_force_field(forcefield_settings), + charged_molecules=stateB_charged_mols, ) # Fetch the alchemical resids for each state from the comp_resids alchem_resids = { "stateA": comp_residsA[small_mols["stateA"][0][0]], - "stateB": np.array([len(interB.topology._molecules) - 1], dtype=int), + "stateB": np.array([interB.topology.n_molecules - 1], dtype=int), } return interA, interB, alchem_resids diff --git a/src/pontibus/tests/utils/test_interchange_packmol.py b/src/pontibus/tests/utils/test_interchange_packmol.py index 9f5c0a0..826f11c 100644 --- a/src/pontibus/tests/utils/test_interchange_packmol.py +++ b/src/pontibus/tests/utils/test_interchange_packmol.py @@ -24,13 +24,15 @@ PackmolSolvationSettings, ) from pontibus.utils.molecules import WATER +from pontibus.utils.molecule_utils import ( + _set_offmol_resname, + _get_offmol_resname, + _check_library_charges, +) from pontibus.utils.system_creation import ( _check_and_deduplicate_charged_mols, - _check_library_charges, _get_comp_resnames, _get_force_field, - _get_offmol_resname, - _set_offmol_resname, _solvate_system, interchange_packmol_creation, ) diff --git a/src/pontibus/utils/molecule_utils.py b/src/pontibus/utils/molecule_utils.py new file mode 100644 index 0000000..ec5440b --- /dev/null +++ b/src/pontibus/utils/molecule_utils.py @@ -0,0 +1,131 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import logging +from typing import Any +from openff.toolkit import ForceField +from openff.toolkit import Molecule as OFFMolecule + + +logger = logging.getLogger(__name__) + + +def _set_offmol_metadata( + offmol: OFFMolecule, + key: Any, + val: Any, +) -> None: + """ + Set a given metadata entry for a whole Molecule. + + Parameters + ---------- + offmol : openff.toolkit.Molecule + The Molecule to set the metadata for. + key : Any + The metadata key. + val : Any + The value to set the metadata entry to. + """ + for a in offmol.atoms: + a.metadata[key] = val + + +def _get_offmol_metadata(offmol: OFFMolecule, key: Any) -> Any | None: + """ + Get an offmol's given metadata entry and make sure it is + consistent across all atoms in the Molecule. + + Parameters + ---------- + offmol : openff.toolkit.Molecule + Molecule to get the metadata value from. + key: Any + The metadata entry key. + + Returns + ------- + value : Any | None + Metadata for the given key in the molecule. ``None`` if the + Molecule does not have that metadata entry set, or if + the value is inconsistent across all the atoms. + """ + value: Any | None = None + for a in offmol.atoms: + if value is None: + try: + value = a.metadata[key] + except KeyError: + return None + + if value != a.metadata[key]: + wmsg = f"Inconsistent metadata {key} in OFFMol: {offmol}" + logger.warning(wmsg) + return None + + return value + + +def _set_offmol_resname( + offmol: OFFMolecule, + resname: str, +) -> None: + """ + Helper method to set offmol residue names + + Parameters + ---------- + offmol : openff.toolkit.Molecule + Molecule to assign a residue name to. + resname : str + Residue name to be set. + + Returns + ------- + None + """ + _set_offmol_metadata(offmol, "residue_name", resname) + + +def _get_offmol_resname(offmol: OFFMolecule) -> str | None: + """ + Helper method to get an offmol's residue name and make sure it is + consistent across all atoms in the Molecule. + + Parameters + ---------- + offmol : openff.toolkit.Molecule + Molecule to get the residue name from. + + Returns + ------- + resname : Optional[str] + Residue name of the molecule. ``None`` if the Molecule + does not have a residue name, or if the residue name is + inconsistent across all the atoms. + """ + return _get_offmol_metadata(offmol, "residue_name") + + +def _check_library_charges( + force_field: ForceField, + offmol: OFFMolecule, +) -> None: + """ + Check that library charges exists for an input molecule. + + force_field : openff.toolkit.ForceField + Force Field object with library charges. + offmol : openff.toolkit.Molecule + Molecule to check for matching library charges. + + Raises + ------ + ValueError + If no library charges are found for the molecule. + """ + handler = force_field.get_parameter_handler("LibraryCharges") + matches = handler.find_matches(offmol.to_topology()) + + if len(matches) == 0: + errmsg = f"No library charges found for {offmol}" + raise ValueError(errmsg) \ No newline at end of file diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py new file mode 100644 index 0000000..a7e0ad5 --- /dev/null +++ b/src/pontibus/utils/system_manipulation.py @@ -0,0 +1,87 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +import numpy as np +from openff.toolkit import Molecule, Topology, ForceField +from openff.interchange import Interchange +from pontibus.utils.system_creation import _check_and_deduplicate_charged_mols +from pontibus.utils.molecule_utils import ( + _get_offmol_metadata, + _set_offmol_metadata, +) + + +def copy_interchange_with_replacement( + interchange: Interchange, + del_mol: Molecule, + insert_mol: Molecule, + force_field: ForceField, + charged_molecules: list[Molecule] | None, +) -> Interchange: + """ + Copy an Interchange deleting one Molecule and appending another. + + Parameters + ---------- + interchange : Interchange + Input Interchange to copy. + del_mol : Molecule + The Molecule to delete from the Interchange. + insert_mol : Molecule + The Molecule to insert to the Interchange. + force_field : ForceField + The ForceField object used to create the initial Interchange. + charged_molecules : list[Molecule] | None + A list of Molecules which partial charges to use in the new Interchange. + + Returns + ------- + new_interchange : Interchange + An copy of the input Interchange but with the Molecule mutation. + + Note + ---- + * ``del_mol`` is always deleted and ``insert_mol`` is appended to the end. + * The residue number of the Molecule matching ``del_mol`` in the input + Interchange is transcribed over to the ``insert_mol`` molecule. + """ + # Validate + if insert_mol.conformers is None or del_mol.conformers is None: + raise ValueError("Input molecules need conformers") + + # Get the del_mol idx + del_mol_idx = None + + # Search the Interchange Topology for a molecule with both + # isomorphic and spatial equality. + for idx, mol in enumerate(interchange.topology.molecules): + if mol.is_isomorphic_with(del_mol): + if np.allclose(mol.conformers[0], del_mol.conformers[0]): + if del_mol_idx is not None: + raise ValueError("equality clash with del_mol") + + del_mol_idx = idx + del_mol_resnum = _get_offmol_metadata(mol, "residue_number") + + if del_mol_idx is None: + errmsg = "No Molecule matching del_mol in input Interchange" + raise ValueError(errmsg) + + # Set molB residue number to molA + _set_offmol_metadata(insert_mol, "residue_number", del_mol_resnum) + + # Get a list of molecules from the input Interchange + mols = [m for m in interchange.topology.molecules] + mols.pop(del_mol_idx) # pop out the Molecule to be deleted + mols.append(insert_mol) # insert the new Molecule + + new_topology = Topology.from_molecules(mols) + new_topology.box_vectors = Interchange.topology.box_vectors + charged_molecules = _check_and_deduplicate_charged_mols(charged_molecules) + + new_interchange = force_field.create_interchange( + topology=new_topology, + charge_from_molecules=charged_molecules, + ) + + return new_interchange From 0308b6f64e684222f624a32a0d1ee90ff41cbb1e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 12:29:52 +0100 Subject: [PATCH 12/32] some fixes --- src/pontibus/tests/conftest.py | 4 ++-- src/pontibus/tests/protocols/relative/test_protocol.py | 2 +- src/pontibus/utils/system_manipulation.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pontibus/tests/conftest.py b/src/pontibus/tests/conftest.py index e4bf014..729f458 100644 --- a/src/pontibus/tests/conftest.py +++ b/src/pontibus/tests/conftest.py @@ -216,7 +216,7 @@ def toluene_system(benzene_modifications): { "ligand": benzene_modifications["toluene"], "solvent": openfe.SolventComponent( - positive_ion="Na", negative_ion="Cl", ion_concentration=0 * unit.molar + positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), }, ) @@ -228,7 +228,7 @@ def toluene_complex_system(benzene_modifications, T4_protein_component): { "ligand": benzene_modifications["toluene"], "solvent": openfe.SolventComponent( - positive_ion="Na", negative_ion="Cl", ion_concentration=0 * unit.molar + positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), "protein": T4_protein_component, } diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index 8deb3df..1663d56 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -7,7 +7,7 @@ import pytest from openff.units import unit from openff.units.openmm import ensure_quantity -from openmm import NonbondedForce +from openmm import NonbondedForce, MonteCarloBarostat from openmm import unit as omm_unit from openmmtools.multistate import MultiStateSampler from rdkit import Chem diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index a7e0ad5..f00a731 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -76,7 +76,7 @@ def copy_interchange_with_replacement( mols.append(insert_mol) # insert the new Molecule new_topology = Topology.from_molecules(mols) - new_topology.box_vectors = Interchange.topology.box_vectors + new_topology.box_vectors = interchange.topology.box_vectors charged_molecules = _check_and_deduplicate_charged_mols(charged_molecules) new_interchange = force_field.create_interchange( From ed3cc86c27ca35384499cacf684015d2c786c0c7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 14:17:37 +0100 Subject: [PATCH 13/32] add tooling to adjust system --- src/pontibus/protocols/solvation/base.py | 17 ++++---- src/pontibus/utils/system_manipulation.py | 47 ++++++++++++++++++++--- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index 85cae80..6d02fd9 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -33,6 +33,7 @@ ) from pontibus.utils.protocol_utils import _get_and_charge_solvent_offmol from pontibus.utils.system_creation import interchange_packmol_creation +from pontibus.utils.system_manipulation import adjust_system logger = logging.getLogger(__name__) @@ -143,13 +144,6 @@ def _get_omm_objects( hydrogen_mass=settings["forcefield_settings"].hydrogen_mass ) - # Pull out the CMMotionRemover - # TODO: add test that checks the number of forces - for idx in reversed(range(omm_system.getNumForces())): - force = omm_system.getForce(idx) - if isinstance(force, openmm.CMMotionRemover): - omm_system.removeForce(idx) - # Add a barostat if needed if solvent_component is not None: barostat = openmm.MonteCarloBarostat( @@ -157,7 +151,14 @@ def _get_omm_objects( to_openmm(settings["thermo_settings"].temperature), settings["integrator_settings"].barostat_frequency.m, ) - omm_system.addForce(barostat) + else: + barostat = None + + adjust_system( + system=omm_system, + remove_forces=openmm.CMMotionRemover, + add_forces=barostat + ) positions = to_openmm_positions(interchange, include_virtual_sites=True) diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index f00a731..1801da1 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -2,6 +2,7 @@ # For details, see https://github.com/OpenFreeEnergy/openfe import numpy as np +from openmm import System, Force, CMMotionRemover from openff.toolkit import Molecule, Topology, ForceField from openff.interchange import Interchange from pontibus.utils.system_creation import _check_and_deduplicate_charged_mols @@ -11,12 +12,48 @@ ) +def adjust_system( + system: System, + remove_forces: Force | list[Force] | None = list[CMMotionRemover], + add_forces: Force | list[Force] | None = None, +) -> None: + """ + Adjust a System by removing and adding forces as necessary. + + Parameters + ---------- + system : System + The OpenMM System to adjust + remove_forces : list[Force] | None + The forces to remove from the System, if present. + add_forces : list[Force] | None + The forces to add to the system. + """ + def _adjust_inputs(var): + if var is not None: + return list(var) + else: + return [] + + remove_forces = _adjust_inputs(remove_forces) + add_forces = _adjust_inputs(add_forces) + + for entry in remove_forces: + for idx in reversed(range(system.getNumForces())): + force = system.getForce(idx) + if isinstance(force, entry): + system.removeForce(idx) + + for force in add_forces: + system.addForce(force) + + def copy_interchange_with_replacement( - interchange: Interchange, - del_mol: Molecule, - insert_mol: Molecule, - force_field: ForceField, - charged_molecules: list[Molecule] | None, + interchange: Interchange, + del_mol: Molecule, + insert_mol: Molecule, + force_field: ForceField, + charged_molecules: list[Molecule] | None, ) -> Interchange: """ Copy an Interchange deleting one Molecule and appending another. From c9342f98836bad6721bf184ed775b1b4a9aba6ce Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 17:32:04 +0100 Subject: [PATCH 14/32] Add position overlap test --- .../protocols/relative/hybridtop_units.py | 184 +++++++++++++++--- .../tests/protocols/relative/test_protocol.py | 2 +- .../tests/utils/test_interchange_packmol.py | 2 +- src/pontibus/utils/system_manipulation.py | 4 +- 4 files changed, 160 insertions(+), 32 deletions(-) diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index d6b1139..5988c7b 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -9,10 +9,12 @@ from itertools import chain from typing import Any +import warnings +import logging import mdtraj import numpy as np import openmmtools -from gufe import SmallMoleculeComponent +from gufe import SmallMoleculeComponent, SolventComponent from gufe.settings import ThermoSettings from openfe.protocols.openmm_rfe import _rfe_utils from openfe.protocols.openmm_rfe.equil_rfe_methods import ( @@ -36,24 +38,109 @@ MultiStateSimulationSettings, ) from openfe.utils import without_oechem_backend +from openff.interchange import Interchange from openff.interchange.interop.openmm import to_openmm_positions from openff.toolkit import Molecule as OFFMolecule -from openff.toolkit import Topology -from openff.units import unit +from openff.units import unit, Quantity from openff.units.openmm import from_openmm, to_openmm from openmmtools import multistate +from openmm import unit as omm_unit +from openmm import System, MonteCarloBarostat, CMMotionRemover +from openmm.app import Topology from pontibus.protocols.relative.settings import HybridTopProtocolSettings from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol -from pontibus.utils.settings import InterchangeFFSettings, PackmolSolvationSettings +from pontibus.utils.settings import ( + InterchangeFFSettings, + PackmolSolvationSettings, +) from pontibus.utils.system_creation import ( _get_force_field, interchange_packmol_creation, ) -from pontibus.utils.system_manipulation import copy_interchange_with_replacement +from pontibus.utils.system_manipulation import ( + copy_interchange_with_replacement, + adjust_system, +) + + +logger = logging.getLogger(__name__) class HybridTopProtocolUnit(RelativeHybridTopologyProtocolUnit): + @staticmethod + def _check_position_overlap( + mapping: dict[str, dict[int, int]], + positionsA: Quantity, + positionsB: Quantity, + threshold: Quantity = 1.0 * unit.angstrom, + ): + """ + Sanity check the overlap in positions. + + Parameters + ---------- + mapping : dict[str, dict[int, int]] + The system mappings between the two sets of positions. + positionsA : openff.units.Quantity + The system A positions. + positionsB : openff.units.Quantity + The system B positions. + tolerance : openff.units.Quantity + The maximum deivation allowed before an error or warning is raised. + + Raises + ------ + ValueError + If any env atoms deviate by more than the threshold. + UserWarning + If any core atoms deviate by more than the threshold. + """ + # Check env mappings + for key, val in mapping['old_to_new_env_atom_map'].items(): + if np.any(np.abs(positionsB[val] - positionsA[key]) > threshold): + msg = f"env mapping {key} : {val} deviates by more than {threshold}" + raise ValueError(msg) + + # Check core mappings + for key, val in mapping['old_to_new_core_atom_map'].items(): + if np.any(np.abs(positionsB[val] - positionsA[key]) > threshold): + msg = f"core mapping {key} : {val} deviates by more than {threshold}" + warnings.warn(msg) + logging.warning(msg) + + @staticmethod + def _get_barostat( + solvent_component: SolventComponent | None, + thermo_settings: ThermoSettings, + integrator_settings: IntegratorSettings, + ) -> MonteCarloBarostat | None: + """ + Helper to get a barostat for the system. + + Parameters + ---------- + solvent_component: SolventComponent | None + The system's SolventComponent, if there is one. + thermo_settings : ThermoSettings + The thermodynamic settings. + integrator_settings : IntegratorSettings + The integrator settings + + Returns + ------- + MonteCarloBarostat | None + None is there is no solvent, a MonteCarloBarostat otherwise. + """ + if solvent_component is None: + return None + + return MonteCarloBarostat( + to_openmm(thermo_settings.pressure), + to_openmm(thermo_settings.temperature), + integrator_settings.barostat_frequency.m + ) + @staticmethod def _get_interchanges( small_mols, @@ -112,6 +199,49 @@ def _get_interchanges( return interA, interB, alchem_resids + def _get_omm_objects( + self, + interchange: Interchange, + forcefield_settings: InterchangeFFSettings, + thermo_settings: ThermoSettings, + integrator_settings: IntegratorSettings, + solvent_component: SolventComponent | None, + ) -> tuple[Topology, omm_unit.Quantity, System]: + """ + Helper method to extract OpenMM objects from an Interchange object. + + Parameters + ---------- + interchange : Interchange + The Interchange object to get OpenMM objects from. + forcefield_settings : InterchangeFFSettings + The force field settings + thermo_settings : ThermoSettings + The thermodynamic parameter settings. + integrator_settings : IntegratorSettings + The integrator settings. + solvent_component : SolventComponent | None + The SolventComponent, if there is one. + """ + topology = interchange.to_openmm_topology(collate=True) + positions = to_openmm_positions( + interchange, + include_virtual_sites=True, + ) + system = interchange.to_openmm_system( + hydrogen_mass=forcefield_settings.hydrogen_mass + ) + adjust_system( + system=system, + remove_forces=CMMotionRemover, + add_forces=self._get_barostat( + solvent_component=solvent_component, + thermo_settings=thermo_settings, + integrator_settings=integrator_settings, + ), + ) + return topology, positions, system + def run( self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None ) -> dict[str, Any]: @@ -225,7 +355,7 @@ def run( self._assign_partial_charges(charge_settings, off_small_mols) - # Get stateA and stateB intercahnges + # Get stateA and stateB interchanges stateA_interchange, stateB_interchange, alchem_resids = self._get_interchanges( off_small_mols, protein_comp, @@ -236,21 +366,19 @@ def run( ) # get topology & positions - stateA_topology = stateA_interchange.to_openmm_topology(collate=True) - stateA_positions = to_openmm_positions( - stateA_interchange, - include_virtual_sites=True, - ) - stateA_system = stateA_interchange.to_openmm_system( - hydrogen_mass=forcefield_settings.hydrogen_mass - ) - stateB_topology = stateB_interchange.to_openmm_topology(collate=True) - stateB_positions = to_openmm_positions( - stateB_interchange, - include_virtual_sites=True, + stateA_topology, stateA_positions, stateA_system = self._get_omm_objects( + interchange=stateA_interchange, + forcefield_settings=forcefield_settings, + thermo_settings=thermo_settings, + integrator_settings=integrator_settings, + solvent_component=solvent_comp ) - stateB_system = stateB_interchange.to_openmm_system( - hydrogen_mass=forcefield_settings.hydrogen_mass + stateB_topology, stateB_positions, stateB_system = self._get_omm_objects( + interchange=stateB_interchange, + forcefield_settings=forcefield_settings, + thermo_settings=thermo_settings, + integrator_settings=integrator_settings, + solvent_component=solvent_comp ) # c. Define correspondence mappings between the two systems @@ -266,6 +394,13 @@ def run( fix_constraints=True, ) + # Sanity check the mappings looking at position overlaps + self._check_position_overlap( + ligand_mappings, + from_openmm(stateA_positions), + from_openmm(stateB_positions), + ) + # d. if a charge correction is necessary, select alchemical waters # and transform them if alchem_settings.explicit_charge_correction: @@ -284,15 +419,6 @@ def run( solvent_comp, ) - # e. Finally get the positions - # stateB_positions = _rfe_utils.topologyhelpers.set_and_check_new_positions( - # ligand_mappings, - # stateA_topology, - # stateB_topology, - # old_positions=ensure_quantity(stateA_positions, 'openmm'), - # insert_positions=ensure_quantity(off_small_mols['stateB'][0][1].conformers[0], 'openmm'), - # ) - # 3. Create the hybrid topology hybrid_factory = _rfe_utils.relative.HybridTopologyFactory( stateA_system, diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index 1663d56..68de628 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -204,7 +204,7 @@ def test_dry_run_ligand(benzene_system, toluene_system, benzene_to_toluene_mappi settings = HybridTopProtocol.default_settings() settings.simulation_settings.sampler_method = method settings.protocol_repeats = 1 - settings.output_settings.output_indices = "resname UNK" + settings.output_settings.output_indices = "resname AAA" protocol = HybridTopProtocol( settings=settings, diff --git a/src/pontibus/tests/utils/test_interchange_packmol.py b/src/pontibus/tests/utils/test_interchange_packmol.py index 38bf05c..607cdaa 100644 --- a/src/pontibus/tests/utils/test_interchange_packmol.py +++ b/src/pontibus/tests/utils/test_interchange_packmol.py @@ -107,7 +107,7 @@ def test_get_and_set_offmol_resname(CN_molecule, caplog): with caplog.at_level(logging.WARNING): assert _get_offmol_resname(CN_off) is None - assert "Inconsistent residue name" in caplog.text + assert "Inconsistent metadata residue_name" in caplog.text def test_check_library_charges_pass(water_off): diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index 1801da1..4152f95 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -31,7 +31,9 @@ def adjust_system( """ def _adjust_inputs(var): if var is not None: - return list(var) + if isinstance(var, list): + return var + return [var] else: return [] From ec824989ad9ab883b2d72c9908a4a828b8c6b4bc Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 24 Jul 2025 17:38:01 +0100 Subject: [PATCH 15/32] fix mypy issues --- src/pontibus/protocols/relative/hybridtop_units.py | 5 +++-- src/pontibus/utils/system_manipulation.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index 5988c7b..f4cd339 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -16,6 +16,7 @@ import openmmtools from gufe import SmallMoleculeComponent, SolventComponent from gufe.settings import ThermoSettings +from gufe.vendor.openff.models.types import ArrayQuantity from openfe.protocols.openmm_rfe import _rfe_utils from openfe.protocols.openmm_rfe.equil_rfe_methods import ( RelativeHybridTopologyProtocolUnit, @@ -71,8 +72,8 @@ class HybridTopProtocolUnit(RelativeHybridTopologyProtocolUnit): @staticmethod def _check_position_overlap( mapping: dict[str, dict[int, int]], - positionsA: Quantity, - positionsB: Quantity, + positionsA: ArrayQuantity, + positionsB: ArrayQuantity, threshold: Quantity = 1.0 * unit.angstrom, ): """ diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index 4152f95..2ef2948 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -116,7 +116,9 @@ def copy_interchange_with_replacement( new_topology = Topology.from_molecules(mols) new_topology.box_vectors = interchange.topology.box_vectors - charged_molecules = _check_and_deduplicate_charged_mols(charged_molecules) + + if charged_molecules is not None: + charged_molecules = _check_and_deduplicate_charged_mols(charged_molecules) new_interchange = force_field.create_interchange( topology=new_topology, From 133870d9034952e1e560b92fa29bd6ac4875969d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 01:19:47 +0100 Subject: [PATCH 16/32] Add serialized results and finish moving utils --- devtools/gen_serialized_results.py | 61 ++++++++- environment.yml | 1 + .../protocols/relative/hybridtop_protocol.py | 2 +- .../HybridTopProtocol_solvent_json_results.gz | Bin 0 -> 25803 bytes .../HybridTopProtocol_vacuum_json_results.gz | Bin 0 -> 26062 bytes .../tests/data/relative_protocol/__init__.py | 0 .../protocols/relative/test_tokenization.py | 116 ++++++++++++++++++ src/pontibus/utils/system_creation.py | 85 +------------ 8 files changed, 183 insertions(+), 82 deletions(-) create mode 100644 src/pontibus/tests/data/relative_protocol/HybridTopProtocol_solvent_json_results.gz create mode 100644 src/pontibus/tests/data/relative_protocol/HybridTopProtocol_vacuum_json_results.gz create mode 100644 src/pontibus/tests/data/relative_protocol/__init__.py create mode 100644 src/pontibus/tests/protocols/relative/test_tokenization.py diff --git a/devtools/gen_serialized_results.py b/devtools/gen_serialized_results.py index c47e117..bf20721 100644 --- a/devtools/gen_serialized_results.py +++ b/devtools/gen_serialized_results.py @@ -20,13 +20,17 @@ toolkit_registry_manager, ) from openff.units import unit +from kartograf.atom_aligner import align_mol_shape +from kartograf import KartografAtomMapper from pontibus.components import ExtendedSolventComponent from pontibus.protocols.solvation import ASFEProtocol +from pontibus.protocols.relative import HybridTopProtocol logger = logging.getLogger(__name__) LIGA = "[H]C([H])([H])C([H])([H])C(=O)C([H])([H])C([H])([H])[H]" +LIGB = "[H]C([H])([H])C(=O)C([H])([H])C([H])([H])C([H])([H])[H]" amber_rdkit = ToolkitRegistry([RDKitToolkitWrapper(), AmberToolsToolkitWrapper()]) @@ -111,7 +115,62 @@ def generate_asfe_json_octanol(smc): execute_and_serialize(dag, protocol, "ASFEProtocol_octanol") +def generate_rfe_settings(): + settings = HybridTopProtocol.default_settings() + settings.simulation_settings.equilibration_length = 10 * unit.picosecond + settings.simulation_settings.production_length = 250 * unit.picosecond + return settings + + +def generate_rfe_inputs(molA, molB): + a_molB = align_mol_shape(molB, ref_mol=molA) + mapper = KartografAtomMapper(atom_map_hydrogens=True) + mapping = next(mapper.suggest_mappings(molA, a_molB)) + + return mapping, molA, a_molB + + +def generate_hybridtop_rfe_vacuum_json(molA,molB): + settings = generate_rfe_settings() + settings.forcefield_settings.nonbonded_method = "nocutoff" + protocol = HybridTopProtocol(settings=settings) + + mapping, molA, molB = generate_rfe_inputs(molA, molB) + + systemA = openfe.ChemicalSystem({'ligand': molA}) + systemB = openfe.ChemicalSystem({'ligand': molB}) + + dag = protocol.create( + stateA=systemA, + stateB=systemB, + mapping=mapping, + ) + + execute_and_serialize(dag, protocol, "HybridTopProtocol_vacuum") + + +def generate_hybridtop_rfe_solvent_json(molA,molB): + settings = generate_rfe_settings() + protocol = HybridTopProtocol(settings=settings) + + mapping, molA, molB = generate_rfe_inputs(molA, molB) + + systemA = openfe.ChemicalSystem({'ligand': molA, 'solvent': openfe.SolventComponent()}) + systemB = openfe.ChemicalSystem({'ligand': molB, 'solvent': openfe.SolventComponent()}) + + dag = protocol.create( + stateA=systemA, + stateB=systemB, + mapping=mapping, + ) + + execute_and_serialize(dag, protocol, "HybridTopProtocol_solvent") + + if __name__ == "__main__": molA = get_molecule(LIGA, "ligandA") - # generate_asfe_json_water(molA) + molB = get_molecule(LIGB, "ligandB") + generate_asfe_json_water(molA) generate_asfe_json_octanol(molA) + generate_hybridtop_rfe_vacuum_json(molA, molB) + generate_hybridtop_rfe_solvent_json(molA, molB) diff --git a/environment.yml b/environment.yml index b7022a0..f2ac0ba 100644 --- a/environment.yml +++ b/environment.yml @@ -26,6 +26,7 @@ dependencies: - openmm >=8.0.0,!=8.1.0,<8.4.0 - openmmtools >=0.25.0 - openmmforcefields + - openfe-anallysis >=0.3.1 - plugcli - tqdm # Testing deps diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index a084ba9..5e29477 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -109,7 +109,7 @@ def _create( mapping: ComponentMapping | list[ComponentMapping] | None, extends: ProtocolDAGResult | None, ) -> list[ProtocolUnit]: - if extends: + if extends: # pragma: no cover raise NotImplementedError("Can't extend simulations yet") # Get alchemical components & validate them + mapping diff --git a/src/pontibus/tests/data/relative_protocol/HybridTopProtocol_solvent_json_results.gz b/src/pontibus/tests/data/relative_protocol/HybridTopProtocol_solvent_json_results.gz new file mode 100644 index 0000000000000000000000000000000000000000..f14d89244e341f0e85be10c1087de6690606390f GIT binary patch literal 25803 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ʔ!prSnDe`L4+>gLdfkzt9z%00vS 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 GApI}rMrA|* literal 0 HcmV?d00001 diff --git a/src/pontibus/tests/data/relative_protocol/__init__.py b/src/pontibus/tests/data/relative_protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pontibus/tests/protocols/relative/test_tokenization.py b/src/pontibus/tests/protocols/relative/test_tokenization.py new file mode 100644 index 0000000..7a3ddec --- /dev/null +++ b/src/pontibus/tests/protocols/relative/test_tokenization.py @@ -0,0 +1,116 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import json + +import gufe +import openfe +from openfe import SolventComponent +import pytest +from gufe.tests.test_tokenization import GufeTokenizableTestsMixin + +from pontibus.protocols.relative import ( + HybridTopProtocol, + HybridTopProtocolResult, + HybridTopProtocolUnit, +) + + +@pytest.fixture +def protocol(): + return HybridTopProtocol(HybridTopProtocol.default_settings()) + + +@pytest.fixture +def solvent_protocol_unit(protocol, benzene_modifications): + pus = protocol.create( + stateA=openfe.ChemicalSystem( + { + "ligand": benzene_modifications["benzene"], + "solvent": SolventComponent(), + } + ), + stateB=openfe.ChemicalSystem({"solvent": SolventComponent()}), + mapping=None, + ) + return list(pus.protocol_units)[0] + + +@pytest.fixture +def protocol_result(afe_solv_water_transformation_json): + d = json.loads(afe_solv_water_transformation_json, cls=gufe.tokenization.JSON_HANDLER.decoder) + pr = ASFEProtocolResult.from_dict(d["protocol_result"]) + return pr + + +class TestProtocol(GufeTokenizableTestsMixin): + cls = ASFEProtocol + key = None + repr = "ASFEProtocol-" + + @pytest.fixture() + def instance(self, protocol): + return protocol + + def test_repr(self, instance): + """ + Overwrites the base `test_repr` call. + """ + assert isinstance(repr(instance), str) + assert self.repr in repr(instance) + + +class TestSolventUnit(GufeTokenizableTestsMixin): + cls = ASFESolventUnit + repr = "ASFESolventUnit(Absolute Solvation, benzene solvent leg" + key = None + + @pytest.fixture() + def instance(self, solvent_protocol_unit): + return solvent_protocol_unit + + def test_key_stable(self): + pytest.skip() + + def test_repr(self, instance): + """ + Overwrites the base `test_repr` call. + """ + assert isinstance(repr(instance), str) + assert self.repr in repr(instance) + + +class TestVacuumUnit(GufeTokenizableTestsMixin): + cls = ASFEVacuumUnit + repr = "ASFEVacuumUnit(Absolute Solvation, benzene vacuum leg" + key = None + + @pytest.fixture() + def instance(self, vacuum_protocol_unit): + return vacuum_protocol_unit + + def test_key_stable(self): + pytest.skip() + + def test_repr(self, instance): + """ + Overwrites the base `test_repr` call. + """ + assert isinstance(repr(instance), str) + assert self.repr in repr(instance) + + +class TestProtocolResult(GufeTokenizableTestsMixin): + cls = ASFEProtocolResult + key = None + repr = "ASFEProtocolResult-" + + @pytest.fixture() + def instance(self, protocol_result): + return protocol_result + + def test_repr(self, instance): + """ + Overwrites the base `test_repr` call. + """ + assert isinstance(repr(instance), str) + assert self.repr in repr(instance) diff --git a/src/pontibus/utils/system_creation.py b/src/pontibus/utils/system_creation.py index bbb2b7b..9c670be 100644 --- a/src/pontibus/utils/system_creation.py +++ b/src/pontibus/utils/system_creation.py @@ -25,90 +25,15 @@ PackmolSolvationSettings, ) from pontibus.utils.molecules import offmol_water +from pontibus.utils.molecule_utils import ( + _set_offmol_resname, + _get_offmol_resname, + _check_library_charges, +) logger = logging.getLogger(__name__) -def _set_offmol_resname( - offmol: OFFMolecule, - resname: str, -) -> None: - """ - Helper method to set offmol residue names - - Parameters - ---------- - offmol : openff.toolkit.Molecule - Molecule to assign a residue name to. - resname : str - Residue name to be set. - - Returns - ------- - None - """ - for a in offmol.atoms: - a.metadata["residue_name"] = resname - - -def _get_offmol_resname(offmol: OFFMolecule) -> str | None: - """ - Helper method to get an offmol's residue name and make sure it is - consistent across all atoms in the Molecule. - - Parameters - ---------- - offmol : openff.toolkit.Molecule - Molecule to get the residue name from. - - Returns - ------- - resname : Optional[str] - Residue name of the molecule. ``None`` if the Molecule - does not have a residue name, or if the residue name is - inconsistent across all the atoms. - """ - resname: str | None = None - for a in offmol.atoms: - if resname is None: - try: - resname = a.metadata["residue_name"] - except KeyError: - return None - - if resname != a.metadata["residue_name"]: - wmsg = f"Inconsistent residue name in OFFMol: {offmol} " - logger.warning(wmsg) - return None - - return resname - - -def _check_library_charges( - force_field: ForceField, - offmol: OFFMolecule, -) -> None: - """ - Check that library charges exists for an input molecule. - - force_field : openff.toolkit.ForceField - Force Field object with library charges. - offmol : openff.toolkit.Molecule - Molecule to check for matching library charges. - - Raises - ------ - ValueError - If no library charges are found for the molecule. - """ - handler = force_field.get_parameter_handler("LibraryCharges") - matches = handler.find_matches(offmol.to_topology()) - - if len(matches) == 0: - errmsg = f"No library charges found for {offmol}" - raise ValueError(errmsg) - - def _check_and_deduplicate_charged_mols( molecules: list[OFFMolecule], ) -> list[OFFMolecule]: From 02476023fce7df68c48e8c80563f39a5e920b1e7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 01:20:57 +0100 Subject: [PATCH 17/32] fix dep typo --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index f2ac0ba..84a96fd 100644 --- a/environment.yml +++ b/environment.yml @@ -26,7 +26,7 @@ dependencies: - openmm >=8.0.0,!=8.1.0,<8.4.0 - openmmtools >=0.25.0 - openmmforcefields - - openfe-anallysis >=0.3.1 + - openfe-analysis >=0.3.1 - plugcli - tqdm # Testing deps From cce7720a0937a43ffd3c02af06057bd2cdeed9a8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 08:39:37 +0100 Subject: [PATCH 18/32] fix up most tokenization tests --- .../tests/protocols/relative/conftest.py | 33 ++++++++++ .../protocols/relative/test_tokenization.py | 62 +++++++++++++------ .../tests/protocols/solvation/conftest.py | 4 +- 3 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 src/pontibus/tests/protocols/relative/conftest.py diff --git a/src/pontibus/tests/protocols/relative/conftest.py b/src/pontibus/tests/protocols/relative/conftest.py new file mode 100644 index 0000000..85aba6f --- /dev/null +++ b/src/pontibus/tests/protocols/relative/conftest.py @@ -0,0 +1,33 @@ +import gzip +from importlib import resources + +import gufe +import pytest + + +@pytest.fixture +def rfe_solv_transformation_json() -> str: + """ + HybridTop results object as created by quickrun. + + generated with devtools/gen-serialized-results.py + """ + d = resources.files("pontibus.tests.data.relative_protocol") + file = d / "HybridTopProtocol_solvent_json_results.gz" + + with gzip.open(file.as_posix(), "r") as f: # type: ignore + return f.read().decode() # type: ignore + + +@pytest.fixture +def rfe_vacuum_transformation_json() -> str: + """ + Hybrid results object as created by quickrun. + + generated with devtools/gen-serialized-results.py + """ + d = resources.files("pontibus.tests.data.relative_protocol") + file = d / "HybridTopProtocol_vacuum_json_results.gz" + + with gzip.open(file.as_posix(), "r") as f: # type: ignore + return f.read().decode() # type: ignore diff --git a/src/pontibus/tests/protocols/relative/test_tokenization.py b/src/pontibus/tests/protocols/relative/test_tokenization.py index 7a3ddec..e20e083 100644 --- a/src/pontibus/tests/protocols/relative/test_tokenization.py +++ b/src/pontibus/tests/protocols/relative/test_tokenization.py @@ -21,31 +21,53 @@ def protocol(): @pytest.fixture -def solvent_protocol_unit(protocol, benzene_modifications): +def vacuum_protocol(): + settings = HybridTopProtocol.default_settings() + settings.forcefield_settings.nonbonded_method = 'nocutoff' + return HybridTopProtocol(settings=settings) + + +@pytest.fixture +def vacuum_protocol_unit( + vacuum_protocol, + benzene_to_toluene_mapping, + benzene_vacuum_system, + toluene_vacuum_system +): + pus = vacuum_protocol.create( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + return list(pus.protocol_units)[0] + + +@pytest.fixture +def solvent_protocol_unit( + protocol, + benzene_to_toluene_mapping, + benzene_system, + toluene_system +): pus = protocol.create( - stateA=openfe.ChemicalSystem( - { - "ligand": benzene_modifications["benzene"], - "solvent": SolventComponent(), - } - ), - stateB=openfe.ChemicalSystem({"solvent": SolventComponent()}), - mapping=None, + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping, ) return list(pus.protocol_units)[0] @pytest.fixture -def protocol_result(afe_solv_water_transformation_json): - d = json.loads(afe_solv_water_transformation_json, cls=gufe.tokenization.JSON_HANDLER.decoder) - pr = ASFEProtocolResult.from_dict(d["protocol_result"]) +def protocol_result(rfe_solv_transformation_json): + d = json.loads(rfe_solv_transformation_json, cls=gufe.tokenization.JSON_HANDLER.decoder) + pr = HybridTopProtocolResult.from_dict(d["protocol_result"]) return pr class TestProtocol(GufeTokenizableTestsMixin): - cls = ASFEProtocol + cls = HybridTopProtocol key = None - repr = "ASFEProtocol-" + repr = "HybridTopProtocol-" @pytest.fixture() def instance(self, protocol): @@ -60,8 +82,8 @@ def test_repr(self, instance): class TestSolventUnit(GufeTokenizableTestsMixin): - cls = ASFESolventUnit - repr = "ASFESolventUnit(Absolute Solvation, benzene solvent leg" + cls = HybridTopProtocolUnit + repr = "HybridTopProtocolUnit(" key = None @pytest.fixture() @@ -80,8 +102,8 @@ def test_repr(self, instance): class TestVacuumUnit(GufeTokenizableTestsMixin): - cls = ASFEVacuumUnit - repr = "ASFEVacuumUnit(Absolute Solvation, benzene vacuum leg" + cls = HybridTopProtocolUnit + repr = "HybridTopProtocolUnit(" key = None @pytest.fixture() @@ -100,9 +122,9 @@ def test_repr(self, instance): class TestProtocolResult(GufeTokenizableTestsMixin): - cls = ASFEProtocolResult + cls = HybridTopProtocolResult key = None - repr = "ASFEProtocolResult-" + repr = "HybridTopProtocolResult-" @pytest.fixture() def instance(self, protocol_result): diff --git a/src/pontibus/tests/protocols/solvation/conftest.py b/src/pontibus/tests/protocols/solvation/conftest.py index fc911b8..89f6069 100644 --- a/src/pontibus/tests/protocols/solvation/conftest.py +++ b/src/pontibus/tests/protocols/solvation/conftest.py @@ -17,7 +17,7 @@ def afe_solv_water_transformation_json() -> str: """ ASFE results object as created by quickrun. - generated with devtools/gent-serialized-results.py + generated with devtools/gen-serialized-results.py """ d = resources.files("pontibus.tests.data.solvation_protocol") file = d / "ASFEProtocol_water_json_results.gz" @@ -31,7 +31,7 @@ def afe_solv_octanol_transformation_json() -> str: """ ASFE results object as created by quickrun. - generated with devtools/gent-serialized-results.py + generated with devtools/gen-serialized-results.py """ d = resources.files("pontibus.tests.data.solvation_protocol") file = d / "ASFEProtocol_octanol_json_results.gz" From 51ad8912bc16e74517faa74e42e64879801235e7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 09:51:22 +0100 Subject: [PATCH 19/32] Fix up tokenization issues --- .../protocols/relative/hybridtop_protocol.py | 2 +- .../HybridTopProtocol_solvent_json_results.gz | Bin 25803 -> 25431 bytes .../HybridTopProtocol_vacuum_json_results.gz | Bin 26062 -> 25570 bytes 3 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pontibus/protocols/relative/hybridtop_protocol.py b/src/pontibus/protocols/relative/hybridtop_protocol.py index 5e29477..acf1d0d 100644 --- a/src/pontibus/protocols/relative/hybridtop_protocol.py +++ b/src/pontibus/protocols/relative/hybridtop_protocol.py @@ -67,7 +67,7 @@ class HybridTopProtocol(RelativeHybridTopologyProtocol): :class:`pontibus.protocols.relative.HybridTopProtocolUnit` """ - results_cls = HybridTopProtocolResult + result_cls = HybridTopProtocolResult _settings_cls = HybridTopProtocolSettings _settings: HybridTopProtocolSettings diff --git a/src/pontibus/tests/data/relative_protocol/HybridTopProtocol_solvent_json_results.gz b/src/pontibus/tests/data/relative_protocol/HybridTopProtocol_solvent_json_results.gz index f14d89244e341f0e85be10c1087de6690606390f..99ada775b6fc34f70c907b88d3e06521fc62ebcd 100644 GIT binary patch literal 25431 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

&-MSW6=XxdMA&f0 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 J^bCOUe*hL_#2x?u literal 25803 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ʔ!prSnDe`L4+>gLdfkzt9z%00vS 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 GApI}rMrA|* From 8a2fe20506bae2136414fd0277050106fd3ecbad Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 10:12:01 +0100 Subject: [PATCH 20/32] Add results tests --- .../tests/protocols/relative/test_results.py | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/pontibus/tests/protocols/relative/test_results.py diff --git a/src/pontibus/tests/protocols/relative/test_results.py b/src/pontibus/tests/protocols/relative/test_results.py new file mode 100644 index 0000000..bb5ca94 --- /dev/null +++ b/src/pontibus/tests/protocols/relative/test_results.py @@ -0,0 +1,174 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import itertools +import json + +import gufe +import numpy as np +import openfe +import pytest +from openff.units import unit as offunit + +from pontibus.protocols.relative import HybridTopProtocolResult + + +class TestSolventProtocolResult: + @pytest.fixture() + def protocolresult(self, rfe_solv_transformation_json): + d = json.loads( + rfe_solv_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder, + ) + + pr = openfe.ProtocolResult.from_dict(d["protocol_result"]) + + return pr + + def test_reload_protocol_result(self, rfe_solv_transformation_json): + d = json.loads( + rfe_solv_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder, + ) + + pr = HybridTopProtocolResult.from_dict(d["protocol_result"]) + + assert pr + + def test_get_estimate(self, protocolresult): + est = protocolresult.get_estimate() + + assert est + assert est.m == pytest.approx(16.94, abs=0.5) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) + + def test_get_uncertainty(self, protocolresult): + est = protocolresult.get_uncertainty() + + assert est + assert est.m == pytest.approx(0.2, abs=0.2) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) + + def test_get_individual(self, protocolresult): + inds = protocolresult.get_individual_estimates() + + assert isinstance(inds, list) + assert len(inds) == 3 + + for e, u in inds: + assert e.is_compatible_with(offunit.kilojoule_per_mole) + assert u.is_compatible_with(offunit.kilojoule_per_mole) + + def test_get_forwards_etc(self, protocolresult): + far = protocolresult.get_forward_and_reverse_energy_analysis() + + assert isinstance(far, list) + far1 = far[0] + assert isinstance(far1, dict) + + for k in [ + "fractions", + "forward_DGs", + "forward_dDGs", + "reverse_DGs", + "reverse_dDGs", + ]: + assert k in far1 + + if k == "fractions": + assert isinstance(far1[k], np.ndarray) + else: + assert isinstance(far1[k], offunit.Quantity) + assert far1[k].is_compatible_with(offunit.kilojoule_per_mole) + + def test_get_frwd_reverse_none_return(self, protocolresult): + # fetch the first result + data = [i for i in protocolresult.data.values()][0][0] + # set the output to None + data.outputs["forward_and_reverse_energies"] = None + + # now fetch the analysis results and expect a warning + wmsg = "One or more ``None`` entries were found in" + with pytest.warns(UserWarning, match=wmsg): + protocolresult.get_forward_and_reverse_energy_analysis() + + def test_get_overlap_matrices(self, protocolresult): + ovp = protocolresult.get_overlap_matrices() + + assert isinstance(ovp, list) + assert len(ovp) == 3 + + ovp1 = ovp[0] + assert isinstance(ovp1["matrix"], np.ndarray) + assert ovp1["matrix"].shape == (11, 11) + + def test_get_replica_transition_statistics(self, protocolresult): + rpx = protocolresult.get_replica_transition_statistics() + + assert isinstance(rpx, list) + assert len(rpx) == 3 + rpx1 = rpx[0] + assert "eigenvalues" in rpx1 + assert "matrix" in rpx1 + assert rpx1["eigenvalues"].shape == (11,) + assert rpx1["matrix"].shape == (11, 11) + + def test_equilibration_iterations(self, protocolresult): + eq = protocolresult.equilibration_iterations() + + assert isinstance(eq, list) + assert len(eq) == 3 + assert all(isinstance(v, float) for v in eq) + + def test_production_iterations(self, protocolresult): + prod = protocolresult.production_iterations() + + assert isinstance(prod, list) + assert len(prod) == 3 + assert all(isinstance(v, float) for v in prod) + + def test_filenotfound_replica_states(self, protocolresult): + errmsg = "File could not be found" + + with pytest.raises(ValueError, match=errmsg): + protocolresult.get_replica_states() + + +class TestVacuumProtocolResult(TestSolventProtocolResult): + @pytest.fixture() + def protocolresult(self, rfe_vacuum_transformation_json): + d = json.loads( + rfe_vacuum_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder, + ) + + pr = openfe.ProtocolResult.from_dict(d["protocol_result"]) + + return pr + + def test_reload_protocol_result(self, rfe_vacuum_transformation_json): + d = json.loads( + rfe_vacuum_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder, + ) + + pr = HybridTopProtocolResult.from_dict(d["protocol_result"]) + + assert pr + + def test_get_estimate(self, protocolresult): + est = protocolresult.get_estimate() + + assert est + assert est.m == pytest.approx(16.94, abs=0.5) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) + + def test_get_uncertainty(self, protocolresult): + est = protocolresult.get_uncertainty() + + assert est + assert est.m == pytest.approx(0.16, abs=0.2) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) From 67946968282b6c72f66230a55b999791ed49c5a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:58:38 +0000 Subject: [PATCH 21/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- devtools/gen_serialized_results.py | 18 +++++------ .../protocols/relative/hybridtop_units.py | 32 ++++++++----------- src/pontibus/protocols/solvation/base.py | 6 +--- .../tests/protocols/relative/conftest.py | 1 - .../tests/protocols/relative/test_protocol.py | 2 +- .../tests/protocols/relative/test_results.py | 1 - .../protocols/relative/test_tokenization.py | 16 ++-------- .../tests/utils/test_interchange_packmol.py | 6 ++-- src/pontibus/utils/molecule_utils.py | 4 +-- src/pontibus/utils/system_creation.py | 12 +++---- src/pontibus/utils/system_manipulation.py | 8 +++-- 11 files changed, 43 insertions(+), 63 deletions(-) diff --git a/devtools/gen_serialized_results.py b/devtools/gen_serialized_results.py index bf20721..8138c0d 100644 --- a/devtools/gen_serialized_results.py +++ b/devtools/gen_serialized_results.py @@ -14,18 +14,18 @@ import gufe import openfe from gufe.tokenization import JSON_HANDLER +from kartograf import KartografAtomMapper +from kartograf.atom_aligner import align_mol_shape from openff.toolkit import AmberToolsToolkitWrapper, Molecule, RDKitToolkitWrapper from openff.toolkit.utils.toolkit_registry import ( ToolkitRegistry, toolkit_registry_manager, ) from openff.units import unit -from kartograf.atom_aligner import align_mol_shape -from kartograf import KartografAtomMapper from pontibus.components import ExtendedSolventComponent -from pontibus.protocols.solvation import ASFEProtocol from pontibus.protocols.relative import HybridTopProtocol +from pontibus.protocols.solvation import ASFEProtocol logger = logging.getLogger(__name__) @@ -130,15 +130,15 @@ def generate_rfe_inputs(molA, molB): return mapping, molA, a_molB -def generate_hybridtop_rfe_vacuum_json(molA,molB): +def generate_hybridtop_rfe_vacuum_json(molA, molB): settings = generate_rfe_settings() settings.forcefield_settings.nonbonded_method = "nocutoff" protocol = HybridTopProtocol(settings=settings) mapping, molA, molB = generate_rfe_inputs(molA, molB) - systemA = openfe.ChemicalSystem({'ligand': molA}) - systemB = openfe.ChemicalSystem({'ligand': molB}) + systemA = openfe.ChemicalSystem({"ligand": molA}) + systemB = openfe.ChemicalSystem({"ligand": molB}) dag = protocol.create( stateA=systemA, @@ -149,14 +149,14 @@ def generate_hybridtop_rfe_vacuum_json(molA,molB): execute_and_serialize(dag, protocol, "HybridTopProtocol_vacuum") -def generate_hybridtop_rfe_solvent_json(molA,molB): +def generate_hybridtop_rfe_solvent_json(molA, molB): settings = generate_rfe_settings() protocol = HybridTopProtocol(settings=settings) mapping, molA, molB = generate_rfe_inputs(molA, molB) - systemA = openfe.ChemicalSystem({'ligand': molA, 'solvent': openfe.SolventComponent()}) - systemB = openfe.ChemicalSystem({'ligand': molB, 'solvent': openfe.SolventComponent()}) + systemA = openfe.ChemicalSystem({"ligand": molA, "solvent": openfe.SolventComponent()}) + systemB = openfe.ChemicalSystem({"ligand": molB, "solvent": openfe.SolventComponent()}) dag = protocol.create( stateA=systemA, diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index f4cd339..9f5487b 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -4,13 +4,13 @@ ProtocolUnit implementations for the HybridTopProtocol. """ +import logging import os import pathlib +import warnings from itertools import chain from typing import Any -import warnings -import logging import mdtraj import numpy as np import openmmtools @@ -42,12 +42,12 @@ from openff.interchange import Interchange from openff.interchange.interop.openmm import to_openmm_positions from openff.toolkit import Molecule as OFFMolecule -from openff.units import unit, Quantity +from openff.units import Quantity, unit from openff.units.openmm import from_openmm, to_openmm -from openmmtools import multistate +from openmm import CMMotionRemover, MonteCarloBarostat, System from openmm import unit as omm_unit -from openmm import System, MonteCarloBarostat, CMMotionRemover from openmm.app import Topology +from openmmtools import multistate from pontibus.protocols.relative.settings import HybridTopProtocolSettings from pontibus.protocols.solvation.base import _get_and_charge_solvent_offmol @@ -60,11 +60,10 @@ interchange_packmol_creation, ) from pontibus.utils.system_manipulation import ( - copy_interchange_with_replacement, adjust_system, + copy_interchange_with_replacement, ) - logger = logging.getLogger(__name__) @@ -98,13 +97,13 @@ def _check_position_overlap( If any core atoms deviate by more than the threshold. """ # Check env mappings - for key, val in mapping['old_to_new_env_atom_map'].items(): + for key, val in mapping["old_to_new_env_atom_map"].items(): if np.any(np.abs(positionsB[val] - positionsA[key]) > threshold): msg = f"env mapping {key} : {val} deviates by more than {threshold}" raise ValueError(msg) # Check core mappings - for key, val in mapping['old_to_new_core_atom_map'].items(): + for key, val in mapping["old_to_new_core_atom_map"].items(): if np.any(np.abs(positionsB[val] - positionsA[key]) > threshold): msg = f"core mapping {key} : {val} deviates by more than {threshold}" warnings.warn(msg) @@ -139,7 +138,7 @@ def _get_barostat( return MonteCarloBarostat( to_openmm(thermo_settings.pressure), to_openmm(thermo_settings.temperature), - integrator_settings.barostat_frequency.m + integrator_settings.barostat_frequency.m, ) @staticmethod @@ -176,10 +175,7 @@ def _get_interchanges( ) # Get a list of the charged molecules to create stateB - stateB_charged_mols = [ - pair[1] - for pair in chain(small_mols["stateB"], small_mols["both"]) - ] + stateB_charged_mols = [pair[1] for pair in chain(small_mols["stateB"], small_mols["both"])] if solvent_component is not None and solvation_settings.assign_solvent_charges: stateB_charged_mols.append(solvent_offmol) @@ -229,9 +225,7 @@ def _get_omm_objects( interchange, include_virtual_sites=True, ) - system = interchange.to_openmm_system( - hydrogen_mass=forcefield_settings.hydrogen_mass - ) + system = interchange.to_openmm_system(hydrogen_mass=forcefield_settings.hydrogen_mass) adjust_system( system=system, remove_forces=CMMotionRemover, @@ -372,14 +366,14 @@ def run( forcefield_settings=forcefield_settings, thermo_settings=thermo_settings, integrator_settings=integrator_settings, - solvent_component=solvent_comp + solvent_component=solvent_comp, ) stateB_topology, stateB_positions, stateB_system = self._get_omm_objects( interchange=stateB_interchange, forcefield_settings=forcefield_settings, thermo_settings=thermo_settings, integrator_settings=integrator_settings, - solvent_component=solvent_comp + solvent_component=solvent_comp, ) # c. Define correspondence mappings between the two systems diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index 6d02fd9..b38f34e 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -154,11 +154,7 @@ def _get_omm_objects( else: barostat = None - adjust_system( - system=omm_system, - remove_forces=openmm.CMMotionRemover, - add_forces=barostat - ) + adjust_system(system=omm_system, remove_forces=openmm.CMMotionRemover, add_forces=barostat) positions = to_openmm_positions(interchange, include_virtual_sites=True) diff --git a/src/pontibus/tests/protocols/relative/conftest.py b/src/pontibus/tests/protocols/relative/conftest.py index 85aba6f..12cf90d 100644 --- a/src/pontibus/tests/protocols/relative/conftest.py +++ b/src/pontibus/tests/protocols/relative/conftest.py @@ -1,7 +1,6 @@ import gzip from importlib import resources -import gufe import pytest diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index 68de628..cdff001 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -7,7 +7,7 @@ import pytest from openff.units import unit from openff.units.openmm import ensure_quantity -from openmm import NonbondedForce, MonteCarloBarostat +from openmm import MonteCarloBarostat, NonbondedForce from openmm import unit as omm_unit from openmmtools.multistate import MultiStateSampler from rdkit import Chem diff --git a/src/pontibus/tests/protocols/relative/test_results.py b/src/pontibus/tests/protocols/relative/test_results.py index bb5ca94..7e7157b 100644 --- a/src/pontibus/tests/protocols/relative/test_results.py +++ b/src/pontibus/tests/protocols/relative/test_results.py @@ -1,6 +1,5 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -import itertools import json import gufe diff --git a/src/pontibus/tests/protocols/relative/test_tokenization.py b/src/pontibus/tests/protocols/relative/test_tokenization.py index e20e083..4137eef 100644 --- a/src/pontibus/tests/protocols/relative/test_tokenization.py +++ b/src/pontibus/tests/protocols/relative/test_tokenization.py @@ -3,8 +3,6 @@ import json import gufe -import openfe -from openfe import SolventComponent import pytest from gufe.tests.test_tokenization import GufeTokenizableTestsMixin @@ -23,16 +21,13 @@ def protocol(): @pytest.fixture def vacuum_protocol(): settings = HybridTopProtocol.default_settings() - settings.forcefield_settings.nonbonded_method = 'nocutoff' + settings.forcefield_settings.nonbonded_method = "nocutoff" return HybridTopProtocol(settings=settings) @pytest.fixture def vacuum_protocol_unit( - vacuum_protocol, - benzene_to_toluene_mapping, - benzene_vacuum_system, - toluene_vacuum_system + vacuum_protocol, benzene_to_toluene_mapping, benzene_vacuum_system, toluene_vacuum_system ): pus = vacuum_protocol.create( stateA=benzene_vacuum_system, @@ -43,12 +38,7 @@ def vacuum_protocol_unit( @pytest.fixture -def solvent_protocol_unit( - protocol, - benzene_to_toluene_mapping, - benzene_system, - toluene_system -): +def solvent_protocol_unit(protocol, benzene_to_toluene_mapping, benzene_system, toluene_system): pus = protocol.create( stateA=benzene_system, stateB=toluene_system, diff --git a/src/pontibus/tests/utils/test_interchange_packmol.py b/src/pontibus/tests/utils/test_interchange_packmol.py index 607cdaa..247e5a2 100644 --- a/src/pontibus/tests/utils/test_interchange_packmol.py +++ b/src/pontibus/tests/utils/test_interchange_packmol.py @@ -23,12 +23,12 @@ InterchangeFFSettings, PackmolSolvationSettings, ) -from pontibus.utils.molecules import WATER from pontibus.utils.molecule_utils import ( - _set_offmol_resname, - _get_offmol_resname, _check_library_charges, + _get_offmol_resname, + _set_offmol_resname, ) +from pontibus.utils.molecules import WATER from pontibus.utils.system_creation import ( _check_and_deduplicate_charged_mols, _get_comp_resnames, diff --git a/src/pontibus/utils/molecule_utils.py b/src/pontibus/utils/molecule_utils.py index ec5440b..c6775c2 100644 --- a/src/pontibus/utils/molecule_utils.py +++ b/src/pontibus/utils/molecule_utils.py @@ -2,10 +2,10 @@ # For details, see https://github.com/OpenFreeEnergy/openfe import logging from typing import Any + from openff.toolkit import ForceField from openff.toolkit import Molecule as OFFMolecule - logger = logging.getLogger(__name__) @@ -128,4 +128,4 @@ def _check_library_charges( if len(matches) == 0: errmsg = f"No library charges found for {offmol}" - raise ValueError(errmsg) \ No newline at end of file + raise ValueError(errmsg) diff --git a/src/pontibus/utils/system_creation.py b/src/pontibus/utils/system_creation.py index 9c670be..50f61da 100644 --- a/src/pontibus/utils/system_creation.py +++ b/src/pontibus/utils/system_creation.py @@ -20,16 +20,16 @@ from openff.toolkit import Molecule as OFFMolecule from openff.units import Quantity, unit +from pontibus.utils.molecule_utils import ( + _check_library_charges, + _get_offmol_resname, + _set_offmol_resname, +) +from pontibus.utils.molecules import offmol_water from pontibus.utils.settings import ( InterchangeFFSettings, PackmolSolvationSettings, ) -from pontibus.utils.molecules import offmol_water -from pontibus.utils.molecule_utils import ( - _set_offmol_resname, - _get_offmol_resname, - _check_library_charges, -) logger = logging.getLogger(__name__) diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index 2ef2948..87fed40 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -2,14 +2,15 @@ # For details, see https://github.com/OpenFreeEnergy/openfe import numpy as np -from openmm import System, Force, CMMotionRemover -from openff.toolkit import Molecule, Topology, ForceField from openff.interchange import Interchange -from pontibus.utils.system_creation import _check_and_deduplicate_charged_mols +from openff.toolkit import ForceField, Molecule, Topology +from openmm import CMMotionRemover, Force, System + from pontibus.utils.molecule_utils import ( _get_offmol_metadata, _set_offmol_metadata, ) +from pontibus.utils.system_creation import _check_and_deduplicate_charged_mols def adjust_system( @@ -29,6 +30,7 @@ def adjust_system( add_forces : list[Force] | None The forces to add to the system. """ + def _adjust_inputs(var): if var is not None: if isinstance(var, list): From 8ef8a9de47de5cdfd516f19a071bb72b713edeb3 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 12:00:19 +0100 Subject: [PATCH 22/32] Fix F841 --- src/pontibus/protocols/relative/hybridtop_units.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index 9f5487b..29786a7 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -505,7 +505,8 @@ def run( traj = mdtraj.Trajectory( hybrid_factory.hybrid_positions[selection_indices, :], hybrid_factory.hybrid_topology.subset(selection_indices), - ).save_pdb( + ) + traj.save_pdb( shared_basepath / output_settings.output_structure, bfactors=bfactors, ) From 30d6af07949bff2bd02e7720e1dba13d5c4e2418 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 12:05:58 +0100 Subject: [PATCH 23/32] Switch back to interchange main --- environment.yml | 2 +- src/pontibus/tests/utils/test_system_manipulation.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/pontibus/tests/utils/test_system_manipulation.py diff --git a/environment.yml b/environment.yml index 84a96fd..6fbfebf 100644 --- a/environment.yml +++ b/environment.yml @@ -37,4 +37,4 @@ dependencies: - pip: - git+https://github.com/OpenFreeEnergy/openfe@main - git+https://github.com/OpenFreeEnergy/ofe-sphinx-theme@a45f3edd5bc3e973c1a01b577c71efa1b62a65d6 - - git+https://github.com/openforcefield/openff-interchange@sltcap + - git+https://github.com/openforcefield/openff-interchange@main diff --git a/src/pontibus/tests/utils/test_system_manipulation.py b/src/pontibus/tests/utils/test_system_manipulation.py new file mode 100644 index 0000000..e69de29 From 182aca5da29227bd769e85c2710466f9abc1ee4a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 25 Jul 2025 13:21:31 +0100 Subject: [PATCH 24/32] update env --- environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index 6fbfebf..5debf74 100644 --- a/environment.yml +++ b/environment.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - pip - - gufe >=1.5 + - gufe >=1.6 - openff-toolkit >0.16.0 - openff-interchange >=0.4 - openff-nagl-base >=0.3.3 @@ -20,7 +20,7 @@ dependencies: - pydantic >=2.0 - pyyaml - coverage - - cinnabar ~=0.4.0 + - cinnabar ~=0.5.0 - click - typing-extensions - openmm >=8.0.0,!=8.1.0,<8.4.0 From 0c5d70f7198eada8bd20361ed062e425e3f712a7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 10:42:45 +0100 Subject: [PATCH 25/32] adjust system manipulation things --- .../protocols/relative/hybridtop_units.py | 2 +- src/pontibus/protocols/solvation/base.py | 2 +- .../tests/utils/test_system_manipulation.py | 35 +++++++++++++++++++ src/pontibus/utils/system_manipulation.py | 12 +++---- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/pontibus/protocols/relative/hybridtop_units.py b/src/pontibus/protocols/relative/hybridtop_units.py index 29786a7..8a12b6c 100644 --- a/src/pontibus/protocols/relative/hybridtop_units.py +++ b/src/pontibus/protocols/relative/hybridtop_units.py @@ -228,7 +228,7 @@ def _get_omm_objects( system = interchange.to_openmm_system(hydrogen_mass=forcefield_settings.hydrogen_mass) adjust_system( system=system, - remove_forces=CMMotionRemover, + remove_force_types=CMMotionRemover, add_forces=self._get_barostat( solvent_component=solvent_component, thermo_settings=thermo_settings, diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index b38f34e..4073ac9 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -154,7 +154,7 @@ def _get_omm_objects( else: barostat = None - adjust_system(system=omm_system, remove_forces=openmm.CMMotionRemover, add_forces=barostat) + adjust_system(system=omm_system, remove_force_types=openmm.CMMotionRemover, add_forces=barostat) positions = to_openmm_positions(interchange, include_virtual_sites=True) diff --git a/src/pontibus/tests/utils/test_system_manipulation.py b/src/pontibus/tests/utils/test_system_manipulation.py index e69de29..c04f8c9 100644 --- a/src/pontibus/tests/utils/test_system_manipulation.py +++ b/src/pontibus/tests/utils/test_system_manipulation.py @@ -0,0 +1,35 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +from openmm import System, CMMotionRemover, MonteCarloBarostat +from openmm import unit as omm_unit +from pontibus.utils.system_manipulation import ( + adjust_system, + copy_interchange_with_replacement, +) + + +def test_adjust_forces_nothing(): + """ + A smoke test, this should just pass. + """ + system = System() + adjust_system(system) + + +def test_ajdust_forces_remove_com_remover(): + system = System() + com_force = CMMotionRemover() + system.addForce(com_force) + adjust_system(system, remove_force_types=CMMotionRemover) + + assert system.getNumForces() == 0 + + +def test_adjust_forces_add_comm_and_barostat(): + system = System() + barostat = MonteCarloBarostat(1.0 * omm_unit.bar, 298.15 * omm_unit.kelvin) + com_force = CMMotionRemover() + adjust_system(system, add_forces=[barostat, com_force]) + + assert system.getNumForces() == 2 diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index 87fed40..66d1005 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -4,7 +4,7 @@ import numpy as np from openff.interchange import Interchange from openff.toolkit import ForceField, Molecule, Topology -from openmm import CMMotionRemover, Force, System +from openmm import Force, System from pontibus.utils.molecule_utils import ( _get_offmol_metadata, @@ -15,7 +15,7 @@ def adjust_system( system: System, - remove_forces: Force | list[Force] | None = list[CMMotionRemover], + remove_force_types: type | list[type] | None = None, add_forces: Force | list[Force] | None = None, ) -> None: """ @@ -25,8 +25,8 @@ def adjust_system( ---------- system : System The OpenMM System to adjust - remove_forces : list[Force] | None - The forces to remove from the System, if present. + remove_force_types : type | list[type] | None + The force types to remove from the System, if present. add_forces : list[Force] | None The forces to add to the system. """ @@ -39,10 +39,10 @@ def _adjust_inputs(var): else: return [] - remove_forces = _adjust_inputs(remove_forces) + remove_force_types = _adjust_inputs(remove_force_types) add_forces = _adjust_inputs(add_forces) - for entry in remove_forces: + for entry in remove_force_types: for idx in reversed(range(system.getNumForces())): force = system.getForce(idx) if isinstance(force, entry): From 28d8d82edbea83048fe8be2accb130a5784bf9ba Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 11:31:11 +0100 Subject: [PATCH 26/32] Add system manipulation tests --- .../tests/utils/test_system_manipulation.py | 114 ++++++++++++++++++ src/pontibus/utils/molecule_utils.py | 16 ++- 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/pontibus/tests/utils/test_system_manipulation.py b/src/pontibus/tests/utils/test_system_manipulation.py index c04f8c9..3de10ab 100644 --- a/src/pontibus/tests/utils/test_system_manipulation.py +++ b/src/pontibus/tests/utils/test_system_manipulation.py @@ -1,12 +1,21 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +import pytest +from numpy.testing import assert_allclose from openmm import System, CMMotionRemover, MonteCarloBarostat from openmm import unit as omm_unit +from openff.toolkit import Molecule, ForceField, Topology +from openff.interchange import Interchange +from openff.interchange.components._packmol import solvate_topology from pontibus.utils.system_manipulation import ( adjust_system, copy_interchange_with_replacement, ) +from pontibus.utils.molecule_utils import ( + _set_offmol_metadata, + _get_offmol_metadata, +) def test_adjust_forces_nothing(): @@ -33,3 +42,108 @@ def test_adjust_forces_add_comm_and_barostat(): adjust_system(system, add_forces=[barostat, com_force]) assert system.getNumForces() == 2 + + +@pytest.fixture(scope='module') +def forcefield(): + return ForceField('openff-2.2.1.offxml') + + +@pytest.fixture(scope='module') +def insert_molecule(): + m = Molecule.from_smiles('CCCC') + m.generate_conformers(n_conformers=1) + return m + + +@pytest.fixture(scope='module') +def del_molecule(): + m = Molecule.from_smiles('CCO') + m.generate_conformers(n_conformers=1) + return m + + +def test_copy_no_conformers(forcefield): + m1 = Molecule.from_smiles('C') + m2 = Molecule.from_smiles('O') + topology = Topology.from_molecules([m1]) + inter = Interchange.from_smirnoff(forcefield, topology) + + with pytest.raises(ValueError, match="molecules need conformers"): + inter_new = copy_interchange_with_replacement( + interchange=inter, + del_mol=m1, + insert_mol=m2, + force_field=forcefield, + charged_molecules=None + ) + + +def test_copy_equality_clash(forcefield, insert_molecule, del_molecule): + topology = Topology.from_molecules([del_molecule, del_molecule]) + inter = Interchange.from_smirnoff(forcefield, topology) + + with pytest.raises(ValueError, match="equality clash"): + inter_new = copy_interchange_with_replacement( + interchange=inter, + del_mol=del_molecule, + insert_mol=insert_molecule, + force_field=forcefield, + charged_molecules=None + ) + + +def test_copy_no_del_match(forcefield, insert_molecule, del_molecule): + fake_del_mol = Molecule.from_smiles('C') + fake_del_mol.generate_conformers(n_conformers=1) + topology = Topology.from_molecules([del_molecule]) + inter = Interchange.from_smirnoff(forcefield, topology) + + with pytest.raises(ValueError, match="matching del_mol in input"): + inter_new = copy_interchange_with_replacement( + interchange=inter, + del_mol=fake_del_mol, + insert_mol=insert_molecule, + force_field=forcefield, + charged_molecules=None + ) + + +def test_copy_full(forcefield): + m1 = Molecule.from_smiles('CCCC') + m1.generate_conformers(n_conformers=1) + m1.assign_partial_charges(partial_charge_method='gasteiger') + _set_offmol_metadata(m1, 'residue_number', 999) + m2 = Molecule.from_smiles('CCCO') + m2.generate_conformers(n_conformers=1) + m2.assign_partial_charges(partial_charge_method='gasteiger') + water = Molecule.from_smiles('O') + + # Solvate m1 + solvated_top = solvate_topology(Topology.from_molecules([m1])) + + # Create interchange + inter = Interchange.from_smirnoff(forcefield, solvated_top, charge_from_molecules=[m1]) + + inter_new = copy_interchange_with_replacement( + interchange=inter, + del_mol=m1, + insert_mol=m2, + force_field=forcefield, + charged_molecules=[m2] + ) + + assert inter.topology.n_molecules == inter_new.topology.n_molecules + assert inter_new.topology.n_unique_molecules == 4 + for idx in range(inter_new.topology.n_molecules-1): + mol_new = inter_new.topology.molecule(idx) + mol_old = inter.topology.molecule(idx+1) + assert mol_new.is_isomorphic_with(mol_old) + + assert_allclose(mol_new.conformers[0], mol_old.conformers[0]) + + insert_mol_new = inter_new.topology.molecule(inter_new.topology.n_molecules-1) + assert insert_mol_new.is_isomorphic_with(m2) + assert_allclose(m2.conformers[0], insert_mol_new.conformers[0]) + assert_allclose(m2.partial_charges, insert_mol_new.partial_charges) + assert _get_offmol_metadata(insert_mol_new, 'residue_number') == 999 \ No newline at end of file diff --git a/src/pontibus/utils/molecule_utils.py b/src/pontibus/utils/molecule_utils.py index c6775c2..798edd8 100644 --- a/src/pontibus/utils/molecule_utils.py +++ b/src/pontibus/utils/molecule_utils.py @@ -12,7 +12,7 @@ def _set_offmol_metadata( offmol: OFFMolecule, key: Any, - val: Any, + val: Any | None, ) -> None: """ Set a given metadata entry for a whole Molecule. @@ -26,8 +26,12 @@ def _set_offmol_metadata( val : Any The value to set the metadata entry to. """ - for a in offmol.atoms: - a.metadata[key] = val + if val is None: + for a in offmol.atoms: + a.metadata.pop(key, None) + else: + for a in offmol.atoms: + a.metadata[key] = val def _get_offmol_metadata(offmol: OFFMolecule, key: Any) -> Any | None: @@ -67,7 +71,7 @@ def _get_offmol_metadata(offmol: OFFMolecule, key: Any) -> Any | None: def _set_offmol_resname( offmol: OFFMolecule, - resname: str, + resname: str | None, ) -> None: """ Helper method to set offmol residue names @@ -76,8 +80,8 @@ def _set_offmol_resname( ---------- offmol : openff.toolkit.Molecule Molecule to assign a residue name to. - resname : str - Residue name to be set. + resname : str | None + Residue name to be set. Set to None to clear it. Returns ------- From 0ce8677d4ac2dc7fae6fc9120cc7a82ae9a234a6 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 11:33:31 +0100 Subject: [PATCH 27/32] type ignore --- src/pontibus/utils/system_manipulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pontibus/utils/system_manipulation.py b/src/pontibus/utils/system_manipulation.py index 66d1005..2d631ed 100644 --- a/src/pontibus/utils/system_manipulation.py +++ b/src/pontibus/utils/system_manipulation.py @@ -42,7 +42,7 @@ def _adjust_inputs(var): remove_force_types = _adjust_inputs(remove_force_types) add_forces = _adjust_inputs(add_forces) - for entry in remove_force_types: + for entry in remove_force_types: # type: ignore[union-attr] for idx in reversed(range(system.getNumForces())): force = system.getForce(idx) if isinstance(force, entry): From f8af5ca45b86f015bd907df4f150e21faf84e941 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 11:39:27 +0100 Subject: [PATCH 28/32] mypy fix --- src/pontibus/utils/system_creation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pontibus/utils/system_creation.py b/src/pontibus/utils/system_creation.py index 50f61da..f4fee22 100644 --- a/src/pontibus/utils/system_creation.py +++ b/src/pontibus/utils/system_creation.py @@ -363,7 +363,7 @@ def _solvate_system( continue if mol.is_isomorphic_with(solvent_offmol): - _set_offmol_resname(mol, _get_offmol_resname(solvent_offmol)) # type: ignore[arg-type] + _set_offmol_resname(mol, _get_offmol_resname(solvent_offmol)) if mol.is_isomorphic_with(na): _set_offmol_resname(mol, "NA+") From 919ffad8147946527482e35850d39981df6e435a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 12:31:28 +0100 Subject: [PATCH 29/32] Adds a full integration tests for vacuum --- src/pontibus/tests/conftest.py | 43 ++- .../data/benzene_modifications_charged.sdf | 295 ++++++++++++++++++ .../protocols/relative/test_protocol_slow.py | 93 ++++++ 3 files changed, 415 insertions(+), 16 deletions(-) create mode 100644 src/pontibus/tests/data/benzene_modifications_charged.sdf create mode 100644 src/pontibus/tests/protocols/relative/test_protocol_slow.py diff --git a/src/pontibus/tests/conftest.py b/src/pontibus/tests/conftest.py index 729f458..13ab201 100644 --- a/src/pontibus/tests/conftest.py +++ b/src/pontibus/tests/conftest.py @@ -140,7 +140,7 @@ def pytest_configure(config): @pytest.fixture(scope="session") def benzene_modifications(): files = {} - with importlib.resources.files("openfe.tests.data") as d: + with importlib.resources.files("pontibus.tests.data") as d: fn = str(d / "benzene_modifications.sdf") supp = Chem.SDMolSupplier(str(fn), removeHs=False) for rdmol in supp: @@ -148,6 +148,17 @@ def benzene_modifications(): return files +@pytest.fixture(scope="session") +def benzene_modifications_charged(): + files = {} + with importlib.resources.files("pontibus.tests.data") as d: + fn = str(d / "benzene_modifications_charged.sdf") + supp = Chem.SDMolSupplier(str(fn), removeHs=False) + for rdmol in supp: + files[rdmol.GetProp("_Name")] = SmallMoleculeComponent(rdmol) + return files + + @pytest.fixture() def CN_molecule(): """ @@ -172,17 +183,17 @@ def T4_protein_component(): @pytest.fixture -def benzene_vacuum_system(benzene_modifications): +def benzene_vacuum_system(benzene_modifications_charged): return openfe.ChemicalSystem( - {"ligand": benzene_modifications["benzene"]}, + {"ligand": benzene_modifications_charged["benzene"]}, ) @pytest.fixture(scope="session") -def benzene_system(benzene_modifications): +def benzene_system(benzene_modifications_charged): return openfe.ChemicalSystem( { - "ligand": benzene_modifications["benzene"], + "ligand": benzene_modifications_charged["benzene"], "solvent": openfe.SolventComponent( positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), @@ -191,10 +202,10 @@ def benzene_system(benzene_modifications): @pytest.fixture -def benzene_complex_system(benzene_modifications, T4_protein_component): +def benzene_complex_system(benzene_modifications_charged, T4_protein_component): return openfe.ChemicalSystem( { - "ligand": benzene_modifications["benzene"], + "ligand": benzene_modifications_charged["benzene"], "solvent": openfe.SolventComponent( positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), @@ -204,17 +215,17 @@ def benzene_complex_system(benzene_modifications, T4_protein_component): @pytest.fixture -def toluene_vacuum_system(benzene_modifications): +def toluene_vacuum_system(benzene_modifications_charged): return openfe.ChemicalSystem( - {"ligand": benzene_modifications["toluene"]}, + {"ligand": benzene_modifications_charged["toluene"]}, ) @pytest.fixture(scope="session") -def toluene_system(benzene_modifications): +def toluene_system(benzene_modifications_charged): return openfe.ChemicalSystem( { - "ligand": benzene_modifications["toluene"], + "ligand": benzene_modifications_charged["toluene"], "solvent": openfe.SolventComponent( positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), @@ -223,10 +234,10 @@ def toluene_system(benzene_modifications): @pytest.fixture -def toluene_complex_system(benzene_modifications, T4_protein_component): +def toluene_complex_system(benzene_modifications_charged, T4_protein_component): return openfe.ChemicalSystem( { - "ligand": benzene_modifications["toluene"], + "ligand": benzene_modifications_charged["toluene"], "solvent": openfe.SolventComponent( positive_ion="Na", negative_ion="Cl", ion_concentration=0.15 * unit.molar ), @@ -236,10 +247,10 @@ def toluene_complex_system(benzene_modifications, T4_protein_component): @pytest.fixture(scope="session") -def benzene_to_toluene_mapping(benzene_modifications): +def benzene_to_toluene_mapping(benzene_modifications_charged): mapper = openfe.setup.LomapAtomMapper(element_change=False) - molA = benzene_modifications["benzene"] - molB = benzene_modifications["toluene"] + molA = benzene_modifications_charged["benzene"] + molB = benzene_modifications_charged["toluene"] return next(mapper.suggest_mappings(molA, molB)) diff --git a/src/pontibus/tests/data/benzene_modifications_charged.sdf b/src/pontibus/tests/data/benzene_modifications_charged.sdf new file mode 100644 index 0000000..f1cebed --- /dev/null +++ b/src/pontibus/tests/data/benzene_modifications_charged.sdf @@ -0,0 +1,295 @@ +benzene + RDKit 3D + + 12 12 0 0 0 0 0 0 0 0999 V2000 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8209 8.0598 5.3863 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 2 0 + 1 6 1 0 + 1 7 1 0 + 2 3 1 0 + 2 8 1 0 + 3 4 2 0 + 3 9 1 0 + 4 5 1 0 + 4 10 1 0 + 5 6 2 0 + 5 11 1 0 + 6 12 1 0 +M END + +> +benzene + +> +-0.13 -0.13 -0.13 -0.13 -0.13 -0.13 0.13 0.13 0.13 0.13 0.13 0.13 + +$$$$ +phenol + RDKit 3D + + 13 13 0 0 0 0 0 0 0 0999 V2000 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.1311 8.0887 6.4624 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.9460 8.3293 5.5517 O 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 2 0 + 1 6 1 0 + 1 7 1 0 + 2 3 1 0 + 2 8 1 0 + 3 4 2 0 + 3 9 1 0 + 4 5 1 0 + 4 10 1 0 + 5 6 2 0 + 5 13 1 0 + 6 12 1 0 + 11 13 1 0 +M END + +> +phenol + +> +-0.094423076923076915 -0.16592307692307692 -0.094423076923076915 -0.18492307692307691 0.12317692307692309 -0.18492307692307691 0.13307692307692309 0.13307692307692309 0.13307692307692309 +0.14157692307692307 0.41807692307692307 0.14157692307692307 -0.4990230769230769 + +$$$$ +benzonitrile + RDKit 3D + + 13 13 0 0 0 0 0 0 0 0999 V2000 + 28.5559 9.5700 6.2831 N 0 0 0 0 0 0 0 0 0 0 0 0 + 27.9981 8.4043 5.5824 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 3 0 + 3 4 2 0 + 3 8 1 0 + 3 9 1 0 + 4 5 1 0 + 4 10 1 0 + 5 6 2 0 + 5 11 1 0 + 6 7 1 0 + 6 12 1 0 + 2 7 1 0 + 7 8 2 0 + 8 13 1 0 +M END + +> +benzonitrile + +> +-0.36380000000000001 0.23380000000000001 -0.13500000000000001 -0.107 -0.13500000000000001 -0.090999999999999998 -0.019000000000000003 -0.090999999999999998 0.14000000000000001 +0.13800000000000001 0.14000000000000001 0.14499999999999999 0.14499999999999999 + +$$$$ +benzaldehyde + RDKit 3D + + 14 14 0 0 0 0 0 0 0 0999 V2000 + 29.2079 8.8492 4.9632 O 0 0 0 0 0 0 0 0 0 0 0 0 + 27.5482 8.8691 6.4597 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.9981 8.4043 5.5824 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 3 2 0 + 2 3 1 0 + 4 5 2 0 + 4 9 1 0 + 4 10 1 0 + 5 6 1 0 + 5 11 1 0 + 6 7 2 0 + 6 12 1 0 + 7 8 1 0 + 7 13 1 0 + 3 8 1 0 + 8 9 2 0 + 9 14 1 0 +M END + +> +benzaldehyde + +> +-0.52817142857142862 -0.0028714285714285795 0.5754285714285714 -0.14507142857142857 -0.098071428571428587 -0.14507142857142857 -0.078071428571428583 -0.19767142857142858 +-0.078071428571428583 0.13742857142857143 0.13492857142857143 0.13742857142857143 0.14392857142857141 0.14392857142857141 + +$$$$ +styrene + RDKit 3D + + 16 16 0 0 0 0 0 0 0 0999 V2000 + 29.2873 8.8784 4.9226 C 0 0 0 0 0 0 0 0 0 0 0 0 + 29.6609 8.3486 4.0463 H 0 0 0 0 0 0 0 0 0 0 0 0 + 29.8344 9.7353 5.3157 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.5365 8.8812 6.4825 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.9864 8.4164 5.6052 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 1 3 1 0 + 1 5 2 0 + 4 5 1 0 + 6 7 2 0 + 6 11 1 0 + 6 12 1 0 + 7 8 1 0 + 7 13 1 0 + 8 9 2 0 + 8 14 1 0 + 9 10 1 0 + 9 15 1 0 + 5 10 1 0 + 10 11 2 0 + 11 16 1 0 +M END + +> +styrene + +> +-0.20899999999999999 0.11349999999999999 0.11349999999999999 0.123 -0.1132 -0.13100000000000001 -0.127 -0.13100000000000001 -0.11849999999999999 -0.057800000000000004 -0.11849999999999999 +0.13100000000000001 0.13100000000000001 0.13100000000000001 0.13150000000000001 0.13150000000000001 + +$$$$ +anisole + RDKit 3D + + 16 16 0 0 0 0 0 0 0 0999 V2000 + 29.2873 8.8784 4.9226 C 0 0 0 0 0 0 0 0 0 0 0 0 + 29.5502 9.7990 5.4437 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.0548 8.3459 5.4720 O 0 0 0 0 0 0 0 0 0 0 0 0 + 30.0866 8.1484 5.0502 H 0 0 0 0 0 0 0 0 0 0 0 0 + 29.1525 9.0868 3.8612 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 2 1 0 + 1 4 1 0 + 1 5 1 0 + 1 3 1 0 + 6 7 2 0 + 6 11 1 0 + 6 12 1 0 + 7 8 1 0 + 7 13 1 0 + 8 9 2 0 + 8 14 1 0 + 9 10 1 0 + 9 15 1 0 + 3 10 1 0 + 10 11 2 0 + 11 16 1 0 +M END + +> +anisole + +> +0.113825 0.043825000000000003 -0.32877500000000004 0.043825000000000003 0.043825000000000003 -0.097875000000000004 -0.16487500000000002 -0.097875000000000004 -0.17987500000000001 0.123225 +-0.17987500000000001 0.13212499999999999 0.13212499999999999 0.13212499999999999 0.14212499999999997 0.14212499999999997 + +$$$$ +toluene + RDKit 3D + + 15 15 0 0 0 0 0 0 0 0999 V2000 + 28.9072 8.7434 5.1220 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.1966 8.1433 6.6393 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.9864 8.4164 5.6052 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.2579 9.2269 5.5838 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9780 5.3270 4.7790 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.3950 5.0740 3.4990 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.3400 5.8600 2.9020 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.8370 6.9210 3.5690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 27.4200 7.1960 4.8560 C 0 0 0 0 0 0 0 0 0 0 0 0 + 26.4980 6.3790 5.4690 C 0 0 0 0 0 0 0 0 0 0 0 0 + 25.2298 4.6859 5.2451 H 0 0 0 0 0 0 0 0 0 0 0 0 + 25.9676 4.2351 2.9497 H 0 0 0 0 0 0 0 0 0 0 0 0 + 27.6890 5.6311 1.8951 H 0 0 0 0 0 0 0 0 0 0 0 0 + 28.5730 7.5660 3.0889 H 0 0 0 0 0 0 0 0 0 0 0 0 + 26.1874 6.5720 6.4958 H 0 0 0 0 0 0 0 0 0 0 0 0 + 1 3 1 0 + 2 3 1 0 + 3 4 1 0 + 5 6 2 0 + 5 10 1 0 + 5 11 1 0 + 6 7 1 0 + 6 12 1 0 + 7 8 2 0 + 7 13 1 0 + 8 9 1 0 + 8 14 1 0 + 3 9 1 0 + 9 10 2 0 + 10 15 1 0 +M END + +> +toluene + +> +0.044033066666666669 0.044033066666666669 -0.053799933333333334 0.044033066666666669 -0.12699993333333334 -0.13499993333333335 -0.12699993333333334 -0.13099993333333335 +-0.077299933333333321 -0.13099993333333335 0.13000006666666666 0.13000006666666666 0.13000006666666666 0.13000006666666666 0.13000006666666666 + +$$$$ diff --git a/src/pontibus/tests/protocols/relative/test_protocol_slow.py b/src/pontibus/tests/protocols/relative/test_protocol_slow.py new file mode 100644 index 0000000..4cddc09 --- /dev/null +++ b/src/pontibus/tests/protocols/relative/test_protocol_slow.py @@ -0,0 +1,93 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +import pytest +import pathlib +from openff.units import unit +from gufe.protocols import execute_DAG +import numpy as np +from numpy.testing import assert_allclose +from pontibus.protocols.relative import HybridTopProtocol + + + +@pytest.mark.gpu +def test_vacuum( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + tmpdir +): + """ + Run a short MD simulation and make sure things didn't fail. + """ + s = HybridTopProtocol.default_settings() + s.simulation_settings.equilibration_length = 100 * unit.picosecond + s.simulation_settings.production_length = 500 * unit.picosecond + s.forcefield_settings.nonbonded_method = 'nocutoff' + s.protocol_repeats = 1 + s.engine_settings.compute_platform = 'CUDA' + + p = HybridTopProtocol(s) + + dag = p.create( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + + cwd = pathlib.Path(str(tmpdir)) + r = execute_DAG( + dag, + shared_basedir=cwd, + scratch_basedir=cwd, + keep_shared=True + ) + + assert r.ok() + for pur in r.protocol_unit_results: + unit_shared = tmpdir / f"shared_{pur.source_key}_attempt_0" + assert unit_shared.exists() + assert pathlib.Path(unit_shared).is_dir() + + # Check the checkpoint file exists + checkpoint = pur.outputs['last_checkpoint'] + assert checkpoint == "checkpoint.chk" + assert (unit_shared / checkpoint).exists() + + # Check the nc simulation file exists + nc = pur.outputs['nc'] + assert nc == unit_shared / "simulation.nc" + assert nc.exists() + + # Check structural analysis contents + # TODO: for now this is disabled due to issue #117 + #structural_analysis_file = unit_shared / "structural_analysis.npz" + #assert (structural_analysis_file).exists() + #assert pur.outputs['structural_analysis'] == structural_analysis_file + + #structural_data = np.load(pur.outputs['structural_analysis']) + #structural_keys = [ + # 'protein_RMSD', 'ligand_RMSD', 'ligand_COM_drift', + # 'protein_2D_RMSD', 'time_ps' + #] + #for key in structural_keys: + # assert key in structural_data.keys() + + ## 6 frames being written to file + #assert_allclose(structural_data['time_ps'], [0.0, 0.02, 0.04, 0.06, 0.08, 0.1]) + #assert structural_data['ligand_RMSD'].shape == (11, 6) + #assert structural_data['ligand_COM_drift'].shape == (11, 6) + ## No protein so should be empty + #assert structural_data['protein_RMSD'].size == 0 + #assert structural_data['protein_2D_RMSD'].size == 0 + + # Test results + results = p.gather([r]) + estimate = results.get_estimate() + assert estimate.m == pytest.approx(0.80, abs=0.2) + uncert = results.get_uncertainty() + assert uncert.m == pytest.approx(0.0) + states = results.get_replica_states() + assert len(states) == 1 + assert states[0].shape[1] == 11 From fedf0d73c50a05b30b821a77d33841ac291f06a0 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 12:53:58 +0100 Subject: [PATCH 30/32] Add force type checking --- .../tests/protocols/relative/test_protocol.py | 95 +++++++++++++++++-- .../tests/protocols/solvation/test_dry_run.py | 1 - 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index cdff001..f3552fe 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -7,12 +7,23 @@ import pytest from openff.units import unit from openff.units.openmm import ensure_quantity -from openmm import MonteCarloBarostat, NonbondedForce +from openmm import ( + CustomBondForce, + CustomAngleForce, + CustomTorsionForce, + CustomNonbondedForce, + HarmonicAngleForce, + HarmonicBondForce, + MonteCarloBarostat, + NonbondedForce, + PeriodicTorsionForce, + MonteCarloBarostat, +) from openmm import unit as omm_unit from openmmtools.multistate import MultiStateSampler from rdkit import Chem -from pontibus.protocols.relative import HybridTopProtocol +from pontibus.protocols.relative import HybridTopProtocol, HybridTopProtocolUnit def test_create_default_settings(): @@ -39,6 +50,36 @@ def test_serialize_protocol(): assert protocol == ret +def test_position_overlap_fail(): + mapping = {'old_to_new_env_atom_map': {i:i for i in range(4)}} + positionsA = np.array([[1, 1, 1]]*4) * unit.angstrom + positionsB = np.array([[1, 1, 2.1]]*4) * unit.angstrom + + with pytest.raises(ValueError, match="deviates by more than"): + HybridTopProtocolUnit._check_position_overlap( + mapping, + positionsA, + positionsB, + ) + + +def test_position_overlap_warn(): + mapping = { + 'old_to_new_env_atom_map': {}, + 'old_to_new_core_atom_map': {i:i for i in range(4)} + } + positionsA = np.array([[1, 1, 1]]*4) * unit.angstrom + positionsB = np.array([[1, 1, 2.1]]*4) * unit.angstrom + + with pytest.warns(UserWarning, match="deviates by more than"): + HybridTopProtocolUnit._check_position_overlap( + mapping, + positionsA, + positionsB, + ) + + + @pytest.mark.parametrize("method", ["repex", "sams", "independent", "InDePeNdENT"]) def test_dry_run_default_vacuum( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, method, tmpdir @@ -85,6 +126,28 @@ def test_dry_run_default_vacuum( pdb = mdt.load_pdb("hybrid_system.pdb") assert pdb.n_atoms == 16 + # check the system forces + system = htf.hybrid_system + assert len(system.getForces()) == 9 + + def assert_force_num(system, forcetype, number): + forces = [f for f in system.getForces() if isinstance(f, forcetype)] + assert len(forces) == number + + assert_force_num(system, NonbondedForce, 1) + assert_force_num(system, CustomNonbondedForce, 1) + assert_force_num(system, CustomBondForce, 2) + assert_force_num(system, CustomAngleForce, 1) + assert_force_num(system, CustomTorsionForce, 1) + assert_force_num(system, HarmonicBondForce, 1) + assert_force_num(system, HarmonicAngleForce, 1) + assert_force_num(system, PeriodicTorsionForce, 1) + + # Check the nonbonded force is NoCutoff + nonbond = [f for f in system.getForces() if isinstance(f, NonbondedForce)] + assert nonbond[0].getNonbondedMethod() == NonbondedForce.NoCutoff + + BENZ = """\ benzene @@ -198,11 +261,9 @@ def test_dry_core_element_change(tmpdir): assert cnf.getInteractionGroupParameters(7) == [(7,), (7,)] -@pytest.mark.parametrize("method", ["repex", "sams", "independent"]) -def test_dry_run_ligand(benzene_system, toluene_system, benzene_to_toluene_mapping, method, tmpdir): +def test_dry_run_ligand(benzene_system, toluene_system, benzene_to_toluene_mapping, tmpdir): # this might be a bit time consuming settings = HybridTopProtocol.default_settings() - settings.simulation_settings.sampler_method = method settings.protocol_repeats = 1 settings.output_settings.output_indices = "resname AAA" @@ -227,8 +288,30 @@ def test_dry_run_ligand(benzene_system, toluene_system, benzene_to_toluene_mappi pdb = mdt.load_pdb("hybrid_system.pdb") assert pdb.n_atoms == 16 + # Check system forces + system = sampler._hybrid_factory.hybrid_system + assert len(system.getForces()) == 10 + + def assert_force_num(system, forcetype, number): + forces = [f for f in system.getForces() if isinstance(f, forcetype)] + assert len(forces) == number + + assert_force_num(system, NonbondedForce, 1) + assert_force_num(system, CustomNonbondedForce, 1) + assert_force_num(system, CustomBondForce, 2) + assert_force_num(system, CustomAngleForce, 1) + assert_force_num(system, CustomTorsionForce, 1) + assert_force_num(system, HarmonicBondForce, 1) + assert_force_num(system, HarmonicAngleForce, 1) + assert_force_num(system, PeriodicTorsionForce, 1) + assert_force_num(system, MonteCarloBarostat, 1) + + # Check the nonbonded force is NoCutoff + nonbond = [f for f in system.getForces() if isinstance(f, NonbondedForce)] + assert nonbond[0].getNonbondedMethod() == NonbondedForce.PME + -def test_dry_run_user_charges(benzene_modifications, tmpdir): +def test_dry_run_vacuum_user_charges(benzene_modifications, tmpdir): """ Create a hybrid system with a set of fictitious user supplied charges and ensure that they are properly passed through to the constructed diff --git a/src/pontibus/tests/protocols/solvation/test_dry_run.py b/src/pontibus/tests/protocols/solvation/test_dry_run.py index b018bc0..9321ee1 100644 --- a/src/pontibus/tests/protocols/solvation/test_dry_run.py +++ b/src/pontibus/tests/protocols/solvation/test_dry_run.py @@ -66,7 +66,6 @@ def test_dry_run_vacuum_benzene(charged_benzene, method, tmpdir): assert not vac_sampler.is_periodic system = vac_sampler._thermodynamic_states[0].get_system(remove_thermostat=True) - print(system.getForces()) assert len(system.getForces()) == 12 def assert_force_num(system, forcetype, number): From 6c9105553bce9425512745e2e07bd8d173194794 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:01:50 +0000 Subject: [PATCH 31/32] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/pontibus/protocols/solvation/base.py | 4 +- .../tests/protocols/relative/test_protocol.py | 22 +++---- .../protocols/relative/test_protocol_slow.py | 59 +++++++---------- .../tests/utils/test_system_manipulation.py | 65 +++++++++---------- 4 files changed, 66 insertions(+), 84 deletions(-) diff --git a/src/pontibus/protocols/solvation/base.py b/src/pontibus/protocols/solvation/base.py index 4073ac9..4f937a8 100644 --- a/src/pontibus/protocols/solvation/base.py +++ b/src/pontibus/protocols/solvation/base.py @@ -154,7 +154,9 @@ def _get_omm_objects( else: barostat = None - adjust_system(system=omm_system, remove_force_types=openmm.CMMotionRemover, add_forces=barostat) + adjust_system( + system=omm_system, remove_force_types=openmm.CMMotionRemover, add_forces=barostat + ) positions = to_openmm_positions(interchange, include_virtual_sites=True) diff --git a/src/pontibus/tests/protocols/relative/test_protocol.py b/src/pontibus/tests/protocols/relative/test_protocol.py index f3552fe..ff32afb 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol.py +++ b/src/pontibus/tests/protocols/relative/test_protocol.py @@ -8,16 +8,15 @@ from openff.units import unit from openff.units.openmm import ensure_quantity from openmm import ( - CustomBondForce, CustomAngleForce, - CustomTorsionForce, + CustomBondForce, CustomNonbondedForce, + CustomTorsionForce, HarmonicAngleForce, HarmonicBondForce, MonteCarloBarostat, NonbondedForce, PeriodicTorsionForce, - MonteCarloBarostat, ) from openmm import unit as omm_unit from openmmtools.multistate import MultiStateSampler @@ -51,9 +50,9 @@ def test_serialize_protocol(): def test_position_overlap_fail(): - mapping = {'old_to_new_env_atom_map': {i:i for i in range(4)}} - positionsA = np.array([[1, 1, 1]]*4) * unit.angstrom - positionsB = np.array([[1, 1, 2.1]]*4) * unit.angstrom + mapping = {"old_to_new_env_atom_map": {i: i for i in range(4)}} + positionsA = np.array([[1, 1, 1]] * 4) * unit.angstrom + positionsB = np.array([[1, 1, 2.1]] * 4) * unit.angstrom with pytest.raises(ValueError, match="deviates by more than"): HybridTopProtocolUnit._check_position_overlap( @@ -64,12 +63,9 @@ def test_position_overlap_fail(): def test_position_overlap_warn(): - mapping = { - 'old_to_new_env_atom_map': {}, - 'old_to_new_core_atom_map': {i:i for i in range(4)} - } - positionsA = np.array([[1, 1, 1]]*4) * unit.angstrom - positionsB = np.array([[1, 1, 2.1]]*4) * unit.angstrom + mapping = {"old_to_new_env_atom_map": {}, "old_to_new_core_atom_map": {i: i for i in range(4)}} + positionsA = np.array([[1, 1, 1]] * 4) * unit.angstrom + positionsB = np.array([[1, 1, 2.1]] * 4) * unit.angstrom with pytest.warns(UserWarning, match="deviates by more than"): HybridTopProtocolUnit._check_position_overlap( @@ -79,7 +75,6 @@ def test_position_overlap_warn(): ) - @pytest.mark.parametrize("method", ["repex", "sams", "independent", "InDePeNdENT"]) def test_dry_run_default_vacuum( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, method, tmpdir @@ -148,7 +143,6 @@ def assert_force_num(system, forcetype, number): assert nonbond[0].getNonbondedMethod() == NonbondedForce.NoCutoff - BENZ = """\ benzene PyMOL2.5 3D 0 diff --git a/src/pontibus/tests/protocols/relative/test_protocol_slow.py b/src/pontibus/tests/protocols/relative/test_protocol_slow.py index 4cddc09..16954a9 100644 --- a/src/pontibus/tests/protocols/relative/test_protocol_slow.py +++ b/src/pontibus/tests/protocols/relative/test_protocol_slow.py @@ -1,32 +1,26 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -import pytest import pathlib -from openff.units import unit + +import pytest from gufe.protocols import execute_DAG -import numpy as np -from numpy.testing import assert_allclose -from pontibus.protocols.relative import HybridTopProtocol +from openff.units import unit +from pontibus.protocols.relative import HybridTopProtocol @pytest.mark.gpu -def test_vacuum( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - tmpdir -): +def test_vacuum(benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, tmpdir): """ Run a short MD simulation and make sure things didn't fail. """ s = HybridTopProtocol.default_settings() s.simulation_settings.equilibration_length = 100 * unit.picosecond s.simulation_settings.production_length = 500 * unit.picosecond - s.forcefield_settings.nonbonded_method = 'nocutoff' + s.forcefield_settings.nonbonded_method = "nocutoff" s.protocol_repeats = 1 - s.engine_settings.compute_platform = 'CUDA' + s.engine_settings.compute_platform = "CUDA" p = HybridTopProtocol(s) @@ -37,12 +31,7 @@ def test_vacuum( ) cwd = pathlib.Path(str(tmpdir)) - r = execute_DAG( - dag, - shared_basedir=cwd, - scratch_basedir=cwd, - keep_shared=True - ) + r = execute_DAG(dag, shared_basedir=cwd, scratch_basedir=cwd, keep_shared=True) assert r.ok() for pur in r.protocol_unit_results: @@ -51,36 +40,36 @@ def test_vacuum( assert pathlib.Path(unit_shared).is_dir() # Check the checkpoint file exists - checkpoint = pur.outputs['last_checkpoint'] + checkpoint = pur.outputs["last_checkpoint"] assert checkpoint == "checkpoint.chk" assert (unit_shared / checkpoint).exists() # Check the nc simulation file exists - nc = pur.outputs['nc'] + nc = pur.outputs["nc"] assert nc == unit_shared / "simulation.nc" assert nc.exists() # Check structural analysis contents # TODO: for now this is disabled due to issue #117 - #structural_analysis_file = unit_shared / "structural_analysis.npz" - #assert (structural_analysis_file).exists() - #assert pur.outputs['structural_analysis'] == structural_analysis_file + # structural_analysis_file = unit_shared / "structural_analysis.npz" + # assert (structural_analysis_file).exists() + # assert pur.outputs['structural_analysis'] == structural_analysis_file - #structural_data = np.load(pur.outputs['structural_analysis']) - #structural_keys = [ + # structural_data = np.load(pur.outputs['structural_analysis']) + # structural_keys = [ # 'protein_RMSD', 'ligand_RMSD', 'ligand_COM_drift', # 'protein_2D_RMSD', 'time_ps' - #] - #for key in structural_keys: + # ] + # for key in structural_keys: # assert key in structural_data.keys() - ## 6 frames being written to file - #assert_allclose(structural_data['time_ps'], [0.0, 0.02, 0.04, 0.06, 0.08, 0.1]) - #assert structural_data['ligand_RMSD'].shape == (11, 6) - #assert structural_data['ligand_COM_drift'].shape == (11, 6) - ## No protein so should be empty - #assert structural_data['protein_RMSD'].size == 0 - #assert structural_data['protein_2D_RMSD'].size == 0 + # 6 frames being written to file + # assert_allclose(structural_data['time_ps'], [0.0, 0.02, 0.04, 0.06, 0.08, 0.1]) + # assert structural_data['ligand_RMSD'].shape == (11, 6) + # assert structural_data['ligand_COM_drift'].shape == (11, 6) + # No protein so should be empty + # assert structural_data['protein_RMSD'].size == 0 + # assert structural_data['protein_2D_RMSD'].size == 0 # Test results results = p.gather([r]) diff --git a/src/pontibus/tests/utils/test_system_manipulation.py b/src/pontibus/tests/utils/test_system_manipulation.py index 3de10ab..f2e3d62 100644 --- a/src/pontibus/tests/utils/test_system_manipulation.py +++ b/src/pontibus/tests/utils/test_system_manipulation.py @@ -3,19 +3,20 @@ import pytest from numpy.testing import assert_allclose -from openmm import System, CMMotionRemover, MonteCarloBarostat -from openmm import unit as omm_unit -from openff.toolkit import Molecule, ForceField, Topology from openff.interchange import Interchange from openff.interchange.components._packmol import solvate_topology +from openff.toolkit import ForceField, Molecule, Topology +from openmm import CMMotionRemover, MonteCarloBarostat, System +from openmm import unit as omm_unit + +from pontibus.utils.molecule_utils import ( + _get_offmol_metadata, + _set_offmol_metadata, +) from pontibus.utils.system_manipulation import ( adjust_system, copy_interchange_with_replacement, ) -from pontibus.utils.molecule_utils import ( - _set_offmol_metadata, - _get_offmol_metadata, -) def test_adjust_forces_nothing(): @@ -44,28 +45,28 @@ def test_adjust_forces_add_comm_and_barostat(): assert system.getNumForces() == 2 -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def forcefield(): - return ForceField('openff-2.2.1.offxml') + return ForceField("openff-2.2.1.offxml") -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def insert_molecule(): - m = Molecule.from_smiles('CCCC') + m = Molecule.from_smiles("CCCC") m.generate_conformers(n_conformers=1) return m -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def del_molecule(): - m = Molecule.from_smiles('CCO') + m = Molecule.from_smiles("CCO") m.generate_conformers(n_conformers=1) return m def test_copy_no_conformers(forcefield): - m1 = Molecule.from_smiles('C') - m2 = Molecule.from_smiles('O') + m1 = Molecule.from_smiles("C") + m2 = Molecule.from_smiles("O") topology = Topology.from_molecules([m1]) inter = Interchange.from_smirnoff(forcefield, topology) @@ -75,7 +76,7 @@ def test_copy_no_conformers(forcefield): del_mol=m1, insert_mol=m2, force_field=forcefield, - charged_molecules=None + charged_molecules=None, ) @@ -89,12 +90,12 @@ def test_copy_equality_clash(forcefield, insert_molecule, del_molecule): del_mol=del_molecule, insert_mol=insert_molecule, force_field=forcefield, - charged_molecules=None + charged_molecules=None, ) def test_copy_no_del_match(forcefield, insert_molecule, del_molecule): - fake_del_mol = Molecule.from_smiles('C') + fake_del_mol = Molecule.from_smiles("C") fake_del_mol.generate_conformers(n_conformers=1) topology = Topology.from_molecules([del_molecule]) inter = Interchange.from_smirnoff(forcefield, topology) @@ -105,19 +106,19 @@ def test_copy_no_del_match(forcefield, insert_molecule, del_molecule): del_mol=fake_del_mol, insert_mol=insert_molecule, force_field=forcefield, - charged_molecules=None + charged_molecules=None, ) def test_copy_full(forcefield): - m1 = Molecule.from_smiles('CCCC') + m1 = Molecule.from_smiles("CCCC") m1.generate_conformers(n_conformers=1) - m1.assign_partial_charges(partial_charge_method='gasteiger') - _set_offmol_metadata(m1, 'residue_number', 999) - m2 = Molecule.from_smiles('CCCO') + m1.assign_partial_charges(partial_charge_method="gasteiger") + _set_offmol_metadata(m1, "residue_number", 999) + m2 = Molecule.from_smiles("CCCO") m2.generate_conformers(n_conformers=1) - m2.assign_partial_charges(partial_charge_method='gasteiger') - water = Molecule.from_smiles('O') + m2.assign_partial_charges(partial_charge_method="gasteiger") + water = Molecule.from_smiles("O") # Solvate m1 solvated_top = solvate_topology(Topology.from_molecules([m1])) @@ -126,24 +127,20 @@ def test_copy_full(forcefield): inter = Interchange.from_smirnoff(forcefield, solvated_top, charge_from_molecules=[m1]) inter_new = copy_interchange_with_replacement( - interchange=inter, - del_mol=m1, - insert_mol=m2, - force_field=forcefield, - charged_molecules=[m2] + interchange=inter, del_mol=m1, insert_mol=m2, force_field=forcefield, charged_molecules=[m2] ) assert inter.topology.n_molecules == inter_new.topology.n_molecules assert inter_new.topology.n_unique_molecules == 4 - for idx in range(inter_new.topology.n_molecules-1): + for idx in range(inter_new.topology.n_molecules - 1): mol_new = inter_new.topology.molecule(idx) - mol_old = inter.topology.molecule(idx+1) + mol_old = inter.topology.molecule(idx + 1) assert mol_new.is_isomorphic_with(mol_old) assert_allclose(mol_new.conformers[0], mol_old.conformers[0]) - insert_mol_new = inter_new.topology.molecule(inter_new.topology.n_molecules-1) + insert_mol_new = inter_new.topology.molecule(inter_new.topology.n_molecules - 1) assert insert_mol_new.is_isomorphic_with(m2) assert_allclose(m2.conformers[0], insert_mol_new.conformers[0]) assert_allclose(m2.partial_charges, insert_mol_new.partial_charges) - assert _get_offmol_metadata(insert_mol_new, 'residue_number') == 999 \ No newline at end of file + assert _get_offmol_metadata(insert_mol_new, "residue_number") == 999 From 7a0079e252680c8e35fc157c1bd0222eb7076e54 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 28 Jul 2025 13:56:36 +0100 Subject: [PATCH 32/32] fix some linter issues --- src/pontibus/tests/utils/test_system_manipulation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pontibus/tests/utils/test_system_manipulation.py b/src/pontibus/tests/utils/test_system_manipulation.py index f2e3d62..5e52beb 100644 --- a/src/pontibus/tests/utils/test_system_manipulation.py +++ b/src/pontibus/tests/utils/test_system_manipulation.py @@ -71,7 +71,7 @@ def test_copy_no_conformers(forcefield): inter = Interchange.from_smirnoff(forcefield, topology) with pytest.raises(ValueError, match="molecules need conformers"): - inter_new = copy_interchange_with_replacement( + _ = copy_interchange_with_replacement( interchange=inter, del_mol=m1, insert_mol=m2, @@ -85,7 +85,7 @@ def test_copy_equality_clash(forcefield, insert_molecule, del_molecule): inter = Interchange.from_smirnoff(forcefield, topology) with pytest.raises(ValueError, match="equality clash"): - inter_new = copy_interchange_with_replacement( + _ = copy_interchange_with_replacement( interchange=inter, del_mol=del_molecule, insert_mol=insert_molecule, @@ -101,7 +101,7 @@ def test_copy_no_del_match(forcefield, insert_molecule, del_molecule): inter = Interchange.from_smirnoff(forcefield, topology) with pytest.raises(ValueError, match="matching del_mol in input"): - inter_new = copy_interchange_with_replacement( + _ = copy_interchange_with_replacement( interchange=inter, del_mol=fake_del_mol, insert_mol=insert_molecule, @@ -118,7 +118,6 @@ def test_copy_full(forcefield): m2 = Molecule.from_smiles("CCCO") m2.generate_conformers(n_conformers=1) m2.assign_partial_charges(partial_charge_method="gasteiger") - water = Molecule.from_smiles("O") # Solvate m1 solvated_top = solvate_topology(Topology.from_molecules([m1]))