Repository: aeye-lab/pymovements
Pull request #338 (2023-04-27 07:25:29)

feat(plot): Use GazeDataFrame in tsplot

codecov-commenter: ## [Codecov](https://codecov.io/gh/aeye-lab/pymovements/pull/338?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Patch coverage: **`81.25`**% and project coverage change: **`-0.19`** :warning:
> Comparison is base [(`b989872`)](https://codecov.io/gh/aeye-lab/pymovements/commit/b989872fdb714b7f6b6b510e62f898b84b8d5ce9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`31303e8`)](https://codecov.io/gh/aeye-lab/pymovements/pull/338?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 99.81%.

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


```diff
@@             Coverage Diff             @@
##              main     #338      +/-   ##
===========================================
- Coverage   100.00%   99.81%   -0.19%     
===========================================
  Files           44       44              
  Lines         1630     1638       +8     
  Branches       354      357       +3     
===========================================
+ Hits          1630     1635       +5     
- Partials         0        3       +3     
```


| [Impacted Files](https://codecov.io/gh/aeye-lab/pymovements/pull/338?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/tsplot.py](https://codecov.io/gh/aeye-lab/pymovements/pull/338?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3Bsb3R0aW5nL3RzcGxvdC5weQ==) | `95.38% <81.25%> (-4.62%)` | :arrow_down: |


</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/aeye-lab/pymovements/pull/338?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/src/pymovements/plotting/tsplot.py b/src/pymovements/plotting/tsplot.py
index bd3b0cd..406f854 100644
--- a/src/pymovements/plotting/tsplot.py
+++ b/src/pymovements/plotting/tsplot.py
@@ -22,14 +22,20 @@ This module holds the time series plot.
 """
 from __future__ import annotations
 
+import math
+
 import matplotlib.pyplot as plt
 import numpy as np
 
+from pymovements.gaze import GazeDataFrame
+
 
 def tsplot(
-        arr: np.ndarray,
-        channel_names: list[str] | None = None,
+        gaze: GazeDataFrame,
+        channels: list[str] | None = None,
         xlabel: str | None = None,
+        n_cols: int | None = None,
+        n_rows: int | None = None,
         rotate_ylabels: bool = True,
         share_y: bool = True,
         zero_centered_yaxis: bool = True,
@@ -47,10 +53,14 @@ def tsplot(
 
     Parameters
     ----------
-    arr: np.ndarray
-        array with channeled time series
-    channel_names: list, optional
+    gaze:
+        The GazeDataFrame to plot.
+    channels: list, optional
         list of channel names
+    n_cols: int
+        Number of channel subplot colunms. If None, it will be automatically inferred.
+    n_rows: int
+        Number of channel subplot rows. If None, it will be automatically inferred.
     xlabel: str, optional
         set x label
     rotate_ylabels: bool
@@ -81,13 +91,15 @@ def tsplot(
     ValueError
         If array has more than two dimensions.
     """
+    if channels is None:
+        channels = gaze.frame.columns
+
+    arr = gaze.frame[channels].to_numpy().transpose()
 
     if arr.ndim == 1:
         arr = np.expand_dims(arr, axis=0)
     elif arr.ndim == 2:
         pass
-    else:
-        raise ValueError(arr.shape)
 
     channel_axis = 0
     sample_axis = 1
@@ -95,19 +107,31 @@ def tsplot(
     n_channels = arr.shape[channel_axis]
     n_samples = arr.shape[sample_axis]
 
+    if n_cols is None:
+        if n_channels % 2 == 0:
+            n_cols = 2
+        else:
+            n_cols = 1
+
+    if n_rows is None:
+        n_rows = math.ceil(n_channels / n_cols)
+
     # determine number of subplots and height ratios for events
-    n_subplots = n_channels
-    height_ratios = [1] * n_channels
+    height_ratios = [1] * n_rows
 
     fig, axs = plt.subplots(
-        nrows=n_subplots,
-        sharex=True, sharey=share_y,
+        ncols=n_cols,
+        nrows=n_rows,
+        sharex=True,
+        sharey=share_y,
+        squeeze=False,
         figsize=figsize,
         gridspec_kw={
             'hspace': 0,
             'height_ratios': height_ratios,
         },
     )
+    axs = axs.flatten()
 
     t = np.arange(n_samples)
     xlims = t.min(), t.max()
@@ -119,13 +143,9 @@ def tsplot(
         ylims = -ylim_abs * y_pad_factor, ylim_abs * y_pad_factor
 
     for channel_id in range(n_channels):
-        if n_channels == 1:
-            ax = axs
-        else:
-            ax = axs[channel_id]
+        ax = axs[channel_id]
 
         x_channel = arr[channel_id, :]
-
         ax.plot(t, x_channel, color=line_color, linewidth=line_width)
 
         if not share_y:
@@ -157,15 +177,14 @@ def tsplot(
         plt.rcParams.update(params)
 
         # set channel names as y-axis labels
-        if channel_names:
-            if rotate_ylabels:
-                ax.set_ylabel(
-                    channel_names[channel_id],
-                    rotation='horizontal',
-                    ha='right', va='center',
-                )
-            else:
-                ax.set_ylabel(channel_names[channel_id])
+        if rotate_ylabels:
+            ax.set_ylabel(
+                channels[channel_id],
+                rotation='horizontal',
+                ha='right', va='center',
+            )
+        else:
+            ax.set_ylabel(channels[channel_id])
 
     # print x label on last used (bottom) axis
     ax.set_xlabel(xlabel)


Test patch:
diff --git a/tests/plotting/tsplot_test.py b/tests/plotting/tsplot_test.py
index 8062185..997ebec 100644
--- a/tests/plotting/tsplot_test.py
+++ b/tests/plotting/tsplot_test.py
@@ -25,16 +25,35 @@ import numpy as np
 import pytest
 from matplotlib import figure
 
-from pymovements.plotting import tsplot
+import pymovements as pm
 
 
-@pytest.fixture(name='arr')
-def arr_fixture():
+@pytest.fixture(name='gaze')
+def gaze_fixture():
     x = np.arange(-100, 100)
     y = np.arange(-100, 100)
     arr = np.column_stack((x, y)).transpose()
 
-    return arr
+    experiment = pm.Experiment(
+        screen_width_px=1280,
+        screen_height_px=1024,
+        screen_width_cm=38,
+        screen_height_cm=30,
+        distance_cm=68,
+        origin='lower left',
+        sampling_rate=1000.0,
+    )
+
+    gaze = pm.gaze.from_numpy(
+        data=arr,
+        schema=['x_pix', 'y_pix'],
+        experiment=experiment,
+    )
+
+    gaze.pix2deg()
+    gaze.pos2vel()
+
+    return gaze
 
 
 @pytest.mark.parametrize(
@@ -43,58 +62,40 @@ def arr_fixture():
         pytest.param({}, id='no_kwargs'),
         pytest.param({'share_y': False}, id='share_y_false'),
         pytest.param({'show_yticks': False}, id='show_yticks_false'),
-        pytest.param({'channel_names': ['foo', 'bar']}, id='channel_names'),
+        pytest.param({'channels': ['x_pix']}, id='single_channel'),
+        pytest.param({'channels': 'x_pix'}, id='single_channel_string'),
+        pytest.param({'channels': ['x_pix', 'y_pix']}, id='two_channels'),
         pytest.param(
-            {'channel_names': ['foo', 'bar'], 'rotate_ylabels': False},
-            id='channel_names_no_rotate',
+            {
+                'channels': ['x_pix', 'y_pix'], 'n_rows': 1, 'n_cols': 2,
+            },
+            id='two_channels_explicit_rows_cols',
+        ),
+        pytest.param(
+            {'channels': ['x_pix', 'y_pix'], 'rotate_ylabels': False},
+            id='channels_no_rotate',
         ),
     ],
 )
-def test_tsplot_show(arr, kwargs, monkeypatch):
-    mock = Mock()
-    monkeypatch.setattr(plt, 'show', mock)
-    tsplot(arr, **kwargs)
-    plt.close()
-    mock.assert_called_once()
-
-
-def test_tsplot_1d(monkeypatch):
+def test_tsplot_show(gaze, kwargs, monkeypatch):
     mock = Mock()
     monkeypatch.setattr(plt, 'show', mock)
-    tsplot(np.arange(-100, 100))
+    pm.plotting.tsplot(gaze=gaze, **kwargs)
     plt.close()
     mock.assert_called_once()
 
 
-def test_tsplot_noshow(arr, monkeypatch):
+def test_tsplot_noshow(gaze, monkeypatch):
     mock = Mock()
     monkeypatch.setattr(plt, 'show', mock)
-    tsplot(arr, show=False)
+    pm.plotting.tsplot(gaze=gaze, show=False)
     plt.close()
     mock.assert_not_called()
 
 
-def test_tsplot_save(arr, monkeypatch, tmp_path):
+def test_tsplot_save(gaze, monkeypatch, tmp_path):
     mock = Mock()
     monkeypatch.setattr(figure.Figure, 'savefig', mock)
-    tsplot(arr, show=False, savepath=str(tmp_path / 'test.svg'))
+    pm.plotting.tsplot(gaze=gaze, show=False, savepath=str(tmp_path / 'test.svg'))
     plt.close()
     mock.assert_called_once()
-
-
-@pytest.mark.parametrize(
-    ('kwargs', 'exception'),
-    [
-        pytest.param(
-            {'arr': np.ones((1000, 3, 3))},
-            ValueError,
-            id='3_dim_input',
-        ),
-    ],
-)
-def test_tsplot_exceptions(kwargs, exception, monkeypatch):
-    mock = Mock()
-    monkeypatch.setattr(plt, 'show', mock)
-
-    with pytest.raises(exception):
-        tsplot(**kwargs)