diff --git a/gufe/__init__.py b/gufe/__init__.py index b3c7798e..66fd04bd 100644 --- a/gufe/__init__.py +++ b/gufe/__init__.py @@ -11,6 +11,7 @@ AtomMapper, # more specific to atom based components AtomMapping, ComponentMapping, # how individual Components relate + LigandAtomMapper, LigandAtomMapping, ) from .network import AlchemicalNetwork diff --git a/gufe/mapping/__init__.py b/gufe/mapping/__init__.py index 29a9bfd2..7ddc4695 100644 --- a/gufe/mapping/__init__.py +++ b/gufe/mapping/__init__.py @@ -6,4 +6,5 @@ from .atom_mapping import AtomMapping from .componentmapping import ComponentMapping from .errors import AtomMappingError +from .ligandatommapper import LigandAtomMapper from .ligandatommapping import LigandAtomMapping diff --git a/gufe/mapping/componentmapping.py b/gufe/mapping/componentmapping.py index 8c665a0a..bea4378d 100644 --- a/gufe/mapping/componentmapping.py +++ b/gufe/mapping/componentmapping.py @@ -1,12 +1,11 @@ # This code is part of gufe and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/gufe -import abc import gufe from gufe.tokenization import GufeTokenizable -class ComponentMapping(GufeTokenizable, abc.ABC): +class ComponentMapping(GufeTokenizable): """A relationship between two Components stating that they transform in some way For components that are atom-based is specialised to :class:`.AtomMapping` diff --git a/gufe/mapping/ligandatommapper.py b/gufe/mapping/ligandatommapper.py new file mode 100644 index 00000000..6fa80a5e --- /dev/null +++ b/gufe/mapping/ligandatommapper.py @@ -0,0 +1,65 @@ +import abc +from typing import Iterator + +from gufe import SmallMoleculeComponent + +from .atom_mapper import AtomMapper +from .ligandatommapping import LigandAtomMapping + + +class LigandAtomMapper(AtomMapper): + """ + Suggest atom mappings between two :class:`SmallMoleculeComponent` instances. + + Subclasses will typically implement the ``_mappings_generator`` method, + which returns an Iterator of :class:`.LigandAtomMapping` suggestions. + """ + + @abc.abstractmethod + def _mappings_generator( + self, + componentA: SmallMoleculeComponent, + componentB: SmallMoleculeComponent, + ) -> Iterator[dict[int, int]]: + """ + Suggest mapping options for the input molecules. + + Parameters + ---------- + componentA, componentB : rdkit.Mol + the two molecules to create a mapping for + + Returns + ------- + Iterator[dict[int, int]] : + an Iterator over proposed mappings from componentA to componentB + """ + ... + + def suggest_mappings( + self, + # TODO: fix overrides when we move to min python 3.12 - see https://peps.python.org/pep-0695/#summary-examples + componentA: SmallMoleculeComponent, # type: ignore[override] + componentB: SmallMoleculeComponent, # type: ignore[override] + ) -> Iterator[LigandAtomMapping]: + """ + Suggest :class:`.LigandAtomMapping` options for the input molecules. + + Parameters + --------- + componentA, componentB : :class:`.SmallMoleculeComponent` + the two molecules to create a mapping for + + Returns + ------- + Iterator[LigandAtomMapping] : + an Iterator over proposed mappings + """ + # For this base class, implementation is redundant with + # _mappings_generator. However, we keep it separate so that abstract + # subclasses of this can customize suggest_mappings while always + # maintaining the consistency that concrete implementations must + # implement _mappings_generator. + + for map_dct in self._mappings_generator(componentA, componentB): + yield LigandAtomMapping(componentA, componentB, map_dct) diff --git a/gufe/tests/test_atommapper.py b/gufe/tests/test_atommapper.py new file mode 100644 index 00000000..d7474827 --- /dev/null +++ b/gufe/tests/test_atommapper.py @@ -0,0 +1,89 @@ +import pytest +from rdkit import Chem + +from gufe import LigandAtomMapper, LigandAtomMapping, SmallMoleculeComponent + +from .test_tokenization import GufeTokenizableTestsMixin + + +def mol_from_smiles(smiles: str) -> Chem.Mol: + m = Chem.MolFromSmiles(smiles) + Chem.AllChem.Compute2DCoords(m) # type: ignore[attr-defined] + + return m + + +@pytest.fixture(scope="session") +def simple_mapping() -> LigandAtomMapping: + """Disappearing oxygen on end + + C C O + + C C + """ + molA = SmallMoleculeComponent(mol_from_smiles("CCO")) + molB = SmallMoleculeComponent(mol_from_smiles("CC")) + + m = LigandAtomMapping(molA, molB, componentA_to_componentB={0: 0, 1: 1}) + + return m + + +@pytest.fixture(scope="session") +def other_mapping() -> LigandAtomMapping: + """Disappearing middle carbon + + C C O + + C C + """ + molA = SmallMoleculeComponent(mol_from_smiles("CCO")) + molB = SmallMoleculeComponent(mol_from_smiles("CC")) + + m = LigandAtomMapping(molA, molB, componentA_to_componentB={0: 0, 2: 1}) + + return m + + +class ExampleLigandAtomMapper(LigandAtomMapper): + def __init__(self, mappings): + self.mappings = mappings + + @classmethod + def _defaults(cls): + return {} + + def _to_dict(self): + return {"mappings": self.mappings} + + @classmethod + def _from_dict(cls, dct): + return cls(**dct) + + def _mappings_generator(self, componentA, componentB): + for mapping in self.mappings: + yield mapping.componentA_to_componentB + + +class TestLigandAtomMapper(GufeTokenizableTestsMixin): + cls = ExampleLigandAtomMapper + repr = None + + @pytest.fixture + def instance(self, simple_mapping, other_mapping): + return ExampleLigandAtomMapper([simple_mapping, other_mapping]) + + def test_abstract_error(self): + # make sure users cannot use LigandAtomMapper directly + with pytest.raises(TypeError): + LigandAtomMapper() + + def test_suggest_mappings(self, instance, simple_mapping, other_mapping): + # a correctly implemented ligand atom mapping should return the + # mappings generated by the _mappings_generator + molA = simple_mapping.componentA + molB = simple_mapping.componentB + + results = list(instance.suggest_mappings(molA, molB)) + assert len(results) == 2 + assert results == [simple_mapping, other_mapping]