Repository: chemosim-lab/ProLIF

Issue:
how can i get the ligand atom id  which have a interaction with pritein atom? 
thanks for your outstnding works ,but how can i   find which atom pair between ligand and protein have a interaction?
thanks! 

Discussion:
codecov[bot]: ## [Codecov](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) Report
> Merging [#116](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) (6b1c149) into [master](https://codecov.io/gh/chemosim-lab/ProLIF/commit/9edd387be68de2e98c5bdcefecd3b51023b28aa3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) (9edd387) will **increase** coverage by `0.22%`.
> The diff coverage is `96.73%`.

```diff
@@            Coverage Diff             @@
##           master     #116      +/-   ##
==========================================
+ Coverage   95.74%   95.96%   +0.22%     
==========================================
  Files          10       11       +1     
  Lines        1080     1066      -14     
==========================================
- Hits         1034     1023      -11     
+ Misses         46       43       -3     
```


| [Impacted Files](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab) | Coverage Δ | |
|---|---|---|
| [prolif/parallel.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL3BhcmFsbGVsLnB5) | `53.19% <0.00%> (+3.19%)` | :arrow_up: |
| [prolif/fingerprint.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ZpbmdlcnByaW50LnB5) | `95.03% <95.00%> (-1.06%)` | :arrow_down: |
| [prolif/ifp.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2lmcC5weQ==) | `100.00% <100.00%> (ø)` | |
| [prolif/interactions.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL2ludGVyYWN0aW9ucy5weQ==) | `99.18% <100.00%> (-0.01%)` | :arrow_down: |
| [prolif/plotting/network.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL3Bsb3R0aW5nL25ldHdvcmsucHk=) | `96.20% <100.00%> (+0.56%)` | :arrow_up: |
| [prolif/utils.py](https://codecov.io/gh/chemosim-lab/ProLIF/pull/116?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab#diff-cHJvbGlmL3V0aWxzLnB5) | `99.09% <100.00%> (-0.12%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chemosim-lab)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf3045d..dcc963a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,12 +6,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- Added the `Fingerprint.to_ligplot` method to generate a `LigNetwork` plot directly.
+- Added `LigNetwork.from_fingerprint` to generate the ligplot from a `Fingerprint`
+  instance.
+- Added `Fingerprint.metadata` to generate a dictionary containing metadata about
+  interactions between two residues. Replaces `Fingerprint.bitvector_atoms`.
+- Added a `vicinity_cutoff` parameter in `Fingerprint` to control the distance cutoff
+  used to automatically restrict the IFP calculation to residues within the specified
+  range of the ligand.
+- Added a `metadata` method to the base `Interaction` class to easily generate metadata
+  for custom interactions.
+- Added an `Interaction.invert_class` classmethod to easily invert the role of the
+  ligand and protein residues in an interaction, e.g. to create a donor class from an
+  acceptor class.
+- Added an `Interaction.update_parameters` classmethod to easily update the
+  initialisation parameters shared between parent and child classes.
+
 ### Changed
+- The `LigNetwork` plot now displays the distance for each interaction on mouse hover.
+- Changed the format of the `Fingerprint.ifp` attribute to be a dictionary
+  containing more complete interaction metadata instead of just atom indices. It can
+  also be more easily indexed by using residue identifier strings (e.g. `ALA216.A`)
+  rather than `ResidueId` objects.
+- All interaction classes now return more complete details about the interaction (e.g.
+  distances, angles, atom indices in the residue and parent molecule).
 - Converting the IFP to a dataframe with atom indices has been optimized and now runs
-  about 5 times faster (Issue #112, PR #113 by @ReneHamburger1993).
+  about 5 times faster (Issue #112, PR #113 by @ReneHamburger1993). *Note: discarded*
+  *by the subsequent updates to the codebase which removed the ability to have*
+  *atom indices in the dataframe.*
 - Various changes related to packaging, code formatting, linting and CI pipelines
   (PR #114).
 
+### Removed
+- Removed the `return_atoms` argument in `Fingerprint.to_dataframe`. Users should
+  directly use `Fingerprint.ifp` instead (the documentation's tutorials have been
+  updated accordingly).
+- Removed the `Fingerprint.bitvector_atoms` method, replaced by `Fingerprint.metadata`.
+- Removed the `__wrapped__` attribute on interaction methods that are available
+  from the `Fingerprint` object. These methods now accept a `metadata` parameter
+  instead.
+- Removed `LigNetwork.from_ifp` in favor of `LigNetwork.from_fingerprint`.
+
 
 ## [1.1.0] - 2022-11-18
 
diff --git a/docs/notebooks/how-to.ipynb b/docs/notebooks/how-to.ipynb
index f1ce20b..b8a2039 100644
--- a/docs/notebooks/how-to.ipynb
+++ b/docs/notebooks/how-to.ipynb
@@ -25,8 +25,7 @@
    "source": [
     "import MDAnalysis as mda\n",
     "import prolif as plf\n",
-    "import pandas as pd\n",
-    "import numpy as np"
+    "import pandas as pd"
    ]
   },
   {
@@ -37,7 +36,8 @@
    "source": [
     "u = mda.Universe(plf.datafiles.TOP, plf.datafiles.TRAJ)\n",
     "lig = u.select_atoms(\"resname LIG\")\n",
-    "prot = u.select_atoms(\"protein\")\n",
+    "# restrict to 7.0 angstroms around the ligand\n",
+    "prot = u.select_atoms(\"protein and byres around 7 group ligand\", ligand=lig)\n",
     "lmol = plf.Molecule.from_mda(lig)\n",
     "pmol = plf.Molecule.from_mda(prot)"
    ]
@@ -83,7 +83,7 @@
    "outputs": [],
    "source": [
     "fp = plf.Fingerprint()\n",
-    "fp.hydrophobic(lmol, pmol[\"TYR109.A\"])"
+    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -120,7 +120,7 @@
    "outputs": [],
    "source": [
     "fp = plf.Fingerprint([\"Hydrophobic\"])\n",
-    "fp.hydrophobic(lmol, pmol[\"TYR109.A\"])"
+    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -141,7 +141,7 @@
     "\n",
     "\n",
     "fp = plf.Fingerprint([\"Hydrophobic\"])\n",
-    "fp.hydrophobic(lmol, pmol[\"TYR109.A\"])"
+    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -177,7 +177,7 @@
     "\n",
     "\n",
     "fp = plf.Fingerprint([\"Hydrophobic\", \"CustomHydrophobic\"])\n",
-    "fp.hydrophobic(lmol, pmol[\"TYR109.A\"])"
+    "fp.hydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -186,7 +186,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp.customhydrophobic(lmol, pmol[\"TYR109.A\"])"
+    "fp.customhydrophobic(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -195,7 +195,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp.bitvector(lmol, pmol[\"TYR109.A\"])"
+    "fp.bitvector(lmol[\"LIG1.G\"], pmol[\"TYR109.A\"])"
    ]
   },
   {
@@ -215,6 +215,7 @@
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -222,7 +223,7 @@
     "\n",
     "* **Inherit the ProLIF Interaction class**\n",
     "  \n",
-    "This class is located in `prolif.interactions.Interaction`. If for any reason you must inherit from another class, you can also define the `prolif.interactions._InteractionMeta` as a metaclass.\n",
+    "This class is located in `prolif.interactions.Interaction` and provides some functionalities to automatically register the interactions in `Fingerprint` objects.\n",
     "\n",
     "* **Naming convention**\n",
     "\n",
@@ -230,15 +231,18 @@
     "\n",
     "* **Define a `detect` method**\n",
     "\n",
-    "This method takes exactly two positional arguments (and as many named arguments as you need): a ligand Residue or Molecule and a protein Residue or Molecule (in this order).\n",
+    "This method takes exactly two positional arguments: a ligand Residue and a protein Residue (in this order).\n",
     "\n",
-    "* **Return values for the `detect` method**\n",
+    "* **Return value for the `detect` method**\n",
     "\n",
-    "You must return the following items in this order: \n",
+    "You must return a dictionary containing some basic metadata about the interaction when it is detected:\n",
     "\n",
-    "  * `True` or `False` for the detection of the interaction\n",
-    "  * The index of the ligand atom, or None if not detected\n",
-    "  * The index of the protein atom, or None if not detected"
+    "  * the input residues, named `lig_res` and `prot_res`,\n",
+    "  * the indices of atoms responsible for the interaction, named `lig_indices` and `prot_indices`,\n",
+    "  * any other metric (distances or angles), named as you want.\n",
+    "  \n",
+    "To help with this process, the `metadata` method should be used (see example below).\n",
+    "Note that you don't have to return anything if no interaction is detected for a pair of residues."
    ]
   },
   {
@@ -251,42 +255,50 @@
     "\n",
     "\n",
     "class CloseContact(plf.interactions.Interaction):\n",
-    "    def __init__(self, threshold=2.0):\n",
-    "        self.threshold = threshold\n",
-    "\n",
-    "    def detect(self, res1, res2):\n",
-    "        dist_matrix = distance_matrix(res1.xyz, res2.xyz)\n",
-    "        mask = dist_matrix <= self.threshold\n",
-    "        if mask.any():\n",
-    "            res1_i, res2_i = np.where(mask)\n",
-    "            # return the first solution\n",
-    "            return True, res1_i[0], res2_i[0]\n",
-    "        return False, None, None\n",
+    "    def __init__(self, contact_threshold=2.0):\n",
+    "        self.contact_threshold = contact_threshold\n",
+    "\n",
+    "    def detect(self, ligand_residue, protein_residue):\n",
+    "        # distance matrix between atoms of both residues\n",
+    "        dist_matrix = distance_matrix(ligand_residue.xyz, protein_residue.xyz)\n",
+    "        if (dist_matrix <= self.contact_threshold).any():\n",
+    "            # indices of atoms with the shortest distance\n",
+    "            ligand_index, protein_index = divmod(\n",
+    "                int(dist_matrix.argmin()), dist_matrix.shape[1]\n",
+    "            )\n",
+    "            # shortest distance value\n",
+    "            min_dist = dist_matrix[ligand_index, protein_index]\n",
+    "            # return dict with metadata on the interaction\n",
+    "            # required arguments: input residues, and tuple of indices of atoms\n",
+    "            #                     responsible for the interaction\n",
+    "            # optional arguments: any additional `key=value` pair (e.g. distance)\n",
+    "            return self.metadata(\n",
+    "                lig_res=ligand_residue,\n",
+    "                prot_res=protein_residue,\n",
+    "                lig_indices=(ligand_index,),\n",
+    "                prot_indices=(protein_index,),\n",
+    "                distance=min_dist,\n",
+    "            )\n",
     "\n",
     "\n",
     "fp = plf.Fingerprint([\"CloseContact\"])\n",
-    "fp.closecontact(lmol, pmol[\"ASP129.A\"])"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "By default, the fingerprint will modify all interaction classes to only return the boolean value. To get the atom indices you must choose one of the following options:"
+    "fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"])"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "* Call `fp.to_dataframe(return_atoms=True)`"
+    "If you want to access metadata about the interaction, you can do one of the following:"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "* Use the `return_atoms=True` argument when calling the `generate` method:"
+    "* Use the `run` or `run_from_iterable` methods and then interact with the `fp.ifp` attribute:"
    ]
   },
   {
@@ -295,16 +307,18 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ifp = fp.generate(lmol, pmol, return_atoms=True)\n",
-    "# check the interactino between the ligand and ASP129\n",
-    "ifp[(plf.ResidueId(\"LIG\", 1, \"G\"), plf.ResidueId(\"ASP\", 129, \"A\"))]"
+    "# run on first frame\n",
+    "fp.run(u.trajectory[0:1], lig, prot)\n",
+    "# check the interactions between the ligand and ASP129 on the first frame\n",
+    "fp.ifp[0][(\"LIG1.G\", \"ASP129.A\")]"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "* Use the `__wrapped__` argument when calling the class as a fingerprint method:"
+    "* Use the `metadata=True` argument when calling the `generate` method:"
    ]
   },
   {
@@ -313,14 +327,17 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp.closecontact.__wrapped__(lmol, pmol[\"ASP129.A\"])"
+    "ifp = fp.generate(lmol, pmol, metadata=True)\n",
+    "# check the interaction between the ligand and ASP129\n",
+    "ifp[(\"LIG1.G\", \"ASP129.A\")]"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "* Use the `bitvector_atoms` method instead of `bitvector`:"
+    "* Use the `metadata=True` argument when calling the interaction directly through the fingerprint object:"
    ]
   },
   {
@@ -329,16 +346,15 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fp = plf.Fingerprint([\"CloseContact\"])\n",
-    "bv, lig_ix, prot_ix = fp.bitvector_atoms(lmol, pmol[\"ASP129.A\"])\n",
-    "bv, lig_ix, prot_ix"
+    "fp.closecontact(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"], metadata=True)"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "* Directly use your class:"
+    "* Use the `metadata` method instead of `bitvector`:"
    ]
   },
   {
@@ -347,8 +363,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "cc = CloseContact()\n",
-    "cc.detect(lmol, pmol[\"ASP129.A\"])"
+    "fp = plf.Fingerprint([\"CloseContact\"])\n",
+    "fp.metadata(lmol[\"LIG1.G\"], pmol[\"ASP129.A\"])"
    ]
   },
   {
@@ -632,13 +648,6 @@
     "df = fp.to_dataframe()\n",
     "df"
    ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
   }
  ],
  "metadata": {
@@ -660,7 +669,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.13"
+   "version": "3.9.13"
   }
  },
  "nbformat": 4,
diff --git a/docs/notebooks/protein-protein_interactions.ipynb b/docs/notebooks/protein-protein_interactions.ipynb
index e4b4177..7806991 100644
--- a/docs/notebooks/protein-protein_interactions.ipynb
+++ b/docs/notebooks/protein-protein_interactions.ipynb
@@ -164,13 +164,11 @@
     "\n",
     "\n",
     "# generate IFP\n",
-    "ifp = []\n",
+    "ifp = {}\n",
     "for ts in tqdm(u.trajectory[::10]):\n",
     "    tm3_mol = remove_backbone(tm3)\n",
     "    prot_mol = remove_backbone(prot)\n",
