Repository: pyfar/pyfar (python)

Issue:
Signal copy
Simple method to return copy of a signal object, similar to numpy.copy().

Pull request description:
Feature copy object
### Which issue(s) are closed by this pull request?

Closes #24 

### Changes proposed in this pull request:

- copy method for classes Signal, Orientations, Coordinates and Filter
- implemented in a new utils.py file

Patch:
diff --git a/examples/pyfar_demo.ipynb b/examples/pyfar_demo.ipynb
index 5b9925be..d6266a55 100644
--- a/examples/pyfar_demo.ipynb
+++ b/examples/pyfar_demo.ipynb
@@ -4,17 +4,21 @@
    "source": [
     "# Welcome\n",
     "\n",
-    "This is the pyfar demo and a good place for getting started. In this notebook, you will see examples of the most important pyfar functionalty.\n",
+    "The **Py**thon packages **f**or **A**coustics **R**esearch (pyfar) contain classes and function for the acquisition, inspection, and processing of audio signals. This is the pyfar demo notebook and a good place for getting started. In this notebook, you will see examples of the most important pyfar functionalty. \n",
     "\n",
     "**Note:** This is not a substitute for the pyfar documentaion at **insert_url**.\n",
     "\n",
     "## Contents\n",
+    "\n",
     "[Signals](#signals)\n",
     "- [FFT normalization](#fft_normalization)\n",
     "- [Accessing Signal data](#accessing_signal_data)\n",
     "- [Iterating Signals](#accessing_signal_data)\n",
     "- [Signal meta data](#signal_meta_data)\n",
     "- [Arithmetic operations](#arithmetic_operations)\n",
+    "- [Plotting](#plotting)\n",
+    "    - [Line plots](#line_plots)\n",
+    "    - [Plane plots](#plane_plots) TODO\n",
     "\n",
     "[Coordinates](#coordinates)\n",
     "- [Entering coordinate points](#coordinates_enter)\n",
@@ -26,10 +30,6 @@
     "- [Retrieving orientations](#retrieving_orientations)\n",
     "- [Rotating orientations](#rotating_orientations)\n",
     "\n",
-    "[Plotting](#plotting)\n",
-    "- [Line plots](#line_plots)\n",
-    "- [Plane plots](#plane_plots) TODO\n",
-    "\n",
     "[DSP](#dsp)\n",
     "- [Filtering](#filtering)\n",
     "\n",
@@ -143,7 +143,11 @@
   },
   {
    "source": [
-    "Please note that the frequency data of signals depends on the Signal's `fft_norm`. Internally, the data are stored either in the `'time'` or `'freq'` domain. It is tranformed every time you access it without changing the internal state. In some cases it might be more efficient to store the data in a specif domain to avoid additional Fourier Transforms. Lets check out the current domain"
+    "Two things are important here:\n",
+    "\n",
+    "1. `time_data` is mutable! That means `x.power.time` changes if you change `time_data`. If this is not what you want use `time_data = x_power.time.copy()` instead.\n",
+    "\n",
+    "2. The frequency data of signals depends on the Signal's `fft_norm`. Internally, the data are stored either in the `'time'` or `'freq'` domain. It is tranformed every time you access it without changing the internal state. In some cases it might be more efficient to store the data in a specif domain to avoid additional Fourier Transforms. Lets check out the current domain"
    ],
    "cell_type": "markdown",
    "metadata": {}
@@ -174,6 +178,67 @@
     "x_power.domain"
    ]
   },
