From 93d39f5716d7b245564bcf686c024b6edeb1283f Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Tue, 17 Feb 2026 22:51:14 +0100 Subject: [PATCH 01/14] added prolif in environment.yml --- environment.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/environment.yml b/environment.yml index e48a81f..3c48d3a 100644 --- a/environment.yml +++ b/environment.yml @@ -8,6 +8,7 @@ dependencies: - openff-units - pip - tqdm + - prolif - pyyaml # for testing - coverage From 5f48f144c10dd1407cdf84dafabc226ecebfffc4 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Tue, 17 Feb 2026 23:32:57 +0100 Subject: [PATCH 02/14] black format --- src/openfe_analysis/prolif.py | 197 +++++++++++++++++++++++ src/openfe_analysis/tests/test_prolif.py | 57 +++++++ 2 files changed, 254 insertions(+) create mode 100644 src/openfe_analysis/prolif.py create mode 100644 src/openfe_analysis/tests/test_prolif.py diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py new file mode 100644 index 0000000..b80776a --- /dev/null +++ b/src/openfe_analysis/prolif.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence, Tuple + +import MDAnalysis as mda + +import prolif as plf + + +class ProLIFAnalysis: + """ + ProLIF interaction fingerprint analysis for an OpenFEReader Universe. + """ + + def __init__( + self, + universe: mda.Universe, + ligand_ag: mda.AtomGroup, + water_order: int = 3, + interactions: Optional[Sequence[str] | str] = None, + guess_bonds: bool = True, + vdwradii: Optional[Dict[str, float]] = None, + ) -> None: + """ + Initialize the ProLIF analysis. + + Parameters + ---------- + universe + MDAnalysis Universe containing topology and trajectory. + ligand_ag + mda.AtomGroup representing the ligand. + water_order + Maximum WaterBridge interaction order (water-water interaction). + Only used if "WaterBridge" is tracked. + interactions + Which interactions to track: + - None: ProLIF defaults + - "all": all registered (non-bridged; depends on ProLIF version) + - Sequence[str]: explicit list like ["VdWContact", "HBDonor"] + guess_bonds + If True, guess bonds for (protein, ligand, water) so ProLIF can + recognize donors/acceptors and bonded hydrogens. + vdwradii + Optional dict of van der Waals radii used by MDAnalysis bond guesser. + Useful when your topology contains types the guesser doesn't know + (e.g. "Cl", "Na"). If None, uses coded defaults. + """ + self.universe = universe + self.ligand_ag = ligand_ag + self.water_order = water_order + + # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- + if guess_bonds: + if vdwradii is None: + # minimal overrides needed for your system (atom types include Cl/Na) + vdwradii = { + "Cl": 1.75, + "CL": 1.75, + "Br": 1.85, + "BR": 1.85, + "Na": 2.27, + "NA": 2.27, + } + + # Protein: guess on the full protein so any pocket residue later has bonds + universe.select_atoms("protein").guess_bonds(vdwradii=vdwradii) + + # Ligand: stable group + self.ligand_ag.guess_bonds(vdwradii=vdwradii) + + # Water: only if you care about water-mediated interactions + if guess_bonds: + wat_all = universe.select_atoms("water") + if wat_all.n_atoms: + wat_all.guess_bonds(vdwradii=vdwradii) + + # Currently adding here but maybe as args? + self.protein_ag = self.universe.select_atoms( + "protein and byres around 12 group ligand", + ligand=self.ligand_ag, + updating=True, + ) + self.water_ag = self.universe.select_atoms( + "water and byres around 8 (group ligand or group pocket)", + ligand=self.ligand_ag, + pocket=self.protein_ag, + updating=True, + ) + + available = plf.Fingerprint.list_available() + + if interactions is None: + fp_interactions = None + + elif interactions == "all": + fp_interactions = "all" + + else: + # Cover case of false interaction + missing = [i for i in interactions if i not in available] + if missing: + raise ValueError( + f"Unknown interaction(s): {missing}. " f"Available: {available}" + ) + fp_interactions = list(interactions) + + parameters = None + if ( + fp_interactions is not None + and fp_interactions != "all" + and "WaterBridge" in fp_interactions + ): + if self.water_ag.n_atoms == 0: + raise ValueError("WaterBridge selected but water selection is empty.") + parameters = { + "WaterBridge": {"water": self.water_ag, "order": self.water_order} + } + + if fp_interactions is None: + self.fp = plf.Fingerprint() + else: + self.fp = plf.Fingerprint(interactions=fp_interactions) + + def run( + self, + *, + start: Optional[int] = None, + stop: Optional[int] = None, + step: Optional[int] = None, + residues: Optional[bool] = None, + progress: bool = True, + n_jobs: Optional[int] = None, + parallel_strategy: Optional[str] = None, + converter_kwargs: Optional[Tuple[Dict[str, Any], Dict[str, Any]]] = None, + ) -> "ProLIFAnalysis": + """ + Run the fingerprint calculation over a slice of the trajectory. + + Parameters + ---------- + start, stop, step + Trajectory slicing parameters. + residues + Passed to ProLIF: whether to aggregate interactions with residues. + If None, ProLIF's default is used and interactions with atoms are identified. + progress + Show progress bar. + n_jobs + Number of workers for parallel execution.). + parallel_strategy + ProLIF parallel strategy. If None, this wrapper sets: + - "chunk" for n_jobs None/1 + - "queue" for n_jobs > 1 + converter_kwargs + Two dicts: (ligand_kwargs, protein_kwargs) forwarded to the MDAnalysis→RDKit + converter. If None, we default to: + - ligand: {"inferrer": None, "implicit_hydrogens": False} (avoid valence issues) + - protein: {"implicit_hydrogens": False} (use topology bonds) + + Returns + ------- + self + Returned for fluent chaining. + """ + # Due to FEReader trajectory only certain strategies work with the format + if parallel_strategy is None: + # avoid ProLIF trying to pickle FEReader/netCDF trajectory to auto-pick strategy + parallel_strategy = "chunk" if (n_jobs is None or n_jobs == 1) else "queue" + traj = self.universe.trajectory[slice(start, stop, step)] + + if converter_kwargs is None: + # Avoid Valence errors + converter_kwargs = ( + {"inferrer": None, "implicit_hydrogens": False}, # ligand + {"implicit_hydrogens": False}, # protein + ) + + self.fp.run( + traj, + self.ligand_ag, + self.protein_ag, + residues=residues, + converter_kwargs=converter_kwargs, + progress=progress, + n_jobs=n_jobs, + parallel_strategy=parallel_strategy, + ) + + return self + + # For now, depending on what we do withe the data + def to_dataframe(self, **kwargs): + """ + Transform fingerprint results to pd.DataFrame. + """ + return self.fp.to_dataframe(**kwargs) diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py new file mode 100644 index 0000000..0a43e89 --- /dev/null +++ b/src/openfe_analysis/tests/test_prolif.py @@ -0,0 +1,57 @@ +import MDAnalysis as mda +import numpy as np +import pytest +from rdkit.Chem import Lipinski + +from openfe_analysis.reader import FEReader +from openfe_analysis.prolif import ProLIFAnalysis + + +def test_prolifanalysis_runs_vdwcontact( + simulation_skipped_nc, hybrid_system_skipped_pdb +): + """ + Test for identification of interactions + """ + u = mda.Universe( + hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 + ) + ligand_ag = u.select_atoms("resname UNK") + + analysis = ProLIFAnalysis( + u, ligand_ag, interactions=["VdWContact"], guess_bonds=True + ) + analysis.run(stop=5, step=1, n_jobs=1, progress=False) + + df = analysis.to_dataframe(dtype=np.uint8) + assert df.shape[0] == 5 + assert hasattr(analysis.fp, "ifp") + assert len(analysis.fp.ifp) == 5 + # Ensure there is at least one detected interaction across all processed frames + assert sum(len(v) for v in analysis.fp.ifp.values()) > 0 + + +def test_guess_bonds_enables_protein_chemistry( + simulation_skipped_nc, hybrid_system_skipped_pdb +): + """ + Test for protein connectivity + """ + u = mda.Universe( + hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 + ) + ligand_ag = u.select_atoms("resname UNK") + + analysis = ProLIFAnalysis( + u, ligand_ag, interactions=["VdWContact"], guess_bonds=True + ) + + # pick a residue from the pocket and check it has connectivity in RDKit + u.trajectory[0] + res_atoms = analysis.protein_ag.residues[0].atoms + res_mol = res_atoms.convert_to("RDKIT", implicit_hydrogens=False) + assert res_mol.GetNumBonds() > 0 + + # ensure the protein donors/acceptors exist + prot_mol = analysis.protein_ag.convert_to("RDKIT", implicit_hydrogens=False) + assert Lipinski.NumHDonors(prot_mol) + Lipinski.NumHAcceptors(prot_mol) > 0 From 2ae635796b1aa18da122c9deba52476880c45b71 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 26 Feb 2026 00:31:44 +0100 Subject: [PATCH 03/14] modified for AnalysisBase --- src/openfe_analysis/prolif.py | 46 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index b80776a..6bb3491 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -1,13 +1,15 @@ from __future__ import annotations +import numpy as np from typing import Any, Dict, Optional, Sequence, Tuple import MDAnalysis as mda +from MDAnalysis.analysis.base import AnalysisBase import prolif as plf -class ProLIFAnalysis: +class ProLIFAnalysis(AnalysisBase): """ ProLIF interaction fingerprint analysis for an OpenFEReader Universe. """ @@ -20,6 +22,7 @@ def __init__( interactions: Optional[Sequence[str] | str] = None, guess_bonds: bool = True, vdwradii: Optional[Dict[str, float]] = None, + **kwargs, ) -> None: """ Initialize the ProLIF analysis. @@ -50,6 +53,9 @@ def __init__( self.ligand_ag = ligand_ag self.water_order = water_order + super().__init__(universe.trajectory, **kwargs) + + # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- if guess_bonds: if vdwradii is None: @@ -105,7 +111,7 @@ def __init__( ) fp_interactions = list(interactions) - parameters = None + self._parameters = None if ( fp_interactions is not None and fp_interactions != "all" @@ -113,7 +119,7 @@ def __init__( ): if self.water_ag.n_atoms == 0: raise ValueError("WaterBridge selected but water selection is empty.") - parameters = { + self._parameters = { "WaterBridge": {"water": self.water_ag, "order": self.water_order} } @@ -122,6 +128,13 @@ def __init__( else: self.fp = plf.Fingerprint(interactions=fp_interactions) + def _prepare(self): + self.results.ifp = None + self.results.ifp_df = None + + def _conclude(self): + self.results.ifp = getattr(self.fp, "ifp", None) + def run( self, *, @@ -167,7 +180,27 @@ def run( if parallel_strategy is None: # avoid ProLIF trying to pickle FEReader/netCDF trajectory to auto-pick strategy parallel_strategy = "chunk" if (n_jobs is None or n_jobs == 1) else "queue" - traj = self.universe.trajectory[slice(start, stop, step)] + + _slice = slice(start, stop, step) + traj = self.universe.trajectory[_slice] + + + try: + n_total = len(self.universe.trajectory) + s0, s1, s2 = _slice.indices(n_total) + self.frames = np.arange(s0, s1, s2, dtype=int) + self.n_frames = len(traj) + + if hasattr(self.universe.trajectory, "times") and self.universe.trajectory.times is not None: + self.times = np.asarray(self.universe.trajectory.times)[self.frames] + elif getattr(self.universe.trajectory, "dt", None) is not None: + self.times = self.frames * self.universe.trajectory.dt + else: + self.times = None + except Exception: + self.frames = None + self.times = None + self.n_frames = None if converter_kwargs is None: # Avoid Valence errors @@ -187,6 +220,7 @@ def run( parallel_strategy=parallel_strategy, ) + self._conclude() return self # For now, depending on what we do withe the data @@ -194,4 +228,6 @@ def to_dataframe(self, **kwargs): """ Transform fingerprint results to pd.DataFrame. """ - return self.fp.to_dataframe(**kwargs) + df = self.fp.to_dataframe(**kwargs) + self.results.ifp_df = df + return df From 976722d97e51aa8f730a045edfa4358b449ef20a Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 26 Feb 2026 00:32:23 +0100 Subject: [PATCH 04/14] black formatting --- src/openfe_analysis/prolif.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 6bb3491..871848e 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -55,7 +55,6 @@ def __init__( super().__init__(universe.trajectory, **kwargs) - # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- if guess_bonds: if vdwradii is None: @@ -184,14 +183,16 @@ def run( _slice = slice(start, stop, step) traj = self.universe.trajectory[_slice] - try: n_total = len(self.universe.trajectory) s0, s1, s2 = _slice.indices(n_total) self.frames = np.arange(s0, s1, s2, dtype=int) self.n_frames = len(traj) - if hasattr(self.universe.trajectory, "times") and self.universe.trajectory.times is not None: + if ( + hasattr(self.universe.trajectory, "times") + and self.universe.trajectory.times is not None + ): self.times = np.asarray(self.universe.trajectory.times)[self.frames] elif getattr(self.universe.trajectory, "dt", None) is not None: self.times = self.frames * self.universe.trajectory.dt From abed8114244f9e4ab281b1255d5ee5dfd2953225 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 26 Feb 2026 01:25:39 +0100 Subject: [PATCH 05/14] added tests --- src/openfe_analysis/tests/test_prolif.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index 0a43e89..7f67586 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -27,6 +27,15 @@ def test_prolifanalysis_runs_vdwcontact( assert df.shape[0] == 5 assert hasattr(analysis.fp, "ifp") assert len(analysis.fp.ifp) == 5 + + # Check AnalysisBase + assert hasattr(analysis, "results") + assert hasattr(analysis.results, "ifp") + assert analysis.results.ifp is analysis.fp.ifp + assert len(analysis.results.ifp) == 5 + + assert analysis.results.ifp_df is df + # Ensure there is at least one detected interaction across all processed frames assert sum(len(v) for v in analysis.fp.ifp.values()) > 0 From 445feb843a0b024f1cdca5c5867fce9f2e712375 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 16 Apr 2026 00:41:18 +0200 Subject: [PATCH 06/14] Added 2D visualizationa and adressed comments --- src/openfe_analysis/prolif.py | 110 ++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 871848e..a493dde 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -2,9 +2,11 @@ import numpy as np from typing import Any, Dict, Optional, Sequence, Tuple +import warnings import MDAnalysis as mda from MDAnalysis.analysis.base import AnalysisBase +from MDAnalysis.guesser.tables import vdwradii as MDA_VDWRADII import prolif as plf @@ -58,15 +60,14 @@ def __init__( # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- if guess_bonds: if vdwradii is None: - # minimal overrides needed for your system (atom types include Cl/Na) - vdwradii = { - "Cl": 1.75, - "CL": 1.75, - "Br": 1.85, - "BR": 1.85, - "Na": 2.27, - "NA": 2.27, - } + vdwradii = dict(MDA_VDWRADII) + vdwradii.update( + { + "Cl": vdwradii["CL"], + "Br": vdwradii["BR"], + "Na": vdwradii["NA"], + } + ) # Protein: guess on the full protein so any pocket residue later has bonds universe.select_atoms("protein").guess_bonds(vdwradii=vdwradii) @@ -117,10 +118,21 @@ def __init__( and "WaterBridge" in fp_interactions ): if self.water_ag.n_atoms == 0: - raise ValueError("WaterBridge selected but water selection is empty.") - self._parameters = { - "WaterBridge": {"water": self.water_ag, "order": self.water_order} - } + warnings.warn( + "WaterBridge selected but water selection is empty at the initial " + "frame; removing WaterBridge from the requested interactions.", + UserWarning, + stacklevel=2, + ) + fp_interactions = [ + interaction + for interaction in fp_interactions + if interaction != "WaterBridge" + ] + else: + self._parameters = { + "WaterBridge": {"water": self.water_ag, "order": self.water_order} + } if fp_interactions is None: self.fp = plf.Fingerprint() @@ -232,3 +244,75 @@ def to_dataframe(self, **kwargs): df = self.fp.to_dataframe(**kwargs) self.results.ifp_df = df return df + + + def plot_lignetwork( + self, + ligand_mol=None, + *, + frame: Optional[int] = None, + kind: Literal["aggregate", "frame"] = "frame", + display_all: bool = False, + threshold: float = 0.3, + use_coordinates: bool = True, + flatten_coordinates: bool = True, + kekulize: bool = False, + molsize: int = 35, + rotation: float = 0, + carbon: float = 0.16, + width: str = "100%", + height: str = "500px", + fontsize: int = 20, + show_interaction_data: bool = False, + ): + """ + 2D ProLIF ligand-network visualization. + """ + if not hasattr(self.fp, "ifp") or not self.fp.ifp: + raise RuntimeError( + "No ProLIF fingerprint data found. Run `analysis.run(...)` first." + ) + + available_frames = list(self.fp.ifp.keys()) + + if frame is None: + frame = available_frames[0] + + if kind == "frame" and frame not in self.fp.ifp: + preview = available_frames[:10] + suffix = " ..." if len(available_frames) > 10 else "" + raise ValueError( + f"frame={frame} not present in fingerprint results. " + f"Available frames: {preview}{suffix}" + ) + + if frame is not None: + self.universe.trajectory[frame] + + if ligand_mol is None: + ligand_mol = plf.Molecule.from_mda( + self.ligand_ag, + inferrer=None, + implicit_hydrogens=False, + use_segid=self.fp.use_segid, + ) + + return self.fp.plot_lignetwork( + ligand_mol, + kind=kind, + frame=frame, + display_all=display_all, + threshold=threshold, + use_coordinates=use_coordinates, + flatten_coordinates=flatten_coordinates, + kekulize=kekulize, + molsize=molsize, + rotation=rotation, + carbon=carbon, + width=width, + height=height, + fontsize=fontsize, + show_interaction_data=show_interaction_data, + ) + + plot_2d = plot_lignetwork \ No newline at end of file From a67363652cb4c0e6335c36f944c1e019a40430e3 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 16 Apr 2026 01:09:36 +0200 Subject: [PATCH 07/14] Added tests --- src/openfe_analysis/tests/test_prolif.py | 96 ++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index 7f67586..f99db90 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -64,3 +64,99 @@ def test_guess_bonds_enables_protein_chemistry( # ensure the protein donors/acceptors exist prot_mol = analysis.protein_ag.convert_to("RDKIT", implicit_hydrogens=False) assert Lipinski.NumHDonors(prot_mol) + Lipinski.NumHAcceptors(prot_mol) > 0 + + +def test_prolifanalysis_accepts_all_keyword( + simulation_skipped_nc, hybrid_system_skipped_pdb +): + """ + The string "all" should be accepted as the special keyword for + all available ProLIF interactions. + """ + u = mda.Universe( + hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 + ) + ligand_ag = u.select_atoms("resname UNK") + + analysis = ProLIFAnalysis(u, ligand_ag, interactions="all", guess_bonds=True) + + assert analysis.fp is not None + + +def test_waterbridge_empty_selection_warns_and_skips_parameters( + simulation_skipped_nc, hybrid_system_skipped_pdb, monkeypatch +): + """ + Requesting WaterBridge with an empty water selection should warn + instead of raising, and should not configure WaterBridge parameters. + """ + u = mda.Universe( + hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 + ) + ligand_ag = u.select_atoms("resname UNK") + + original_select_atoms = u.select_atoms + + def patched_select_atoms(selection, *args, **kwargs): + if selection == "water and byres around 8 (group ligand or group pocket)": + return u.atoms[[]] + return original_select_atoms(selection, *args, **kwargs) + + monkeypatch.setattr(u, "select_atoms", patched_select_atoms) + + with pytest.warns(UserWarning, match="WaterBridge selected"): + analysis = ProLIFAnalysis( + u, + ligand_ag, + interactions=["WaterBridge"], + guess_bonds=True, + ) + + assert analysis._parameters is None + + +def test_plot_2d_builds_ligand_mol_and_delegates( + simulation_skipped_nc, hybrid_system_skipped_pdb, monkeypatch +): + """ + plot_2d should build a ligand molecule internally when one is not + provided and delegate to ProLIF's plot_lignetwork. + """ + u = mda.Universe( + hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 + ) + ligand_ag = u.select_atoms("resname UNK") + + analysis = ProLIFAnalysis( + u, ligand_ag, interactions=["VdWContact"], guess_bonds=True + ) + + analysis.fp.ifp = {0: {"dummy": []}} + + fake_ligand_mol = object() + calls = {} + + def fake_from_mda(atomgroup, **kwargs): + calls["from_mda"] = (atomgroup, kwargs) + return fake_ligand_mol + + def fake_plot_lignetwork(ligand_mol, **kwargs): + calls["plot_lignetwork"] = (ligand_mol, kwargs) + return "fake-view" + + monkeypatch.setattr( + "openfe_analysis.prolif.plf.Molecule.from_mda", + fake_from_mda, + ) + monkeypatch.setattr(analysis.fp, "plot_lignetwork", fake_plot_lignetwork) + + view = analysis.plot_2d(frame=0, kind="frame") + + assert view == "fake-view" + assert calls["from_mda"][0] is ligand_ag + assert calls["from_mda"][1]["inferrer"] is None + assert calls["from_mda"][1]["implicit_hydrogens"] is False + assert calls["from_mda"][1]["use_segid"] == analysis.fp.use_segid + assert calls["plot_lignetwork"][0] is fake_ligand_mol + assert calls["plot_lignetwork"][1]["frame"] == 0 + assert calls["plot_lignetwork"][1]["kind"] == "frame" \ No newline at end of file From bf4723a855b63dea8223667a71943b478fa7d80a Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 16 Apr 2026 01:54:24 +0200 Subject: [PATCH 08/14] added args for selection --- src/openfe_analysis/prolif.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index a493dde..f290b8e 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -21,6 +21,8 @@ def __init__( universe: mda.Universe, ligand_ag: mda.AtomGroup, water_order: int = 3, + protein_cutoff: int = 12, + water_cutoff: int = 8, interactions: Optional[Sequence[str] | str] = None, guess_bonds: bool = True, vdwradii: Optional[Dict[str, float]] = None, @@ -38,6 +40,12 @@ def __init__( water_order Maximum WaterBridge interaction order (water-water interaction). Only used if "WaterBridge" is tracked. + protein_cutoff + Distance cutoff in angstrom used to define the protein pocket + around the ligand. + water_cutoff + Distance cutoff in angstrom used to define waters considered + around the ligand/protein pocket. interactions Which interactions to track: - None: ProLIF defaults @@ -81,14 +89,13 @@ def __init__( if wat_all.n_atoms: wat_all.guess_bonds(vdwradii=vdwradii) - # Currently adding here but maybe as args? self.protein_ag = self.universe.select_atoms( - "protein and byres around 12 group ligand", + f"protein and byres around {protein_cutoff} group ligand", ligand=self.ligand_ag, updating=True, ) self.water_ag = self.universe.select_atoms( - "water and byres around 8 (group ligand or group pocket)", + f"water and byres around {water_cutoff} (group ligand or group pocket)", ligand=self.ligand_ag, pocket=self.protein_ag, updating=True, From 3e9a2b468b4533aed21848ef18792319c9bfb035 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 16 Apr 2026 01:56:12 +0200 Subject: [PATCH 09/14] black formatting --- src/openfe_analysis/prolif.py | 3 +-- src/openfe_analysis/tests/test_prolif.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index f290b8e..69800c0 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -252,7 +252,6 @@ def to_dataframe(self, **kwargs): self.results.ifp_df = df return df - def plot_lignetwork( self, ligand_mol=None, @@ -322,4 +321,4 @@ def plot_lignetwork( show_interaction_data=show_interaction_data, ) - plot_2d = plot_lignetwork \ No newline at end of file + plot_2d = plot_lignetwork diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index f99db90..8441559 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -159,4 +159,4 @@ def fake_plot_lignetwork(ligand_mol, **kwargs): assert calls["from_mda"][1]["use_segid"] == analysis.fp.use_segid assert calls["plot_lignetwork"][0] is fake_ligand_mol assert calls["plot_lignetwork"][1]["frame"] == 0 - assert calls["plot_lignetwork"][1]["kind"] == "frame" \ No newline at end of file + assert calls["plot_lignetwork"][1]["kind"] == "frame" From 990c86fc731c8b54b9ed08be6f98cb4cc8f0ac4a Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Tue, 21 Apr 2026 22:28:13 +0200 Subject: [PATCH 10/14] adjusted test error --- src/openfe_analysis/prolif.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 69800c0..283eff9 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -101,7 +101,7 @@ def __init__( updating=True, ) - available = plf.Fingerprint.list_available() + available = plf.Fingerprint.list_available(show_bridged=True) if interactions is None: fp_interactions = None @@ -142,9 +142,14 @@ def __init__( } if fp_interactions is None: - self.fp = plf.Fingerprint() + self.fp = plf.Fingerprint(parameters=self._parameters) + elif len(fp_interactions) == 0: + self.fp = plf.Fingerprint(parameters=self._parameters) else: - self.fp = plf.Fingerprint(interactions=fp_interactions) + self.fp = plf.Fingerprint( + interactions=fp_interactions, + parameters=self._parameters, + ) def _prepare(self): self.results.ifp = None From 23e218ba773c52d18902996bce08c9823937904a Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Tue, 21 Apr 2026 22:38:07 +0200 Subject: [PATCH 11/14] adjusted tests --- src/openfe_analysis/tests/test_prolif.py | 45 +++++++++++++++--------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index 8441559..d38c3bc 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -115,40 +115,50 @@ def patched_select_atoms(selection, *args, **kwargs): assert analysis._parameters is None -def test_plot_2d_builds_ligand_mol_and_delegates( - simulation_skipped_nc, hybrid_system_skipped_pdb, monkeypatch -): +def test_plot_2d_builds_ligand_mol_and_delegates(monkeypatch): """ plot_2d should build a ligand molecule internally when one is not provided and delegate to ProLIF's plot_lignetwork. """ - u = mda.Universe( - hybrid_system_skipped_pdb, simulation_skipped_nc, format=FEReader, index=0 - ) - ligand_ag = u.select_atoms("resname UNK") - analysis = ProLIFAnalysis( - u, ligand_ag, interactions=["VdWContact"], guess_bonds=True - ) + class DummyTrajectory: + def __init__(self): + self.last_frame = None - analysis.fp.ifp = {0: {"dummy": []}} + def __getitem__(self, frame): + self.last_frame = frame + return None - fake_ligand_mol = object() + class DummyFP: + def __init__(self): + self.ifp = {0: {"dummy": []}} + self.use_segid = False + + def plot_lignetwork(self, ligand_mol, **kwargs): + calls["plot_lignetwork"] = (ligand_mol, kwargs) + return "fake-view" + + ligand_ag = object() calls = {} + analysis = object.__new__(ProLIFAnalysis) + analysis.ligand_ag = ligand_ag + analysis.universe = type( + "DummyUniverse", + (), + {"trajectory": DummyTrajectory()}, + )() + analysis.fp = DummyFP() + + fake_ligand_mol = object() def fake_from_mda(atomgroup, **kwargs): calls["from_mda"] = (atomgroup, kwargs) return fake_ligand_mol - def fake_plot_lignetwork(ligand_mol, **kwargs): - calls["plot_lignetwork"] = (ligand_mol, kwargs) - return "fake-view" - monkeypatch.setattr( "openfe_analysis.prolif.plf.Molecule.from_mda", fake_from_mda, ) - monkeypatch.setattr(analysis.fp, "plot_lignetwork", fake_plot_lignetwork) view = analysis.plot_2d(frame=0, kind="frame") @@ -160,3 +170,4 @@ def fake_plot_lignetwork(ligand_mol, **kwargs): assert calls["plot_lignetwork"][0] is fake_ligand_mol assert calls["plot_lignetwork"][1]["frame"] == 0 assert calls["plot_lignetwork"][1]["kind"] == "frame" + assert analysis.universe.trajectory.last_frame == 0 From 2d1f22eeed5b13128e39980079674268fcb027b2 Mon Sep 17 00:00:00 2001 From: "talagayv95@gmail.com" Date: Thu, 14 May 2026 01:20:25 +0200 Subject: [PATCH 12/14] adjusted code to add additional visualization and changed the wrapper --- src/openfe_analysis/prolif.py | 134 +++++++++++++++++++---- src/openfe_analysis/tests/test_prolif.py | 10 +- 2 files changed, 113 insertions(+), 31 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 283eff9..84cb22c 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -1,17 +1,16 @@ from __future__ import annotations import numpy as np -from typing import Any, Dict, Optional, Sequence, Tuple +from typing import Any, Dict, Optional, Sequence, Tuple, Literal import warnings import MDAnalysis as mda -from MDAnalysis.analysis.base import AnalysisBase from MDAnalysis.guesser.tables import vdwradii as MDA_VDWRADII import prolif as plf -class ProLIFAnalysis(AnalysisBase): +class ProLIFAnalysis: """ ProLIF interaction fingerprint analysis for an OpenFEReader Universe. """ @@ -21,12 +20,11 @@ def __init__( universe: mda.Universe, ligand_ag: mda.AtomGroup, water_order: int = 3, - protein_cutoff: int = 12, - water_cutoff: int = 8, + protein_cutoff: float = 12.0, + water_cutoff: float = 8.0, interactions: Optional[Sequence[str] | str] = None, guess_bonds: bool = True, vdwradii: Optional[Dict[str, float]] = None, - **kwargs, ) -> None: """ Initialize the ProLIF analysis. @@ -63,7 +61,10 @@ def __init__( self.ligand_ag = ligand_ag self.water_order = water_order - super().__init__(universe.trajectory, **kwargs) + self.frames = None + self.times = None + self.n_frames = None + self.ifp_df = None # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- if guess_bonds: @@ -84,10 +85,9 @@ def __init__( self.ligand_ag.guess_bonds(vdwradii=vdwradii) # Water: only if you care about water-mediated interactions - if guess_bonds: - wat_all = universe.select_atoms("water") - if wat_all.n_atoms: - wat_all.guess_bonds(vdwradii=vdwradii) + wat_all = universe.select_atoms("water") + if wat_all.n_atoms: + wat_all.guess_bonds(vdwradii=vdwradii) self.protein_ag = self.universe.select_atoms( f"protein and byres around {protein_cutoff} group ligand", @@ -141,9 +141,7 @@ def __init__( "WaterBridge": {"water": self.water_ag, "order": self.water_order} } - if fp_interactions is None: - self.fp = plf.Fingerprint(parameters=self._parameters) - elif len(fp_interactions) == 0: + if not fp_interactions: self.fp = plf.Fingerprint(parameters=self._parameters) else: self.fp = plf.Fingerprint( @@ -151,13 +149,6 @@ def __init__( parameters=self._parameters, ) - def _prepare(self): - self.results.ifp = None - self.results.ifp_df = None - - def _conclude(self): - self.results.ifp = getattr(self.fp, "ifp", None) - def run( self, *, @@ -245,16 +236,22 @@ def run( parallel_strategy=parallel_strategy, ) - self._conclude() return self + @property + def ifp(self): + """ + Convenience accessor for underlying ProLIF fingerprint results. + """ + return getattr(self.fp, "ifp", None) + # For now, depending on what we do withe the data def to_dataframe(self, **kwargs): """ Transform fingerprint results to pd.DataFrame. """ df = self.fp.to_dataframe(**kwargs) - self.results.ifp_df = df + self.ifp_df = df return df def plot_lignetwork( @@ -279,7 +276,7 @@ def plot_lignetwork( """ 2D ProLIF ligand-network visualization. """ - if not hasattr(self.fp, "ifp") or not self.fp.ifp: + if not self.ifp: raise RuntimeError( "No ProLIF fingerprint data found. Run `analysis.run(...)` first." ) @@ -327,3 +324,92 @@ def plot_lignetwork( ) plot_2d = plot_lignetwork + + def plot_barcode( + self, + *, + figsize: tuple[int, int] = (8, 10), + dpi: int = 100, + interactive: bool = True, + n_frame_ticks: int = 10, + residues_tick_location: Literal["top", "bottom"] = "top", + xlabel: str = "Frame", + subplots_kwargs: Optional[dict] = None, + tight_layout_kwargs: Optional[dict] = None, + ): + """ + Barcode plot of interactions across frames. + """ + if not self.ifp: + raise RuntimeError( + "No ProLIF fingerprint data found. Run `analysis.run(...)` first." + ) + + return self.fp.plot_barcode( + figsize=figsize, + dpi=dpi, + interactive=interactive, + n_frame_ticks=n_frame_ticks, + residues_tick_location=residues_tick_location, + xlabel=xlabel, + subplots_kwargs=subplots_kwargs, + tight_layout_kwargs=tight_layout_kwargs, + ) + + def plot_3d( + self, + ligand_mol=None, + protein_mol=None, + water_mol=None, + *, + frame: int = 0, + size: tuple[int, int] = (650, 600), + display_all: bool = False, + only_interacting: bool = True, + remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True, + ): + """ + 3D ProLIF interaction visualization using py3Dmol. + """ + if not self.ifp: + raise RuntimeError( + "No ProLIF fingerprint data found. Run `analysis.run(...)` first." + ) + + if frame not in self.fp.ifp: + raise ValueError(f"frame={frame} not present in fingerprint results.") + + self.universe.trajectory[frame] + + if ligand_mol is None: + ligand_mol = plf.Molecule.from_mda( + self.ligand_ag, + inferrer=None, + implicit_hydrogens=False, + use_segid=self.fp.use_segid, + ) + + if protein_mol is None: + protein_mol = plf.Molecule.from_mda( + self.protein_ag, + implicit_hydrogens=False, + use_segid=self.fp.use_segid, + ) + + if water_mol is None and self.water_ag.n_atoms: + water_mol = plf.Molecule.from_mda( + self.water_ag, + implicit_hydrogens=False, + use_segid=self.fp.use_segid, + ) + + return self.fp.plot_3d( + ligand_mol, + protein_mol, + water_mol=water_mol, + frame=frame, + size=size, + display_all=display_all, + only_interacting=only_interacting, + remove_hydrogens=remove_hydrogens, + ) diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index d38c3bc..f406eab 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -28,13 +28,9 @@ def test_prolifanalysis_runs_vdwcontact( assert hasattr(analysis.fp, "ifp") assert len(analysis.fp.ifp) == 5 - # Check AnalysisBase - assert hasattr(analysis, "results") - assert hasattr(analysis.results, "ifp") - assert analysis.results.ifp is analysis.fp.ifp - assert len(analysis.results.ifp) == 5 - - assert analysis.results.ifp_df is df + assert analysis.ifp is analysis.fp.ifp + assert len(analysis.ifp) == 5 + assert analysis.ifp_df is df # Ensure there is at least one detected interaction across all processed frames assert sum(len(v) for v in analysis.fp.ifp.values()) > 0 From 0d8779a7298b24f3cdc2f20ce0692ff6dc241165 Mon Sep 17 00:00:00 2001 From: Valerij Talagayev Date: Thu, 18 Jun 2026 09:11:58 +0200 Subject: [PATCH 13/14] Moving plots to plotting --- src/openfe_analysis/prolif.py | 126 ++-------------------- src/openfe_analysis/tests/test_prolif.py | 39 ++++++- src/openfe_analysis/utils/plotting.py | 130 +++++++++++++++++++++++ 3 files changed, 175 insertions(+), 120 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 84cb22c..2fc1466 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -9,6 +9,7 @@ import prolif as plf +from .utils.plotting import plot_prolif_3d, plot_prolif_lignetwork class ProLIFAnalysis: """ @@ -254,74 +255,11 @@ def to_dataframe(self, **kwargs): self.ifp_df = df return df - def plot_lignetwork( - self, - ligand_mol=None, - *, - frame: Optional[int] = None, - kind: Literal["aggregate", "frame"] = "frame", - display_all: bool = False, - threshold: float = 0.3, - use_coordinates: bool = True, - flatten_coordinates: bool = True, - kekulize: bool = False, - molsize: int = 35, - rotation: float = 0, - carbon: float = 0.16, - width: str = "100%", - height: str = "500px", - fontsize: int = 20, - show_interaction_data: bool = False, - ): + def plot_lignetwork(self, ligand_mol=None, **kwargs): """ 2D ProLIF ligand-network visualization. """ - if not self.ifp: - raise RuntimeError( - "No ProLIF fingerprint data found. Run `analysis.run(...)` first." - ) - - available_frames = list(self.fp.ifp.keys()) - - if frame is None: - frame = available_frames[0] - - if kind == "frame" and frame not in self.fp.ifp: - preview = available_frames[:10] - suffix = " ..." if len(available_frames) > 10 else "" - raise ValueError( - f"frame={frame} not present in fingerprint results. " - f"Available frames: {preview}{suffix}" - ) - - if frame is not None: - self.universe.trajectory[frame] - - if ligand_mol is None: - ligand_mol = plf.Molecule.from_mda( - self.ligand_ag, - inferrer=None, - implicit_hydrogens=False, - use_segid=self.fp.use_segid, - ) - - return self.fp.plot_lignetwork( - ligand_mol, - kind=kind, - frame=frame, - display_all=display_all, - threshold=threshold, - use_coordinates=use_coordinates, - flatten_coordinates=flatten_coordinates, - kekulize=kekulize, - molsize=molsize, - rotation=rotation, - carbon=carbon, - width=width, - height=height, - fontsize=fontsize, - show_interaction_data=show_interaction_data, - ) + return plot_prolif_lignetwork(self, ligand_mol, **kwargs) plot_2d = plot_lignetwork @@ -356,60 +294,10 @@ def plot_barcode( tight_layout_kwargs=tight_layout_kwargs, ) - def plot_3d( - self, - ligand_mol=None, - protein_mol=None, - water_mol=None, - *, - frame: int = 0, - size: tuple[int, int] = (650, 600), - display_all: bool = False, - only_interacting: bool = True, - remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True, - ): + def plot_3d(self, ligand_mol=None, protein_mol=None, water_mol=None, **kwargs): """ 3D ProLIF interaction visualization using py3Dmol. """ - if not self.ifp: - raise RuntimeError( - "No ProLIF fingerprint data found. Run `analysis.run(...)` first." - ) - - if frame not in self.fp.ifp: - raise ValueError(f"frame={frame} not present in fingerprint results.") - - self.universe.trajectory[frame] - - if ligand_mol is None: - ligand_mol = plf.Molecule.from_mda( - self.ligand_ag, - inferrer=None, - implicit_hydrogens=False, - use_segid=self.fp.use_segid, - ) - - if protein_mol is None: - protein_mol = plf.Molecule.from_mda( - self.protein_ag, - implicit_hydrogens=False, - use_segid=self.fp.use_segid, - ) - - if water_mol is None and self.water_ag.n_atoms: - water_mol = plf.Molecule.from_mda( - self.water_ag, - implicit_hydrogens=False, - use_segid=self.fp.use_segid, - ) - - return self.fp.plot_3d( - ligand_mol, - protein_mol, - water_mol=water_mol, - frame=frame, - size=size, - display_all=display_all, - only_interacting=only_interacting, - remove_hydrogens=remove_hydrogens, - ) + return plot_prolif_3d( + self, ligand_mol, protein_mol, water_mol=water_mol, **kwargs + ) \ No newline at end of file diff --git a/src/openfe_analysis/tests/test_prolif.py b/src/openfe_analysis/tests/test_prolif.py index f406eab..879da63 100644 --- a/src/openfe_analysis/tests/test_prolif.py +++ b/src/openfe_analysis/tests/test_prolif.py @@ -152,7 +152,7 @@ def fake_from_mda(atomgroup, **kwargs): return fake_ligand_mol monkeypatch.setattr( - "openfe_analysis.prolif.plf.Molecule.from_mda", + "openfe_analysis.utils.plotting.plf.Molecule.from_mda", fake_from_mda, ) @@ -167,3 +167,40 @@ def fake_from_mda(atomgroup, **kwargs): assert calls["plot_lignetwork"][1]["frame"] == 0 assert calls["plot_lignetwork"][1]["kind"] == "frame" assert analysis.universe.trajectory.last_frame == 0 + +def test_plot_3d_builds_mols_and_delegates(monkeypatch): + """plot_3d builds ligand/protein/water mols and delegates to fp.plot_3d.""" + ag = lambda n: type("AG", (), {"n_atoms": n})() + calls = {} + + class DummyFP: + ifp = {0: {"x": []}} + use_segid = False + + def plot_3d(self, lig, prot, **kw): + calls.update(args=(lig, prot), kw=kw) + return "fake-3d" + + a = object.__new__(ProLIFAnalysis) + a.ligand_ag, a.protein_ag, a.water_ag = ag(10), ag(100), ag(3) + a.universe = type("U", (), {"trajectory": {0: None}})() + a.fp = DummyFP() + + made = [] + monkeypatch.setattr( + "openfe_analysis.utils.plotting.plf.Molecule.from_mda", + lambda ag, **kw: made.append(ag) or object(), + ) + + assert a.plot_3d(frame=0) == "fake-3d" + assert made == [a.ligand_ag, a.protein_ag, a.water_ag] + assert calls["kw"]["frame"] == 0 + + +def test_plot_methods_raise_without_ifp(): + """Plotting before run() raises a clear RuntimeError.""" + a = object.__new__(ProLIFAnalysis) + a.fp = type("FP", (), {"ifp": {}})() + for call in (a.plot_2d, a.plot_3d, a.plot_barcode): + with pytest.raises(RuntimeError, match=r"Run `analysis\.run"): + call() \ No newline at end of file diff --git a/src/openfe_analysis/utils/plotting.py b/src/openfe_analysis/utils/plotting.py index 18f20df..5dd4c54 100644 --- a/src/openfe_analysis/utils/plotting.py +++ b/src/openfe_analysis/utils/plotting.py @@ -1,7 +1,10 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +from typing import Literal, Optional + import matplotlib.pyplot as plt import numpy as np +import prolif as plf def plot_2D_rmsd(data: list[np.ndarray] | list[list[float]], vmax: float = 5.0) -> plt.Figure: @@ -137,3 +140,130 @@ def plot_ligand_RMSD(time: list[float], data: list[np.ndarray]) -> plt.Figure: ylabel=r"RMSD ($\AA$)", title="Ligand RMSD", ) + +def plot_prolif_lignetwork( + analysis, + ligand_mol=None, + *, + frame: Optional[int] = None, + kind: Literal["aggregate", "frame"] = "frame", + display_all: bool = False, + threshold: float = 0.3, + use_coordinates: bool = True, + flatten_coordinates: bool = True, + kekulize: bool = False, + molsize: int = 35, + rotation: float = 0, + carbon: float = 0.16, + width: str = "100%", + height: str = "500px", + fontsize: int = 20, + show_interaction_data: bool = False, +): + """ + 2D ProLIF ligand-network visualization. + """ + if not analysis.ifp: + raise RuntimeError( + "No ProLIF fingerprint data found. Run `analysis.run(...)` first." + ) + + available_frames = list(analysis.fp.ifp.keys()) + + if frame is None: + frame = available_frames[0] + + if kind == "frame" and frame not in analysis.fp.ifp: + preview = available_frames[:10] + suffix = " ..." if len(available_frames) > 10 else "" + raise ValueError( + f"frame={frame} not present in fingerprint results. " + f"Available frames: {preview}{suffix}" + ) + + if frame is not None: + analysis.universe.trajectory[frame] + + if ligand_mol is None: + ligand_mol = plf.Molecule.from_mda( + analysis.ligand_ag, + inferrer=None, + implicit_hydrogens=False, + use_segid=analysis.fp.use_segid, + ) + + return analysis.fp.plot_lignetwork( + ligand_mol, + kind=kind, + frame=frame, + display_all=display_all, + threshold=threshold, + use_coordinates=use_coordinates, + flatten_coordinates=flatten_coordinates, + kekulize=kekulize, + molsize=molsize, + rotation=rotation, + carbon=carbon, + width=width, + height=height, + fontsize=fontsize, + show_interaction_data=show_interaction_data, + ) + +def plot_prolif_3d( + analysis, + ligand_mol=None, + protein_mol=None, + water_mol=None, + *, + frame: int = 0, + size: tuple[int, int] = (650, 600), + display_all: bool = False, + only_interacting: bool = True, + remove_hydrogens: bool | Literal["ligand", "protein", "water"] = True, +): + """ + 3D ProLIF interaction visualization using py3Dmol. + """ + if not analysis.ifp: + raise RuntimeError( + "No ProLIF fingerprint data found. Run `analysis.run(...)` first." + ) + + if frame not in analysis.fp.ifp: + raise ValueError(f"frame={frame} not present in fingerprint results.") + + analysis.universe.trajectory[frame] + + if ligand_mol is None: + ligand_mol = plf.Molecule.from_mda( + analysis.ligand_ag, + inferrer=None, + implicit_hydrogens=False, + use_segid=analysis.fp.use_segid, + ) + + if protein_mol is None: + protein_mol = plf.Molecule.from_mda( + analysis.protein_ag, + implicit_hydrogens=False, + use_segid=analysis.fp.use_segid, + ) + + if water_mol is None and analysis.water_ag.n_atoms: + water_mol = plf.Molecule.from_mda( + analysis.water_ag, + implicit_hydrogens=False, + use_segid=analysis.fp.use_segid, + ) + + return analysis.fp.plot_3d( + ligand_mol, + protein_mol, + water_mol=water_mol, + frame=frame, + size=size, + display_all=display_all, + only_interacting=only_interacting, + remove_hydrogens=remove_hydrogens, + ) From a210c3f5b8075b475528fa9b51846d972622ee23 Mon Sep 17 00:00:00 2001 From: Valerij Talagayev Date: Thu, 18 Jun 2026 09:34:37 +0200 Subject: [PATCH 14/14] Adjusting style --- src/openfe_analysis/prolif.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/openfe_analysis/prolif.py b/src/openfe_analysis/prolif.py index 2fc1466..6f30896 100644 --- a/src/openfe_analysis/prolif.py +++ b/src/openfe_analysis/prolif.py @@ -11,6 +11,7 @@ from .utils.plotting import plot_prolif_3d, plot_prolif_lignetwork + class ProLIFAnalysis: """ ProLIF interaction fingerprint analysis for an OpenFEReader Universe. @@ -62,9 +63,9 @@ def __init__( self.ligand_ag = ligand_ag self.water_order = water_order - self.frames = None - self.times = None - self.n_frames = None + self.frames: Optional[np.ndarray] = None + self.times: Optional[np.ndarray] = None + self.n_frames: Optional[int] = None self.ifp_df = None # --- Guess bonds once on stable selections so RDKit/ProLIF can detect HBonds --- @@ -104,6 +105,7 @@ def __init__( available = plf.Fingerprint.list_available(show_bridged=True) + fp_interactions: Optional[list[str] | str] if interactions is None: fp_interactions = None @@ -156,10 +158,10 @@ def run( start: Optional[int] = None, stop: Optional[int] = None, step: Optional[int] = None, - residues: Optional[bool] = None, + residues: Optional[Literal["all"] | Sequence[str | int]] = None, progress: bool = True, n_jobs: Optional[int] = None, - parallel_strategy: Optional[str] = None, + parallel_strategy: Optional[Literal["chunk", "queue"]] = None, converter_kwargs: Optional[Tuple[Dict[str, Any], Dict[str, Any]]] = None, ) -> "ProLIFAnalysis": """ @@ -170,8 +172,9 @@ def run( start, stop, step Trajectory slicing parameters. residues - Passed to ProLIF: whether to aggregate interactions with residues. - If None, ProLIF's default is used and interactions with atoms are identified. + Passed to ProLIF: ``"all"`` to track every residue, or an explicit + sequence of residue identifiers. If None, ProLIF's default is used + and interactions with atoms are identified. progress Show progress bar. n_jobs