-    "    data = fp.generate(tm3_mol, prot_mol)\n",
-    "    data[\"Frame\"] = ts.frame\n",
-    "    ifp.append(data)\n",
+    "    ifp[ts.frame] = fp.generate(tm3_mol, prot_mol, metadata=True)\n",
     "df = plf.to_dataframe(ifp, fp.interactions.keys())\n",
     "df.head()"
    ]
diff --git a/docs/notebooks/visualisation.ipynb b/docs/notebooks/visualisation.ipynb
index c554258..f5dede8 100644
--- a/docs/notebooks/visualisation.ipynb
+++ b/docs/notebooks/visualisation.ipynb
@@ -17,12 +17,14 @@
    "source": [
     "import MDAnalysis as mda\n",
     "import prolif as plf\n",
-    "import numpy as np\n",
+    "from rdkit import Chem\n",
+    "from rdkit.Geometry import Point3D\n",
     "\n",
     "# load topology\n",
     "u = mda.Universe(plf.datafiles.TOP, plf.datafiles.TRAJ)\n",
     "lig = u.select_atoms(\"resname LIG\")\n",
-    "prot = u.select_atoms(\"protein\")"
+    "# restrict protein to 10 angstroms around the ligand\n",
+    "prot = u.select_atoms(\"protein and byres around 10 group ligand\", ligand=lig)"
    ]
   },
   {
@@ -42,14 +44,13 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "# get lig-prot interactions with atom info\n",
+    "# get lig-prot interactions\n",
     "fp = plf.Fingerprint()\n",
-    "fp.run(u.trajectory[0:1], lig, prot)\n",
-    "df = fp.to_dataframe(return_atoms=True)\n",
-    "df.T"
+    "fp.run(u.trajectory[0:1], lig, prot)"
    ]
   },
   {
+   "attachments": {},
    "cell_type": "markdown",
    "metadata": {},
    "source": [
@@ -57,7 +58,7 @@
     "\n",
     "With py3Dmol we can easily display the interactions.\n",
     "\n",
-    "For interactions involving a ring (pi-cation, pi-stacking...etc.) ProLIF returns the index of one of the ring atoms, but for visualisation having the centroid of the ring looks nicer. We'll start by writing a function to find the centroid, given the index of one of the ring atoms."
+    "For interactions involving a ring (pi-cation, pi-stacking...etc.) ProLIF returns the index of one of the ring atoms, but for visualisation having the centroid of the ring looks nicer. We'll start by writing a function to find the centroid, given the indices of the ring atoms."
    ]
   },
   {
@@ -66,25 +67,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from rdkit import Chem\n",
-    "from rdkit import Geometry\n",
-    "\n",
-    "\n",
-    "def get_ring_centroid(mol, index):\n",
-    "    # find ring using the atom index\n",
-    "    Chem.SanitizeMol(mol, Chem.SanitizeFlags.SANITIZE_SETAROMATICITY)\n",
-    "    ri = mol.GetRingInfo()\n",
-    "    for r in ri.AtomRings():\n",
-    "        if index in r:\n",
-    "            break\n",
-    "    else:\n",
-    "        raise ValueError(\n",
-    "            \"No ring containing this atom index was found in the given molecule\"\n",
-    "        )\n",
-    "    # get centroid\n",
-    "    coords = mol.xyz[list(r)]\n",
-    "    ctd = plf.utils.get_centroid(coords)\n",
-    "    return Geometry.Point3D(*ctd)"
+    "def get_ring_centroid(mol, indices):\n",
+    "    centroid = mol.xyz[list(indices)].mean(axis=0)\n",
+    "    return Point3D(*centroid)"
    ]
   },
   {
@@ -142,9 +127,8 @@
     "\n",
     "models = {}\n",
     "mid = -1\n",
-    "for i, row in df.T.iterrows():\n",
-    "    lresid, presid, interaction = i\n",
-    "    lindex, pindex = row[0]\n",
+    "ifp = next(iter(fp.ifp.values()))\n",
+    "for (lresid, presid), interactions in ifp.items():\n",
     "    lres = lmol[lresid]\n",
     "    pres = pmol[presid]\n",
     "    # set model ids for reusing later\n",
@@ -160,36 +144,50 @@
     "            # add residue label\n",
     "            model.setHoverable({}, True, resid_hover.format(resid), unhover_func)\n",
     "            models[resid] = mid\n",
-    "    # get coordinates for both points of the interaction\n",
-    "    if interaction in [\"PiStacking\", \"EdgeToFace\", \"FaceToFace\", \"PiCation\"]:\n",
-    "        p1 = get_ring_centroid(lres, lindex)\n",
-    "    else:\n",
-    "        p1 = lres.GetConformer().GetAtomPosition(lindex)\n",
-    "    if interaction in [\"PiStacking\", \"EdgeToFace\", \"FaceToFace\", \"CationPi\"]:\n",
-    "        p2 = get_ring_centroid(pres, pindex)\n",
-    "    else:\n",
-    "        p2 = pres.GetConformer().GetAtomPosition(pindex)\n",
-    "    # add interaction line\n",
-    "    v.addCylinder(\n",
-    "        {\n",
-    "            \"start\": dict(x=p1.x, y=p1.y, z=p1.z),\n",
-    "            \"end\": dict(x=p2.x, y=p2.y, z=p2.z),\n",
-    "            \"color\": colors.get(interaction, \"grey\"),\n",
-    "            \"radius\": 0.15,\n",
-    "            \"dashed\": True,\n",
-    "            \"fromCap\": 1,\n",
-    "            \"toCap\": 1,\n",
-    "        }\n",
-    "    )\n",
-    "    # add label when hovering the middle of the dashed line by adding a dummy atom\n",
-    "    c = Geometry.Point3D(*plf.utils.get_centroid([p1, p2]))\n",
-    "    modelID = models[lresid]\n",
-    "    model = v.getModel(modelID)\n",
-    "    model.addAtoms(\n",
-    "        [{\"elem\": \"Z\", \"x\": c.x, \"y\": c.y, \"z\": c.z, \"interaction\": interaction}]\n",
-    "    )\n",
-    "    model.setStyle({\"interaction\": interaction}, {\"clicksphere\": {\"radius\": 0.5}})\n",
-    "    model.setHoverable({\"interaction\": interaction}, True, hover_func, unhover_func)\n",
+    "    for interaction, metadata in interactions.items():\n",
+    "        # get coordinates for both points of the interaction\n",
+    "        if interaction in [\"PiStacking\", \"EdgeToFace\", \"FaceToFace\", \"PiCation\"]:\n",
+    "            p1 = get_ring_centroid(lres, metadata[\"indices\"][\"ligand\"])\n",
+    "        else:\n",
+    "            p1 = lres.GetConformer().GetAtomPosition(metadata[\"indices\"][\"ligand\"][0])\n",
+    "        if interaction in [\"PiStacking\", \"EdgeToFace\", \"FaceToFace\", \"CationPi\"]:\n",
+    "            p2 = get_ring_centroid(pres, metadata[\"indices\"][\"protein\"])\n",
+    "        else:\n",
+    "            p2 = pres.GetConformer().GetAtomPosition(metadata[\"indices\"][\"protein\"][0])\n",
+    "        # add interaction line\n",
+    "        v.addCylinder(\n",
+    "            {\n",
+    "                \"start\": dict(x=p1.x, y=p1.y, z=p1.z),\n",
+    "                \"end\": dict(x=p2.x, y=p2.y, z=p2.z),\n",
+    "                \"color\": colors.get(interaction, \"grey\"),\n",
+    "                \"radius\": 0.15,\n",
+    "                \"dashed\": True,\n",
+    "                \"fromCap\": 1,\n",
+    "                \"toCap\": 1,\n",
+    "            }\n",
+    "        )\n",
+    "        # add label when hovering the middle of the dashed line by adding a dummy atom\n",
+    "        c = Point3D(*plf.utils.get_centroid([p1, p2]))\n",
+    "        modelID = models[lresid]\n",
+    "        model = v.getModel(modelID)\n",
+    "        interaction_label = f\"{interaction}: {metadata['distance']:.2f}Å\"\n",
+    "        model.addAtoms(\n",
+    "            [\n",
+    "                {\n",
+    "                    \"elem\": \"Z\",\n",
+    "                    \"x\": c.x,\n",
+    "                    \"y\": c.y,\n",
+    "                    \"z\": c.z,\n",
+    "                    \"interaction\": interaction_label,\n",
+    "                }\n",
+    "            ]\n",
+    "        )\n",
+    "        model.setStyle(\n",
+    "            {\"interaction\": interaction_label}, {\"clicksphere\": {\"radius\": 0.5}}\n",
+    "        )\n",
+    "        model.setHoverable(\n",
+    "            {\"interaction\": interaction_label}, True, hover_func, unhover_func\n",
+    "        )\n",
     "\n",
     "# show protein\n",
     "mol = Chem.RemoveAllHs(pmol)\n",
@@ -223,14 +221,9 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "from prolif.plotting.network import LigNetwork\n",
-    "\n",
     "fp = plf.Fingerprint()\n",
     "fp.run(u.trajectory[::10], lig, prot)\n",
-    "df = fp.to_dataframe(return_atoms=True)\n",
-    "\n",
-    "net = LigNetwork.from_ifp(\n",
-    "    df,\n",
+    "net = fp.to_ligplot(\n",
     "    lmol,\n",
     "    # replace with `kind=\"frame\", frame=0` for the other depiction\n",
     "    kind=\"aggregate\",\n",
@@ -616,7 +609,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.13"
+   "version": "3.9.13"
   }
  },
  "nbformat": 4,
diff --git a/docs/source/modules/interaction-fingerprint.rst b/docs/source/modules/interaction-fingerprint.rst
index 38a0b4e..9d95e2f 100644
--- a/docs/source/modules/interaction-fingerprint.rst
+++ b/docs/source/modules/interaction-fingerprint.rst
@@ -4,6 +4,9 @@ Interaction fingerprint
 .. automodule:: prolif.fingerprint
     :members:
 
+.. automodule:: prolif.ifp
+    :members:
+
 .. automodule:: prolif.interactions
     :members:
     :show-inheritance:
diff --git a/prolif/_version.py b/prolif/_version.py
index 25c1897..05b56ba 100644
--- a/prolif/_version.py
+++ b/prolif/_version.py
@@ -1,1 +1,1 @@
-__version__ = "1.1.0.post1"
+__version__ = "2.0.0.dev"
diff --git a/prolif/fingerprint.py b/prolif/fingerprint.py
index 78fae1c..c56a533 100644
--- a/prolif/fingerprint.py
+++ b/prolif/fingerprint.py
@@ -13,7 +13,7 @@ Calculate a Protein-Ligand Interaction Fingerprint --- :mod:`prolif.fingerprint`
     lig = u.select_atoms("resname LIG")
     fp = prolif.Fingerprint(["HBDonor", "HBAcceptor", "PiStacking", "CationPi",
                              "Cationic"])
-    fp.run(u.trajectory[::10], lig, prot)
+    fp.run(u.trajectory[:5], lig, prot)
     df = fp.to_dataframe()
     df
     bv = fp.to_bitvectors()
@@ -22,7 +22,6 @@ Calculate a Protein-Ligand Interaction Fingerprint --- :mod:`prolif.fingerprint`
     fp.to_pickle("fingerprint.pkl")
     # load
     fp = prolif.Fingerprint.from_pickle("fingerprint.pkl")
-    df.equals(fp.to_dataframe())
 
 """
 import multiprocessing as mp
@@ -35,6 +34,7 @@ import numpy as np
 from rdkit import Chem
 from tqdm.auto import tqdm
 
+from .ifp import IFP
 from .interactions import _INTERACTIONS
 from .molecule import Molecule
 from .parallel import (
@@ -48,67 +48,6 @@ from .parallel import (
 from .utils import get_residues_near_ligand, to_bitvectors, to_dataframe
 
 
-class _InteractionWrapper:
-    """Modifies the return signature of an interaction ``detect`` method by
-    forcing it to return only the first element when multiple values are
-    returned.
-
-    Raises
-    ------
-    TypeError
-        If the ``detect`` method doesn't return three values
-
-    Notes
-    -----
-    The original return signature of the decorated function is still accessible
-    by calling ``function.__wrapped__(*args, **kwargs)``.
-
-    Example
-    -------
-    ::
-
-        >>> class Foo:
-        ...     def detect(self, *args):
-        ...         return 1, 2, 3
-        ...
-        >>> foo = Foo().detect
-        >>> bar = _InteractionWrapper(foo)
-        >>> foo()
-        (1, 2, 3)
-        >>> bar()
-        1
-        >>> bar.__wrapped__()
-        (1, 2, 3)
-
-    .. versionchanged:: 0.3.3
-        The function now must return three values
-
-    .. versionchanged:: 1.0.0
-        Changed from a wrapper function to a class for easier pickling support
-
-    """
-
-    def __init__(self, func):
-        self.__wrapped__ = func
-        # add docstring to descriptor
-        self.__doc__ = func
-
-    def __repr__(self):  # pragma: no cover
-        cls = self.__wrapped__.__self__.__class__
-        return f"<{cls.__module__}.{cls.__name__} at {id(self):#x}>"
-
-    def __call__(self, *args, **kwargs):
-        results = self.__wrapped__(*args, **kwargs)
-        try:
-            bool_, lig_idx, prot_idx = results
-        except (TypeError, ValueError):
-            raise TypeError(
-                "Incorrect function signature: the interaction class must "
-                "return 3 values (boolean, int, int)"
-            ) from None
-        return bool_
-
-
 class Fingerprint:
     """Class that generates an interaction fingerprint between two molecules
 
@@ -121,16 +60,24 @@ class Fingerprint:
     interactions : list
         List of names (str) of interaction classes as found in the
         :mod:`prolif.interactions` module
+    vicinity_cutoff : float
+        Automatically restrict the analysis to residues within this range of the ligand.
+        This parameter is ignored if the ``residues`` parameter of the ``run`` methods
+        is set to anything other than ``None``.
 
     Attributes
     ----------
     interactions : dict