+  {
+   "source": [
+    "`Signals` and some other pyfar objects support slicing. Let's illustrate that for a two channel signal"
+   ],
+   "cell_type": "markdown",
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# generate two channel time data\n",
+    "time = np.zeros((2, 4))\n",
+    "time[0,0] = 1   # first sample of first channel\n",
+    "time[1,0] = 2   # first sample of second channel\n",
+    "\n",
+    "x_two_channels = Signal(time, 44100)\n",
+    "x_first_channel = x_two_channels[0]"
+   ]
+  },
+  {
+   "source": [
+    "`x_first_channel` is a `Signal` object itself, which contains the first channel of `x_two_channels`:"
+   ],
+   "cell_type": "markdown",
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x_first_channel.time"
+   ]
+  },
+  {
+   "source": [
+    "A third option to access `Signals` is to copy it"
+   ],
+   "cell_type": "markdown",
+   "metadata": {}
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "x_copy = x_two_channels.copy()"
+   ]
+  },
+  {
+   "source": [
+    "It is important to note that his return an independent copy of `x_two_channels`. Note that the operation `x_copy = x_two_channles` should be avoided. In this case changes to `x_copy` will also change `x_two_channels`. The `copy()` operation is available for all pyfar object."
+   ],
+   "cell_type": "markdown",
+   "metadata": {}
+  },
   {
    "source": [
     "## Iterating Signals<a class=\"signal_meta_data\" id=\"accessing_signal_data\"></a>\n",
@@ -224,13 +289,6 @@
    "cell_type": "markdown",
    "metadata": {}
   },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": []
-  },
   {
    "source": [
     "## Arithmetic operations<a class=\"arithmetic_operations\" id=\"signals\"></a>\n",
@@ -650,8 +708,12 @@
  "metadata": {
   "kernelspec": {
    "name": "python3",
-   "display_name": "Python 3",
-   "language": "python"
+   "display_name": "Python 3.8.5 64-bit ('pyfar': conda)",
+   "metadata": {
+    "interpreter": {
+     "hash": "b5c190152f026de65d6bc39d2c11a64d23d25156fef68ab6c34c3a6b6f89ef72"
+    }
+   }
   },
   "language_info": {
    "codemirror_mode": {
diff --git a/pyfar/coordinates.py b/pyfar/coordinates.py
index bff64207..5b91bc6c 100644
--- a/pyfar/coordinates.py
+++ b/pyfar/coordinates.py
@@ -4,6 +4,7 @@ from scipy.spatial import cKDTree
 from scipy.spatial.transform import Rotation as sp_rot
 import copy
 import re
+from . import utils
 
 import pyfar
 
@@ -1135,6 +1136,10 @@ class Coordinates(object):
                       points[:, 1].reshape(shape),
                       points[:, 2].reshape(shape))
 
+    def copy(self):
+        """Return a deep copy of the Coordinates object."""
+        return utils.copy(self)
+
     @staticmethod
     def _systems():
         """
diff --git a/pyfar/dsp/classes.py b/pyfar/dsp/classes.py
index f87555bb..76b5fd76 100644
--- a/pyfar/dsp/classes.py
+++ b/pyfar/dsp/classes.py
@@ -5,6 +5,7 @@ import numpy as np
 import scipy.signal as spsignal
 
 from pyfar import Signal
+from .. import utils
 
 
 def atleast_3d_first_dim(arr):
@@ -211,6 +212,10 @@ class Filter(object):
         """Set comment."""
         self._comment = str(value)
 
+    def copy(self):
+        """Return a deep copy of the Filter object."""
+        return utils.copy(self)
+
 
 class FilterFIR(Filter):
     """
diff --git a/pyfar/orientations.py b/pyfar/orientations.py
index 0baa856d..57c5035c 100644
--- a/pyfar/orientations.py
+++ b/pyfar/orientations.py
@@ -1,5 +1,6 @@
 from scipy.spatial.transform import Rotation
 import numpy as np
+from . import utils
 
 import pyfar
 
@@ -189,6 +190,10 @@ class Orientations(Rotation):
             return np.swapaxes(vector_triple, 0, 1)
         return vector_triple
 
+    def copy(self):
+        """Return a deep copy of the Orientations object."""
+        return utils.copy(self)
+
     def __setitem__(self, idx, val):
         """
         Assign orientations(s) at given index(es) from object.
diff --git a/pyfar/signal.py b/pyfar/signal.py
index daee2671..71cf4da7 100644
--- a/pyfar/signal.py
+++ b/pyfar/signal.py
@@ -13,6 +13,7 @@ import warnings
 import numpy as np
 from pyfar import fft as fft
 from typing import Callable
+from . import utils
 
 
 class Audio(object):
@@ -145,45 +146,6 @@ class Signal(Audio):
         """Time instances the signal is sampled at."""
         return np.atleast_1d(np.arange(0, self.n_samples) / self.sampling_rate)
 
-    def find_nearest_frequency(self, value):
-        """Returns the closest frequency index for a given frequency
-
-        Parameters
-        ----------
-        value : float, array-like
-            The frequency for which the indices are to be returned
-
-        Returns
-        -------
-        indices : int, array-like
-            The index for the given frequency. If the input was an array like,
-            a numpy array of indices is returned.
-        """
-        freqs = np.atleast_1d(value)
-        indices = np.zeros_like(freqs).astype(np.int)
-        for idx, freq in enumerate(freqs):
-            indices[idx] = np.argmin(np.abs(self.frequencies - freq))
-        return np.squeeze(indices)
-
-    def find_nearest_time(self, value):
-        """Returns the closest time index for a given time
-        Parameters
-        ----------
-        value : float, array-like
-            The times for which the indices are to be returned
-
-        Returns
-        -------
-        indices : int, array-like
-            The index for the given time instance. If the input was an array
-            like, a numpy array of indices is returned.
-        """
-        times = np.atleast_1d(value)
-        indices = np.zeros_like(times).astype(np.int)
-        for idx, time in enumerate(times):
-            indices[idx] = np.argmin(np.abs(self.times - time))
-        return np.squeeze(indices)
-
     @property
     def time(self):
         """The signal data in the time domain."""
@@ -311,6 +273,10 @@ class Signal(Audio):
         """Set comment."""
         self._comment = str(value)
 
+    def copy(self):
+        """Return a deep copy of the Signal object."""
+        return utils.copy(self)
+
     def __add__(self, data):
         return add((self, data), 'freq')
 
diff --git a/pyfar/utils.py b/pyfar/utils.py
new file mode 100644
index 00000000..bab8efde
--- /dev/null
+++ b/pyfar/utils.py
@@ -0,0 +1,10 @@
+"""
+Provide methods used by several Classes.
+"""
+
+from copy import deepcopy
+
+
+def copy(obj):
+    """Return a deep copy of the object."""
+    return deepcopy(obj)


Test patch:
diff --git a/tests/test_signal.py b/tests/test_signal.py
index 7d101147..462f14e7 100644
--- a/tests/test_signal.py
+++ b/tests/test_signal.py
@@ -320,30 +320,6 @@ def test_magic_len(impulse):
     assert len(signal) == 1000
 
 
-def test_find_nearest_time():
-    sampling_rate = 100
-    signal = Signal(np.zeros(100), sampling_rate)
-    actual = signal.find_nearest_time(0.5)
-    expected = 50
-    assert actual == expected
-
-    actual = signal.find_nearest_time([0.5, 0.75])
-    expected = [50, 75]
-    npt.assert_allclose(actual, expected)
-
-
-def test_find_nearest_frequency():
-    sampling_rate = 100
-    signal = Signal(np.zeros(100*2), sampling_rate*2)
-    actual = signal.find_nearest_frequency(50)
-    expected = 50
-    assert actual == expected
-
-    actual = signal.find_nearest_frequency([50, 75])
-    expected = [50, 75]
-    npt.assert_allclose(actual, expected)
-
-
 @pytest.fixture
 def sine():
     """Generate a sine signal with f = 440 Hz and sampling_rate = 44100 Hz.
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 00000000..b32808c7
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,29 @@
+import pyfar
+import numpy as np
+import numpy.testing as npt
+
+
+def test_copy():
+    """ Test copy method used by several classes."""
+    obj_list = [pyfar.Signal(1000, 44100),
+                pyfar.Orientations(),
+                pyfar.Coordinates(),
+                pyfar.dsp.Filter()]
+
+    for obj in obj_list:
+        # Create copy
+        obj_copy = obj.copy()
+        # Check class
+        assert isinstance(obj_copy, obj.__class__)
+        # Check ID
+        assert id(obj) != id(obj_copy)
+        # Check attributes
+        assert len(obj.__dict__) == len(obj_copy.__dict__)
+        for key, value in obj.__dict__.items():
+            assert key in obj_copy.__dict__.keys()
+            value_copy = obj_copy.__dict__[key]
+            if isinstance(value, np.ndarray):
+                npt.assert_allclose(value, value_copy)
+            else:
+                assert value == value_copy
+