Repository: aeye-lab/pymovements
Pull request #276 (2023-03-13 20:29:56)

Add Heatmap plotting function and notebook for demonstration (#31)
## Description

I implemented a heatmap function using `histogram2d` and `imshow` and a very very basic notebook. I used imshow cause i thought  it has better interpolation capabilities than `pcolormesh`. Maybe you have to correct me. 
Please have a look and tell me what needs to be adjusted.

Fixes issue #31

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [x] Implement heatmap function
- [x] tutorial Notebook

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

This has just been tested by using the function in the tutorial notebook

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


jakobchwastek: > I would just like to remove the `origin` argument from the plotting function.
> this should be inferable from the `experiment.screen.origin` shouldn't it?
> 
> Is this related to the bug which you mentioned, where the origin should actually be `upper left` instead `lower left`?

Yes, but i think when data is loaded with the correct origin in the first place it is not necessary to use origin here at all. How I understand all data should have the same coordinates system when choosing the correct origin in the Experiment/Screen class.

So should I remove argument or leave it for now?
codecov-commenter: ## [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`17.94`**% and project coverage change: **`-2.38`** :warning:
> Comparison is base [(`e0e1729`)](https://codecov.io/gh/aeye-lab/pymovements/commit/e0e1729f17d1a8234acd4590e8c505321bdace34?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`a4d62c2`)](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 97.62%.

> :exclamation: Current head a4d62c2 differs from pull request most recent head cdb70bb. Consider uploading reports for the commit cdb70bb to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #276      +/-   ##
===========================================
- Coverage   100.00%   97.62%   -2.38%     
===========================================
  Files           33       33              
  Lines         1339     1349      +10     
  Branches       309      316       +7     
===========================================
- Hits          1339     1317      -22     
- Misses           0       32      +32     
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/plotting/heatmap.py](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3Bsb3R0aW5nL2hlYXRtYXAucHk=) | `15.78% <15.78%> (ø)` | |
| [src/pymovements/plotting/\_\_init\_\_.py](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3Bsb3R0aW5nL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |

... and [13 files with indirect coverage changes](https://codecov.io/gh/aeye-lab/pymovements/pull/276/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab)

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=aeye-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=aeye-lab)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/aeye-lab/pymovements/pull/276?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

dkrako: > Looks like there is a problem because GazeDataFrame.experiment is optional..

Hmm.. okay then for this PR let's just raise an exception if experiment is None
dkrako: If mypy still doesn't get it, just add a line with `assert gaze.experiment is not None`

we will fix this in a future PR

Patch:
diff --git a/docs/source/tutorials/heatmap.ipynb b/docs/source/tutorials/heatmap.ipynb
new file mode 100644
index 0000000..a564991
--- /dev/null
+++ b/docs/source/tutorials/heatmap.ipynb
@@ -0,0 +1,181 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Heatmap Plotting\n",
+    "\n",
+    "In this tutorial, we will demonstrate how to use the `heatmap` function from the `pymovements.plotting` module to create a heatmap of gaze data. The heatmap will show the distribution of gaze positions across the experiment screen, with color values indicating the time spent at each position in seconds."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pymovements as pm"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Loading the Dataset\n",
+    "\n",
+    "For demonstration purposes, we will use the `ToyDataset` from `pymovements.datasets`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Load Data\n",
+    "dataset = pm.datasets.ToyDataset(\n",
+    "    root='data/',\n",
+    "    download=True,\n",
+    "    extract=True,\n",
+    "    remove_finished=True,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "After loading the dataset, we will transform the pixel coordinates to degrees."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.load()\n",
+    "dataset.pix2deg()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now our available columns for each GazeDataFrame are the following. We will use the `x_right_pos` and `y_right_pos` columns for plotting. However, it is also possible to use the pixel data columns `x_right_pix` and `y_right_pix`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.gaze[5].frame"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Creating a Heatmap\n",
+    "\n",
+    "Let's create a heatmap using the `heatmap` function from the `pymovements` library. We will use the default `gridsize` of 10x10 with interpolation and display the colorbar."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "figure = pm.plotting.heatmap(\n",
+    "    gaze=dataset.gaze[5],\n",
+    "    position_columns=('x_right_pix', 'y_right_pix'),\n",
+    "    origin='upper',\n",
+    "    show_cbar=True,\n",
+    "    cbar_label='Time [s]',\n",
+    "    title='Gaze Heatmap with Interpolation On',\n",
+    "    xlabel='X [pix]',\n",
+    "    ylabel='Y [pix]',\n",
+    "    gridsize=[10, 10]\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To better understand the effect of the `gridsize` parameter on the heatmap, we can turn off the interpolation. By doing this, we can clearly visualize the individual bins used to calculate the heatmap. With interpolation turned off, the heatmap will display the raw bin values rather than a smoothed representation."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "figure = pm.plotting.heatmap(\n",
+    "    dataset.gaze[5],\n",
+    "    position_columns=('x_right_pix', 'y_right_pix'),\n",
+    "    origin='upper',\n",
+    "    show_cbar=True,\n",
+    "    cbar_label='Time [s]',\n",
+    "    title='Gaze Heatmap with Interpolation Off',\n",
+    "    xlabel='X [pix]',\n",
+    "    ylabel='Y [pix]',\n",
+    "    gridsize=[10, 10],\n",
+    "    interpolation='none'\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Increasing the `gridsize` parameter results in a finer grid and more detailed heatmap representation. With a higher grid size, we divide the plot into smaller bins, which can capture more nuances in the data distribution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "figure = pm.plotting.heatmap(\n",
+    "    dataset.gaze[5],\n",
+    "    position_columns=('x_right_pix', 'y_right_pix'),\n",
+    "    origin='upper',\n",
+    "    show_cbar=True,\n",
+    "    cbar_label='Time [s]',\n",
+    "    title='Gaze Heatmap with Higher Grid Size',\n",
+    "    xlabel='X [pix]',\n",
+    "    ylabel='Y [pix]',\n",
+    "    gridsize=[25, 25]\n",
+    ")"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 2
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython2",
+   "version": "2.7.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst
index 3fe5835..9663236 100644
--- a/docs/source/tutorials/index.rst
+++ b/docs/source/tutorials/index.rst
@@ -17,3 +17,4 @@ These How-Tos provide detailed guides to implement the most important use cases
     event-properties
     saving-loading-events
     plot-main-sequence
+    heatmap
diff --git a/src/pymovements/plotting/__init__.py b/src/pymovements/plotting/__init__.py
index 05906dd..574ec5d 100644
--- a/src/pymovements/plotting/__init__.py
+++ b/src/pymovements/plotting/__init__.py
@@ -17,22 +17,14 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-"""This module holds all plotting related functionality.
-
-.. rubric:: Functions
-
-.. autosummary::
-   :toctree:
-
-    pymovements.plotting.traceplot
-    pymovements.plotting.tsplot
-    pymovements.plotting.main_sequence_plot
-"""
+"""This module holds all plotting related functionality."""
+from pymovements.plotting.heatmap import heatmap  # noqa: F401
 from pymovements.plotting.main_sequence_plot import main_sequence_plot  # noqa: F401
 from pymovements.plotting.traceplot import traceplot  # noqa: F401
 from pymovements.plotting.tsplot import tsplot  # noqa: F401
 
 __all__ = [
+    'heatmap',
     'traceplot',
     'tsplot',
     'main_sequence_plot',
diff --git a/src/pymovements/plotting/heatmap.py b/src/pymovements/plotting/heatmap.py
new file mode 100644
index 0000000..924d891
--- /dev/null
+++ b/src/pymovements/plotting/heatmap.py
@@ -0,0 +1,166 @@
+# Copyright (c) 2022-2023 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""
+Heatmap module.
+"""
+from __future__ import annotations
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib import colors
+
+from pymovements.gaze import GazeDataFrame
+
+
+def heatmap(
+    gaze: GazeDataFrame,
+    position_columns: tuple[str, str] = ('x_pix', 'y_pix'),
+    gridsize=(10, 10),
+    cmap: colors.Colormap | str = 'jet',
+    interpolation: str = 'gaussian',
+    origin: str = 'lower',
+    figsize: tuple[float, float] = (15, 10),
+    cbar_label: str | None = None,
+    show_cbar: bool = True,
+    title: str | None = None,
+    xlabel: str | None = None,
+    ylabel: str | None = None,
+    show: bool = True,
+    savepath: str | None = None,
+) -> plt.Figure:
+    """Plot a heatmap of gaze data.
+
+    The heatmap displays the distribution of gaze positions across the experiment screen,
+    for a given GazeDataFrame object.
+    The color values indicate the time spent at each position in seconds.
+
+    Parameters
+    ----------
+    gaze : GazeDataFrame
+        A GazeDataFrame object.
+    position_columns : tuple[str, str], optional
+        The column names of the x and y position data
+    gridsize : tuple[int, int], optional
+        The number of bins in the x and y dimensions.
+    cmap : colors.Colormap | str, optional
+        The colormap to use
+    interpolation : str, optional
+        The interpolation method to use for plotting the heatmap.
+        See matplotlib.pyplot.imshow for more information on available methods
+        for interpolation. By default, 'gaussian' is used.
+    origin : str, optional
+        origin of y-axis, valid values are 'lower' or 'upper'
+    figsize : tuple[float, float], optional
+        Figure size
+    cbar_label : str | None, optional
+        Label for the colorbar
+    show_cbar : bool, optional
+        Whether to show the colorbar.
+    title : str | None, optional
+        Figure title
+    xlabel : str | None, optional
+        x-axis label
+    ylabel : str | None, optional
+        y-axis label
+    show : bool, optional
+        Whether to show the plot
+    savepath : str | None, optional
+        If provided, the figure will be saved to this path
+
+    Raises
+    ------
+    ValueError
+        If the position columns are not in pixels or degrees
+    ValueError
+        If the experiment property of the GazeDataFrame is None
+    Returns
+    -------
+    plt.Figure
+        The heatmap figure.
+    """
+
+    # Extract x and y positions from the gaze dataframe
+    x = gaze.frame[position_columns[0]]
+    y = gaze.frame[position_columns[1]]
+
+    # Check if experiment properties are available
+    if not gaze.experiment:
+        raise ValueError(
+            'Experiment property of GazeDataFrame is None. '
+            'GazeDataFrame must be associated with an experiment.',
+        )
+
+    # Get experiment screen properties
+    screen = gaze.experiment.screen
+
+    # Use screen properties to define the grid or degrees of visual angle
+    if 'pix' in position_columns[0] and 'pix' in position_columns[1]:
+        xmin, xmax = 0, screen.width_px
+        ymin, ymax = 0, screen.height_px
+    else:
+        xmin, xmax = int(screen.x_min_dva), int(screen.x_max_dva)
+        ymin, ymax = int(screen.y_min_dva), int(screen.y_max_dva)
+
+    # Define the grid and bin the gaze data
+    x_bins = np.linspace(xmin, xmax, num=gridsize[0]).astype(int)
+    y_bins = np.linspace(ymin, ymax, num=gridsize[1]).astype(int)
+
+    # Bin the gaze data
+    heatmap_value, x_edges, y_edges = np.histogram2d(x, y, bins=[x_bins, y_bins])
+
+    # Transpose to match the orientation of the screen
+    heatmap_value = heatmap_value.T
+
+    # Convert heatmap values from sample count to seconds
+    heatmap_value /= gaze.experiment.sampling_rate
+
+    # Create the plot
+    fig, ax = plt.subplots(figsize=figsize)
+
+    # Plot the heatmap
+    heatmap_plot = ax.imshow(
+        heatmap_value,
+        cmap=cmap,
+        origin=origin,
+        interpolation=interpolation,
+        extent=[x_edges[0], x_edges[-1], y_edges[0], y_edges[-1]],
+    )
+
+    # Set the plot title and axis labels
+    if title:
+        ax.set_title(title)
+    if xlabel:
+        ax.set_xlabel(xlabel)
+    if ylabel:
+        ax.set_ylabel(ylabel)
+
+    # Add a color bar to the plot
+    if show_cbar:
+        cbar = fig.colorbar(heatmap_plot, ax=ax)
+        if cbar_label:
+            cbar.set_label(cbar_label)
+
+    # Show or save the plot
+    if savepath:
+        plt.savefig(savepath)
+    if show:
+        plt.show()
+
+    return fig


Test patch:
diff --git a/tests/plotting/heatmap_test.py b/tests/plotting/heatmap_test.py
new file mode 100644
index 0000000..b0a2ab9
--- /dev/null
+++ b/tests/plotting/heatmap_test.py
@@ -0,0 +1,155 @@
+# Copyright (c) 2023 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test heatmap."""
+from unittest.mock import Mock
+
+import matplotlib.colors
+import matplotlib.pyplot as plt
+import numpy as np
+import polars as pl
+import pytest
+from matplotlib import figure
+from polars import ColumnNotFoundError
+
+from pymovements.gaze import Experiment
+from pymovements.gaze.gaze_dataframe import GazeDataFrame
+from pymovements.plotting import heatmap
+
+
+@pytest.fixture(name='experiment_fixture')
+def fixture_experiment():
+    return Experiment(1024, 768, 38, 30, 60, 'center', 1000)
+
+
+# Fixture for x and y position columns
+@pytest.fixture(
+    name='column_names_fixture',
+    params=[('x_pos', 'y_pos'), ('x_pix', 'y_pix')],
+)
+def fixture_column_names(request):
+    return request.param
+
+
+@pytest.fixture(name='args')
+def args_fixture(experiment_fixture, column_names_fixture):
+    # Init a dataframe with 2 columns and 100 rows
+    df = pl.DataFrame(
+        {
+            column_names_fixture[0]: np.arange(0, 100),
+            column_names_fixture[1]: np.arange(0, 100),
+        },
+    )
+
+    # Init a GazeDataFrame
+    gdf = GazeDataFrame(data=df, experiment=experiment_fixture)
+
+    return {'gaze': gdf, 'position_columns': column_names_fixture}
+
+
+@pytest.mark.parametrize(
+    'kwargs',
+    [
+        pytest.param(
+            {'cmap': 'jet'}, id='str_cmap',
+        ),
+        pytest.param(
+            {'cmap': matplotlib.colors.ListedColormap(['red', 'blue', 'green'])}, id='custom_cmap',
+        ),
+        pytest.param(
+            {'gridsize': (10, 10)}, id='default_gridsize',
+        ),
+        pytest.param(
+            {'gridsize': (15, 20)}, id='custom_gridsize',
+        ),
+        pytest.param(
+            {'interpolation': 'gaussian'}, id='default_interpolation',
+        ),
+        pytest.param(
+            {'interpolation': 'bilinear'}, id='custom_interpolation',
+        ),
+        pytest.param(
+            {'origin': 'lower'}, id='default_origin',
+        ),
+        pytest.param(
+            {'origin': 'upper'}, id='custom_origin',
+        ),
+        pytest.param(
+            {
+                'title': None,
+                'xlabel': None,
+                'ylabel': None,
+                'cbar_label': None,
+            }, id='default_labels',
+        ),
+        pytest.param(
+            {
+                'title': 'Custom Title',
+                'xlabel': 'Custom X Label',
+                'ylabel': 'Custom Y Label',
+                'cbar_label': 'Custom Colorbar Label',
+            },
+            id='custom_labels',
+        ),
+        pytest.param(
+            {'show_cbar': True}, id='show_cbar_true',
+        ),
+        pytest.param(
+            {'show_cbar': False}, id='show_cbar_false',
+        ),
+    ],
+)
+def test_heatmap_show(args, kwargs, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    heatmap(**args, **kwargs)
+    mock.assert_called_once()
+
+
+def test_heatmap_noshow(args, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    heatmap(**args, show=False)
+    mock.assert_not_called()
+
+
+def test_heatmap_save(args, monkeypatch, tmp_path):
+    mock = Mock()
+    monkeypatch.setattr(figure.Figure, 'savefig', mock)
+    heatmap(**args, show=False, savepath=str(tmp_path / 'test.svg'))
+    mock.assert_called_once()
+
+
+def test_heatmap_invalid_position_columns(args):
+    with pytest.raises(ColumnNotFoundError):
+        heatmap(gaze=args['gaze'], position_columns=('x_invalid', 'y_invalid'), show=False)
+
+
+def test_heatmap_no_experiment_property():
+    df = pl.DataFrame(
+        {
+            'x_pix': np.arange(0, 100),
+            'y_pix': np.arange(0, 100),
+        },
+    )
+
+    gdf = GazeDataFrame(data=df, experiment=None)
+
+    with pytest.raises(ValueError):
+        heatmap(gdf, show=False)