-        Dictionnary of interaction functions indexed by class name. For more
+        Dictionary of interaction classes indexed by class name. For more
         details, see :mod:`prolif.interactions`
     n_interactions : int
         Number of interaction functions registered by the fingerprint
-    ifp : list, optionnal
-        List of interactions fingerprints for the given trajectory.
+    vicinity_cutoff : float
+        Used when calling :func:`prolif.utils.get_residues_near_ligand`.
+    ifp : dict, optionnal
+        Dict of interaction fingerprints in a sparse format for the given trajectory or
+        docking poses: ``{<frame number>: <IFP>}``. See the :class:`~prolif.ifp.IFP`
+        class for more information.
 
     Raises
     ------
@@ -159,8 +106,7 @@ class Fingerprint:
         prot = prolif.Molecule.from_mda(prot)
         lig = prolif.Molecule.from_mda(lig)
         ifp = fp.generate(lig, prot)
-        ifp["Frame"] = 0
-        prolif.to_dataframe([ifp], fp.interactions.keys())
+        prolif.to_dataframe({0: ifp}, fp.interactions.keys())
 
     - On a specific pair of residues for a specific interaction:
 
@@ -168,20 +114,36 @@ class Fingerprint:
 
         # ligand-protein
         fp.hbdonor(lig, prot["ASP129.A"])
-        # protein-protein (alpha helix)
+        # protein-protein
         fp.hbacceptor(prot["ASP129.A"], prot["CYS133.A"])
 
     You can also obtain the indices of atoms responsible for the interaction:
 
     .. ipython:: python
 
-        fp.bitvector_atoms(lig, prot["ASP129.A"])
-        fp.hbdonor.__wrapped__(lig, prot["ASP129.A"])
+        fp.metadata(lig, prot["ASP129.A"])
+        fp.hbdonor(lig, prot["ASP129.A"], metadata=True)
 
 
     .. versionchanged:: 1.0.0
         Added pickle support
 
+    .. versionchanged:: 2.0.0
+        Changed the format of the :attr:`~Fingerprint.ifp` attribute to be a dictionary
+        containing more complete interaction metadata instead of just atom indices.
+        Removed the ``return_atoms`` argument in :meth:`~Fingerprint.to_dataframe`.
+        Users should directly use :attr:`~Fingerprint.ifp` instead.
+        Added the :meth:`~Fingerprint.to_ligplot` method to generate the
+        :class:`~prolif.plotting.network.LigNetwork` plot.
+        Replaced the ``Fingerprint.bitvector_atoms`` method with
+        :meth:`Fingerprint.metadata`.
+        Added a ``vicinity_cutoff`` parameter controlling the distance used
+        to automatically restrict the IFP calculation to residues within the specified
+        range of the ligand.
+        Removed the ``__wrapped__`` attribute on interaction methods that are available
+        from the fingerprint object. These methods now accept a ``metadata`` parameter
+        instead.
+
     """
 
     def __init__(
@@ -197,8 +159,10 @@ class Fingerprint:
             "PiCation",
             "VdWContact",
         ],
+        vicinity_cutoff=6.0,
     ):
         self._set_interactions(interactions)
+        self.vicinity_cutoff = vicinity_cutoff
 
     def _set_interactions(self, interactions):
         # read interactions to compute
@@ -214,11 +178,10 @@ class Fingerprint:
         for name, interaction_cls in _INTERACTIONS.items():
             if name.startswith("_") or name == "Interaction":
                 continue
-            func = interaction_cls().detect
-            func = _InteractionWrapper(func)
-            setattr(self, name.lower(), func)
+            interaction = interaction_cls()
+            setattr(self, name.lower(), interaction)
             if name in interactions:
-                self.interactions[name] = func
+                self.interactions[name] = interaction
 
     def __repr__(self):  # pragma: no cover
         name = ".".join([self.__class__.__module__, self.__class__.__name__])
@@ -251,14 +214,14 @@ class Fingerprint:
 
     def bitvector(self, res1, res2):
         """Generates the complete bitvector for the interactions between two
-        residues. To get the indices of atoms responsible for each interaction,
-        see :meth:`bitvector_atoms`
+        residues. To access metadata for each interaction, see
+        :meth:`~Fingerprint.metadata`.
 
         Parameters
         ----------
-        res1 : prolif.residue.Residue or prolif.molecule.Molecule
+        res1 : prolif.residue.Residue
             A residue, usually from a ligand
-        res2 : prolif.residue.Residue or prolif.molecule.Molecule
+        res2 : prolif.residue.Residue
             A residue, usually from a protein
 
         Returns
@@ -266,53 +229,45 @@ class Fingerprint:
         bitvector : numpy.ndarray
             An array storing the encoded interactions between res1 and res2
         """
-        bitvector = []
-        for func in self.interactions.values():
-            bit = func(res1, res2)
-            bitvector.append(bit)
-        return np.array(bitvector)
+        bitvector = [
+            interaction(res1, res2) for interaction in self.interactions.values()
+        ]
+        return np.array(bitvector, dtype=bool)
 
-    def bitvector_atoms(self, res1, res2):
-        """Generates the complete bitvector for the interactions between two
-        residues, and returns the indices of atoms responsible for these
-        interactions
+    def metadata(self, res1, res2):
+        """Generates a metadata dictionary for the interactions between two residues.
 
         Parameters
         ----------
-        res1 : prolif.residue.Residue or prolif.molecule.Molecule
+        res1 : prolif.residue.Residue
             A residue, usually from a ligand
-        res2 : prolif.residue.Residue or prolif.molecule.Molecule
+        res2 : prolif.residue.Residue
             A residue, usually from a protein
 
         Returns
         -------
-        bitvector : numpy.ndarray
-            An array storing the encoded interactions between res1 and res2
-        lig_atoms : list
-            A list containing indices for the ligand atoms responsible for each
-            interaction
-        pro_atoms : list
-            A list containing indices for the protein atoms responsible for
-            each interaction
+        metadata : dict
+            Metadata dictionnary indexed by interaction name. If a specific interaction
+            is not present between residues, it is filtered out of the dictionary.
 
 
         .. versionchanged:: 0.3.2
             Atom indices are returned as two separate lists instead of a single
             list of tuples
 
+        .. versionchanged:: 2.0.0
+            Returns a dictionnary with all available metadata for each interaction,
+            rather than just atom indices.
+
         """
-        bitvector = []
-        lig_atoms = []
-        prot_atoms = []
-        for func in self.interactions.values():
-            bit, la, pa = func.__wrapped__(res1, res2)
-            bitvector.append(bit)
-            lig_atoms.append(la)
-            prot_atoms.append(pa)
-        bitvector = np.array(bitvector)
-        return bitvector, lig_atoms, prot_atoms
-
-    def generate(self, lig, prot, residues=None, return_atoms=False):
+        metadata = {
+            name: metadata
+            for name, interaction in self.interactions.items()
+            if (metadata := interaction(res1, res2, metadata=True)) is not None
+        }
+        return metadata
+
+    def generate(self, lig, prot, residues=None, metadata=False):
         """Generates the interaction fingerprint between 2 molecules
 
         Parameters
@@ -328,20 +283,19 @@ class Fingerprint:
             used. If ``None``, at each frame the
             :func:`~prolif.utils.get_residues_near_ligand` function is used to
             automatically use protein residues that are distant of 6.0 Å or
-            less from each ligand residue.
-        return_atoms : bool
-            For each residue pair and interaction, return indices of atoms
-            responsible for the interaction instead of bits.
+            less from each ligand residue (see :attr:`~Fingerprint.vicinity_cutoff`)
+        metadata : bool
+            For each residue pair and interaction, return an interaction metadata
+            dictionary instead of bits.
 
         Returns
         -------
         ifp : dict
-            A dictionnary indexed by ``(ligand, protein)`` residue pairs. The
-            format for values will depend on ``return_atoms``:
+            A dictionary indexed by ``(ligand, protein)`` residue pairs. The
+            format for values will depend on ``metadata``:
 
-            - A single bitvector if ``return_atoms=False``
-            - A tuple of bitvector, ligand atom indices and protein atom
-              indices if ``return_atoms=True``
+            - A single bitvector if ``metadata=False``
+            - A sparse dictionary of metadata for each interaction if ``metadata=True``
 
         Example
         -------
@@ -354,21 +308,28 @@ class Fingerprint:
             >>> ifp = fp.generate(lig, prot)
 
         .. versionadded:: 0.3.2
+
+        .. versionchanged:: 2.0.0
+            ``return_atoms`` replaced by ``metadata``, and it now returns a sparse
+            dictionary of metadata indexed by interaction name instead of a tuple of
+            arrays.
         """
-        ifp = {}
-        resids = residues
+        ifp = IFP()
+        prot_residues = residues
         if residues == "all":
-            resids = prot.residues.keys()
+            prot_residues = prot.residues.keys()
+        get_interactions = self.metadata if metadata else self.bitvector
         for lresid, lres in lig.residues.items():
             if residues is None:
-                resids = get_residues_near_ligand(lres, prot)
-            for prot_key in resids:
+                prot_residues = get_residues_near_ligand(
+                    lres, prot, self.vicinity_cutoff
+                )
+            for prot_key in prot_residues:
                 pres = prot[prot_key]
                 key = (lresid, pres.resid)
-                if return_atoms:
-                    ifp[key] = self.bitvector_atoms(lres, pres)
-                else:
-                    ifp[key] = self.bitvector(lres, pres)
+                interactions = get_interactions(lres, pres)
+                if any(interactions):
+                    ifp[key] = interactions
         return ifp
 
     def run(
@@ -447,6 +408,11 @@ class Fingerprint:
             Added support for passing kwargs to the RDKitConverter through
             the ``converter_kwargs`` parameter
 
+        .. versionchanged:: 2.0.0
+            Changed the format of the :attr:`~Fingerprint.ifp` attribute to be a
+            dictionary containing more complete interaction metadata instead of just
+            atom indices.
+
         """
         if n_jobs is not None and n_jobs < 1:
             raise ValueError("n_jobs must be > 0 or None")
@@ -467,15 +433,13 @@ class Fingerprint:
         iterator = tqdm(traj) if progress else traj
         if residues == "all":
             residues = Molecule.from_mda(prot, **prot_kwargs).residues.keys()
-        ifp = []
+        ifp = {}
         for ts in iterator:
             prot_mol = Molecule.from_mda(prot, **prot_kwargs)
             lig_mol = Molecule.from_mda(lig, **lig_kwargs)
-            data = self.generate(
-                lig_mol, prot_mol, residues=residues, return_atoms=True
+            ifp[ts.frame] = self.generate(
+                lig_mol, prot_mol, residues=residues, metadata=True
             )
-            data["Frame"] = ts.frame
-            ifp.append(data)
         self.ifp = ifp
         return self
 
@@ -520,11 +484,11 @@ class Fingerprint:
         ) as pool:
             pbar_thread.start()
             args = ((traj, lig, prot, chunk) for chunk in chunks)
-            results = []
-            for ifp in pool.imap_unordered(process_chunk, args):
-                results.extend(ifp)
-        results.sort(key=lambda ifp: ifp["Frame"])
-        self.ifp = results
+            ifp = {}
+            for ifp_data_chunk in pool.imap_unordered(process_chunk, args):
+                ifp.update(ifp_data_chunk)
+        # sort
+        self.ifp = {frame: ifp[frame] for frame in sorted(ifp)}
         return self
 
     def run_from_iterable(
@@ -586,6 +550,11 @@ class Fingerprint:
         .. versionchanged:: 1.0.0
             Added support for multiprocessing
 
+        .. versionchanged:: 2.0.0
+            Changed the format of the :attr:`~Fingerprint.ifp` attribute to be a
+            dictionary containing more complete interaction metadata instead of just
+            atom indices.
+
         """
         if n_jobs is not None and n_jobs < 1:
             raise ValueError("n_jobs must be > 0 or None")
@@ -601,13 +570,9 @@ class Fingerprint:
         iterator = tqdm(lig_iterable) if progress else lig_iterable
         if residues == "all":
             residues = prot_mol.residues.keys()
-        ifp = []
+        ifp = {}
         for i, lig_mol in enumerate(iterator):
-            data = self.generate(
-                lig_mol, prot_mol, residues=residues, return_atoms=True
-            )
-            data["Frame"] = i
-            ifp.append(data)
+            ifp[i] = self.generate(lig_mol, prot_mol, residues=residues, metadata=True)
         self.ifp = ifp
         return self
 
@@ -615,6 +580,9 @@ class Fingerprint:
         self, lig_iterable, prot_mol, residues=None, progress=True, n_jobs=None
     ):
         """Parallel implementation of :meth:`~Fingerprint.run_from_iterable`"""
+        previous_pkl_props = Chem.GetDefaultPickleProperties()
+        Chem.SetDefaultPickleProperties(Chem.PropertyPickleOptions.AllProps)
+
         if isinstance(lig_iterable, Chem.SDMolSupplier) or (
             isinstance(lig_iterable, Iterable) and not isgenerator(lig_iterable)
         ):
@@ -630,15 +598,15 @@ class Fingerprint:
             initializer=declare_shared_objs_for_mol,
             initargs=(self, prot_mol, residues),
         ) as pool:
