Repository: aeye-lab/pymovements
Pull request #282 (2023-03-14 13:25:47)

Feature: main sequence plot
## Description

Implemented function to plot main sequence + added notebook how it is used.

Fixes issue #29

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [x] notebook to show usage of main sequence plotting function
- [x] main sequence plotting function

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?
 
Unit tests have been added such that the coverage is again at 100%.

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] I have checked my code and corrected any misspellings


codecov-commenter: ## [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/282?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`137a5af`)](https://codecov.io/gh/aeye-lab/pymovements/commit/137a5af0742935c30f63b7539fcf8e92fc10893c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`dca5cf7`)](https://codecov.io/gh/aeye-lab/pymovements/pull/282?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

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


```diff
@@            Coverage Diff            @@
##              main      #282   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           32        33    +1     
  Lines         1310      1334   +24     
  Branches       307       309    +2     
=========================================
+ Hits          1310      1334   +24     
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/282?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/\_\_init\_\_.py](https://codecov.io/gh/aeye-lab/pymovements/pull/282?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% <ø> (ø)` | |
| [src/pymovements/plotting/main\_sequence\_plot.py](https://codecov.io/gh/aeye-lab/pymovements/pull/282?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3Bsb3R0aW5nL21haW5fc2VxdWVuY2VfcGxvdC5weQ==) | `100.00% <100.00%> (ø)` | |

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/282?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).


Patch:
diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst
index 530c4d7..8747141 100644
--- a/docs/source/tutorials/index.rst
+++ b/docs/source/tutorials/index.rst
@@ -10,3 +10,4 @@ These How-Tos provide detailed guides to implement the most important use cases
     :maxdepth: 1
 
     dataset
+    plots
diff --git a/docs/source/tutorials/plots.ipynb b/docs/source/tutorials/plots.ipynb
new file mode 100644
index 0000000..43ca8c4
--- /dev/null
+++ b/docs/source/tutorials/plots.ipynb
@@ -0,0 +1,153 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Plot main sequence\n",
+    "\n",
+    "In this notebook we show how you can load a dataset, compute all the necessary properties and the plot the main sequence."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pymovements as pm\n",
+    "from pymovements.events import microsaccades\n",
+    "from pymovements.plotting.main_sequence_plot import main_sequence_plot"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": []
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "First, you have to define your dataset. You can already download and extract all the files."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset = pm.datasets.toy_dataset.ToyDataset(\n",
+    "    root='data/', download=True, extract=True, remove_finished=True)\n",
+    "dataset.load()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now, you have to convert the raw x and y coordinates in pixels to degrees in visual angel. Then we can convert these positions into velocitites."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# convert raw coordinates in pixels into degrees of visual angle\n",
+    "dataset.pix2deg()\n",
+    "\n",
+    "# convert positions into velocities\n",
+    "dataset.pos2vel()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "As we have the velocities, we can run our microsaccade detection algorithm. We then save the detected events."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.detect_events(microsaccades)\n",
+    "dataset.save_events()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "As the next step we compute the event properties 'amplitude' and 'peak velocity' for the detected saccades."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dataset.compute_event_properties(['amplitude', 'peak_velocity'])\n",
+    "dataset.save()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We load the event files which we can then use as input for the plotting function. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "event_dfs = dataset.load_event_files()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# only showing the first three event dataframes here.\n",
+    "for event_df in event_dfs:\n",
+    "    print(\n",
+    "        f'Showing main sequence plot for '\n",
+    "        f'text {event_df[0, \"text_id\"]}, '\n",
+    "        f'page {event_df[0, \"page_id\"]}:')\n",
+    "    main_sequence_plot(event_df)"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 1
+}
diff --git a/src/pymovements/plotting/__init__.py b/src/pymovements/plotting/__init__.py
index 267ce7c..05906dd 100644
--- a/src/pymovements/plotting/__init__.py
+++ b/src/pymovements/plotting/__init__.py
@@ -26,11 +26,14 @@
 
     pymovements.plotting.traceplot
     pymovements.plotting.tsplot
