diff --git a/prolif/debugger.py b/prolif/debugger.py index 84c8a8a..2936a98 100644 --- a/prolif/debugger.py +++ b/prolif/debugger.py @@ -1,7 +1,8 @@ from abc import abstractmethod +from collections import defaultdict from dataclasses import dataclass from inspect import Parameter, signature -from typing import Generic, Literal, Optional, Self, Type, TypeVar +from typing import Generic, Literal, Optional, Self, Type, TypeVar, Union from rdkit import Chem @@ -9,7 +10,7 @@ from prolif.interactions.base import Interaction from prolif.interactions.interactions import PiStacking from prolif.residue import Residue -from prolif.types import Geometry, Pattern, Specs, is_geometry, is_pattern +from prolif.types import Angles, Geometry, Pattern, Specs, is_geometry, is_pattern T = TypeVar("T") @@ -83,15 +84,15 @@ def debug( target=self.target, explanation=( f"{self.target} does not match {self.interaction} parameter" - f" {self.parameter!r}={smarts}" + f" {self.parameter}={smarts!r}" ), ) @dataclass class DebugDistance(DebugAction): - distance: float cls: Type[Interaction] + distance: float padding: float @classmethod @@ -124,16 +125,71 @@ def debug( value=self.distance, explanation=( f"Could not find {self.interaction} interaction for parameter" - f" {self.parameter!r}={padded_distance}" + f" {self.parameter}={padded_distance}" + ), + ) + return None + + +@dataclass +class DebugAngles(DebugAction): + cls: Type[Interaction] + angles: Angles + padding: Angles + + @classmethod + def from_spec( + cls, parameter: str, interaction: Interaction, spec: Geometry, padding: Angles + ) -> Self: + icls = interaction.__class__ + itype = icls.__name__ + attr = spec.attr or parameter + angles: Angles = getattr(interaction, attr) + return cls( + interaction=itype, + parameter=parameter, + attr=attr, + angles=angles, + cls=icls, + padding=padding, + ) + + def debug( + self, ligand_residue: Residue, protein_residue: Residue + ) -> Optional[DebugResult]: + padded_angles = ( + self.angles[0] - self.padding[0], + self.angles[1] + self.padding[1], + ) + interaction = self.cls(**{self.parameter: padded_angles}) + metadata = next(interaction.detect(ligand_residue, protein_residue), None) + if metadata is None: + return DebugResult( + interaction=self.interaction, + parameter=self.parameter, + value=self.angles, + explanation=( + f"Could not find {self.interaction} interaction for parameter" + f" {self.parameter}={padded_angles}" ), ) return None class InteractionDebugger: - def __init__(self, fp: Fingerprint, distance_padding: float = 0.5) -> None: + def __init__( + self, + fp: Fingerprint, + distance_padding: float = 0.5, + angles_padding: Union[float, Angles] = 5, + ) -> None: self.fp = fp self.distance_padding = distance_padding + self.angles_padding = ( + (angles_padding, angles_padding) + if isinstance(angles_padding, float) + else angles_padding + ) interactions: dict[str, Interaction] = {} for itype in fp.interactions: @@ -175,33 +231,51 @@ def debug( protein_residue: Residue, interaction: Optional[str] = None, ) -> list[DebugResult]: - actions = self.gather_actions(interaction) - return [ - result - for action in actions - if (result := action.debug(ligand_residue, protein_residue)) is not None - ] - - def gather_actions(self, interaction_name: Optional[str] = None): + results = [] + for actions in self.gather_actions(interaction).values(): + for action in actions: + if ( + result := action.debug(ligand_residue, protein_residue) + ) is not None: + results.append(result) + break + return results + + def gather_actions( + self, interaction_name: Optional[str] = None + ) -> dict[str, list[DebugAction]]: + actions: dict[str, list[DebugAction]] = defaultdict(list) + if interaction_name is None: - actions: list[DebugAction] = [] - for itype in self.interactions: - actions.extend(self.gather_actions(itype)) - return actions + for interaction_name in self.interactions: + acts = next(iter(self.gather_actions(interaction_name).values())) + actions[interaction_name].extend(acts) + return dict(actions) interaction = self.interactions[interaction_name] specs = self.specs[interaction_name] - actions: list[DebugAction] = [] for parameter, spec in specs.items(): if is_pattern(spec): - actions.extend(DebugPattern.from_spec(parameter, interaction, spec)) - elif is_geometry(spec) and spec.type == "distance": - actions.append( - DebugDistance.from_spec( - parameter, - interaction, - spec, - padding=self.distance_padding, - ) + actions[interaction_name].extend( + DebugPattern.from_spec(parameter, interaction, spec) ) - return actions + elif is_geometry(spec): + if spec.type == "distance": + actions[interaction_name].append( + DebugDistance.from_spec( + parameter, + interaction, + spec, + padding=self.distance_padding, + ) + ) + else: + actions[interaction_name].append( + DebugAngles.from_spec( + parameter, + interaction, + spec, + padding=self.angles_padding, + ) + ) + return dict(actions) diff --git a/prolif/interactions/interactions.py b/prolif/interactions/interactions.py index ea22e61..55cc018 100644 --- a/prolif/interactions/interactions.py +++ b/prolif/interactions/interactions.py @@ -25,7 +25,7 @@ SingleAngle, ) from prolif.interactions.constants import VDW_PRESETS, VDWRADII # noqa -from prolif.types import Geometry, Pattern +from prolif.types import Angles, Geometry, Pattern from prolif.utils import angle_between_limits, get_centroid, get_ring_normal_vector __all__ = [ @@ -102,10 +102,14 @@ class HBAcceptor(SingleAngle): def __init__( self, - acceptor="[#7&!$([nX3])&!$([NX3]-*=[O,N,P,S])&!$([NX3]-[a])&!$([Nv4&+1]),O&!$([OX2](C)C=O)&!$(O(~a)~a)&!$(O=N-*)&!$([O-]-N=O),o+0,F&$(F-[#6])&!$(F-[#6][F,Cl,Br,I])]", - donor="[$([O,S;+0]),$([N;v3,v4&+1]),n+0]-[H]", - distance=3.5, - DHA_angle=(130, 180), + acceptor: Annotated[str, Pattern(ligand="lig_pattern")] = ( + "[#7&!$([nX3])&!$([NX3]-*=[O,N,P,S])&!$([NX3]-[a])&!$([Nv4&+1]),O&!$([OX2](C)C=O)&!$(O(~a)~a)&!$(O=N-*)&!$([O-]-N=O),o+0,F&$(F-[#6])&!$(F-[#6][F,Cl,Br,I])]" + ), + donor: Annotated[ + str, Pattern(protein="prot_pattern") + ] = "[$([O,S;+0]),$([N;v3,v4&+1]),n+0]-[H]", + distance: Annotated[float, Geometry("distance")] = 3.5, + DHA_angle: Annotated[Angles, Geometry("angles", attr="angle")] = (130, 180), ): super().__init__( lig_pattern=acceptor, @@ -156,11 +160,21 @@ class XBAcceptor(DoubleAngle): def __init__( self, - acceptor="[#7,#8,P,S,Se,Te,a;!+{1-}]!#[*]", - donor="[#6,#7,Si,F,Cl,Br,I]-[Cl,Br,I,At]", - distance=3.5, - AXD_angle=(130, 180), - XAR_angle=(80, 140), + acceptor: Annotated[ + str, Pattern(ligand="lig_pattern") + ] = "[#7,#8,P,S,Se,Te,a;!+{1-}]!#[*]", + donor: Annotated[ + str, Pattern(protein="prot_pattern") + ] = "[#6,#7,Si,F,Cl,Br,I]-[Cl,Br,I,At]", + distance: Annotated[float, Geometry("distance")] = 3.5, + AXD_angle: Annotated[Angles, Geometry("angles", attr="L1P2P1_angle")] = ( + 130, + 180, + ), + XAR_angle: Annotated[Angles, Geometry("angles", attr="L2L1P2_angle")] = ( + 80, + 140, + ), ): super().__init__( lig_pattern=acceptor, diff --git a/prolif/types.py b/prolif/types.py index c0c9f02..423196b 100644 --- a/prolif/types.py +++ b/prolif/types.py @@ -19,7 +19,6 @@ class Pattern(Specs): class Geometry(Specs): type: Literal["distance", "angles"] attr: Optional[str] = None - metadata: Optional[str] = None def is_pattern(spec: Specs) -> TypeGuard[Pattern]: diff --git a/tests/test_interactions.py b/tests/test_interactions.py index 01aac52..b755944 100644 --- a/tests/test_interactions.py +++ b/tests/test_interactions.py @@ -4,11 +4,13 @@ from MDAnalysis.transformations import rotateby, translate from rdkit import Chem, RDLogger -import prolif +import prolif as plf +from prolif.debugger import DebugResult from prolif.fingerprint import Fingerprint from prolif.interactions import VdWContact from prolif.interactions.base import _INTERACTIONS, Interaction, get_mapindex from prolif.interactions.constants import VDW_PRESETS +from prolif.molecule import Molecule # disable rdkit warnings lg = RDLogger.logger() @@ -17,7 +19,7 @@ @pytest.fixture(scope="module") def benzene_universe(): - benzene = mda.Universe(prolif.datafiles.datapath / "benzene.mol2") + benzene = mda.Universe(plf.datafiles.datapath / "benzene.mol2") elements = mda.topology.guessers.guess_types(benzene.atoms.names) benzene.add_TopologyAttr("elements", elements) benzene.segments.segids = np.array(["U1"], dtype=object) @@ -35,12 +37,12 @@ def interaction_instances(): @pytest.fixture(scope="session") -def any_mol(request): +def any_mol(request) -> Molecule: return request.getfixturevalue(request.param) @pytest.fixture(scope="session") -def any_other_mol(request): +def any_other_mol(request) -> Molecule: return request.getfixturevalue(request.param) @@ -129,24 +131,47 @@ def test_interaction( assert next(interaction(any_mol[0], any_other_mol[0]), False) is expected @pytest.mark.parametrize( - ("any_mol", "any_other_mol"), + ("any_mol", "any_other_mol", "fp_kwargs", "expected"), [ - ("benzene", "ftf"), + ( + "benzene", + "cation", + {"interactions": ["Hydrophobic"]}, + ["hydrophobic"], + ), + ( + "benzene", + "ftf", + { + "interactions": ["Hydrophobic"], + "parameters": {"Hydrophobic": {"distance": 0}}, + }, + ["distance"], + ), + ( + "xb_donor", + "xb_acceptor_false_axd", + {"interactions": ["XBDonor"]}, + ["AXD_angle"], + ), ], indirect=["any_mol", "any_other_mol"], ) - def test_debugger(self, any_mol, any_other_mol): + def test_debugger( + self, + any_mol: Molecule, + any_other_mol: Molecule, + fp_kwargs: dict, + expected: list[DebugResult], + ) -> None: from prolif.debugger import InteractionDebugger - fp = Fingerprint( - ["Hydrophobic"], - parameters={ - "Hydrophobic": {"distance": 0}, - }, - ) - debugger = InteractionDebugger(fp, distance_padding=1.0) + fp = Fingerprint(**fp_kwargs) + debugger = InteractionDebugger(fp) results = debugger.debug(any_mol, any_other_mol) - results + assert len(results) == len(expected) + for r, e in zip(results, expected): + assert r.parameter == e def test_warning_supersede(self): old = id(_INTERACTIONS["Hydrophobic"]) @@ -160,8 +185,8 @@ def detect(self): assert old != new # fix dummy Hydrophobic class being reused in later unrelated tests - class Hydrophobic(prolif.interactions.Hydrophobic): - __doc__ = prolif.interactions.Hydrophobic.__doc__ + class Hydrophobic(plf.interactions.Hydrophobic): + __doc__ = plf.interactions.Hydrophobic.__doc__ def test_error_no_detect(self): with pytest.raises( @@ -358,8 +383,8 @@ def create_rings(benzene_universe, xyz, rotation): rotz = rotateby(rotation[2], [0, 0, 1], ag=r2.atoms) r2.trajectory.add_transformations(tr, rotx, roty, rotz) return ( - prolif.Molecule.from_mda(benzene_universe)[0], - prolif.Molecule.from_mda(r2)[0], + Molecule.from_mda(benzene_universe)[0], + Molecule.from_mda(r2)[0], ) def test_edgetoface_phe331(self, ligand_mol, protein_mol):