-            results = []
-            for data in tqdm(
+            results = {}
+            for i, ifp_data in tqdm(
                 pool.imap_unordered(process_mol, suppl),
                 total=total,
                 disable=not progress,
             ):
-                results.append(data)
-        results.sort(key=lambda ifp: ifp["Frame"])
-        self.ifp = results
+                results[i] = ifp_data
+        self.ifp = {frame: results[frame] for frame in sorted(results)}
+        Chem.SetDefaultPickleProperties(previous_pkl_props)
         return self
 
     def to_dataframe(self, **kwargs):
@@ -651,9 +619,6 @@ class Fingerprint:
             keep the data as is
         drop_empty : bool
             Drop columns with only empty values
-        return_atoms : bool
-            For each residue pair and interaction, return indices of atoms
-            responsible for the interaction instead of bits
 
         Returns
         -------
@@ -680,6 +645,9 @@ class Fingerprint:
             0                       0          1           0           0          0
             ...
 
+        .. versionchanged:: 2.0.0
+            Removed the ``return_atoms`` parameter. You can access more metadata
+            information directly through :attr:`~Fingerprint.ifp`.
         """
         if hasattr(self, "ifp"):
             return to_dataframe(self.ifp, self.interactions.keys(), **kwargs)
@@ -758,7 +726,8 @@ class Fingerprint:
         ----------
         path_or_bytes : str, pathlib.Path or bytes
             The path to the pickle file, or bytes corresponding to a pickle
-            dump
+            dump.
+
 
         .. seealso::
 
@@ -771,3 +740,24 @@ class Fingerprint:
             return pickle.loads(path_or_bytes)
         with open(path_or_bytes, "rb") as f:
             return pickle.load(f)
+
+    def to_ligplot(
+        self, ligand_mol, kind="aggregate", frame=0, threshold=0.3, **kwargs
+    ):
+        """Generate a :class:`~prolif.plotting.network.LigNetwork` plot from a
+        fingerprint object that has been executed.
+
+        .. versionadded:: 2.0.0
+        """
+        from prolif.plotting.network import LigNetwork
+
+        if hasattr(self, "ifp"):
+            return LigNetwork.from_fingerprint(
+                fp=self,
+                ligand_mol=ligand_mol,
+                kind=kind,
+                frame=frame,
+                threshold=threshold,
+                **kwargs,
+            )
+        raise AttributeError("Please use the `run` method before")
diff --git a/prolif/ifp.py b/prolif/ifp.py
new file mode 100644
index 0000000..f0fa2db
--- /dev/null
+++ b/prolif/ifp.py
@@ -0,0 +1,68 @@
+"""
+Storing interactions --- :mod:`prolif.ifp`
+==========================================
+"""
+from collections import UserDict
+
+from .residue import ResidueId
+
+
+class IFP(UserDict):
+    """Mapping between residue pairs and interaction fingerprint.
+
+    Notes
+    -----
+    This class provides an easy way to access interaction data from the
+    :attr:`~prolif.fingerprint.Fingerprint.ifp` dictionary. This class is a dictionary
+    formatted as:
+
+    .. code-block:: text
+
+        {
+            tuple[<residue_id>, <residue_id>]: {
+                <interaction name>: {
+                    "indices": {
+                        "ligand": tuple[int, ...],
+                        "protein": tuple[int, ...]
+                    },
+                    "parent_indices": {
+                        "ligand": tuple[int, ...],
+                        "protein": tuple[int, ...]
+                    },
+                    <other metadata>: <value>
+                }
+            }
+        }
+
+    Here ``<residue_id>`` corresponds to a :class:`~prolif.residue.ResidueId` object.
+    For convenience, one can directly use strings rather than ``ResidueId`` objects when
+    indexing the IFP, e.g. ``ifp[("LIG1.G", "ASP129.A")]``.
+    You can also use a single ``ResidueId`` or string to returned a filtered IFP only
+    containing interactions with the specified residue, e.g. ``ifp["ASP129.A"]``.
+    """
+
+    def __getitem__(self, key):
+        try:
+            return self.data[key]
+        except KeyError as exc:
+            if isinstance(key, tuple) and len(key) == 2:
+                lig_res, prot_res = key
+                if isinstance(lig_res, ResidueId):
+                    raise exc from None
+                key = ResidueId.from_string(lig_res), ResidueId.from_string(prot_res)
+                return self.data[key]
+            if isinstance(key, str):
+                key = ResidueId.from_string(key)
+            if isinstance(key, ResidueId):
+                return IFP(
+                    {
+                        residue_tuple: interactions
+                        for residue_tuple, interactions in self.data.items()
+                        if key in residue_tuple
+                    }
+                )
+        raise KeyError(
+            f"{key} does not correspond to a valid IFP key: it must be a tuple of "
+            "either ResidueId or residue string. If you need to filter the IFP, a "
+            "single ResidueId or residue string can also be used."
+        )
diff --git a/prolif/interactions.py b/prolif/interactions.py
index fc5f4d5..c376e30 100644
--- a/prolif/interactions.py
+++ b/prolif/interactions.py
@@ -6,13 +6,31 @@ You can declare your own interaction class like this::
 
     from scipy.spatial import distance_matrix
 
-    class CloseContact(prolif.Interaction):
-        def detect(self, res1, res2, threshold=2.0):
-            dist_matrix = distance_matrix(res1.xyz, res2.xyz)
-            if (dist_matrix <= threshold).any():
-                # return format: bool, ligand atom index, protein atom index
-                return True, None, None
-            return False, None, None
+    class CloseContact(prolif.interactions.Interaction):
+        def __init__(self, contact_threshold=2.0):
+            self.contact_threshold = contact_threshold
+
+        def detect(self, ligand_residue, protein_residue):
+            # distance matrix between atoms of both residues
+            dist_matrix = distance_matrix(ligand_residue.xyz, protein_residue.xyz)
+            if (dist_matrix <= self.contact_threshold).any():
+                # indices of atoms with the shortest distance
+                ligand_index, protein_index = divmod(
+                    int(dist_matrix.argmin()), dist_matrix.shape[1]
+                )
+                # shortest distance value
+                min_dist = dist_matrix[ligand_index, protein_index]
+                # return dict with metadata on the interaction
+                # required arguments: input residues, and tuple of indices of atoms
+                #                     responsible for the interaction
+                # optional arguments: any additional `key=value` pair (e.g. distance)
+                return self.metadata(
+                    lig_res=ligand_residue,
+                    prot_res=protein_residue,
+                    lig_indices=(ligand_index,),
+                    prot_indices=(protein_index,),
+                    distance=min_dist,
+                )
 
 .. warning:: Your custom class must inherit from :class:`prolif.interactions.Interaction`
 
@@ -22,15 +40,19 @@ interactions available to the fingerprint generator::
     >>> u = mda.Universe(prolif.datafiles.TOP, prolif.datafiles.TRAJ)
     >>> prot = u.select_atoms("protein")
     >>> lig = u.select_atoms("resname LIG")
-    >>> fp = prolif.Fingerprint(interactions="all")
-    >>> df = fp.run(u.trajectory[0:1], lig, prot).to_dataframe()
+    >>> fp = prolif.Fingerprint(interactions=["CloseContact"])
+    >>> fp.run(u.trajectory[0:1], lig, prot)
+    >>> df = fp.to_dataframe()
     >>> df.xs("CloseContact", level=1, axis=1)
-       ASP129.A  VAL201.A
-    0         1         1
+    ligand            LIG1.G             
+    protein         ASP129.A     VAL201.A
+    interaction CloseContact CloseContact
+    Frame                                
+    0                   True         True
     >>> lmol = prolif.Molecule.from_mda(lig)
     >>> pmol = prolif.Molecule.from_mda(prot)
     >>> fp.closecontact(lmol, pmol["ASP129.A"])
-    True
+    {'indices': {'ligand': (52,), 'protein': (8,)}, 'parent_indices': {'ligand': (52,), 'protein': (1489,)}, 'distance': 1.7540241205623204}
 
 Note that some of the SMARTS patterns used in the interaction classes are inspired from
 `Pharmit`_ and `RDKit`_.
@@ -41,7 +63,6 @@ Note that some of the SMARTS patterns used in the interaction classes are inspir
 """
 
 import warnings
-from abc import ABC, ABCMeta, abstractmethod
 from itertools import product
 from math import radians
 
@@ -53,13 +74,18 @@ from rdkit.Chem import MolFromSmarts
 from .utils import angle_between_limits, get_centroid, get_ring_normal_vector
 
 _INTERACTIONS = {}
+VDWRADII = {symbol.capitalize(): radius for symbol, radius in vdwradii.items()}
 
 
-class _InteractionMeta(ABCMeta):
+class _InteractionMeta(type):
     """Metaclass to register interactions automatically"""
 
     def __init__(cls, name, bases, classdict):
         type.__init__(cls, name, bases, classdict)
+        if name != "Interaction" and not hasattr(cls, "detect"):
+            raise TypeError(
+                f"Can't instantiate interaction class {name} without a `detect` method."
+            )
         if name in _INTERACTIONS.keys():
             warnings.warn(
                 f"The {name!r} interaction has been superseded by a "
@@ -68,18 +94,6 @@ class _InteractionMeta(ABCMeta):
         _INTERACTIONS[name] = cls
 
 
-class Interaction(ABC, metaclass=_InteractionMeta):
-    """Abstract class for interactions
-
-    All interaction classes must inherit this class and define a
-    :meth:`~detect` method
-    """
-
-    @abstractmethod
-    def detect(self, lig_res, prot_res):
-        pass
-
-
 def get_mapindex(res, index):
     """Get the index of the atom in the original molecule
 
@@ -98,6 +112,89 @@ def get_mapindex(res, index):
     return res.GetAtomWithIdx(index).GetUnsignedProp("mapindex")
 
 
+class Interaction(metaclass=_InteractionMeta):
+    """Base class for interactions
+
+    All interaction classes must inherit this class and define a
+    :meth:`~detect` method.
+
+    .. versionchanged:: 2.0.0
+        Changed the return type of interactions. Added some helper methods to easily
+        update/derive interaction classes.
+    """
+
+    def __call__(self, lig_res, prot_res, metadata=False):
+        int_data = self.detect(lig_res, prot_res)
+        return int_data if metadata else (int_data is not None)
+
+    def __repr__(self):  # pragma: no cover
+        cls = self.__class__
+        return f"<{cls.__module__}.{cls.__name__} at {id(self):#x}>"
+
+    @staticmethod
+    def metadata(lig_res, prot_res, lig_indices, prot_indices, **data):
+        """Returns a dict containing the indices of atoms responsible for the
+        interaction, alongside any other metrics (e.g. distance, angle...etc.)."""
+        return {
+            "indices": {
+                "ligand": lig_indices,
+                "protein": prot_indices,
+            },
+            "parent_indices": {
+                "ligand": tuple(
+                    [get_mapindex(lig_res, index) for index in lig_indices]
+                ),
+                "protein": tuple(
+                    [get_mapindex(prot_res, index) for index in prot_indices]
+                ),
+            },
+            **data,
+        }
+
+    @staticmethod
+    def _invert_metadata(metadata):
+        """Invert the role of the ligand and protein components in the dict returned
+        by :meth:`~Interaction.metadata`."""
+        if metadata is not None:
+            metadata["indices"]["protein"], metadata["indices"]["ligand"] = (
+                metadata["indices"]["ligand"],
+                metadata["indices"]["protein"],
+            )
+            (
+                metadata["parent_indices"]["protein"],
+                metadata["parent_indices"]["ligand"],
+            ) = (
+                metadata["parent_indices"]["ligand"],
+                metadata["parent_indices"]["protein"],
+            )
+        return metadata
+
+    @classmethod
+    def invert_class(cls, name, doc):
+        """Creates a new interaction class where the role of the ligand and protein
+        residues have been swapped. Usefull to create e.g. an acceptor class from a
+        donor class.
+        """
+        inverted = type(name, (cls,), {"__doc__": doc})
+
+        def detect(self, ligand, residue):
+            metadata = super(inverted, self).detect(residue, ligand)
+            return self._invert_metadata(metadata)
+
+        inverted.detect = detect
+        return inverted
+
+    @classmethod
+    def update_parameters(cls, source):
+        """Creates a new class with updated parameters from a source class."""
+        if issubclass(cls, source):
+            __init__ = source.__init__
+        else:
+            __init__ = cls.__init__
+            __init__.__defaults__ = source.__init__.__defaults__
+        return type(cls.__name__, (cls,), {"__init__": __init__})
+
+
 class _Distance(Interaction):
     """Generic class for distance-based interactions
 
@@ -123,9 +220,11 @@ class _Distance(Interaction):
             for lig_match, prot_match in product(lig_matches, prot_matches):
                 alig = Geometry.Point3D(*lig_res.xyz[lig_match[0]])
                 aprot = Geometry.Point3D(*prot_res.xyz[prot_match[0]])
-                if alig.Distance(aprot) <= self.distance:
-                    return True, lig_match[0], prot_match[0]
-        return False, None, None
+                dist = alig.Distance(aprot)
+                if dist <= self.distance:
+                    return self.metadata(
+                        lig_res, prot_res, lig_match, prot_match, distance=dist
+                    )
 
 
 class Hydrophobic(_Distance):
@@ -199,29 +298,30 @@ class _BaseHBond(Interaction):
                 d = Geometry.Point3D(*donor.xyz[donor_match[0]])
                 h = Geometry.Point3D(*donor.xyz[donor_match[1]])
                 a = Geometry.Point3D(*acceptor.xyz[acceptor_match[0]])
-                if d.Distance(a) <= self.distance:
+                dist = d.Distance(a)
+                if dist <= self.distance:
                     hd = h.DirectionVector(d)
                     ha = h.DirectionVector(a)
                     # get DHA angle
                     angle = hd.AngleTo(ha)
                     if angle_between_limits(angle, *self.angles):
-                        return True, acceptor_match[0], donor_match[1]
-        return False, None, None
-
-
-class HBDonor(_BaseHBond):
-    """Hbond interaction between a ligand (donor) and a residue (acceptor)"""
-
-    def detect(self, ligand, residue):
-        bit, ires, ilig = super().detect(residue, ligand)
-        return bit, ilig, ires
+                        return self.metadata(
+                            acceptor,
+                            donor,
+                            acceptor_match,
+                            donor_match,
+                            distance=dist,
+                            angle=np.degrees(angle),
+                        )
 
 
 class HBAcceptor(_BaseHBond):
     """Hbond interaction between a ligand (acceptor) and a residue (donor)"""
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
+
+HBDonor = HBAcceptor.invert_class(
+    "HBDonor", "Hbond interaction between a ligand (donor) and a residue (acceptor)"
+)
 
 
 class _BaseXBond(Interaction):
@@ -268,35 +368,37 @@ class _BaseXBond(Interaction):
                 d = Geometry.Point3D(*donor.xyz[donor_match[0]])
                 x = Geometry.Point3D(*donor.xyz[donor_match[1]])
                 a = Geometry.Point3D(*acceptor.xyz[acceptor_match[0]])
-                if x.Distance(a) <= self.distance:
+                dist = x.Distance(a)
+                if dist <= self.distance:
                     # D-X ... A angle
                     xd = x.DirectionVector(d)
                     xa = x.DirectionVector(a)
-                    angle = xd.AngleTo(xa)
-                    if angle_between_limits(angle, *self.axd_angles):
+                    axd = xd.AngleTo(xa)
+                    if angle_between_limits(axd, *self.axd_angles):
                         # X ... A-R angle
                         r = Geometry.Point3D(*acceptor.xyz[acceptor_match[1]])
                         ax = a.DirectionVector(x)
                         ar = a.DirectionVector(r)
-                        angle = ax.AngleTo(ar)
-                        if angle_between_limits(angle, *self.xar_angles):
-                            return True, acceptor_match[0], donor_match[1]
-        return False, None, None
+                        xar = ax.AngleTo(ar)
+                        if angle_between_limits(xar, *self.xar_angles):
+                            return self.metadata(
+                                acceptor,
+                                donor,
+                                acceptor_match,
+                                donor_match,
+                                distance=dist,
+                                AXD_angle=np.degrees(axd),
+                                XAR_angle=np.degrees(xar),
+                            )
 
 
 class XBAcceptor(_BaseXBond):
     """Halogen bonding between a ligand (acceptor) and a residue (donor)"""
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
 
-
-class XBDonor(_BaseXBond):
-    """Halogen bonding between a ligand (donor) and a residue (acceptor)"""
-
-    def detect(self, ligand, residue):
-        bit, ires, ilig = super().detect(residue, ligand)
-        return bit, ilig, ires
+XBDonor = XBAcceptor.invert_class(
+    "XBDonor", "Halogen bonding between a ligand (donor) and a residue (acceptor)"
+)
 
 
 class _BaseIonic(_Distance):
@@ -319,16 +421,10 @@ class _BaseIonic(_Distance):
 class Cationic(_BaseIonic):
     """Ionic interaction between a ligand (cation) and a residue (anion)"""
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
-
 
-class Anionic(_BaseIonic):
-    """Ionic interaction between a ligand (anion) and a residue (cation)"""
-
-    def detect(self, ligand, residue):
-        bit, ires, ilig = super().detect(residue, ligand)
-        return bit, ilig, ires
+Anionic = Cationic.invert_class(
+    "Anionic", "Ionic interaction between a ligand (anion) and a residue (cation)"
+)
 
 
 class _BaseCationPi(Interaction):
@@ -380,7 +476,8 @@ class _BaseCationPi(Interaction):
                 # centroid of pi-system as 3d point
                 centroid = Geometry.Point3D(*get_centroid(pi_coords))
                 # distance between cation and centroid
-                if cat.Distance(centroid) > self.distance:
+                dist = cat.Distance(centroid)
+                if dist > self.distance:
                     continue
                 # vector normal to ring plane
                 normal = get_ring_normal_vector(centroid, pi_coords)
@@ -390,25 +487,24 @@ class _BaseCationPi(Interaction):
                 # centroid-cation
                 angle = normal.AngleTo(centroid_cation)
                 if angle_between_limits(angle, *self.angles, ring=True):
-                    return True, cation_match[0], pi_match[0]
-        return False, None, None
-
-
-class PiCation(_BaseCationPi):
-    """Cation-Pi interaction between a ligand (aromatic ring) and a residue
-    (cation)"""
-
-    def detect(self, ligand, residue):
-        bit, ires, ilig = super().detect(residue, ligand)
-        return bit, ilig, ires
+                    return self.metadata(
+                        cation,
+                        pi,
+                        cation_match,
+                        pi_match,
+                        distance=dist,
+                        angle=np.degrees(angle),
+                    )
 
 
 class CationPi(_BaseCationPi):
-    """Cation-Pi interaction between a ligand (cation) and a residue
-    (aromatic ring)"""
+    """Cation-Pi interaction between a ligand (cation) and a residue (aromatic ring)"""
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
+
+PiCation = CationPi.invert_class(
+    "PiCation",
+    "Cation-Pi interaction between a ligand (aromatic ring) and a residue (cation)",
+)
 
 
 class _BasePiStacking(Interaction):
@@ -501,8 +597,14 @@ class _BasePiStacking(Interaction):
                     )
                     if intersect_dist > self.ring_radius:
                         continue
-                return True, lig_match[0], res_match[0]
-        return False, None, None
+                return self.metadata(
+                    ligand,
+                    residue,
+                    lig_match,
+                    res_match,
+                    distance=cdist,
+                    angle=np.degrees(plane_angle),
+                )
 
     @staticmethod
     def _get_intersect_point(
@@ -583,9 +685,6 @@ class EdgeToFace(_BasePiStacking):
         self.edge = True
         self.ring_radius = ring_radius
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
-
 
 class PiStacking(Interaction):
     """Pi-Stacking interaction between a ligand and a residue
@@ -612,10 +711,7 @@ class PiStacking(Interaction):
         self.etf = EdgeToFace(**etf_kwargs or {})
 
     def detect(self, ligand, residue):
-        ftf = self.ftf.detect(ligand, residue)
-        if ftf[0] is True:
-            return ftf
-        return self.etf.detect(ligand, residue)
+        return self.ftf.detect(ligand, residue) or self.etf.detect(ligand, residue)
 
 
 class _BaseMetallic(_Distance):
@@ -648,16 +744,11 @@ class _BaseMetallic(_Distance):
 class MetalDonor(_BaseMetallic):
     """Metallic interaction between a metal and a residue (chelated)"""
 
-    def detect(self, ligand, residue):
-        return super().detect(ligand, residue)
 
-
-class MetalAcceptor(_BaseMetallic):
-    """Metallic interaction between a ligand (chelated) and a metal residue"""
-
-    def detect(self, ligand, residue):
-        bit, ires, ilig = super().detect(residue, ligand)
-        return bit, ilig, ires
+MetalAcceptor = MetalDonor.invert_class(
+    "MetalAcceptor",
+    "Metallic interaction between a ligand (chelated) and a metal residue",
+)
 
 
 class VdWContact(Interaction):
@@ -669,33 +760,42 @@ class VdWContact(Interaction):
         Tolerance added to the sum of vdW radii of atoms before comparing to
         the interatomic distance. If ``distance <= sum_vdw + tolerance`` the
         atoms are identified as a contact
+    vdwradii : dict, optional
+        Updates to the vdW radii dictionary
 
     Raises
     ------
     ValueError : ``tolerance`` parameter cannot be negative
+
+
+    .. versionchanged:: 2.0.0
+        Added the `vdwradii``parameter.
+
     """
 
-    def __init__(self, tolerance=0.0):
+    def __init__(self, tolerance=0.0, vdwradii=None):
         if tolerance >= 0:
             self.tolerance = tolerance
         else:
             raise ValueError("`tolerance` must be 0 or positive")
         self._vdw_cache = {}
+        self.vdwradii = {**VDWRADII, **vdwradii} if vdwradii else VDWRADII
 
     def detect(self, ligand, residue):
         lxyz = ligand.GetConformer()
         rxyz = residue.GetConformer()
         for la, ra in product(ligand.GetAtoms(), residue.GetAtoms()):
-            lig = la.GetSymbol().upper()
-            res = ra.GetSymbol().upper()
+            lig = la.GetSymbol()
+            res = ra.GetSymbol()
             try:
                 vdw = self._vdw_cache[frozenset((lig, res))]
             except KeyError:
-                vdw = vdwradii[lig] + vdwradii[res] + self.tolerance
+                vdw = self.vdwradii[lig] + self.vdwradii[res] + self.tolerance
                 self._vdw_cache[frozenset((lig, res))] = vdw
             dist = lxyz.GetAtomPosition(la.GetIdx()).Distance(
                 rxyz.GetAtomPosition(ra.GetIdx())
             )
             if dist <= vdw:
-                return True, la.GetIdx(), ra.GetIdx()
-        return False, None, None
+                return self.metadata(
+                    ligand, residue, (la.GetIdx(),), (ra.GetIdx(),), distance=dist
+                )
diff --git a/prolif/parallel.py b/prolif/parallel.py
index c8b86c5..74532fb 100644
--- a/prolif/parallel.py
+++ b/prolif/parallel.py
@@ -11,13 +11,12 @@ def process_chunk(args):
     """Generates a fingerprint for a chunk of frame indices"""
     traj, lig, prot, chunk = args
     lig_kwargs, prot_kwargs = converter_kwargs
-    ifp = []
+    ifp = {}
     for ts in traj[chunk]:
         lig_mol = Molecule.from_mda(lig, **lig_kwargs)
         prot_mol = Molecule.from_mda(prot, **prot_kwargs)
-        data = fp.generate(lig_mol, prot_mol, residues=residues, return_atoms=True)
-        data["Frame"] = ts.frame
-        ifp.append(data)
+        data = fp.generate(lig_mol, prot_mol, residues=residues, metadata=True)
+        ifp[ts.frame] = data
         if display_progress:
             with pcount.lock:
                 pcount.counter.value += 1
@@ -40,9 +39,7 @@ def declare_shared_objs_for_chunk(
 def process_mol(args):
     """Generates a fingerprint for a single molecule"""
     index, mol = args
-    data = fp.generate(mol, prot_mol, residues=residues, return_atoms=True)
-    data["Frame"] = index
-    return data
+    return index, fp.generate(mol, prot_mol, residues=residues, metadata=True)
 
 
 def declare_shared_objs_for_mol(fingerprint, pmol, resid_list):
diff --git a/prolif/plotting/network.py b/prolif/plotting/network.py
index 9d57f70..a4a9437 100644
--- a/prolif/plotting/network.py
+++ b/prolif/plotting/network.py
@@ -4,6 +4,10 @@ Plot a Ligand Interaction Network --- :mod:`prolif.plotting.network`
 
 .. versionadded:: 0.3.2
 
+.. versionchanged:: 2.0.0
+    Replaced ``LigNetwork.from_ifp`` with ``LigNetwork.from_fingerprint`` which works
+    without requiring a dataframe with atom indices.
+
 .. autoclass:: LigNetwork
    :members:
 
@@ -14,6 +18,7 @@ import warnings
 from collections import defaultdict
 from copy import deepcopy
 from html import escape
+from pathlib import Path
 
 import numpy as np
 import pandas as pd
@@ -39,7 +44,7 @@ class LigNetwork:
     Parameters
     ----------
     df : pandas.DataFrame
-        Dataframe with a 4-level index (ligand, protein, interaction, atom)
+        Dataframe with a 4-level index (ligand, protein, interaction, atoms)
         and a weight column for values
     lig_mol : rdkit.Chem.rdChem.Mol
         Ligand molecule
@@ -77,6 +82,10 @@ class LigNetwork:
     You can customize the diagram by tweaking :attr:`LigNetwork.COLORS` and
     :attr:`LigNetwork.RESIDUE_TYPES` by adding or modifying the
     dictionaries inplace.
+
+    .. versionchanged:: 2.0.0
+        Replaced ``LigNetwork.from_ifp`` with ``LigNetwork.from_fingerprint`` which
+        works without requiring a dataframe with atom indices.
     """
 
     COLORS = {
@@ -146,6 +155,10 @@ class LigNetwork:
         "MET": "Sulfur",
     }
     _LIG_PI_INTERACTIONS = ["EdgeToFace", "FaceToFace", "PiStacking", "PiCation"]
+    _DISPLAYED_ATOM = {  # index 0 in indices tuple by default
+        "HBDonor": 1,
+        "XBDonor": 1,
+    }
     _JS_TEMPLATE = """
         var ifp, legend, nodes, edges, legend_buttons;
         function drawGraph(_id, nodes, edges, options) {
@@ -205,9 +218,10 @@ class LigNetwork:
         carbon=0.16,
     ):
         self.df = df
+        self._interacting_atoms = set(
+            [atom for atoms in df.index.get_level_values("atoms") for atom in atoms]
+        )
         mol = deepcopy(lig_mol)
-        Chem.SanitizeMol(mol, Chem.SanitizeFlags.SANITIZE_SETAROMATICITY)
-        self._ring_info = mol.GetRingInfo()
         if kekulize:
             Chem.Kekulize(mol)
         if match3D:
@@ -258,79 +272,109 @@ class LigNetwork:
         }
 
     @classmethod
-    def from_ifp(cls, ifp, lig, kind="aggregate", frame=0, threshold=0.3, **kwargs):
-        """Helper method to create a ligand interaction diagram from an IFP
-        DataFrame obtained with ``fp.to_dataframe(return_atoms=True)``
+    def from_fingerprint(
+        cls, fp, ligand_mol, kind="aggregate", frame=0, threshold=0.3, **kwargs
+    ):
+        """Helper method to create a ligand interaction diagram from a
+        :class:`~prolif.fingerprint.Fingerprint` object.
 
         Notes
         -----
         Two kinds of diagrams can be rendered: either for a designated frame or
         by aggregating the results on the whole IFP and optionnally discarding
         interactions that occur less frequently than a threshold. In the latter
-        case (aggregate), only the most frequent ligand atom interaction is
-        rendered.
+        case (aggregate), only the group of atoms most frequently involved in
+        each interaction is used to draw the edge.
 
         Parameters
         ----------
-        ifp : pandas.DataFrame
-            The result of ``fp.to_dataframe(return_atoms=True)``
+        fp : prolif.fingerprint.Fingerprint
+            The fingerprint object already executed using one of the ``run`` or
+            ``run_from_iterable`` methods.
         lig : rdkit.Chem.rdChem.Mol
             Ligand molecule
         kind : str
-            One of "aggregate" or "frame"
+            One of ``"aggregate"`` or ``"frame"``
         frame : int or str
-            Frame number, as read in ``ifp.index``. Only applicable for
-            ``kind="frame"``
+            Frame number (see :attr:`~prolif.fingerprint.Fingerprint.ifp`). Only
+            applicable for ``kind="frame"``
         threshold : float
             Frequency threshold, between 0 and 1. Only applicable for
             ``kind="aggregate"``
         kwargs : object
             Other arguments passed to the :class:`LigNetwork` class
         """
-        if kind == "aggregate":
-            data = (
-                pd.get_dummies(
-                    ifp.applymap(lambda x: x[0]).astype(object), prefix_sep=", "
-                )
-                .rename(columns=lambda x: x.translate({ord(c): None for c in "()'"}))
-                .mean()
-            )
-            index = [i.split(", ") for i in data.index]
-            index = [[j for j in i[:-1] + [int(float(i[-1]))]] for i in index]
-            data.index = pd.MultiIndex.from_tuples(
-                index, names=["ligand", "protein", "interaction", "atom"]
+        if not hasattr(fp, "ifp"):
+            raise AttributeError(
+                "Please run the interaction fingerprint analysis before plotting."
             )
-            data = data.to_frame()
-            data.rename(columns={data.columns[-1]: "weight"}, inplace=True)
-            # merge different ligand atoms before applying the threshold
-            data = data.join(
-                data.groupby(level=["ligand", "protein", "interaction"]).sum(),
-                rsuffix="_total",
-            )
-            # threshold and keep most occuring atom
-            data = (
-                data.loc[data["weight_total"] >= threshold]
-                .drop(columns="weight_total")
-                .sort_values("weight", ascending=False)
-                .groupby(level=["ligand", "protein", "interaction"])
-                .head(1)
-                .sort_index()
-            )
-            return cls(data, lig, **kwargs)
-        elif kind == "frame":
-            data = (
-                ifp.loc[ifp.index == frame]
-                .T.applymap(lambda x: x[0])
-                .dropna()
-                .astype(int)
-                .reset_index()
-            )
-            data.rename(columns={data.columns[-1]: "atom"}, inplace=True)
-            data["weight"] = 1
-            data.set_index(["ligand", "protein", "interaction", "atom"], inplace=True)
-            return cls(data, lig, **kwargs)
-        else:
-            raise ValueError(f'{kind!r} must be "aggregate" or "frame"')
+        if kind == "frame":
+            df = cls._make_frame_df_from_fp(fp, frame=frame)
+            return cls(df, ligand_mol, **kwargs)
+        if kind == "aggregate":
+            df = cls._make_agg_df_from_fp(fp, threshold=threshold)
+            return cls(df, ligand_mol, **kwargs)
+        raise ValueError(f'{kind!r} must be "aggregate" or "frame"')
+
+    @staticmethod
+    def _make_agg_df_from_fp(fp, threshold=0.3):
+        data = [
+            {
+                "Frame": frame,
+                "ligand": str(lig_resid),
+                "protein": str(prot_resid),
+                "interaction": int_name,
+                "atoms": metadata["indices"]["ligand"],
+                "distance": metadata.get("distance", 0),
+            }
+            for frame, ifp in fp.ifp.items()
+            for (lig_resid, prot_resid), int_data in ifp.items()
+            for int_name, metadata in int_data.items()
+        ]
+        df = pd.DataFrame(data)
+        df["weight"] = 1
+        df = df.groupby(["ligand", "protein", "interaction", "atoms"]).agg(
+            weight=("weight", "sum"), distance=("distance", "mean")
+        )
+        df["weight"] = df["weight"] / len(fp.ifp)
+        # merge different ligand atoms of the same residue/interaction group before
+        # applying the threshold
+        df = df.join(
+            df.groupby(level=["ligand", "protein", "interaction"]).agg(
+                weight_total=("weight", "sum")
+            ),
+        )
+        # threshold and keep most occuring ligand atom
+        df = (
+            df.loc[df["weight_total"] >= threshold]
+            .drop(columns="weight_total")
+            .sort_values("weight", ascending=False)
+            .groupby(level=["ligand", "protein", "interaction"])
+            .head(1)
+            .sort_index()
+        )
+        return df
+
+    @staticmethod
+    def _make_frame_df_from_fp(fp, frame=0):
+        ifp = fp.ifp[frame]
+        data = [
+            {
+                "ligand": str(lig_resid),
+                "protein": str(prot_resid),
+                "interaction": int_name,
+                "atoms": metadata["indices"]["ligand"],
+                "distance": metadata.get("distance", 0),
+            }
+            for (lig_resid, prot_resid), int_data in ifp.items()
+            for int_name, metadata in int_data.items()
+        ]
+        df = pd.DataFrame(data)
+        df["weight"] = 1
+        df = df.set_index(["ligand", "protein", "interaction", "atoms"]).reindex(
+            columns=["weight", "distance"]
+        )
+        return df
 
     def _make_carbon(self):
         return deepcopy(self._carbon)
@@ -339,7 +383,7 @@ class LigNetwork:
         """Prepare ligand atoms"""
         idx = atom.GetIdx()
         elem = atom.GetSymbol()
-        if elem == "H" and idx not in self.df.index.get_level_values("atom"):
+        if elem == "H" and idx not in self._interacting_atoms:
             self.exclude.append(idx)
             return
         charge = atom.GetFormalCharge()
@@ -474,9 +518,12 @@ class LigNetwork:
                 "residue_type": restype,
             }
             self.nodes[prot_res] = node
-        for (lig_res, prot_res, interaction, lig_id), (weight,) in self.df.iterrows():
+        for (lig_res, prot_res, interaction, lig_indices), (
+            weight,
+            distance,
+        ) in self.df.iterrows():
             if interaction in self._LIG_PI_INTERACTIONS:
-                centroid = self._get_ring_centroid(lig_id)
+                centroid = self._get_ring_centroid(lig_indices)
                 origin = str((lig_res, prot_res, interaction))
                 self.nodes[origin] = {
                     "id": origin,
@@ -488,11 +535,12 @@ class LigNetwork:
                     "physics": False,
                 }
             else:
-                origin = int(lig_id)
+                i = self._DISPLAYED_ATOM.get(interaction, 0)
+                origin = lig_indices[i]
             edge = {
                 "from": origin,
                 "to": prot_res,
-                "title": interaction,
+                "title": f"{interaction}: {distance:.2f}Å",
                 "interaction_type": self._interaction_types.get(
                     interaction, interaction
                 ),
@@ -506,16 +554,9 @@ class LigNetwork:
             }
             self.edges.append(edge)
 
-    def _get_ring_centroid(self, index):
-        """Find ring coordinates using the index of one of the ring atoms"""
-        for r in self._ring_info.AtomRings():
-            if index in r:
-                break
-        else:
-            raise ValueError(
-                "No ring containing this atom index was found in " "the given molecule"
-            )
-        return self.xyz[list(r)].mean(axis=0)
+    def _get_ring_centroid(self, indices):
+        """Find ring centroid coordinates using the indices of the ring atoms"""
+        return self.xyz[list(indices)].mean(axis=0)
 
     def _patch_hydrogens(self):
         """Patch hydrogens on heteroatoms
@@ -766,8 +807,8 @@ class LigNetwork:
             Name of the output file, or file-like object
         """
         html = self._get_html(**kwargs)
-        try:
-            fp.write(html)
-        except AttributeError:
+        if isinstance(fp, (str, Path)):
             with open(fp, "w") as f:
                 f.write(html)
+        elif hasattr(fp, "write") and callable(fp.write):
+            fp.write(html)
diff --git a/prolif/utils.py b/prolif/utils.py
index bf681b0..3cfdf37 100644
--- a/prolif/utils.py
+++ b/prolif/utils.py
@@ -4,7 +4,6 @@ Helper functions --- :mod:`prolif.utils`
 """
 import warnings
 from collections import defaultdict
-from collections.abc import Iterable
 from contextlib import contextmanager
 from copy import deepcopy
 from functools import wraps
@@ -113,8 +112,8 @@ def get_residues_near_ligand(lig, prot, cutoff=6.0):
 
     Parameters
     ----------
-    lig : prolif.molecule.Molecule
-        Select residues that are near this ligand
+    lig : prolif.molecule.Molecule or prolif.residue.Residue
+        Select residues that are near this ligand/residue
     prot : prolif.molecule.Molecule
         Protein containing the residues
     cutoff : float
@@ -190,30 +189,25 @@ def to_dataframe(
     index_col="Frame",
     dtype=None,
     drop_empty=True,
-    return_atoms=False,
 ):
     """Converts IFPs to a pandas DataFrame
 
     Parameters
     ----------
-    ifp : list
-        A list of dict in the format {key: bitvector}. "key" is a tuple of
-        ligand and protein ResidueId. "bitvector" is either a numpy.ndarray
-        of bits, or a list of bitarray, ligand atom indices, and protein atom
-        indices. Each dictionnary must also contain an entry that will be used
-        as an index, typically a frame number.
+    ifp : dict
+        A dict in the format
+        ``{<frame number>: {(<residue_id>, <residue_id>): <interactions>}}``.
+        ``<interactions>`` is either a :class:`numpy.ndarray` bitvector, or a dict in
+        the format ``{<interaction name>: <metadata dict>}``.
     interactions : list
-        A list of interactions, in the same order as the bitvector.
+        A list of interactions, in the same order as used to detect the interactions.
     index_col : str
-        The dictionnary key that will be used as an index in the DataFrame
+        Name of the index column in the DataFrame
     dtype : object or None
         Cast the input of each bit in the bitvector to this type. If None, keep
-        the data as is. Not compatible with ``return_atoms=True``
+        the data as is.
     drop_empty : bool
         Drop columns with only empty values
-    return_atoms : bool
-        For each residue pair and interaction, return indices of atoms
-        responsible for the interaction instead of bits
 
     Returns
     -------
@@ -237,47 +231,45 @@ def to_dataframe(
     .. versionchanged:: 0.3.2
         Moved the ``return_atoms`` parameter from the ``run`` methods to the
         dataframe conversion code
+
+    .. versionchanged:: 2.0.0
+        Removed the ``return_atoms`` parameter.
     """
-    if dtype and return_atoms:
-        raise ValueError("`dtype` cannot be used with `return_atoms=True`")
     ifp = deepcopy(ifp)
     n_interactions = len(interactions)
     empty_value = dtype(False) if dtype else False
-    # residue pairs
-    keys = sorted(set([k for d in ifp for k in d.keys() if k != index_col]))
     # check if each interaction value is a list of atom indices or smthg else
     has_atom_indices = False
-    for d in ifp:
-        for key, value in d.items():
-            if key != index_col:
-                has_atom_indices = isinstance(value[0], Iterable)
-                break
-    if return_atoms and not has_atom_indices:
-        raise ValueError(
-            "The IFP either doesn't contain atom indices or is formatted incorrectly"
-        )
+    for frame_ifp in ifp.values():
+        for residue_tuple, value in frame_ifp.items():
+            has_atom_indices = isinstance(value, dict)
+            break
     # create empty array for each residue pair interaction that doesn't exist
     # in a particular frame
-    if has_atom_indices and return_atoms:
-        empty_arr = [[None, None]] * n_interactions
-    else:
-        empty_arr = np.array([empty_value] * n_interactions)
+    empty_arr = np.array([empty_value for _ in range(n_interactions)])
+    # residue pairs
+    residue_pairs = sorted(
+        set(
+            [residue_tuple for frame_ifp in ifp.values() for residue_tuple in frame_ifp]
+        )
+    )
     # sparse to dense
     data = defaultdict(list)
     index = []
-    for d in ifp:
-        index.append(d.pop(index_col))
-        for key in keys:
+    for i, frame_ifp in ifp.items():
+        index.append(i)
+        for residue_tuple in residue_pairs:
             try:
-                arr = d[key]
+                # np.ndarray or sparse metadata dict
+                bitvector = frame_ifp[residue_tuple]
             except KeyError:
-                data[key].append(empty_arr)
+                data[residue_tuple].append(empty_arr[:])
             else:
-                if has_atom_indices and return_atoms:
-                    arr = list(zip(*arr[1:]))
-                elif has_atom_indices:
-                    arr = arr[0]
-                data[key].append(arr)
+                if has_atom_indices:
+                    bitvector = np.array(
+                        [i in bitvector for i in interactions], dtype=bool
+                    )
+                data[residue_tuple].append(bitvector)
     index = pd.Series(index, name=index_col)
     # create dataframes
     if not data:
@@ -286,42 +278,20 @@ def to_dataframe(
     values = np.array(
         [np.hstack([np.ravel(a[i]) for a in data.values()]) for i in range(len(index))]
     )
-    if has_atom_indices and return_atoms:
-        columns = pd.MultiIndex.from_tuples(
-            [
-                (str(k[0]), str(k[1]), i, a)
-                for k in keys
-                for i in interactions
-                for a in ["ligand", "protein"]
-            ],
-            names=["ligand", "protein", "interaction", "atom"],
-        )
-    else:
-        columns = pd.MultiIndex.from_tuples(
-            [(str(k[0]), str(k[1]), i) for k in keys for i in interactions],
-            names=["ligand", "protein", "interaction"],
-        )
+    columns = pd.MultiIndex.from_tuples(
+        [
+            (str(lig_res), str(prot_res), i)
+            for lig_res, prot_res in residue_pairs
+            for i in interactions
+        ],
+        names=["ligand", "protein", "interaction"],
+    )
     df = pd.DataFrame(values, columns=columns, index=index)
-    if has_atom_indices and return_atoms:
-        if drop_empty:
-            # remove empty columns before grouping
-            df = df[df.columns[~df.applymap(lambda x: x is None).all()]]
-            # ensure columns are still in pairs of two after groupby
-            assert (
-                df.groupby(axis=1, level=["ligand", "protein", "interaction"])
-                .apply(lambda g: len(g.columns))
-                .eq(2)
-                .all()
-            )
-        # aggregate each interaction for a pair of residues as a tuple of ligand and
-        # protein atom indices
-        df = df.groupby(axis=1, level=["ligand", "protein", "interaction"]).agg(tuple)
-    else:
-        if dtype:
-            df = df.astype(dtype)
-        if drop_empty:
-            mask = (df != empty_value).any(axis=0)
-            df = df.loc[:, mask]
+    if dtype:
+        df = df.astype(dtype)
+    if drop_empty:
+        mask = (df != empty_value).any(axis=0)
+        df = df.loc[:, mask]
     return df
 
 
diff --git a/pyproject.toml b/pyproject.toml
index 97a826c..f67e79c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,7 +46,6 @@ build = [
 tests = [
     "pytest>=6.1.2",
     "pytest-cov",
-    "codecov",
 ]
 dev = [
     "prolif[build,tests]",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 6cf2ff5..2ce9a28 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -44,8 +44,8 @@ def ligand_mol(ligand_ag):
 
 
 @pytest.fixture(scope="session")
-def protein_ag(u):
-    return u.select_atoms("protein")
+def protein_ag(u, ligand_ag):
+    return u.select_atoms("protein and byres around 6.5 group ligand", ligand=ligand_ag)
 
 
 @pytest.fixture(scope="session")
diff --git a/tests/plotting/test_network.py b/tests/plotting/test_network.py
index 3b5a11e..0701bc9 100644
--- a/tests/plotting/test_network.py
+++ b/tests/plotting/test_network.py
@@ -1,7 +1,5 @@
-import os
-from contextlib import contextmanager
+from functools import partial
 from io import StringIO
-from tempfile import NamedTemporaryFile
 
 import MDAnalysis as mda
 import pytest
@@ -10,66 +8,56 @@ import prolif as plf
 from prolif.plotting.network import LigNetwork
 
 
-@contextmanager
-def TempFilename(mode="w"):
-    f = NamedTemporaryFile(delete=False, mode=mode)
-    try:
-        f.close()
-        yield f.name
-    finally:
-        os.unlink(f.name)
-
-
 class TestLigNetwork:
     @pytest.fixture(scope="class")
-    def lignetwork_data(self):
-        u = mda.Universe(plf.datafiles.TOP)
-        prot = u.select_atoms("protein and resid 200:331")
+    def get_ligplot(self):
+        u = mda.Universe(plf.datafiles.TOP, plf.datafiles.TRAJ)
         lig = u.select_atoms("resname LIG")
+        prot = u.select_atoms("protein and byres around 6.5 group ligand", ligand=lig)
         fp = plf.Fingerprint()
-        fp.run(u.trajectory, lig, prot)
-        df = fp.to_dataframe(return_atoms=True)
+        fp.run(u.trajectory[0:2], lig, prot)
         lig = plf.Molecule.from_mda(lig)
-        return lig, df
+        return partial(fp.to_ligplot, lig)
 
-    def test_integration_frame(self, lignetwork_data):
-        lig, df = lignetwork_data
-        net = LigNetwork.from_ifp(df, lig, kind="frame", frame=0)
+    def test_integration_frame(self, get_ligplot):
+        net = get_ligplot(kind="frame", frame=0)
         with StringIO() as buffer:
             net.save(buffer)
             buffer.seek(0)
             html = buffer.read()
         assert "PHE331.B" in html
 
-    def test_integration_agg(self, lignetwork_data):
-        lig, df = lignetwork_data
-        net = LigNetwork.from_ifp(df, lig, kind="aggregate", threshold=0)
+    def test_integration_agg(self, get_ligplot):
+        net = get_ligplot(kind="aggregate", threshold=0)
         with StringIO() as buffer:
             net.save(buffer)
             buffer.seek(0)
             html = buffer.read()
         assert "PHE331.B" in html
 
-    def test_kwargs(self, lignetwork_data):
-        lig, df = lignetwork_data
-        net = LigNetwork.from_ifp(
-            df, lig, kekulize=True, match3D=False, rotation=42, carbon=0
-        )
+    def test_kwargs(self, get_ligplot):
+        net = get_ligplot(kekulize=True, match3D=False, rotation=42, carbon=0)
         with StringIO() as buffer:
             net.save(buffer)
             buffer.seek(0)
             html = buffer.read()
         assert "PHE331.B" in html
 
-    def test_save_file(self, lignetwork_data):
-        lig, df = lignetwork_data
-        net = LigNetwork.from_ifp(df, lig)
-        with TempFilename("w+") as filename:
-            net.save(filename)
-            with open(filename, "r") as f:
-                assert "PHE331.B" in f.read()
+    def test_save_file(self, get_ligplot, tmp_path):
+        net = get_ligplot()
+        output = tmp_path / "lignetwork.html"
+        net.save(output)
+        with open(output, "r") as f:
+            assert "PHE331.B" in f.read()
 
-    def test_from_ifp_raises_kind(self, lignetwork_data):
-        lig, df = lignetwork_data
+    def test_from_fingerprint_raises_kind(self, get_ligplot):
         with pytest.raises(ValueError, match='must be "aggregate" or "frame"'):
-            LigNetwork.from_ifp(df, lig, kind="foo")
+            get_ligplot(kind="foo")
+
+    def test_from_fingerprint_raises_not_executed(self, ligand_mol):
+        fp = plf.Fingerprint()
+        with pytest.raises(
+            AttributeError,
+            match="Please run the interaction fingerprint analysis before plotting",
+        ):
+            LigNetwork.from_fingerprint(fp, ligand_mol)
diff --git a/tests/test_fingerprint.py b/tests/test_fingerprint.py
index 47b24d7..946264e 100644
--- a/tests/test_fingerprint.py
+++ b/tests/test_fingerprint.py
@@ -7,7 +7,7 @@ from pandas import DataFrame
 from rdkit.DataStructs import ExplicitBitVect
 
 from prolif.datafiles import datapath
-from prolif.fingerprint import Fingerprint, _InteractionWrapper
+from prolif.fingerprint import Fingerprint
 from prolif.interactions import _INTERACTIONS, Interaction
 from prolif.molecule import sdf_supplier
 from prolif.residue import ResidueId
@@ -15,34 +15,14 @@ from prolif.residue import ResidueId
 
 class Dummy(Interaction):
     def detect(self, res1, res2):
-        return True, 4, 2
+        return self.metadata(res1, res2, (2,), (4,), distance=4.2)
 
 
-class Return:
-    def detect(self, *args):
-        return args if len(args) > 1 else args[0]
-
-
-def test_wrapper_return():
-    detect = Dummy().detect
-    mod = _InteractionWrapper(detect)
-    assert detect("foo", "bar") == (True, 4, 2)
-    assert mod("foo", "bar") is True
-    assert mod.__wrapped__("foo", "bar") == (True, 4, 2)
-
-
-def test_wrapper_repr():
-    mod = _InteractionWrapper(Dummy().detect)
-    _repr = repr(mod)
+def test_interaction_base():
+    interaction = Dummy()
+    _repr = repr(interaction)
     assert _repr.startswith("<") and ".Dummy at " in _repr
-
-
-@pytest.mark.parametrize("returned", [True, (True,), (True, 4)])
-def test_wrapper_incorrect_return(returned):
-    mod = _InteractionWrapper(Return().detect)
-    assert mod.__wrapped__(returned) == returned
-    with pytest.raises(TypeError, match="Incorrect function signature"):
-        mod(returned)
+    assert callable(interaction)
 
 
 class TestFingerprint:
@@ -74,51 +54,47 @@ class TestFingerprint:
     def test_n_interactions(self, fp):
         assert fp.n_interactions == len(fp.interactions)
 
-    def test_wrapped(self, fp_simple):
-        assert fp_simple.dummy("foo", "bar") == 1
-        assert fp_simple.dummy.__wrapped__("foo", "bar") == (True, 4, 2)
-
     def test_bitvector(self, fp, ligand_mol, protein_mol):
-        bv = fp.bitvector(ligand_mol, protein_mol["ASP129.A"])
+        bv = fp.bitvector(ligand_mol[0], protein_mol["ASP129.A"])
         assert len(bv) == fp.n_interactions
         assert bv.sum() > 0
 
-    def test_bitvector_atoms(self, fp, ligand_mol, protein_mol):
-        bv, lig_ix, prot_ix = fp.bitvector_atoms(ligand_mol, protein_mol["ASP129.A"])
-        assert len(bv) == fp.n_interactions
-        assert len(lig_ix) == fp.n_interactions
-        assert len(prot_ix) == fp.n_interactions
-        assert bv.sum() > 0
-        ids = np.where(bv == 1)[0]
-        assert lig_ix[ids[0]] is not None and prot_ix[ids[0]] is not None
+    def test_metadata(self, fp, ligand_mol, protein_mol):
+        metadata = fp.metadata(ligand_mol[0], protein_mol["ASP129.A"])
+        assert metadata
+        assert isinstance(metadata["HBDonor"]["indices"]["ligand"], tuple)
 
     def test_run_residues(self, fp_simple, u, ligand_ag, protein_ag):
         fp_simple.run(
-            u.trajectory[0:1], ligand_ag, protein_ag, residues="all", progress=False
+            u.trajectory[0:1],
+            ligand_ag,
+            protein_ag,
+            residues=["TYR109.A"],
+            progress=False,
         )
         lig_id = ResidueId.from_string("LIG1.G")
         assert hasattr(fp_simple, "ifp")
         assert len(fp_simple.ifp) == 1
-        res = ResidueId.from_string("LYS387.B")
+        res = ResidueId.from_string("TYR109.A")
         assert (lig_id, res) in fp_simple.ifp[0].keys()
         fp_simple.run(
             u.trajectory[1:2],
             ligand_ag,
             protein_ag,
-            residues=["ASP129.A"],
+            residues="all",
             progress=False,
         )
         assert hasattr(fp_simple, "ifp")
         assert len(fp_simple.ifp) == 1
-        res = ResidueId.from_string("ASP129.A")
-        assert (lig_id, res) in fp_simple.ifp[0].keys()
+        res = ResidueId.from_string("TRP125.A")
+        assert (lig_id, res) in fp_simple.ifp[1].keys()
         fp_simple.run(
             u.trajectory[:3], ligand_ag, protein_ag, residues=None, progress=False
         )
         assert hasattr(fp_simple, "ifp")
         assert len(fp_simple.ifp) == 3
         assert len(fp_simple.ifp[0]) > 1
-        res = ResidueId.from_string("VAL201.A")
+        res = ResidueId.from_string("ALA216.A")
         assert (lig_id, res) in fp_simple.ifp[0].keys()
         u.trajectory[0]
 
@@ -135,11 +111,12 @@ class TestFingerprint:
         )
         assert hasattr(fp_simple, "ifp")
         ifp = fp_simple.ifp[0]
-        ifp.pop("Frame")
-        data = list(ifp.values())[0]
-        assert isinstance(data[0], np.ndarray)
-        assert isinstance(data[1], list)
-        assert isinstance(data[2], list)
+        interactions = next(iter(ifp.values()))
+        assert isinstance(interactions, dict)
+        metadata = next(iter(interactions.values()))
+        assert all(
+            [key in metadata for key in ["indices", "parent_indices", "distance"]]
+        )
 
     def test_run_from_iterable(self, fp_simple, protein_mol):
         path = str(datapath / "vina" / "vina_output.sdf")
@@ -164,7 +141,7 @@ class TestFingerprint:
         df = fp_simple.to_dataframe(dtype=np.uint8)
         assert df.dtypes[0].type is np.uint8
         df = fp_simple.to_dataframe(drop_empty=False)
-        resids = set([key for d in fp_simple.ifp for key in d.keys() if key != "Frame"])
+        resids = set([key for d in fp_simple.ifp.values() for key in d.keys()])
         assert df.shape == (3, len(resids))
 
     def test_to_bv(self, fp_simple, u, ligand_ag, protein_ag):
@@ -221,17 +198,8 @@ class TestFingerprint:
         fp.run_from_iterable(lig_suppl[:2], protein_mol, progress=False)
         assert fp.interactions.keys() == fp_pkled.interactions.keys()
         assert len(fp.ifp) == len(fp_pkled.ifp)
-        for d1, d2 in zip(fp.ifp, fp_pkled.ifp):
-            d1.setdefault("Frame", None)
-            assert d1.keys() == d2.keys()
-            d1.pop("Frame", None)
-            d2.pop("Frame")
-            for (fp1, fpal1, fpap1), (fp2, fpal2, fpap2) in zip(
-                d1.values(), d2.values()
-            ):
-                assert (fp1 == fp2).all()
-                assert fpal1 == fpal2
-                assert fpap1 == fpap2
+        for frame_ifp, frame_pkl_ifp in zip(fp.ifp, fp_pkled.ifp):
+            assert frame_ifp == frame_pkl_ifp
 
     def test_pickle_custom_interaction(self, fp_unpkl):
         assert hasattr(fp_unpkl, "dummy")
diff --git a/tests/test_ifp.py b/tests/test_ifp.py
new file mode 100644
index 0000000..0e8d87f
--- /dev/null
+++ b/tests/test_ifp.py
@@ -0,0 +1,32 @@
+import pytest
+
+from prolif.fingerprint import Fingerprint
+from prolif.residue import ResidueId
+
+
+@pytest.fixture(scope="session")
+def ifp(u, ligand_ag, protein_ag):
+    fp = Fingerprint(["Hydrophobic", "VdWContact"])
+    fp.run(u.trajectory[0:1], ligand_ag, protein_ag)
+    return fp.ifp[0]
+
+
+def test_ifp_indexing(ifp):
+    lig_id, prot_id = "LIG1.G", "LEU126.A"
+    metadata1 = ifp[(ResidueId.from_string(lig_id), ResidueId.from_string(prot_id))]
+    metadata2 = ifp[(lig_id, prot_id)]
+    assert metadata1 is metadata2
+
+
+def test_ifp_filtering(ifp):
+    lig_id, prot_id = "LIG1.G", "LEU126.A"
+    assert ifp[lig_id] == ifp
+    assert (
+        next(iter(ifp[prot_id].values()))
+        == ifp[(ResidueId.from_string(lig_id), ResidueId.from_string(prot_id))]
+    )
+
+
+def test_wrong_key(ifp):
+    with pytest.raises(KeyError, match="does not correspond to a valid IFP key"):
+        ifp[0]
diff --git a/tests/test_interactions.py b/tests/test_interactions.py
index 98f5df6..ae714dd 100644
--- a/tests/test_interactions.py
+++ b/tests/test_interactions.py
@@ -1,7 +1,6 @@
 import MDAnalysis as mda
 import numpy as np
 import pytest
-from MDAnalysis.topology.tables import vdwradii
 from MDAnalysis.transformations import rotateby, translate
 from rdkit import Chem, RDLogger
 
@@ -15,17 +14,24 @@ from . import mol2factory
 lg = RDLogger.logger()
 lg.setLevel(RDLogger.ERROR)
 
-benzene = mda.Universe(prolif.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)
-benzene.transfer_to_memory()
 
-interaction_instances = {
-    name: cls()
-    for name, cls in _INTERACTIONS.items()
-    if name not in ["Interaction", "_Distance"]
-}
+@pytest.fixture(scope="module")
+def benzene():
+    benzene = mda.Universe(prolif.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)
+    benzene.transfer_to_memory()
+    return benzene
+
+
+@pytest.fixture(scope="module")
+def interaction_instances():
+    return {
+        name: cls()
+        for name, cls in _INTERACTIONS.items()
+        if name not in ["Interaction", "_Distance"]
+    }
 
 
 @pytest.fixture(scope="module")
@@ -39,7 +45,7 @@ def prot_mol(request):
 
 
 @pytest.fixture(scope="module")
-def interaction_qmol(request):
+def interaction_qmol(request, interaction_instances):
     int_name, parameter = request.param.split(".")
     return getattr(interaction_instances[int_name], parameter)
 
@@ -113,7 +119,7 @@ class TestInteractions:
     )
     def test_interaction(self, fingerprint, func_name, lig_mol, prot_mol, expected):
         interaction = getattr(fingerprint, func_name)
-        assert interaction(lig_mol, prot_mol) is expected
+        assert interaction(lig_mol[0], prot_mol[0]) is expected
 
     def test_warning_supersede(self):
         old = id(_INTERACTIONS["Hydrophobic"])
@@ -131,11 +137,12 @@ class TestInteractions:
             __doc__ = prolif.interactions.Hydrophobic.__doc__
 
     def test_error_no_detect(self):
-        class _Dummy(Interaction):
-            pass
+        with pytest.raises(
+            TypeError, match="Can't instantiate interaction class _Dummy"
+        ):
 
-        with pytest.raises(TypeError, match="Can't instantiate abstract class _Dummy"):
-            _Dummy()
+            class _Dummy(Interaction):
+                pass
 
     @pytest.mark.parametrize("index", [0, 1, 3, 42, 78])
     def test_get_mapindex(self, index, ligand_mol):
@@ -152,11 +159,23 @@ class TestInteractions:
     def test_vdwcontact_cache(self, lig_mol, prot_mol):
         vdw = VdWContact()
         assert vdw._vdw_cache == {}
-        vdw.detect(lig_mol, prot_mol)
+        vdw.detect(lig_mol[0], prot_mol[0])
         for (lig, res), value in vdw._vdw_cache.items():
-            vdw_dist = vdwradii[lig] + vdwradii[res] + vdw.tolerance
+            vdw_dist = vdw.vdwradii[lig] + vdw.vdwradii[res] + vdw.tolerance
             assert vdw_dist == value
 
+    @pytest.mark.parametrize(
+        "lig_mol, prot_mol", [("benzene", "cation")], indirect=["lig_mol", "prot_mol"]
+    )
+    def test_vdwcontact_vdwradii_update(self, lig_mol, prot_mol):
+        class CustomVdW(VdWContact):
+            def __init__(self, tolerance=0, vdwradii={"Na": 0}):
+                super().__init__(tolerance, vdwradii)
+
+        metadata = CustomVdW().detect(lig_mol[0], prot_mol[0])
+        assert metadata is None
+        _INTERACTIONS.pop("CustomVdW")
+
     @pytest.mark.parametrize(
         ["interaction_qmol", "smiles", "expected"],
         [
@@ -260,8 +279,8 @@ class TestInteractions:
             ([0, 1.5, -4.5], [75, 0, 0], "edgetoface", False),
         ],
     )
-    def test_pi_stacking(self, xyz, rotation, pi_type, expected, fingerprint):
-        r1, r2 = self.create_rings(xyz, rotation)
+    def test_pi_stacking(self, benzene, xyz, rotation, pi_type, expected, fingerprint):
+        r1, r2 = self.create_rings(benzene, xyz, rotation)
         assert getattr(fingerprint, pi_type)(r1, r2) is expected
         if expected is True:
             other = "edgetoface" if pi_type == "facetoface" else "facetoface"
@@ -269,7 +288,7 @@ class TestInteractions:
             assert getattr(fingerprint, "pistacking")(r1, r2) is expected
 
     @staticmethod
-    def create_rings(xyz, rotation):
+    def create_rings(benzene, xyz, rotation):
         r2 = benzene.copy()
         r2.segments.segids = np.array(["U2"], dtype=object)
         tr = translate(xyz)
@@ -277,10 +296,33 @@ class TestInteractions:
         roty = rotateby(rotation[1], [0, 1, 0], ag=r2.atoms)
         rotz = rotateby(rotation[2], [0, 0, 1], ag=r2.atoms)
         r2.trajectory.add_transformations(tr, rotx, roty, rotz)
-        return prolif.Molecule.from_mda(benzene), prolif.Molecule.from_mda(r2)
+        return prolif.Molecule.from_mda(benzene)[0], prolif.Molecule.from_mda(r2)[0]
 
     def test_edgetoface_phe331(self, ligand_mol, protein_mol):
         fp = Fingerprint()
         lig, phe331 = ligand_mol[0], protein_mol["PHE331.B"]
         assert fp.edgetoface(lig, phe331) is True
         assert fp.pistacking(lig, phe331) is True
+
+    def test_copy_parameters(self):
+        class Dummy1(VdWContact):
+            def __init__(self, tolerance=1):
+                super().__init__(tolerance)
+
+        class Dummy2(Dummy1):
+            def __init__(self, tolerance=2):
+                super().__init__(tolerance)
+
+        assert Dummy1().tolerance == 1
+        assert Dummy2().tolerance == 2
+
+        DummyUpdated = Dummy2.update_parameters(Dummy1)
+        assert DummyUpdated().tolerance == 1
+        assert _INTERACTIONS["Dummy2"] is DummyUpdated
+
+        DummyUpdated = Dummy1.update_parameters(Dummy2)
+        assert DummyUpdated().tolerance == 2
+        assert _INTERACTIONS["Dummy1"] is DummyUpdated
+
+        for name in ["Dummy1", "Dummy2"]:
+            _INTERACTIONS.pop(name)
diff --git a/tests/test_residues.py b/tests/test_residues.py
index 5119ee2..4130a04 100644
--- a/tests/test_residues.py
+++ b/tests/test_residues.py
@@ -204,20 +204,20 @@ class TestResidueGroup:
 
     def test_select(self, protein_mol):
         rg = protein_mol.residues
-        assert rg.select(rg.name == "LYS").n_residues == 16
-        assert rg.select(rg.number == 300).n_residues == 1
+        assert rg.select(rg.name == "TYR").n_residues == 6
+        assert rg.select(rg.number == 109).n_residues == 1
         assert rg.select(rg.number == 1).n_residues == 0
-        assert rg.select(rg.chain == "B").n_residues == 90
+        assert rg.select(rg.chain == "A").n_residues == 36
         # and
-        assert rg.select((rg.chain == "B") & (rg.name == "ALA")).n_residues == 7
+        assert rg.select((rg.chain == "A") & (rg.name == "TYR")).n_residues == 5
         # or
-        assert rg.select((rg.chain == "B") | (rg.name == "ALA")).n_residues == 110
+        assert rg.select((rg.chain == "A") | (rg.name == "TYR")).n_residues == 37
         # xor
-        assert rg.select((rg.chain == "B") ^ (rg.name == "ALA")).n_residues == 103
+        assert rg.select((rg.chain == "A") ^ (rg.name == "TYR")).n_residues == 32
         # not
-        assert rg.select(~(rg.chain == "B")).n_residues == 212
+        assert rg.select(~(rg.chain == "A")).n_residues == 20
 
     def test_select_sameas_getitem(self, protein_mol):
         rg = protein_mol.residues
-        sel = rg.select((rg.name == "LYS") & (rg.number == 49))[0]
-        assert sel.resid is rg["LYS49.A"].resid
+        sel = rg.select((rg.name == "TYR") & (rg.number == 38))[0]
+        assert sel.resid is rg["TYR38.A"].resid
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 6951d4d..c413b92 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -19,6 +19,42 @@ from prolif.utils import (
 )
 
 
+@pytest.fixture
+def ifp_bitvector():
+    return {
+        0: {
+            ("LIG", "ALA1"): np.array([True, False, False]),
+            ("LIG", "GLU2"): np.array([False, True, False]),
+        },
+        1: {
+            ("LIG", "ALA1"): np.array([True, True, False]),
+            ("LIG", "ASP3"): np.array([False, True, False]),
+        },
+    }
+
+
+@pytest.fixture
+def ifp_metadata():
+    return {
+        0: {
+            ("LIG", "ALA1"): {"A": {"indices": {"ligand": (0,), "protein": (1,)}}},
+            ("LIG", "GLU2"): {"B": {"indices": {"ligand": (1,), "protein": (3,)}}},
+        },
+        1: {
+            ("LIG", "ALA1"): {
+                "A": {"indices": {"ligand": (2,), "protein": (4,)}},
+                "B": {"indices": {"ligand": (2,), "protein": (5,)}},
+            },
+            ("LIG", "ASP3"): {"B": {"indices": {"ligand": (8,), "protein": (10,)}}},
+        },
+    }
+
+
+@pytest.fixture(params=["ifp_bitvector", "ifp_metadata"])
+def ifp(request):
+    return request.getfixturevalue(request.param)
+
+
 def test_centroid():
     xyz = np.array(
         [(0, 0, 0), (0, 0, 0), (0, 0, 0), (2, 2, 2), (2, 2, 2), (2, 2, 2)],
@@ -146,34 +182,7 @@ def test_series_to_bv():
     assert bv.GetNumOnBits() == 3
 
 
-ifp = [
-    {
-        "Frame": 0,
-        ("LIG", "ALA1"): np.array([True, False, False]),
-        ("LIG", "GLU2"): np.array([False, True, False]),
-    },
-    {
-        "Frame": 1,
-        ("LIG", "ALA1"): np.array([True, True, False]),
-        ("LIG", "ASP3"): np.array([False, True, False]),
-    },
-]
-
-ifp_atoms = [
-    {
-        "Frame": 0,
-        ("LIG", "ALA1"): [[1, 0, 0], [0, None, None], [1, None, None]],
-        ("LIG", "GLU2"): [[0, 1, 0], [None, 1, None], [None, 3, None]],
-    },
-    {
-        "Frame": 1,
-        ("LIG", "ALA1"): [[1, 1, 0], [2, 2, None], [4, 5, None]],
-        ("LIG", "ASP3"): [[0, 1, 0], [None, 8, None], [None, 10, None]],
-    },
-]
-
-
-def test_to_df():
+def test_to_df(ifp):
     df = to_dataframe(ifp, ["A", "B", "C"])
     assert df.shape == (2, 4)
     assert df.dtypes[0].type is np.bool_
@@ -188,20 +197,6 @@ def test_to_df():
     assert df[("LIG", "ASP3", "B")][0] is np.bool_(False)
 
 
-def test_to_df_atom_pairs():
-    df = to_dataframe(ifp_atoms, ["A", "B", "C"], return_atoms=True)
-    assert df.shape == (2, 4)
-    assert df.index.name == "Frame"
-    assert ("LIG", "ALA1", "A") in df.columns
-    assert df[("LIG", "ALA1", "A")][0] == (0, 1)
-    assert ("LIG", "ALA1", "B") in df.columns
-    assert df[("LIG", "ALA1", "B")][0] == (None, None)
-    assert ("LIG", "ALA1", "C") not in df.columns
-    assert ("LIG", "GLU2", "A") not in df.columns
-    assert ("LIG", "ASP3", "B") in df.columns
-    assert df[("LIG", "ASP3", "B")][0] == (None, None)
-
-
 @pytest.mark.parametrize(
     "dtype",
     [
@@ -210,7 +205,7 @@ def test_to_df_atom_pairs():
         np.bool_,
     ],
 )
-def test_to_df_dtype(dtype):
+def test_to_df_dtype(dtype, ifp):
     df = to_dataframe(ifp, ["A", "B", "C"], dtype=dtype)
     assert df.dtypes[0].type is dtype
     assert df[("LIG", "ALA1", "A")][0] == dtype(True)
@@ -218,37 +213,24 @@ def test_to_df_dtype(dtype):
     assert df[("LIG", "ASP3", "B")][0] == dtype(False)
 
 
-def test_to_df_drop_empty():
+def test_to_df_drop_empty(ifp):
     df = to_dataframe(ifp, ["A", "B", "C"], drop_empty=False)
     assert df.shape == (2, 9)
 
 
-def test_to_df_raise_dtype_return_atoms():
-    with pytest.raises(
-        ValueError, match="`dtype` cannot be used with `return_atoms=True`"
-    ):
-        to_dataframe(ifp_atoms, ["A", "B", "C"], dtype=int, return_atoms=True)
-
-
-def test_to_df_raise_return_atoms_if_only_bitvector():
-    with pytest.raises(ValueError, match="doesn't contain atom indices"):
-        to_dataframe(ifp, ["A", "B", "C"], return_atoms=True)
-
-
-@pytest.mark.parametrize("ifp", [ifp, ifp_atoms])
-def test_to_df_no_interaction_in_first_frame(ifp):
-    fp = deepcopy(ifp)
-    fp[0] = {"Frame": 0}
+def test_to_df_no_interaction_in_first_frame(ifp_metadata):
+    fp = deepcopy(ifp_metadata)
+    fp[0] = {}
     to_dataframe(fp, ["A", "B", "C"])
 
 
 def test_to_df_empty_ifp():
-    ifp = [{"Frame": 0}, {"Frame": 1}]
+    ifp = {0: {}, 1: {}}
     df = to_dataframe(ifp, ["A"])
     assert df.to_numpy().shape == (2, 0)
 
 
-def test_to_bv():
+def test_to_bv(ifp):
     df = to_dataframe(ifp, ["A", "B", "C"])
     bvs = to_bitvectors(df)
     assert len(bvs) == 2