+    pymovements.plotting.main_sequence_plot
 """
+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__ = [
     'traceplot',
     'tsplot',
+    'main_sequence_plot',
 ]
diff --git a/src/pymovements/plotting/main_sequence_plot.py b/src/pymovements/plotting/main_sequence_plot.py
new file mode 100644
index 0000000..7dc66ff
--- /dev/null
+++ b/src/pymovements/plotting/main_sequence_plot.py
@@ -0,0 +1,92 @@
+# 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.
+"""
+This module holds the main sequence plot.
+"""
+from __future__ import annotations
+
+import matplotlib.pyplot as plt
+import polars as pl
+from polars import ColumnNotFoundError
+
+
+def main_sequence_plot(
+        data: pl.DataFrame,
+        figsize: tuple[int, int] = (15, 5),
+        title: str | None = None,
+        savepath: str | None = None,
+        show: bool = True,
+) -> None:
+    """
+    Plots the saccade main sequence.
+
+    Parameters
+    ----------
+    data:
+        Data frame. It must contain columns "peak_velocity" and "amplitude".
+    figsize: tuple
+        Figure size.
+    title: str, optional
+        Figure title.
+    savepath: str, optional
+        If given, figure will be saved to this path.
+    show: bool
+        If True, figure will be shown.
+
+    Raises
+    ------
+    KeyError
+        If the input dataframe has no 'amplitude' and/or 'peak_velocity' column.
+        Those are needed to create the plot.
+    """
+
+    try:
+        peak_velocities = data['peak_velocity'].to_list()
+    except ColumnNotFoundError as exc:
+        raise KeyError(
+            'The input dataframe you provided does not contain '
+            'the saccade peak velocities which are needed to create '
+            'the main sequence plot. ',
+        ) from exc
+
+    try:
+        amplitudes = data['amplitude'].to_list()
+    except ColumnNotFoundError as exc:
+        raise KeyError(
+            'The input dataframe you provided does not contain '
+            'the saccade amplitudes which are needed to create '
+            'the main sequence plot. ',
+        ) from exc
+
+    fig = plt.figure(figsize=figsize)
+
+    plt.scatter(amplitudes, peak_velocities, color='purple', alpha=0.5)
+
+    plt.title(title)
+    plt.xlabel('Amplitude [dva]')
+    plt.ylabel('Peak Velocity [dva/s]')
+
+    if savepath is not None:
+        fig.savefig(savepath)
+
+    if show:
+        plt.show()
+
+    plt.close(fig)


Test patch:
diff --git a/tests/plotting/main_sequence_plot_test.py b/tests/plotting/main_sequence_plot_test.py
new file mode 100644
index 0000000..ced9a69
--- /dev/null
+++ b/tests/plotting/main_sequence_plot_test.py
@@ -0,0 +1,132 @@
+# 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 main_sequence_plot."""
+from unittest.mock import Mock
+
+import numpy as np
+import polars as pl
+import pytest
+from matplotlib import pyplot as plt
+
+from pymovements.plotting.main_sequence_plot import main_sequence_plot
+
+
+@pytest.mark.parametrize(
+    ('input_df', 'show'),
+    [
+        pytest.param(
+            pl.DataFrame(
+                {
+                    'amplitude': [1.0, 1.0, 2.0, 2.0, 3.0, 4.0],
+                    'peak_velocity': [10.0, 11.0, 12.0, 11.0, 13.0, 13.0],
+                },
+            ),
+            True,
+            id='show_plot',
+        ),
+    ],
+)
+def test_main_sequence_plot_show_plot(input_df, show, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    main_sequence_plot(input_df, show=show)
+    mock.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    'input_df',
+    [
+        pytest.param(
+            pl.DataFrame(
+                {
+                    'amplitude': np.arange(100),
+                    'peak_velocity': np.linspace(10, 50, num=100),
+                },
+            ),
+            id='save_path',
+        ),
+    ],
+)
+def test_main_sequence_plot_save_path(input_df, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt.Figure, 'savefig', mock)
+    main_sequence_plot(input_df, show=False, savepath='mock')
+    mock.assert_called_once()
+
+
+@pytest.mark.parametrize(
+    ('input_df', 'show'),
+    [
+        pytest.param(
+            pl.DataFrame(
+                {
+                    'amplitude': np.arange(100),
+                    'peak_velocity': np.linspace(10, 50, num=100),
+                },
+            ),
+            False,
+            id='do_not_show_plot',
+        ),
+    ],
+)
+def test_main_sequence_plot_not_show(input_df, show, monkeypatch):
+    mock = Mock()
+    monkeypatch.setattr(plt, 'show', mock)
+    main_sequence_plot(input_df, show=show)
+    mock.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    ('input_df', 'expected_error', 'error_msg'),
+    [
+        pytest.param(
+            pl.DataFrame(
+                {
+                    'peak_velocity': np.linspace(10, 50, num=100),
+                },
+            ),
+            KeyError,
+            'The input dataframe you provided does not contain '
+            'the saccade amplitudes which are needed to create '
+            'the main sequence plot. ',
+            id='amplitude_missing',
+        ),
+        pytest.param(
+            pl.DataFrame(
+                {
+                    'amplitude': np.arange(100),
+                },
+            ),
+            KeyError,
+            'The input dataframe you provided does not contain '
+            'the saccade peak velocities which are needed to create '
+            'the main sequence plot. ',
+            id='peak_velocity_missing',
+        ),
+    ],
+)
+def test_main_sequence_plot_not_show_plot(input_df, expected_error, error_msg):
+
+    with pytest.raises(expected_error) as actual_error:
+        main_sequence_plot(input_df)
+
+    msg, = actual_error.value.args
+
+    assert msg == error_msg