Repository: stefmolin/data-morph

Issue:
Factor out pytweening dependency
Replace [`pytweening` code](https://github.com/asweigart/pytweening/blob/master/pytweening/__init__.py) with embedded logic here:

https://github.com/stefmolin/data-morph/blob/f9af20101aeb5e1971c3ff5ecf731dcff43dee4b/src/data_morph/morpher.py#L158-L165

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.40%. Comparing base [(`2b0666e`)](https://app.codecov.io/gh/stefmolin/data-morph/commit/2b0666eba9ff86d563b2041aced521838fa9ae11?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) to head [(`d10729b`)](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin).
> Report is 7 commits behind head on main.

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


[![Impacted file tree graph](https://app.codecov.io/gh/stefmolin/data-morph/pull/158/graphs/tree.svg?width=650&height=150&src=pr&token=3SEEG9SZQO&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin)](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin)

```diff
@@            Coverage Diff             @@
##             main     #158      +/-   ##
==========================================
+ Coverage   98.38%   98.40%   +0.01%     
==========================================
  Files          43       43              
  Lines        1737     1751      +14     
  Branches      345      346       +1     
==========================================
+ Hits         1709     1723      +14     
  Misses         25       25              
  Partials        3        3              
```

| [Files](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin) | Coverage Δ | |
|---|---|---|
| [src/data\_morph/morpher.py](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?src=pr&el=tree&filepath=src%2Fdata_morph%2Fmorpher.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin#diff-c3JjL2RhdGFfbW9ycGgvbW9ycGhlci5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/data\_morph/plotting/animation.py](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?src=pr&el=tree&filepath=src%2Fdata_morph%2Fplotting%2Fanimation.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin#diff-c3JjL2RhdGFfbW9ycGgvcGxvdHRpbmcvYW5pbWF0aW9uLnB5) | `96.55% <100.00%> (+3.21%)` | :arrow_up: |
| [tests/test\_main.py](https://app.codecov.io/gh/stefmolin/data-morph/pull/158?src=pr&el=tree&filepath=tests%2Ftest_main.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stefanie+Molin#diff-dGVzdHMvdGVzdF9tYWluLnB5) | `100.00% <100.00%> (ø)` | |

</details>

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 1ae5117a..0f11b539 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,6 @@ dependencies = [
     "matplotlib>=3.3",
     "numpy>=1.20",
     "pandas>=1.2",
-    "pytweening>=1.0.5",
     "scipy>=1.10.0",
     "tqdm>=4.64.1",
 ]
diff --git a/src/data_morph/morpher.py b/src/data_morph/morpher.py
index be50a8e4..e0d7da76 100644
--- a/src/data_morph/morpher.py
+++ b/src/data_morph/morpher.py
@@ -7,13 +7,19 @@ from typing import Optional, Union
 
 import numpy as np
 import pandas as pd
-import pytweening
 import tqdm
 
 from .bounds.bounding_box import BoundingBox
 from .data.dataset import Dataset
 from .data.stats import get_values
-from .plotting.animation import stitch_gif_animation
+from .plotting.animation import (
+    ease_in_out_quadratic,
+    ease_in_out_sine,
+    ease_in_sine,
+    ease_out_sine,
+    linear,
+    stitch_gif_animation,
+)
 from .plotting.static import plot
 from .shapes.bases.shape import Shape
 
@@ -156,13 +162,13 @@ class DataMorpher:
         frames = [0] * freeze_for
 
         if ramp_in and not ramp_out:
-            easing_function = pytweening.easeInSine
+            easing_function = ease_in_sine
         elif ramp_out and not ramp_in:
-            easing_function = pytweening.easeOutSine
+            easing_function = ease_out_sine
         elif ramp_out and ramp_in:
-            easing_function = pytweening.easeInOutSine
+            easing_function = ease_in_out_sine
         else:
-            easing_function = pytweening.linear
+            easing_function = linear
 
         # add transition frames
         frames.extend(
@@ -447,7 +453,7 @@ class DataMorpher:
 
         def _tweening(frame, *, min_value, max_value):  # numpydoc ignore=PR01,RT01
             """Determine the next value with tweening."""
-            return (max_value - min_value) * pytweening.easeInOutQuad(
+            return (max_value - min_value) * ease_in_out_quadratic(
                 (iterations - frame) / iterations
             ) + min_value
 
diff --git a/src/data_morph/plotting/animation.py b/src/data_morph/plotting/animation.py
index 760ba66a..fb7a0430 100644
--- a/src/data_morph/plotting/animation.py
+++ b/src/data_morph/plotting/animation.py
@@ -1,8 +1,10 @@
 """Utility functions for animations."""
 
 import glob
+import math
+from functools import wraps
 from pathlib import Path
-from typing import Union
+from typing import Callable, Union
 
 from PIL import Image
 
@@ -63,3 +65,136 @@ def stitch_gif_animation(
         # remove the image files
         for img in imgs:
             Path(img).unlink()
+
+
+def check_step(
+    easing_function: Callable[[Union[int, float]], Union[int, float]],
+) -> Callable[[Union[int, float]], Union[int, float]]:
+    """
+    Decorator to check if the step is a float or int and if it is between 0 and 1.
+
+    Parameters
+    ----------
+    easing_function : Callable
+        The easing function to be checked.
+
+    Returns
+    -------
+    Callable
+        The easing function with the check for the step.
+    """
+
+    @wraps(easing_function)
+    def wrapper(step: Union[int, float]) -> Union[int, float]:
+        """
+        Wrapper function to check the step.
+
+        Parameters
+        ----------
+        step : int or float
+            The current step of the animation, from 0 to 1.
+
+        Returns
+        -------
+        int or float
+            The eased value at the current step, from 0.0 to 1.0.
+        """
+        if not (isinstance(step, (int, float)) and 0 <= step <= 1):
+            raise ValueError('Step must be an integer or float, between 0 and 1.')
+        return easing_function(step)
+
+    return wrapper
+
+
+@check_step
+def ease_in_sine(step: Union[int, float]) -> float:
+    """
+    An ease-in sinusoidal function to generate animation steps (slow to fast).
+
+    Parameters
+    ----------
+    step : int or float
+        The current step of the animation, from 0 to 1.
+
+    Returns
+    -------
+    float
+        The eased value at the current step, from 0.0 to 1.0.
+    """
+    return -1 * math.cos(step * math.pi / 2) + 1
+
+
+@check_step
+def ease_out_sine(step: Union[int, float]) -> float:
+    """
+    An ease-out sinusoidal function to generate animation steps (fast to slow).
+
+    Parameters
+    ----------
+    step : int or float
+        The current step of the animation, from 0 to 1.
+
+    Returns
+    -------
+    float
+        The eased value at the current step, from 0.0 to 1.0.
+    """
+    return math.sin(step * math.pi / 2)
+
+
+@check_step
+def ease_in_out_sine(step: Union[int, float]) -> float:
+    """
+    An ease-in and ease-out sinusoidal function to generate animation steps (slow to fast to slow).
+
+    Parameters
+    ----------
+    step : int or float
+        The current step of the animation, from 0 to 1.
+
+    Returns
+    -------
+    float
+        The eased value at the current step, from 0.0 to 1.0.
+    """
+    return -0.5 * (math.cos(math.pi * step) - 1)
+
+
+@check_step
+def ease_in_out_quadratic(step: Union[int, float]) -> Union[int, float]:
+    """
+    An ease-in and ease-out quadratic function to generate animation steps (slow to fast to slow).
+
+    Parameters
+    ----------
+    step : int or float
+        The current step of the animation, from 0 to 1.
+
+    Returns
+    -------
+    int or float
+        The eased value at the current step, from 0.0 to 1.0.
+    """
+    if step < 0.5:
+        return 2 * step**2
+    else:
+        step = step * 2 - 1
+        return -0.5 * (step * (step - 2) - 1)
+
+
+@check_step
+def linear(step: Union[int, float]) -> Union[int, float]:
+    """
+    A linear function to generate animation steps.
+
+    Parameters
+    ----------
+    step : int or float
+        The current step of the animation, from 0 to 1.
+
+    Returns
+    -------
+    int or float
+        The eased value at the current step, from 0.0 to 1.0.
+    """
+    return step


Test patch:
diff --git a/tests/plotting/test_animation.py b/tests/plotting/test_animation.py
index 4a8e4474..fd6d0b00 100644
--- a/tests/plotting/test_animation.py
+++ b/tests/plotting/test_animation.py
@@ -3,6 +3,7 @@
 import numpy as np
 import pytest
 
+from data_morph.plotting import animation
 from data_morph.plotting.animation import stitch_gif_animation
 from data_morph.plotting.static import plot
 
@@ -35,3 +36,56 @@ def test_frame_stitching(sample_data, tmp_path):
     animation_file = tmp_path / f'{start_shape}_to_{target_shape}.gif'
     assert animation_file.is_file()
     assert not (tmp_path / f'{start_shape}-to-{target_shape}-{frame}.png').is_file()
+
+
+@pytest.mark.parametrize(
+    ['ease_function', 'step', 'expected'],
+    [
+        ('linear', 0.1, 0.1),
+        ('linear', 0.5, 0.5),
+        ('linear', 0.9, 0.9),
+        ('ease_in_sine', 0.1, 0.012312),
+        ('ease_in_sine', 0.5, 0.292893),
+        ('ease_in_sine', 0.9, 0.843566),
+        ('ease_out_sine', 0.1, 0.156434),
+        ('ease_out_sine', 0.5, 0.707107),
+        ('ease_out_sine', 0.9, 0.987688),
+        ('ease_in_out_sine', 0.1, 0.024472),
+        ('ease_in_out_sine', 0.5, 0.5),
+        ('ease_in_out_sine', 0.9, 0.975528),
+        ('ease_in_out_quadratic', 0.1, 0.02),
+        ('ease_in_out_quadratic', 0.5, 0.5),
+        ('ease_in_out_quadratic', 0.9, 0.98),
+    ],
+)
+def test_easing_functions(ease_function, step, expected):
+    """Test that easing functions return expected values."""
+    ease_func = getattr(animation, ease_function)
+    assert round(ease_func(step), ndigits=6) == expected
+
+
+@pytest.mark.parametrize(
+    'invalid_step',
+    [
+        'string',
+        -1,
+        2,
+    ],
+)
+@pytest.mark.parametrize(
+    'ease_function',
+    [
+        'linear',
+        'ease_in_sine',
+        'ease_out_sine',
+        'ease_in_out_sine',
+        'ease_in_out_quadratic',
+    ],
+)
+def test_invalid_easing_step(ease_function, invalid_step):
+    """Test that an invalid step type will produce a ValueError when passed to an easing function."""
+    with pytest.raises(
+        ValueError, match='Step must be an integer or float, between 0 and 1.'
+    ):
+        ease_func = getattr(animation, ease_function)
+        ease_func(invalid_step)
diff --git a/tests/test_main.py b/tests/test_main.py
index 2693af40..014e8723 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,6 +1,7 @@
 """Test the __main__ module."""
 
 import subprocess
+import sys
 
 import pytest
 
@@ -10,5 +11,5 @@ pytestmark = pytest.mark.cli
 @pytest.mark.parametrize(['flag', 'return_code'], [['--version', 0], ['', 2]])
 def test_main_access_cli(flag, return_code):
     """Confirm that CLI can be accessed via __main__."""
-    result = subprocess.run(['python', '-m', 'data_morph', flag])
+    result = subprocess.run([sys.executable, '-m', 'data_morph', flag])
     assert result.returncode == return_code