Repository: aeye-lab/pymovements
Pull request #193 (2023-02-22 16:47:06)

Synthetic: Add noise to step_function()
## Description

This adds gaussian noise to the synthetic.step_function()

## Implemented changes

- [ ] Add noise argument to step_function and superimpose gaussian noise with this scale.

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change is or requires a documentation update

## How Has This Been Tested?

- [x] Added new test function which generates both clean and noisy data
  - [ ] asserts inequality between those two
  - [ ] asserts that noisy and clean values are close

## 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



Patch:
diff --git a/src/pymovements/synthetic/step_function.py b/src/pymovements/synthetic/step_function.py
index c2a0727..435e5aa 100644
--- a/src/pymovements/synthetic/step_function.py
+++ b/src/pymovements/synthetic/step_function.py
@@ -32,6 +32,7 @@ def step_function(
     steps: list[int],
     values: list[float | tuple[float, ...]],
     start_value: float | tuple[float, ...] = 0,
+    noise: float = 0,
 ) -> np.ndarray:
     """
     Create a synthetic eye gaze by using a simple step function.
@@ -46,6 +47,9 @@ def step_function(
         Array values to set at each step.
     start_value: int
         Array value to start with.
+    noise: float
+        If greater than zero, gaussian noise is scaled according to value and superimposed on the
+        output array.
 
     Returns
     -------
@@ -55,8 +59,8 @@ def step_function(
     Raises
     ------
     ValueError
-        If steps not sorted in ascending order or length of steps not equal to length of values.
-
+        If steps not sorted in ascending order or length of steps not equal to length of values. If
+        noise is negative.
 
     Examples
     --------
@@ -97,6 +101,9 @@ def step_function(
     if sorted(steps) != steps:
         raise ValueError('steps must be sorted in ascending order.')
 
+    if noise < 0:
+        raise ValueError('noise must not be less than zero')
+
     # Infer number of channels from values.
     if isinstance(values[0], (int, float)):
         n_channels = 1
@@ -136,4 +143,8 @@ def step_function(
     # Set value for each step until the end.
     arr[steps[-1]:] = values[-1]
 
+    # Add noise if desired.
+    if noise > 0:
+        arr += np.random.normal(loc=0.0, scale=noise, size=arr.shape)
+
     return arr


Test patch:
diff --git a/tests/synthetic/test_step_function.py b/tests/synthetic/test_step_function.py
index 19f4509..09940c1 100644
--- a/tests/synthetic/test_step_function.py
+++ b/tests/synthetic/test_step_function.py
@@ -94,6 +94,11 @@ from pymovements.synthetic import step_function
             {'exception': ValueError},
             id='number_of_channels_unequal_start_value_channels_raises_value_error',
         ),
+        pytest.param(
+            {'length': 0, 'steps': [0], 'values': [0], 'noise': -1},
+            {'exception': ValueError},
+            id='negative_noise_raises_value_error',
+        ),
     ],
 )
 def test_step_function(params, expected):
@@ -104,3 +109,41 @@ def test_step_function(params, expected):
 
     arr = step_function(**params)
     assert np.array_equal(arr, expected['value']), f"arr = {arr}, expected = {expected['value']}"
+
+
+@pytest.mark.parametrize(
+    'params',
+    [
+        pytest.param(
+            {'length': 10, 'steps': [0], 'values': [1], 'start_value': 0, 'noise': 0.1},
+            id='length_10_with_step_at_start',
+        ),
+        pytest.param(
+            {'length': 10, 'steps': [5], 'values': [0], 'start_value': 1, 'noise': 0.1},
+            id='length_10_start_value_1_step_5_to_0',
+        ),
+        pytest.param(
+            {
+                'length': 100,
+                'steps': [10, 50, 90],
+                'values': [1, 0, 20],
+                'start_value': 0,
+                'noise': 0.1,
+            },
+            id='length_100_3_steps',
+        ),
+    ],
+)
+def test_step_function_with_noise(params):
+    params_clean = {key: value for key, value in params.items() if key != 'noise'}
+
+    arr_clean = step_function(**params_clean)
+    arr_noise = step_function(**params)
+
+    # First assert that arr is not exactly as the non-noisy output.
+    assert not np.array_equal(arr_clean, arr_noise), (
+        f'arr_clean = {arr_clean} must not be equal to arr_noise = {arr_noise}'
+    )
+
+    # Next check that all noisy values are still close to the clean equivalent.
+    assert np.allclose(arr_clean, arr_noise, atol=params['noise'] * 5)