From d904502b84a57b617bd5690ccbe0befa9c3d953c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 10:16:46 -0500 Subject: [PATCH 01/23] added array support and tests --- pynapple/core/__init__.py | 1 + pynapple/core/config.py | 51 ++++++++++++ pynapple/core/time_series.py | 150 ++++++++++++++++++++++++++++++++-- pyproject.toml | 2 + tests/__init__.py | 1 + tests/mock.py | 50 ++++++++++++ tests/test_config.py | 84 +++++++++++++++++++ tests/test_non_numpy_array.py | 58 +++++++++++++ tox.ini | 10 +-- 9 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 pynapple/core/config.py create mode 100644 tests/mock.py create mode 100644 tests/test_config.py create mode 100644 tests/test_non_numpy_array.py diff --git a/pynapple/core/__init__.py b/pynapple/core/__init__.py index 0fe00c5e..d26dafd0 100644 --- a/pynapple/core/__init__.py +++ b/pynapple/core/__init__.py @@ -1,3 +1,4 @@ +from . import config from .interval_set import IntervalSet from .time_index import TsIndex from .time_series import Ts, Tsd, TsdFrame, TsdTensor diff --git a/pynapple/core/config.py b/pynapple/core/config.py new file mode 100644 index 00000000..5ca588f5 --- /dev/null +++ b/pynapple/core/config.py @@ -0,0 +1,51 @@ +"""Package configurations. +""" + + +class PynappleConfig: + """ + A class to hold configuration settings for pynapple. + + This class includes all configuration settings that control the behavior of + pynapple. It offers a structured way to access and modify settings. + + Attributes: + ----------- + suppress_conversion_warnings (bool): + Determines whether to suppress warnings when automatically converting non-NumPy + array-like objects to NumPy arrays. + This is useful for users who frequently work with array-like objects from other + libraries (e.g., JAX, TensorFlow) and prefer not to receive warnings for automatic + conversions. Defaults to False, which means warnings will be shown. + """ + + def __init__(self): + self.suppress_conversion_warnings = False + + @property + def suppress_conversion_warnings(self): + """ + bool: Gets or sets the suppression state for conversion warnings. When set to True, + warnings for automatic conversions of non-NumPy array-like objects to NumPy arrays + are suppressed. Ensures that only boolean values are assigned. + """ + return self._suppress_conversion_warnings + + @suppress_conversion_warnings.setter + def suppress_conversion_warnings(self, value): + if not isinstance(value, bool): + raise ValueError("suppress_conversion_warnings must be a boolean value.") + self._suppress_conversion_warnings = value + + def restore_defaults(self): + """ + Set all configuration settings to their default values. + + This method can be used to easily set/reset the configuration state of pynapple + to its initial, default configuration. + """ + self.suppress_conversion_warnings = False + + +# Initialize a config instance +nap_config = PynappleConfig() diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 3c0b2e5d..3a3e795f 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -48,10 +48,122 @@ jitvaluefromtensor, pjitconvolve, ) +from .config import nap_config from .interval_set import IntervalSet from .time_index import TsIndex +def is_array_like(obj): + """ + Check if an object is array-like. + + This function determines if an object has array-like properties. An object + is considered array-like if it has attributes typically associated with arrays + (such as `.shape`, `.dtype`, and `.ndim`), supports indexing, and is iterable. + + Parameters + ---------- + obj : object + The object to check for array-like properties. + + Returns + ------- + bool + True if the object is array-like, False otherwise. + + Notes + ----- + This function uses a combination of checks for attributes (`shape`, `dtype`, `ndim`), + indexability, and iterability to determine if the given object behaves like an array. + It is designed to be flexible and work with various types of array-like objects, including + but not limited to NumPy arrays and JAX arrays. However, it may not be foolproof for all + possible array-like types or objects that mimic these properties without being suitable for + numerical operations. + + Examples + -------- + >>> import numpy as np + >>> numpy_array = np.array([1, 2, 3]) + >>> jax_array = jnp.array([1, 2, 3]) + >>> non_array = "not an array" + >>> is_array_like(numpy_array) + True + >>> is_array_like(non_array) + False + """ + # Check for array-like attributes + has_shape = hasattr(obj, "shape") + has_dtype = hasattr(obj, "dtype") + has_ndim = hasattr(obj, "ndim") + + # Check for indexability (try to access the first element) + try: + obj[0] + is_indexable = True + except (TypeError, IndexError): + is_indexable = False + + # Check for iterable property + try: + iter(obj) + is_iterable = True + except TypeError: + is_iterable = False + + return has_shape and has_dtype and has_ndim and is_indexable and is_iterable + + +def convert_to_numpy(array, array_name): + """ + Convert an input array-like object to a NumPy array. + + This function attempts to convert an input object to a NumPy array using `np.asarray`. + If the input is not already a NumPy ndarray, it issues a warning indicating that a conversion + has taken place and shows the original type of the input. This function is useful for + ensuring compatibility with Numba operations in cases where the input might come from + various array-like sources (for instance, jax.numpy.Array). + + Parameters + ---------- + array : array_like + The input object to convert. This can be any object that `np.asarray` is capable of + converting to a NumPy array, such as lists, tuples, and other array-like objects, + including those from libraries like JAX or TensorFlow that adhere to the array interface. + array_name : str + The name of the variable that we are converting, printed in the warning message. + + Returns + ------- + ndarray + A NumPy ndarray representation of the input `values`. If `values` is already a NumPy + ndarray, it is returned unchanged. Otherwise, a new NumPy ndarray is created and returned. + + Warnings + -------- + A warning is issued if the input `values` is not already a NumPy ndarray, indicating + that a conversion has taken place and showing the original type of the input. + + Examples + -------- + >>> import jax.numpy as jnp + >>> list_int = [1, 2, 3] + >>> numpy_array = convert_to_numpy(list_int, "list_int") + UserWarning: Converting data to numpy.array. The provided array was of type 'list'. + >>> type(numpy_array) + + """ + if ( + not isinstance(array, np.ndarray) + and not nap_config.suppress_conversion_warnings + ): + original_type = type(array).__name__ + warnings.warn( + f"Converting '{array_name}' to numpy.array. The provided array was of type '{original_type}'.", + UserWarning, + ) + return np.asarray(array) + + def _split_tsd(func, tsd, indices_or_sections, axis=0): """ Wrappers of numpy split functions @@ -641,7 +753,8 @@ def restrict(self, iset): starts = iset.start.values ends = iset.end.values - if isinstance(self.values, np.ndarray): + if is_array_like(self.values): + data_array = self.values t, d = jitrestrict(time_array, data_array, starts, ends) @@ -1019,13 +1132,17 @@ def __init__(self, t, d, time_units="s", time_support=None, **kwargs): time_support : IntervalSet, optional The time support of the TsdFrame object """ - if isinstance(t, np.ndarray) and d is None: + if is_array_like(t) and d is None: raise RuntimeError("Missing argument d when initializing TsdTensor") if isinstance(t, (list, tuple)): t = np.array(t) + else: + t = convert_to_numpy(t, "t") if isinstance(d, (list, tuple)): d = np.array(d) + else: + d = convert_to_numpy(d, "d") assert ( d.ndim >= 3 @@ -1221,8 +1338,6 @@ def __init__(self, t, d=None, time_units="s", time_support=None, columns=None): columns : iterables Column names """ - if isinstance(t, np.ndarray) and d is None: - raise RuntimeError("Missing argument d when initializing TsdFrame") c = columns @@ -1231,10 +1346,18 @@ def __init__(self, t, d=None, time_units="s", time_support=None, columns=None): c = t.columns.values t = t.index.values + if is_array_like(t) and d is None: + raise RuntimeError("Missing argument d when initializing TsdFrame") + if isinstance(t, (list, tuple)): t = np.array(t) + elif is_array_like(t): + t = convert_to_numpy(t, "t") + if isinstance(d, (list, tuple)): d = np.array(d) + elif is_array_like(d): + d = convert_to_numpy(d, "d") assert d.ndim <= 2, "Data should be 1 or 2 dimensional" @@ -1541,17 +1664,22 @@ def __init__(self, t, d=None, time_units="s", time_support=None): time_support : IntervalSet, optional The time support of the tsd object """ - if isinstance(t, np.ndarray) and d is None: - raise RuntimeError("Missing argument d when initializing Tsd") - if isinstance(t, pd.Series): - d = t.values - t = t.index.values + d = np.asarray(t.values) + t = np.asarray(t.index.values) + + if is_array_like(t) and d is None: + raise RuntimeError("Missing argument d when initializing Tsd") if isinstance(t, (list, tuple)): t = np.array(t) + elif is_array_like(t): + t = convert_to_numpy(t, "t") + if isinstance(d, (list, tuple)): d = np.array(d) + elif is_array_like(d): + d = convert_to_numpy(d, "d") assert d.ndim == 1, "Data should be 1 dimension" @@ -1871,6 +1999,10 @@ def __init__(self, t, time_units="s", time_support=None): """ if isinstance(t, Number): t = np.array([t]) + # convert array-like data to numpy. + # raise a warning to avoid silent conversion if non-numpy array is provided (jax arrays for instance) + elif is_array_like(t): + t = convert_to_numpy(t, "t") if isinstance(t, TsIndex): self.index = t diff --git a/pyproject.toml b/pyproject.toml index 4e0e9bc5..4b55b7a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ dev = [ "pytest", # Testing framework "flake8", # Code linter "coverage", # Test coverage measurement + "jax", + "jaxlib" ] docs = [ "mkdocs", # Documentation generator diff --git a/tests/__init__.py b/tests/__init__.py index 82dc6660..55f838c8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ """Unit test package for pynapple.""" +from . import mock diff --git a/tests/mock.py b/tests/mock.py new file mode 100644 index 00000000..56722a97 --- /dev/null +++ b/tests/mock.py @@ -0,0 +1,50 @@ +"""Test configuration script.""" + + +class MockArray: + """ + A mock array class designed for testing purposes. It mimics the behavior of array-like objects + by providing necessary attributes and supporting indexing and iteration, but it is not a direct + instance of numpy.ndarray. + """ + + def __init__(self, data): + """ + Initializes the MockArray with data. + + Parameters + ---------- + data : Union[numpy.ndarray, List] + A list of data elements that the MockArray will contain. + """ + self.data = data + self.shape = (len(data),) # Simplified shape attribute + self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic + self.ndim = 1 # Simplified ndim for a 1-dimensional array + + def __getitem__(self, index): + """ + Supports indexing into the mock array. + + Parameters + ---------- + index : int or slice + The index or slice of the data to access. + + Returns + ------- + The element(s) at the specified index. + """ + return self.data[index] + + def __iter__(self): + """ + Supports iteration over the mock array. + """ + return iter(self.data) + + def __len__(self): + """ + Returns the length of the mock array. + """ + return len(self.data) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..2c81f58d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,84 @@ +from contextlib import nullcontext as does_not_raise + +import pytest + +import pynapple as nap + +from .mock import MockArray + + +@pytest.mark.parametrize("param, expectation", + [ + (True, does_not_raise()), + (False, does_not_raise()), + (1, pytest.raises(ValueError, + match="suppress_conversion_warnings must be a boolean value")) + ]) +def test_config_setter_input_validity(param, expectation): + """Test setting suppress_conversion_warnings with various inputs to validate type checking.""" + with expectation: + nap.config.nap_config.suppress_conversion_warnings = param + + +def test_config_setter_output(): + """Test if suppress_conversion_warnings property correctly retains a True value after being set.""" + nap.config.nap_config.suppress_conversion_warnings = True + assert nap.config.nap_config.suppress_conversion_warnings + + +def test_config_restore_default(): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = True + nap.config.nap_config.restore_defaults() + assert not nap.config.nap_config.suppress_conversion_warnings + + +@pytest.mark.parametrize("cls, t, d, conf, expectation", + [ + (nap.Ts, [0, 1], None, True, does_not_raise()), + (nap.Ts, [0, 1], None, False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.Tsd, [0, 1], [0, 1], True, does_not_raise()), + (nap.Tsd, [0, 1], [0, 1], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.TsdFrame, [0, 1], [[0], [1]], True, does_not_raise()), + (nap.TsdFrame, [0, 1], [[0], [1]], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], True, does_not_raise()), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + + ]) +def test_config_supress_warining_t(cls, t, d, conf, expectation): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = conf + try: + with expectation: + if d is None: + cls(t=MockArray(t)) + else: + cls(t=MockArray(t), d=d) + finally: + nap.config.nap_config.restore_defaults() + +@pytest.mark.parametrize("cls, t, d, conf, expectation", + [ + (nap.Tsd, [0, 1], [0, 1], True, does_not_raise()), + (nap.Tsd, [0, 1], [0, 1], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + (nap.TsdFrame, [0, 1], [[0], [1]], True, does_not_raise()), + (nap.TsdFrame, [0, 1], [[0], [1]], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], True, does_not_raise()), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + + ]) +def test_config_supress_warining_d(cls, t, d, conf, expectation): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = conf + try: + with expectation: + cls(t=t, d=MockArray(d)) + finally: + nap.config.nap_config.restore_defaults() diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py new file mode 100644 index 00000000..5d2ff6a1 --- /dev/null +++ b/tests/test_non_numpy_array.py @@ -0,0 +1,58 @@ +from contextlib import nullcontext as does_not_raise + +import jax.numpy as jnp +import numpy as np +import pytest + +import pynapple as nap + +from .mock import MockArray + + +class TestTsArray: + + @pytest.mark.parametrize("time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()), + ("abc", pytest.raises(AttributeError, + match="'str' object has no attribute 'astype'")) + ]) + def test_ts_init(self, time, expectation): + with expectation: + nap.Ts(t=time) + + @pytest.mark.parametrize("time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()) + ]) + def test_ts_type(self, time, expectation): + with expectation: + ts = nap.Ts(t=time) + assert isinstance(ts.t, np.ndarray) + + @pytest.mark.parametrize("time, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")), + (MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")) + ]) + def test_ts_type(self, time, expectation): + with expectation: + nap.Ts(t=time) + + @pytest.mark.parametrize("time, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")), + (MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")) + ]) + def test_ts_type(self, time, expectation): + with expectation: + nap.Ts(t=time) + diff --git a/tox.ini b/tox.ini index 0dc67bb5..a4020096 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,10 @@ envlist = py37,py38,py39,py310 requires = tox-conda [testenv] -deps = - black - flake8 - isort - pytest - coverage +# means we'll run the equivalent of `pip install .[dev]`, also installing pytest +# and the linters from pyproject.toml. The advantage is that you can to set your +# dev dependencies in a single place (pyproject.toml). +extras = dev commands = black --check pynapple From ea9daa2c4a1003da6916e542c506189c63d0860d Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 10:16:46 -0500 Subject: [PATCH 02/23] added array support and tests --- pynapple/core/__init__.py | 1 + pynapple/core/config.py | 51 ++++++++++++ pynapple/core/time_series.py | 150 ++++++++++++++++++++++++++++++++-- pyproject.toml | 2 + tests/__init__.py | 1 + tests/mock.py | 50 ++++++++++++ tests/test_config.py | 84 +++++++++++++++++++ tests/test_non_numpy_array.py | 58 +++++++++++++ tox.ini | 10 +-- 9 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 pynapple/core/config.py create mode 100644 tests/mock.py create mode 100644 tests/test_config.py create mode 100644 tests/test_non_numpy_array.py diff --git a/pynapple/core/__init__.py b/pynapple/core/__init__.py index 0fe00c5e..d26dafd0 100644 --- a/pynapple/core/__init__.py +++ b/pynapple/core/__init__.py @@ -1,3 +1,4 @@ +from . import config from .interval_set import IntervalSet from .time_index import TsIndex from .time_series import Ts, Tsd, TsdFrame, TsdTensor diff --git a/pynapple/core/config.py b/pynapple/core/config.py new file mode 100644 index 00000000..5ca588f5 --- /dev/null +++ b/pynapple/core/config.py @@ -0,0 +1,51 @@ +"""Package configurations. +""" + + +class PynappleConfig: + """ + A class to hold configuration settings for pynapple. + + This class includes all configuration settings that control the behavior of + pynapple. It offers a structured way to access and modify settings. + + Attributes: + ----------- + suppress_conversion_warnings (bool): + Determines whether to suppress warnings when automatically converting non-NumPy + array-like objects to NumPy arrays. + This is useful for users who frequently work with array-like objects from other + libraries (e.g., JAX, TensorFlow) and prefer not to receive warnings for automatic + conversions. Defaults to False, which means warnings will be shown. + """ + + def __init__(self): + self.suppress_conversion_warnings = False + + @property + def suppress_conversion_warnings(self): + """ + bool: Gets or sets the suppression state for conversion warnings. When set to True, + warnings for automatic conversions of non-NumPy array-like objects to NumPy arrays + are suppressed. Ensures that only boolean values are assigned. + """ + return self._suppress_conversion_warnings + + @suppress_conversion_warnings.setter + def suppress_conversion_warnings(self, value): + if not isinstance(value, bool): + raise ValueError("suppress_conversion_warnings must be a boolean value.") + self._suppress_conversion_warnings = value + + def restore_defaults(self): + """ + Set all configuration settings to their default values. + + This method can be used to easily set/reset the configuration state of pynapple + to its initial, default configuration. + """ + self.suppress_conversion_warnings = False + + +# Initialize a config instance +nap_config = PynappleConfig() diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 3c0b2e5d..3a3e795f 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -48,10 +48,122 @@ jitvaluefromtensor, pjitconvolve, ) +from .config import nap_config from .interval_set import IntervalSet from .time_index import TsIndex +def is_array_like(obj): + """ + Check if an object is array-like. + + This function determines if an object has array-like properties. An object + is considered array-like if it has attributes typically associated with arrays + (such as `.shape`, `.dtype`, and `.ndim`), supports indexing, and is iterable. + + Parameters + ---------- + obj : object + The object to check for array-like properties. + + Returns + ------- + bool + True if the object is array-like, False otherwise. + + Notes + ----- + This function uses a combination of checks for attributes (`shape`, `dtype`, `ndim`), + indexability, and iterability to determine if the given object behaves like an array. + It is designed to be flexible and work with various types of array-like objects, including + but not limited to NumPy arrays and JAX arrays. However, it may not be foolproof for all + possible array-like types or objects that mimic these properties without being suitable for + numerical operations. + + Examples + -------- + >>> import numpy as np + >>> numpy_array = np.array([1, 2, 3]) + >>> jax_array = jnp.array([1, 2, 3]) + >>> non_array = "not an array" + >>> is_array_like(numpy_array) + True + >>> is_array_like(non_array) + False + """ + # Check for array-like attributes + has_shape = hasattr(obj, "shape") + has_dtype = hasattr(obj, "dtype") + has_ndim = hasattr(obj, "ndim") + + # Check for indexability (try to access the first element) + try: + obj[0] + is_indexable = True + except (TypeError, IndexError): + is_indexable = False + + # Check for iterable property + try: + iter(obj) + is_iterable = True + except TypeError: + is_iterable = False + + return has_shape and has_dtype and has_ndim and is_indexable and is_iterable + + +def convert_to_numpy(array, array_name): + """ + Convert an input array-like object to a NumPy array. + + This function attempts to convert an input object to a NumPy array using `np.asarray`. + If the input is not already a NumPy ndarray, it issues a warning indicating that a conversion + has taken place and shows the original type of the input. This function is useful for + ensuring compatibility with Numba operations in cases where the input might come from + various array-like sources (for instance, jax.numpy.Array). + + Parameters + ---------- + array : array_like + The input object to convert. This can be any object that `np.asarray` is capable of + converting to a NumPy array, such as lists, tuples, and other array-like objects, + including those from libraries like JAX or TensorFlow that adhere to the array interface. + array_name : str + The name of the variable that we are converting, printed in the warning message. + + Returns + ------- + ndarray + A NumPy ndarray representation of the input `values`. If `values` is already a NumPy + ndarray, it is returned unchanged. Otherwise, a new NumPy ndarray is created and returned. + + Warnings + -------- + A warning is issued if the input `values` is not already a NumPy ndarray, indicating + that a conversion has taken place and showing the original type of the input. + + Examples + -------- + >>> import jax.numpy as jnp + >>> list_int = [1, 2, 3] + >>> numpy_array = convert_to_numpy(list_int, "list_int") + UserWarning: Converting data to numpy.array. The provided array was of type 'list'. + >>> type(numpy_array) + + """ + if ( + not isinstance(array, np.ndarray) + and not nap_config.suppress_conversion_warnings + ): + original_type = type(array).__name__ + warnings.warn( + f"Converting '{array_name}' to numpy.array. The provided array was of type '{original_type}'.", + UserWarning, + ) + return np.asarray(array) + + def _split_tsd(func, tsd, indices_or_sections, axis=0): """ Wrappers of numpy split functions @@ -641,7 +753,8 @@ def restrict(self, iset): starts = iset.start.values ends = iset.end.values - if isinstance(self.values, np.ndarray): + if is_array_like(self.values): + data_array = self.values t, d = jitrestrict(time_array, data_array, starts, ends) @@ -1019,13 +1132,17 @@ def __init__(self, t, d, time_units="s", time_support=None, **kwargs): time_support : IntervalSet, optional The time support of the TsdFrame object """ - if isinstance(t, np.ndarray) and d is None: + if is_array_like(t) and d is None: raise RuntimeError("Missing argument d when initializing TsdTensor") if isinstance(t, (list, tuple)): t = np.array(t) + else: + t = convert_to_numpy(t, "t") if isinstance(d, (list, tuple)): d = np.array(d) + else: + d = convert_to_numpy(d, "d") assert ( d.ndim >= 3 @@ -1221,8 +1338,6 @@ def __init__(self, t, d=None, time_units="s", time_support=None, columns=None): columns : iterables Column names """ - if isinstance(t, np.ndarray) and d is None: - raise RuntimeError("Missing argument d when initializing TsdFrame") c = columns @@ -1231,10 +1346,18 @@ def __init__(self, t, d=None, time_units="s", time_support=None, columns=None): c = t.columns.values t = t.index.values + if is_array_like(t) and d is None: + raise RuntimeError("Missing argument d when initializing TsdFrame") + if isinstance(t, (list, tuple)): t = np.array(t) + elif is_array_like(t): + t = convert_to_numpy(t, "t") + if isinstance(d, (list, tuple)): d = np.array(d) + elif is_array_like(d): + d = convert_to_numpy(d, "d") assert d.ndim <= 2, "Data should be 1 or 2 dimensional" @@ -1541,17 +1664,22 @@ def __init__(self, t, d=None, time_units="s", time_support=None): time_support : IntervalSet, optional The time support of the tsd object """ - if isinstance(t, np.ndarray) and d is None: - raise RuntimeError("Missing argument d when initializing Tsd") - if isinstance(t, pd.Series): - d = t.values - t = t.index.values + d = np.asarray(t.values) + t = np.asarray(t.index.values) + + if is_array_like(t) and d is None: + raise RuntimeError("Missing argument d when initializing Tsd") if isinstance(t, (list, tuple)): t = np.array(t) + elif is_array_like(t): + t = convert_to_numpy(t, "t") + if isinstance(d, (list, tuple)): d = np.array(d) + elif is_array_like(d): + d = convert_to_numpy(d, "d") assert d.ndim == 1, "Data should be 1 dimension" @@ -1871,6 +1999,10 @@ def __init__(self, t, time_units="s", time_support=None): """ if isinstance(t, Number): t = np.array([t]) + # convert array-like data to numpy. + # raise a warning to avoid silent conversion if non-numpy array is provided (jax arrays for instance) + elif is_array_like(t): + t = convert_to_numpy(t, "t") if isinstance(t, TsIndex): self.index = t diff --git a/pyproject.toml b/pyproject.toml index 4e0e9bc5..4b55b7a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,8 @@ dev = [ "pytest", # Testing framework "flake8", # Code linter "coverage", # Test coverage measurement + "jax", + "jaxlib" ] docs = [ "mkdocs", # Documentation generator diff --git a/tests/__init__.py b/tests/__init__.py index 82dc6660..55f838c8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ """Unit test package for pynapple.""" +from . import mock diff --git a/tests/mock.py b/tests/mock.py new file mode 100644 index 00000000..56722a97 --- /dev/null +++ b/tests/mock.py @@ -0,0 +1,50 @@ +"""Test configuration script.""" + + +class MockArray: + """ + A mock array class designed for testing purposes. It mimics the behavior of array-like objects + by providing necessary attributes and supporting indexing and iteration, but it is not a direct + instance of numpy.ndarray. + """ + + def __init__(self, data): + """ + Initializes the MockArray with data. + + Parameters + ---------- + data : Union[numpy.ndarray, List] + A list of data elements that the MockArray will contain. + """ + self.data = data + self.shape = (len(data),) # Simplified shape attribute + self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic + self.ndim = 1 # Simplified ndim for a 1-dimensional array + + def __getitem__(self, index): + """ + Supports indexing into the mock array. + + Parameters + ---------- + index : int or slice + The index or slice of the data to access. + + Returns + ------- + The element(s) at the specified index. + """ + return self.data[index] + + def __iter__(self): + """ + Supports iteration over the mock array. + """ + return iter(self.data) + + def __len__(self): + """ + Returns the length of the mock array. + """ + return len(self.data) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..2c81f58d --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,84 @@ +from contextlib import nullcontext as does_not_raise + +import pytest + +import pynapple as nap + +from .mock import MockArray + + +@pytest.mark.parametrize("param, expectation", + [ + (True, does_not_raise()), + (False, does_not_raise()), + (1, pytest.raises(ValueError, + match="suppress_conversion_warnings must be a boolean value")) + ]) +def test_config_setter_input_validity(param, expectation): + """Test setting suppress_conversion_warnings with various inputs to validate type checking.""" + with expectation: + nap.config.nap_config.suppress_conversion_warnings = param + + +def test_config_setter_output(): + """Test if suppress_conversion_warnings property correctly retains a True value after being set.""" + nap.config.nap_config.suppress_conversion_warnings = True + assert nap.config.nap_config.suppress_conversion_warnings + + +def test_config_restore_default(): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = True + nap.config.nap_config.restore_defaults() + assert not nap.config.nap_config.suppress_conversion_warnings + + +@pytest.mark.parametrize("cls, t, d, conf, expectation", + [ + (nap.Ts, [0, 1], None, True, does_not_raise()), + (nap.Ts, [0, 1], None, False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.Tsd, [0, 1], [0, 1], True, does_not_raise()), + (nap.Tsd, [0, 1], [0, 1], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.TsdFrame, [0, 1], [[0], [1]], True, does_not_raise()), + (nap.TsdFrame, [0, 1], [[0], [1]], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], True, does_not_raise()), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], False, + pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), + + ]) +def test_config_supress_warining_t(cls, t, d, conf, expectation): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = conf + try: + with expectation: + if d is None: + cls(t=MockArray(t)) + else: + cls(t=MockArray(t), d=d) + finally: + nap.config.nap_config.restore_defaults() + +@pytest.mark.parametrize("cls, t, d, conf, expectation", + [ + (nap.Tsd, [0, 1], [0, 1], True, does_not_raise()), + (nap.Tsd, [0, 1], [0, 1], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + (nap.TsdFrame, [0, 1], [[0], [1]], True, does_not_raise()), + (nap.TsdFrame, [0, 1], [[0], [1]], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], True, does_not_raise()), + (nap.TsdTensor, [0, 1], [[[0]], [[1]]], False, + pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), + + ]) +def test_config_supress_warining_d(cls, t, d, conf, expectation): + """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" + nap.config.nap_config.suppress_conversion_warnings = conf + try: + with expectation: + cls(t=t, d=MockArray(d)) + finally: + nap.config.nap_config.restore_defaults() diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py new file mode 100644 index 00000000..5d2ff6a1 --- /dev/null +++ b/tests/test_non_numpy_array.py @@ -0,0 +1,58 @@ +from contextlib import nullcontext as does_not_raise + +import jax.numpy as jnp +import numpy as np +import pytest + +import pynapple as nap + +from .mock import MockArray + + +class TestTsArray: + + @pytest.mark.parametrize("time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()), + ("abc", pytest.raises(AttributeError, + match="'str' object has no attribute 'astype'")) + ]) + def test_ts_init(self, time, expectation): + with expectation: + nap.Ts(t=time) + + @pytest.mark.parametrize("time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()) + ]) + def test_ts_type(self, time, expectation): + with expectation: + ts = nap.Ts(t=time) + assert isinstance(ts.t, np.ndarray) + + @pytest.mark.parametrize("time, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")), + (MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")) + ]) + def test_ts_type(self, time, expectation): + with expectation: + nap.Ts(t=time) + + @pytest.mark.parametrize("time, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")), + (MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 't' to numpy.array")) + ]) + def test_ts_type(self, time, expectation): + with expectation: + nap.Ts(t=time) + diff --git a/tox.ini b/tox.ini index 0dc67bb5..a4020096 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,10 @@ envlist = py37,py38,py39,py310 requires = tox-conda [testenv] -deps = - black - flake8 - isort - pytest - coverage +# means we'll run the equivalent of `pip install .[dev]`, also installing pytest +# and the linters from pyproject.toml. The advantage is that you can to set your +# dev dependencies in a single place (pyproject.toml). +extras = dev commands = black --check pynapple From 98dd9c5fb11a1d60e26ef48afb78573f62daa076 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 11:09:24 -0500 Subject: [PATCH 03/23] completed tests --- tests/mock.py | 7 +- tests/test_non_numpy_array.py | 222 +++++++++++++++++++++++++++++----- 2 files changed, 195 insertions(+), 34 deletions(-) diff --git a/tests/mock.py b/tests/mock.py index 56722a97..de6ff917 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -1,4 +1,5 @@ """Test configuration script.""" +import numpy as np class MockArray: @@ -17,10 +18,10 @@ def __init__(self, data): data : Union[numpy.ndarray, List] A list of data elements that the MockArray will contain. """ - self.data = data - self.shape = (len(data),) # Simplified shape attribute + self.data = np.asarray(data) + self.shape = self.data.shape # Simplified shape attribute self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic - self.ndim = 1 # Simplified ndim for a 1-dimensional array + self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array def __getitem__(self, index): """ diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index 5d2ff6a1..5496a840 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -11,48 +11,208 @@ class TestTsArray: - @pytest.mark.parametrize("time, expectation", - [ - (jnp.array([1, 2, 3]), does_not_raise()), - (MockArray(np.array([1, 2, 3])), does_not_raise()), - ("abc", pytest.raises(AttributeError, - match="'str' object has no attribute 'astype'")) - ]) + @pytest.mark.parametrize( + "time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()), + ( + "abc", + pytest.raises( + AttributeError, match="'str' object has no attribute 'astype'" + ), + ), + ], + ) def test_ts_init(self, time, expectation): with expectation: nap.Ts(t=time) - @pytest.mark.parametrize("time, expectation", - [ - (jnp.array([1, 2, 3]), does_not_raise()), - (MockArray(np.array([1, 2, 3])), does_not_raise()) - ]) + @pytest.mark.parametrize( + "time, expectation", + [ + (jnp.array([1, 2, 3]), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()), + ], + ) def test_ts_type(self, time, expectation): with expectation: ts = nap.Ts(t=time) assert isinstance(ts.t, np.ndarray) - @pytest.mark.parametrize("time, expectation", - [ - (np.array([1, 2, 3]), does_not_raise()), - (jnp.array([1, 2, 3]), - pytest.warns(UserWarning, match="Converting 't' to numpy.array")), - (MockArray(np.array([1, 2, 3])), - pytest.warns(UserWarning, match="Converting 't' to numpy.array")) - ]) - def test_ts_type(self, time, expectation): + @pytest.mark.parametrize( + "time, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + ( + jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 't' to numpy.array"), + ), + ( + MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 't' to numpy.array"), + ), + ], + ) + def test_ts_warn(self, time, expectation): with expectation: nap.Ts(t=time) - @pytest.mark.parametrize("time, expectation", - [ - (np.array([1, 2, 3]), does_not_raise()), - (jnp.array([1, 2, 3]), - pytest.warns(UserWarning, match="Converting 't' to numpy.array")), - (MockArray(np.array([1, 2, 3])), - pytest.warns(UserWarning, match="Converting 't' to numpy.array")) - ]) - def test_ts_type(self, time, expectation): + +class TestTsdArray: + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (jnp.array([1, 2, 3]), jnp.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + ( + jnp.array([1, 2, 3]), + "abc", + pytest.raises( + AttributeError, match="'str' object has no attribute 'ndim'" + ), + ), + ], + ) + def test_tsd_init(self, time, data, expectation): with expectation: - nap.Ts(t=time) + nap.Tsd(t=time, d=data) + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([1, 2, 3]), does_not_raise()), + (np.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + ], + ) + def test_tsd_type(self, time, data, expectation): + with expectation: + ts = nap.Tsd(t=time, d=data) + assert isinstance(ts.d, np.ndarray) + + @pytest.mark.parametrize( + "data, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + ( + jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ( + MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ], + ) + def test_tsd_warn(self, data, expectation): + with expectation: + nap.Tsd(t=np.array(data), d=data) + + +class TestTsdFrameArray: + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (jnp.array([1, 2, 3]), jnp.array([1, 2, 3]), does_not_raise()), + (jnp.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + ( + jnp.array([1, 2, 3]), + "abc", + pytest.raises( + AttributeError, match="'str' object has no attribute 'ndim'" + ), + ), + ], + ) + def test_tsdframe_init(self, time, data, expectation): + with expectation: + nap.TsdFrame(t=time, d=data) + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([1, 2, 3]), does_not_raise()), + (np.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + ], + ) + def test_tsdframe_type(self, time, data, expectation): + with expectation: + ts = nap.TsdFrame(t=time, d=data) + assert isinstance(ts.d, np.ndarray) + + @pytest.mark.parametrize( + "data, expectation", + [ + (np.array([1, 2, 3]), does_not_raise()), + ( + jnp.array([1, 2, 3]), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ( + MockArray(np.array([1, 2, 3])), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ], + ) + def test_tsdframe_warn(self, data, expectation): + with expectation: + nap.TsdFrame(t=np.array(data), d=data) + +class TestTsdTensorArray: + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (jnp.array([1, 2, 3]), jnp.array([[[1]], [[2]], [[3]]]), does_not_raise()), + ( + jnp.array([1, 2, 3]), + MockArray(np.array([[[1]], [[2]], [[3]]])), + does_not_raise(), + ), + ( + jnp.array([1, 2, 3]), + "abc", + pytest.raises(AssertionError, match="Data should have more than"), + ), + ], + ) + def test_tsdtensor_init(self, time, data, expectation): + with expectation: + nap.TsdTensor(t=time, d=data) + + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([[[1]], [[2]], [[3]]]), does_not_raise()), + ( + np.array([1, 2, 3]), + MockArray(np.array([[[1]], [[2]], [[3]]])), + does_not_raise(), + ), + ], + ) + def test_tsdtensor_type(self, time, data, expectation): + with expectation: + ts = nap.TsdTensor(t=time, d=data) + assert isinstance(ts.d, np.ndarray) + + @pytest.mark.parametrize( + "data, expectation", + [ + (np.array([[[1]], [[2]], [[3]]]), does_not_raise()), + ( + jnp.array([[[1]], [[2]], [[3]]]), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ( + MockArray(np.array([[[1]], [[2]], [[3]]])), + pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), + ), + ], + ) + def test_tsdtensor_warn(self, data, expectation): + with expectation: + nap.TsdTensor(t=np.array(data), d=data) From c27e551159ba079f85c070d126a1ebac99df3c08 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 13:09:42 -0500 Subject: [PATCH 04/23] added docstrigs to test --- tests/test_non_numpy_array.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index c2bec1f1..e3d05158 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -25,6 +25,7 @@ class TestTsArray: ], ) def test_ts_init(self, time, expectation): + """Verify the expected behavior of the initialization for Ts objects.""" with expectation: nap.Ts(t=time) @@ -36,6 +37,7 @@ def test_ts_init(self, time, expectation): ], ) def test_ts_type(self, time, expectation): + """Verify that the time attribute 't' of a Ts object is stored as a numpy.ndarray.""" with expectation: ts = nap.Ts(t=time) assert isinstance(ts.t, np.ndarray) @@ -55,6 +57,7 @@ def test_ts_type(self, time, expectation): ], ) def test_ts_warn(self, time, expectation): + """Check for warnings when the time attribute 't' is automatically converted to numpy.ndarray.""" with expectation: nap.Ts(t=time) @@ -76,6 +79,7 @@ class TestTsdArray: ], ) def test_tsd_init(self, time, data, expectation): + """Verify the expected behavior of the initialization for Tsd objects.""" with expectation: nap.Tsd(t=time, d=data) @@ -87,6 +91,7 @@ def test_tsd_init(self, time, data, expectation): ], ) def test_tsd_type(self, time, data, expectation): + """Verify that the data attribute 'd' of a Tsd object is stored as a numpy.ndarray.""" with expectation: ts = nap.Tsd(t=time, d=data) assert isinstance(ts.d, np.ndarray) @@ -106,6 +111,7 @@ def test_tsd_type(self, time, data, expectation): ], ) def test_tsd_warn(self, data, expectation): + """Check for warnings when the data attribute 'd' is automatically converted to numpy.ndarray.""" with expectation: nap.Tsd(t=np.array(data), d=data) @@ -127,6 +133,7 @@ class TestTsdFrameArray: ], ) def test_tsdframe_init(self, time, data, expectation): + """Verify the expected behavior of the initialization for TsdFrame objects.""" with expectation: nap.TsdFrame(t=time, d=data) @@ -138,6 +145,7 @@ def test_tsdframe_init(self, time, data, expectation): ], ) def test_tsdframe_type(self, time, data, expectation): + """Verify that the data attribute 'd' of a TsdFrame object is stored as a numpy.ndarray.""" with expectation: ts = nap.TsdFrame(t=time, d=data) assert isinstance(ts.d, np.ndarray) @@ -157,6 +165,7 @@ def test_tsdframe_type(self, time, data, expectation): ], ) def test_tsdframe_warn(self, data, expectation): + """Check for warnings when the data attribute 'd' is automatically converted to numpy.ndarray.""" with expectation: nap.TsdFrame(t=np.array(data), d=data) @@ -180,6 +189,7 @@ class TestTsdTensorArray: ], ) def test_tsdtensor_init(self, time, data, expectation): + """Verify the expected behavior of the initialization for TsdTensor objects.""" with expectation: nap.TsdTensor(t=time, d=data) @@ -195,6 +205,7 @@ def test_tsdtensor_init(self, time, data, expectation): ], ) def test_tsdtensor_type(self, time, data, expectation): + """Verify that the data attribute 'd' of a TsdTensor object is stored as a numpy.ndarray.""" with expectation: ts = nap.TsdTensor(t=time, d=data) assert isinstance(ts.d, np.ndarray) @@ -214,6 +225,7 @@ def test_tsdtensor_type(self, time, data, expectation): ], ) def test_tsdtensor_warn(self, data, expectation): + """Check for warnings when the data attribute 'd' is automatically converted to numpy.ndarray.""" with expectation: nap.TsdTensor(t=np.array(data), d=data) From 68e1c4f80a89faec6555e508fcb227a98e232141 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 13:15:31 -0500 Subject: [PATCH 05/23] added test for t attribute --- tests/test_non_numpy_array.py | 55 +++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index e3d05158..0b66efc2 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -90,12 +90,29 @@ def test_tsd_init(self, time, data, expectation): (np.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), ], ) - def test_tsd_type(self, time, data, expectation): + def test_tsd_type_d(self, time, data, expectation): """Verify that the data attribute 'd' of a Tsd object is stored as a numpy.ndarray.""" with expectation: ts = nap.Tsd(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([1, 2, 3]), does_not_raise()), + ( + MockArray(np.array([1, 2, 3])), + np.array([1, 2, 3]), + does_not_raise(), + ), + ], + ) + def test_tsd_type_t(self, time, data, expectation): + """Verify that the time attribute 't' of a TsdFrame object is stored as a numpy.ndarray.""" + with expectation: + ts = nap.Tsd(t=time, d=data) + assert isinstance(ts.t, np.ndarray) + @pytest.mark.parametrize( "data, expectation", [ @@ -150,6 +167,23 @@ def test_tsdframe_type(self, time, data, expectation): ts = nap.TsdFrame(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([[1], [2], [3]]), does_not_raise()), + ( + MockArray(np.array([1, 2, 3])), + np.array([[1], [2], [3]]), + does_not_raise(), + ), + ], + ) + def test_tsdframe_type_t(self, time, data, expectation): + """Verify that the time attribute 't' of a TsdFrame object is stored as a numpy.ndarray.""" + with expectation: + ts = nap.TsdFrame(t=time, d=data) + assert isinstance(ts.t, np.ndarray) + @pytest.mark.parametrize( "data, expectation", [ @@ -204,12 +238,29 @@ def test_tsdtensor_init(self, time, data, expectation): ), ], ) - def test_tsdtensor_type(self, time, data, expectation): + def test_tsdtensor_type_d(self, time, data, expectation): """Verify that the data attribute 'd' of a TsdTensor object is stored as a numpy.ndarray.""" with expectation: ts = nap.TsdTensor(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.parametrize( + "time, data, expectation", + [ + (np.array([1, 2, 3]), np.array([[[1]], [[2]], [[3]]]), does_not_raise()), + ( + MockArray(np.array([1, 2, 3])), + np.array([[[1]], [[2]], [[3]]]), + does_not_raise(), + ), + ], + ) + def test_tsdtensor_type_t(self, time, data, expectation): + """Verify that the time attribute 't' of a TsdTensor object is stored as a numpy.ndarray.""" + with expectation: + ts = nap.TsdTensor(t=time, d=data) + assert isinstance(ts.t, np.ndarray) + @pytest.mark.parametrize( "data, expectation", [ From 62da4a69d3eca891f4eb23fc48b5d42139ddf75c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 19:15:28 -0500 Subject: [PATCH 06/23] removed jax dependency --- pynapple/core/time_series.py | 4 ++-- pyproject.toml | 2 -- tests/test_non_numpy_array.py | 37 ++++++++--------------------------- 3 files changed, 10 insertions(+), 33 deletions(-) diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 3a3e795f..aca7518d 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -1665,8 +1665,8 @@ def __init__(self, t, d=None, time_units="s", time_support=None): The time support of the tsd object """ if isinstance(t, pd.Series): - d = np.asarray(t.values) - t = np.asarray(t.index.values) + d = t.values + t = t.index.values if is_array_like(t) and d is None: raise RuntimeError("Missing argument d when initializing Tsd") diff --git a/pyproject.toml b/pyproject.toml index 4b55b7a1..4e0e9bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,6 @@ dev = [ "pytest", # Testing framework "flake8", # Code linter "coverage", # Test coverage measurement - "jax", - "jaxlib" ] docs = [ "mkdocs", # Documentation generator diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index 0b66efc2..17b0519b 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -1,6 +1,5 @@ from contextlib import nullcontext as does_not_raise -import jax.numpy as jnp import numpy as np import pytest @@ -14,7 +13,6 @@ class TestTsArray: @pytest.mark.parametrize( "time, expectation", [ - (jnp.array([1, 2, 3]), does_not_raise()), (MockArray(np.array([1, 2, 3])), does_not_raise()), ( "abc", @@ -32,8 +30,7 @@ def test_ts_init(self, time, expectation): @pytest.mark.parametrize( "time, expectation", [ - (jnp.array([1, 2, 3]), does_not_raise()), - (MockArray(np.array([1, 2, 3])), does_not_raise()), + (MockArray(np.array([1, 2, 3])), does_not_raise()) ], ) def test_ts_type(self, time, expectation): @@ -46,10 +43,6 @@ def test_ts_type(self, time, expectation): "time, expectation", [ (np.array([1, 2, 3]), does_not_raise()), - ( - jnp.array([1, 2, 3]), - pytest.warns(UserWarning, match="Converting 't' to numpy.array"), - ), ( MockArray(np.array([1, 2, 3])), pytest.warns(UserWarning, match="Converting 't' to numpy.array"), @@ -67,10 +60,10 @@ class TestTsdArray: @pytest.mark.parametrize( "time, data, expectation", [ - (jnp.array([1, 2, 3]), jnp.array([1, 2, 3]), does_not_raise()), - (jnp.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + (MockArray([1, 2, 3]), MockArray([1, 2, 3]), does_not_raise()), + (MockArray([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), ( - jnp.array([1, 2, 3]), + MockArray([1, 2, 3]), "abc", pytest.raises( AttributeError, match="'str' object has no attribute 'ndim'" @@ -117,10 +110,6 @@ def test_tsd_type_t(self, time, data, expectation): "data, expectation", [ (np.array([1, 2, 3]), does_not_raise()), - ( - jnp.array([1, 2, 3]), - pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), - ), ( MockArray(np.array([1, 2, 3])), pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), @@ -138,10 +127,9 @@ class TestTsdFrameArray: @pytest.mark.parametrize( "time, data, expectation", [ - (jnp.array([1, 2, 3]), jnp.array([1, 2, 3]), does_not_raise()), - (jnp.array([1, 2, 3]), MockArray(np.array([1, 2, 3])), does_not_raise()), + (MockArray([1, 2, 3]), MockArray([1, 2, 3]), does_not_raise()), ( - jnp.array([1, 2, 3]), + MockArray([1, 2, 3]), "abc", pytest.raises( AttributeError, match="'str' object has no attribute 'ndim'" @@ -188,10 +176,6 @@ def test_tsdframe_type_t(self, time, data, expectation): "data, expectation", [ (np.array([1, 2, 3]), does_not_raise()), - ( - jnp.array([1, 2, 3]), - pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), - ), ( MockArray(np.array([1, 2, 3])), pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), @@ -209,14 +193,13 @@ class TestTsdTensorArray: @pytest.mark.parametrize( "time, data, expectation", [ - (jnp.array([1, 2, 3]), jnp.array([[[1]], [[2]], [[3]]]), does_not_raise()), ( - jnp.array([1, 2, 3]), + MockArray([1, 2, 3]), MockArray(np.array([[[1]], [[2]], [[3]]])), does_not_raise(), ), ( - jnp.array([1, 2, 3]), + MockArray([1, 2, 3]), "abc", pytest.raises(AssertionError, match="Data should have more than"), ), @@ -265,10 +248,6 @@ def test_tsdtensor_type_t(self, time, data, expectation): "data, expectation", [ (np.array([[[1]], [[2]], [[3]]]), does_not_raise()), - ( - jnp.array([[[1]], [[2]], [[3]]]), - pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), - ), ( MockArray(np.array([[[1]], [[2]], [[3]]])), pytest.warns(UserWarning, match="Converting 'd' to numpy.array"), From 232a15362effa15d64a44fdcc96c65eefc4d5589 Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Wed, 7 Feb 2024 19:22:54 -0500 Subject: [PATCH 07/23] removed jax from docstrings --- pynapple/core/time_series.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index aca7518d..900b34ba 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -80,16 +80,6 @@ def is_array_like(obj): possible array-like types or objects that mimic these properties without being suitable for numerical operations. - Examples - -------- - >>> import numpy as np - >>> numpy_array = np.array([1, 2, 3]) - >>> jax_array = jnp.array([1, 2, 3]) - >>> non_array = "not an array" - >>> is_array_like(numpy_array) - True - >>> is_array_like(non_array) - False """ # Check for array-like attributes has_shape = hasattr(obj, "shape") @@ -143,14 +133,6 @@ def convert_to_numpy(array, array_name): A warning is issued if the input `values` is not already a NumPy ndarray, indicating that a conversion has taken place and showing the original type of the input. - Examples - -------- - >>> import jax.numpy as jnp - >>> list_int = [1, 2, 3] - >>> numpy_array = convert_to_numpy(list_int, "list_int") - UserWarning: Converting data to numpy.array. The provided array was of type 'list'. - >>> type(numpy_array) - """ if ( not isinstance(array, np.ndarray) From 20eef9a5cddd0fb0e9bf424dffd67090ee68386a Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 8 Feb 2024 09:02:49 -0500 Subject: [PATCH 08/23] generalized TsGroup behavior --- pynapple/core/time_series.py | 8 +++++--- pynapple/core/ts_group.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 900b34ba..d2df9404 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -57,8 +57,8 @@ def is_array_like(obj): """ Check if an object is array-like. - This function determines if an object has array-like properties. An object - is considered array-like if it has attributes typically associated with arrays + This function determines if an object has array-like properties but isn't an _AstractTsd. + An object is considered array-like if it has attributes typically associated with arrays (such as `.shape`, `.dtype`, and `.ndim`), supports indexing, and is iterable. Parameters @@ -100,7 +100,9 @@ def is_array_like(obj): except TypeError: is_iterable = False - return has_shape and has_dtype and has_ndim and is_indexable and is_iterable + not_tsd_type = not isinstance(obj, _AbstractTsd) + + return has_shape and has_dtype and has_ndim and is_indexable and is_iterable and not_tsd_type def convert_to_numpy(array, array_name): diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index 0a3a930a..3aee9086 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -23,7 +23,7 @@ # from .time_units import format_timestamps from .time_index import TsIndex -from .time_series import Ts, Tsd, TsdFrame +from .time_series import Ts, Tsd, TsdFrame, is_array_like def union_intervals(i_sets): @@ -105,13 +105,13 @@ def __init__( # Transform elements to Ts/Tsd objects for k in self.index: - if isinstance(data[k], (np.ndarray, list)): + if isinstance(data[k], list) or is_array_like(data[k]): warnings.warn( "Elements should not be passed as numpy array. Default time units is seconds when creating the Ts object.", stacklevel=2, ) data[k] = Ts( - t=data[k], time_support=time_support, time_units=time_units + t=np.asarray(data[k]), time_support=time_support, time_units=time_units ) # If time_support is passed, all elements of data are restricted prior to init From 60345006e8f08c8bd50fc5efe84382194444711c Mon Sep 17 00:00:00 2001 From: BalzaniEdoardo Date: Thu, 8 Feb 2024 09:11:58 -0500 Subject: [PATCH 09/23] linted --- pynapple/core/time_series.py | 9 ++++++++- pynapple/core/ts_group.py | 4 +++- tox.ini | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index d2df9404..22d9e461 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -102,7 +102,14 @@ def is_array_like(obj): not_tsd_type = not isinstance(obj, _AbstractTsd) - return has_shape and has_dtype and has_ndim and is_indexable and is_iterable and not_tsd_type + return ( + has_shape + and has_dtype + and has_ndim + and is_indexable + and is_iterable + and not_tsd_type + ) def convert_to_numpy(array, array_name): diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index 3aee9086..e89d2842 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -111,7 +111,9 @@ def __init__( stacklevel=2, ) data[k] = Ts( - t=np.asarray(data[k]), time_support=time_support, time_units=time_units + t=np.asarray(data[k]), + time_support=time_support, + time_units=time_units, ) # If time_support is passed, all elements of data are restricted prior to init diff --git a/tox.ini b/tox.ini index a4020096..e417d66e 100644 --- a/tox.ini +++ b/tox.ini @@ -21,4 +21,4 @@ commands = python = 3.8: py38 3.9: py39 - 3.10: py310 + 3.10: py310 \ No newline at end of file From 539c838c47ac72b0ce56a1d896a9eaa6ed15ea84 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Tue, 13 Feb 2024 17:44:50 -0500 Subject: [PATCH 10/23] Changing pynapple core; adding abstract Base class and abstract BaseTsd class --- pynapple/__init__.py | 2 +- pynapple/core/_jitted_functions.py | 6 +- pynapple/core/base_class.py | 482 ++++++++++++ pynapple/core/config.py | 42 +- pynapple/core/time_index.py | 4 +- pynapple/core/time_series.py | 1140 ++++++---------------------- pynapple/core/ts_group.py | 35 +- pynapple/core/utils.py | 201 +++++ tests/__init__.py | 1 - tests/mock.py | 52 -- tests/test_abstract_tsd.py | 93 ++- tests/test_config.py | 54 +- tests/test_jitted.py | 4 +- tests/test_non_numpy_array.py | 80 +- tests/test_numpy_compatibility.py | 6 +- tests/test_time_series.py | 54 +- tests/test_ts_group.py | 4 +- 17 files changed, 1227 insertions(+), 1033 deletions(-) create mode 100644 pynapple/core/base_class.py create mode 100644 pynapple/core/utils.py delete mode 100644 tests/mock.py diff --git a/pynapple/__init__.py b/pynapple/__init__.py index 0c57730a..1ea30652 100644 --- a/pynapple/__init__.py +++ b/pynapple/__init__.py @@ -1,4 +1,4 @@ __version__ = "0.5.1" -from .core import * +from .core import IntervalSet, Ts, Tsd, TsdFrame, TsdTensor, TsGroup, TsIndex, config from .io import * from .process import * diff --git a/pynapple/core/_jitted_functions.py b/pynapple/core/_jitted_functions.py index 8679adfa..48d6839f 100644 --- a/pynapple/core/_jitted_functions.py +++ b/pynapple/core/_jitted_functions.py @@ -891,9 +891,9 @@ def jitcontinuous_perievent( left = np.minimum(windowsize[0], t_pos - start_t[k, 0]) right = np.minimum(windowsize[1], maxt - t_pos - 1) center = windowsize[0] + 1 - new_data_array[center - left - 1 : center + right, cnt_i] = ( - data_array[t_pos - left : t_pos + right + 1] - ) + new_data_array[ + center - left - 1 : center + right, cnt_i + ] = data_array[t_pos - left : t_pos + right + 1] t -= 1 i += 1 diff --git a/pynapple/core/base_class.py b/pynapple/core/base_class.py new file mode 100644 index 00000000..71c8683a --- /dev/null +++ b/pynapple/core/base_class.py @@ -0,0 +1,482 @@ +# -*- coding: utf-8 -*- +# @Author: Guillaume Viejo +# @Date: 2024-02-09 12:09:18 +# @Last Modified by: Guillaume Viejo +# @Last Modified time: 2024-02-13 17:26:35 + +""" + Abstract class for `core` time series. + +""" + +import abc +from numbers import Number + +import numpy as np + +from ._jitted_functions import ( + jitcount, + jitrestrict, + jittsrestrict, + jittsrestrict_with_count, + jitvaluefrom, + jitvaluefromtensor, +) +from .interval_set import IntervalSet +from .time_index import TsIndex +from .utils import convert_to_numpy, is_array_like + + +class Base(abc.ABC): + """ + Abstract base class for time series and timestamps objects. + Implement most of the shared functions across concrete classes `Ts`, `Tsd`, `TsdFrame`, `TsdTensor` + """ + + _initialized = False + + def __init__(self, t, time_units="s", time_support=None): + # Converting t to TsIndex array + if isinstance(t, TsIndex): + self.index = t + elif isinstance(t, Number): + self.index = TsIndex(np.array([t]), time_units) + elif isinstance(t, (list, tuple)): + self.index = TsIndex(np.array(t).flatten(), time_units) + elif isinstance(t, np.ndarray): + assert t.ndim == 1, "t should be 1 dimensional" + self.index = TsIndex(t, time_units) + # convert array-like data to numpy. + # raise a warning to avoid silent conversion if non-numpy array is provided (jax arrays for instance) + elif is_array_like(t): + t = convert_to_numpy(t, "t") + self.index = TsIndex(t, time_units) + else: + raise RuntimeError( + "Unknown format for t. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." + ) + + if time_support is not None: + assert isinstance( + time_support, IntervalSet + ), "time_support should be an IntervalSet" + + # Restrict should occur in the inherited class + if len(self.index): + if isinstance(time_support, IntervalSet): + self.time_support = time_support + else: + self.time_support = IntervalSet(start=self.index[0], end=self.index[-1]) + + self.rate = self.index.shape[0] / np.sum( + self.time_support.values[:, 1] - self.time_support.values[:, 0] + ) + else: + self.rate = np.NaN + self.time_support = IntervalSet(start=[], end=[]) + + @property + def t(self): + return self.index.values + + @property + def start(self): + return self.start_time() + + @property + def end(self): + return self.end_time() + + @property + def shape(self): + return self.index.shape + + def __repr__(self): + return str(self.__class__) + + def __str__(self): + return self.__repr__() + + def __len__(self): + return len(self.index) + + def __setattr__(self, name, value): + """Object is immutable""" + if self._initialized: + raise RuntimeError( + "Changing directly attributes is not permitted for {}.".format( + self.nap_class + ) + ) + else: + object.__setattr__(self, name, value) + + @abc.abstractmethod + def __getitem__(self, key, *args, **kwargs): + """getter for time series""" + pass + + def __setitem__(self, key, value): + pass + + def times(self, units="s"): + """ + The time index of the object, returned as np.double in the desired time units. + + Parameters + ---------- + units : str, optional + ('us', 'ms', 's' [default]) + + Returns + ------- + out: numpy.ndarray + the time indexes + """ + return self.index.in_units(units) + + def start_time(self, units="s"): + """ + The first time index in the time series object + + Parameters + ---------- + units : str, optional + ('us', 'ms', 's' [default]) + + Returns + ------- + out: numpy.float64 + _ + """ + if len(self.index): + return self.times(units=units)[0] + else: + return None + + def end_time(self, units="s"): + """ + The last time index in the time series object + + Parameters + ---------- + units : str, optional + ('us', 'ms', 's' [default]) + + Returns + ------- + out: numpy.float64 + _ + """ + if len(self.index): + return self.times(units=units)[-1] + else: + return None + + def value_from(self, data, ep=None): + """ + Replace the value with the closest value from Tsd/TsdFrame/TsdTensor argument + + Parameters + ---------- + data : Tsd, TsdFrame or TsdTensor + The object holding the values to replace. + ep : IntervalSet (optional) + The IntervalSet object to restrict the operation. + If None, the time support of the tsd input object is used. + + Returns + ------- + out : Tsd, TsdFrame or TsdTensor + Object with the new values + + Examples + -------- + In this example, the ts object will receive the closest values in time from tsd. + + >>> import pynapple as nap + >>> import numpy as np + >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) # random times + >>> ts = nap.Ts(t=t, time_units='s') + >>> tsd = nap.Tsd(t=np.arange(0,1000), d=np.random.rand(1000), time_units='s') + >>> ep = nap.IntervalSet(start = 0, end = 500, time_units = 's') + + The variable ts is a time series object containing only nan. + The tsd object containing the values, for example the tracking data, and the epoch to restrict the operation. + + >>> newts = ts.value_from(tsd, ep) + + newts is the same size as ts restrict to ep. + + >>> print(len(ts.restrict(ep)), len(newts)) + 52 52 + """ + if ep is None: + ep = data.time_support + time_array = self.index.values + time_target_array = data.index.values + data_target_array = data.values + starts = ep.start.values + ends = ep.end.values + + if data_target_array.ndim == 1: + t, d, ns, ne = jitvaluefrom( + time_array, time_target_array, data_target_array, starts, ends + ) + else: + t, d, ns, ne = jitvaluefromtensor( + time_array, time_target_array, data_target_array, starts, ends + ) + + time_support = IntervalSet(start=ns, end=ne) + + kwargs = {} + if hasattr(data, "columns"): + kwargs["columns"] = data.columns + + return t, d, time_support, kwargs + + def count(self, *args, **kwargs): + """ + Count occurences of events within bin_size or within a set of bins defined as an IntervalSet. + You can call this function in multiple ways : + + 1. *tsd.count(bin_size=1, time_units = 'ms')* + -> Count occurence of events within a 1 ms bin defined on the time support of the object. + + 2. *tsd.count(1, ep=my_epochs)* + -> Count occurent of events within a 1 second bin defined on the IntervalSet my_epochs. + + 3. *tsd.count(ep=my_bins)* + -> Count occurent of events within each epoch of the intervalSet object my_bins + + 4. *tsd.count()* + -> Count occurent of events within each epoch of the time support. + + bin_size should be seconds unless specified. + If bin_size is used and no epochs is passed, the data will be binned based on the time support of the object. + + Parameters + ---------- + bin_size : None or float, optional + The bin size (default is second) + ep : None or IntervalSet, optional + IntervalSet to restrict the operation + time_units : str, optional + Time units of bin size ('us', 'ms', 's' [default]) + + Returns + ------- + out: Tsd + A Tsd object indexed by the center of the bins. + + Examples + -------- + This example shows how to count events within bins of 0.1 second. + + >>> import pynapple as nap + >>> import numpy as np + >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) + >>> ts = nap.Ts(t=t, time_units='s') + >>> bincount = ts.count(0.1) + + An epoch can be specified: + + >>> ep = nap.IntervalSet(start = 100, end = 800, time_units = 's') + >>> bincount = ts.count(0.1, ep=ep) + + And bincount automatically inherit ep as time support: + + >>> bincount.time_support + >>> start end + >>> 0 100.0 800.0 + """ + bin_size = None + if "bin_size" in kwargs: + bin_size = kwargs["bin_size"] + if isinstance(bin_size, int): + bin_size = float(bin_size) + if not isinstance(bin_size, float): + raise ValueError("bin_size argument should be float.") + else: + for a in args: + if isinstance(a, (float, int)): + bin_size = float(a) + + time_units = "s" + if "time_units" in kwargs: + time_units = kwargs["time_units"] + if not isinstance(time_units, str): + raise ValueError("time_units argument should be 's', 'ms' or 'us'.") + else: + for a in args: + if isinstance(a, str) and a in ["s", "ms", "us"]: + time_units = a + + ep = self.time_support + if "ep" in kwargs: + ep = kwargs["ep"] + if not isinstance(ep, IntervalSet): + raise ValueError("ep argument should be IntervalSet") + else: + for a in args: + if isinstance(a, IntervalSet): + ep = a + + time_array = self.index.values + starts = ep.start.values + ends = ep.end.values + + if isinstance(bin_size, (float, int)): + bin_size = TsIndex.format_timestamps(np.array([bin_size]), time_units)[0] + t, d = jitcount(time_array, starts, ends, bin_size) + else: + _, d = jittsrestrict_with_count(time_array, starts, ends) + t = starts + (ends - starts) / 2 + + return t, d, ep + + def restrict(self, iset): + """ + Restricts a time series object to a set of time intervals delimited by an IntervalSet object + + Parameters + ---------- + iset : IntervalSet + the IntervalSet object + + Returns + ------- + out: Ts, Tsd, TsdFrame or TsdTensor + Tsd object restricted to ep + + Examples + -------- + The Ts object is restrict to the intervals defined by ep. + + >>> import pynapple as nap + >>> import numpy as np + >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) + >>> ts = nap.Ts(t=t, time_units='s') + >>> ep = nap.IntervalSet(start=0, end=500, time_units='s') + >>> newts = ts.restrict(ep) + + The time support of newts automatically inherit the epochs defined by ep. + + >>> newts.time_support + >>> start end + >>> 0 0.0 500.0 + + """ + assert isinstance(iset, IntervalSet), "Argument should be IntervalSet" + + time_array = self.index.values + starts = iset.start.values + ends = iset.end.values + + if hasattr(self, "values"): + data_array = self.values + t, d = jitrestrict(time_array, data_array, starts, ends) + + kwargs = {} + if hasattr(self, "columns"): + kwargs["columns"] = self.columns + + return self.__class__(t=t, d=d, time_support=iset, **kwargs) + + else: + t = jittsrestrict(time_array, starts, ends) + return self.__class__(t=t, time_support=iset) + + def copy(self): + """Copy the data, index and time support""" + return self.__class__(t=self.index.copy(), time_support=self.time_support) + + def find_support(self, min_gap, time_units="s"): + """ + find the smallest (to a min_gap resolution) IntervalSet containing all the times in the Tsd + + Parameters + ---------- + min_gap : float or int + minimal interval between timestamps + time_units : str, optional + Time units of min gap + + Returns + ------- + IntervalSet + Description + """ + assert isinstance(min_gap, Number), "min_gap should be a float or int" + min_gap = TsIndex.format_timestamps(np.array([min_gap]), time_units)[0] + time_array = self.index.values + + starts = [time_array[0]] + ends = [] + for i in range(len(time_array) - 1): + if (time_array[i + 1] - time_array[i]) > min_gap: + ends.append(time_array[i] + 1e-6) + starts.append(time_array[i + 1]) + + ends.append(time_array[-1] + 1e-6) + + return IntervalSet(start=starts, end=ends) + + def get(self, start, end=None, time_units="s"): + """Slice the time series from `start` to `end` such that all the timestamps satisfy `start<=t<=end`. + If `end` is None, only the timepoint closest to `start` is returned. + + By default, the time support doesn't change. If you want to change the time support, use the `restrict` function. + + Parameters + ---------- + start : float or int + The start + end : float or int + The end + """ + assert isinstance(start, Number), "start should be a float or int" + time_array = self.index.values + + if end is None: + start = TsIndex.format_timestamps(np.array([start]), time_units)[0] + idx = int(np.searchsorted(time_array, start)) + if idx == 0: + return self[idx] + elif idx >= self.shape[0]: + return self[-1] + else: + if start - time_array[idx - 1] < time_array[idx] - start: + return self[idx - 1] + else: + return self[idx] + else: + assert isinstance(end, Number), "end should be a float or int" + assert start < end, "Start should not precede end" + start, end = TsIndex.format_timestamps(np.array([start, end]), time_units) + idx_start = np.searchsorted(time_array, start) + idx_end = np.searchsorted(time_array, end, side="right") + return self[idx_start:idx_end] + + # def find_gaps(self, min_gap, time_units='s'): + # """ + # finds gaps in a tsd larger than min_gap. Return an IntervalSet. + # Epochs are defined by adding and removing 1 microsecond to the time index. + + # Parameters + # ---------- + # min_gap : float + # The minimum interval size considered to be a gap (default is second). + # time_units : str, optional + # Time units of min_gap ('us', 'ms', 's' [default]) + # """ + # min_gap = format_timestamps(np.array([min_gap]), time_units)[0] + + # time_array = self.index + # starts = self.time_support.start.values + # ends = self.time_support.end.values + + # s, e = jitfind_gaps(time_array, starts, ends, min_gap) + + # return nap.IntervalSet(s, e) diff --git a/pynapple/core/config.py b/pynapple/core/config.py index 5ca588f5..4548403e 100644 --- a/pynapple/core/config.py +++ b/pynapple/core/config.py @@ -1,4 +1,4 @@ -"""Package configurations. +"""This module deals with package configurations. For now it includes only warning configurations. """ @@ -9,12 +9,40 @@ class PynappleConfig: This class includes all configuration settings that control the behavior of pynapple. It offers a structured way to access and modify settings. - Attributes: - ----------- - suppress_conversion_warnings (bool): + Examples + -------- + >>> import pynapple as nap + >>> import jax.numpy as jnp + >>> t = jnp.arange(3) + >>> print(t) + Array([0, 1, 2], dtype=int32) + + >>> # Suppress warnings when converting a non-numpy array to numpy array + >>> nap.config.nap_config.suppress_conversion_warnings = True + >>> nap.Ts(t=t) + Time (s) + 0.0 + 1.0 + 2.0 + shape: 3 + + >>> # Restore to defaults + >>> nap.config.nap_config.restore_defaults() + >>> nap.Ts(t=t) + /mnt/home/gviejo/pynapple/pynapple/core/time_series.py:151: UserWarning: Converting 't' to n + umpy.array. The provided array was of type 'ArrayImpl'. + warnings.warn( + Time (s) + 0.0 + 1.0 + 2.0 + shape: 3 + + Attributes + ---------- + suppress_conversion_warnings : boolean Determines whether to suppress warnings when automatically converting non-NumPy - array-like objects to NumPy arrays. - This is useful for users who frequently work with array-like objects from other + array-like objects to NumPy arrays. This is useful for users who frequently work with array-like objects from other libraries (e.g., JAX, TensorFlow) and prefer not to receive warnings for automatic conversions. Defaults to False, which means warnings will be shown. """ @@ -25,7 +53,7 @@ def __init__(self): @property def suppress_conversion_warnings(self): """ - bool: Gets or sets the suppression state for conversion warnings. When set to True, + Gets or sets the suppression state for conversion warnings. When set to True, warnings for automatic conversions of non-NumPy array-like objects to NumPy arrays are suppressed. Ensures that only boolean values are assigned. """ diff --git a/pynapple/core/time_index.py b/pynapple/core/time_index.py index aae634d3..44634230 100644 --- a/pynapple/core/time_index.py +++ b/pynapple/core/time_index.py @@ -2,11 +2,11 @@ # @Author: Guillaume Viejo # @Date: 2023-09-21 13:32:03 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2023-09-25 11:28:11 +# @Last Modified time: 2024-02-13 16:55:44 """ - Similar to pd.Index, TsIndex holds the timestamps associated with the data of a time series. + Similar to pandas.Index, TsIndex holds the timestamps associated with the data of a time series. This class deals with conversion between different time units for all pynapple objects as well as making sure that timestamps are property sorted before initializing any objects. - `us`: microseconds diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 22d9e461..33b60eb0 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -2,11 +2,11 @@ # @Author: gviejo # @Date: 2022-01-27 18:33:31 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 14:36:05 +# @Last Modified time: 2024-02-13 17:41:21 """ - # Pynapple time series + # pynapple time series Pynapple time series are containers specialized for neurophysiological time series. @@ -22,7 +22,6 @@ Most of the same functions are available through all classes. Objects behaves like numpy.ndarray. Slicing can be done the same way for example `tsd[0:10]` returns the first 10 rows. Similarly, you can call any numpy functions like `np.mean(tsd, 1)`. """ - import abc import importlib import os @@ -38,237 +37,97 @@ from ._jitted_functions import ( jitbin, jitbin_array, - jitcount, jitremove_nan, jitrestrict, jitthreshold, jittsrestrict, - jittsrestrict_with_count, - jitvaluefrom, - jitvaluefromtensor, pjitconvolve, ) -from .config import nap_config +from .base_class import Base from .interval_set import IntervalSet from .time_index import TsIndex +from .utils import ( + _concatenate_tsd, + _split_tsd, + _TsdFrameSliceHelper, + convert_to_numpy, + is_array_like, +) -def is_array_like(obj): - """ - Check if an object is array-like. - - This function determines if an object has array-like properties but isn't an _AstractTsd. - An object is considered array-like if it has attributes typically associated with arrays - (such as `.shape`, `.dtype`, and `.ndim`), supports indexing, and is iterable. - - Parameters - ---------- - obj : object - The object to check for array-like properties. - - Returns - ------- - bool - True if the object is array-like, False otherwise. - - Notes - ----- - This function uses a combination of checks for attributes (`shape`, `dtype`, `ndim`), - indexability, and iterability to determine if the given object behaves like an array. - It is designed to be flexible and work with various types of array-like objects, including - but not limited to NumPy arrays and JAX arrays. However, it may not be foolproof for all - possible array-like types or objects that mimic these properties without being suitable for - numerical operations. - - """ - # Check for array-like attributes - has_shape = hasattr(obj, "shape") - has_dtype = hasattr(obj, "dtype") - has_ndim = hasattr(obj, "ndim") - - # Check for indexability (try to access the first element) - try: - obj[0] - is_indexable = True - except (TypeError, IndexError): - is_indexable = False - - # Check for iterable property - try: - iter(obj) - is_iterable = True - except TypeError: - is_iterable = False - - not_tsd_type = not isinstance(obj, _AbstractTsd) - - return ( - has_shape - and has_dtype - and has_ndim - and is_indexable - and is_iterable - and not_tsd_type - ) - - -def convert_to_numpy(array, array_name): - """ - Convert an input array-like object to a NumPy array. - - This function attempts to convert an input object to a NumPy array using `np.asarray`. - If the input is not already a NumPy ndarray, it issues a warning indicating that a conversion - has taken place and shows the original type of the input. This function is useful for - ensuring compatibility with Numba operations in cases where the input might come from - various array-like sources (for instance, jax.numpy.Array). +def _get_class(data): + """Select the right time series object and return the class Parameters ---------- - array : array_like - The input object to convert. This can be any object that `np.asarray` is capable of - converting to a NumPy array, such as lists, tuples, and other array-like objects, - including those from libraries like JAX or TensorFlow that adhere to the array interface. - array_name : str - The name of the variable that we are converting, printed in the warning message. + data : numpy.ndarray + The data to hold in the time series object Returns ------- - ndarray - A NumPy ndarray representation of the input `values`. If `values` is already a NumPy - ndarray, it is returned unchanged. Otherwise, a new NumPy ndarray is created and returned. - - Warnings - -------- - A warning is issued if the input `values` is not already a NumPy ndarray, indicating - that a conversion has taken place and showing the original type of the input. - - """ - if ( - not isinstance(array, np.ndarray) - and not nap_config.suppress_conversion_warnings - ): - original_type = type(array).__name__ - warnings.warn( - f"Converting '{array_name}' to numpy.array. The provided array was of type '{original_type}'.", - UserWarning, - ) - return np.asarray(array) - - -def _split_tsd(func, tsd, indices_or_sections, axis=0): - """ - Wrappers of numpy split functions + Class + The class """ - if func in [np.split, np.array_split, np.vsplit] and axis == 0: - out = func._implementation(tsd.values, indices_or_sections) - index_list = np.split(tsd.index.values, indices_or_sections) - kwargs = {"columns": tsd.columns.values} if hasattr(tsd, "columns") else {} - return [tsd.__class__(t=t, d=d, **kwargs) for t, d in zip(index_list, out)] - elif func in [np.dsplit, np.hsplit]: - out = func._implementation(tsd.values, indices_or_sections) - kwargs = {"columns": tsd.columns.values} if hasattr(tsd, "columns") else {} - return [tsd.__class__(t=tsd.index, d=d, **kwargs) for d in out] + if data.ndim == 1: + return Tsd + elif data.ndim == 2: + return TsdFrame else: - return func._implementation(tsd.values, indices_or_sections, axis) + return TsdTensor -def _concatenate_tsd(func, tsds): +class BaseTsd(Base, NDArrayOperatorsMixin, abc.ABC): """ - Wrappers of np.concatenate and np.vstack + Abstract base class for time series objects. + Implement most of the shared functions across concrete classes `Tsd`, `TsdFrame`, `TsdTensor` """ - if isinstance(tsds, (tuple, list)): - assert all( - [hasattr(tsd, "nap_class") and hasattr(tsd, "values") for tsd in tsds] - ), "Inputs should be Tsd, TsdFrame or TsdTensor" - - nap_type = np.unique([tsd.nap_class for tsd in tsds]) - assert len(nap_type) == 1, "Objects should all be the same." - - if len(tsds) > 1: - new_index = np.hstack([tsd.index.values for tsd in tsds]) - if np.any(np.diff(new_index) <= 0): - raise RuntimeError( - "The order of the Tsd index should be strictly increasing and non overlapping." - ) - - if nap_type == "Tsd": - new_values = func._implementation( - [tsd.values[:, np.newaxis] for tsd in tsds] - ) - new_values = new_values.flatten() - else: - new_values = func._implementation([tsd.values for tsd in tsds]) - - # Joining Time support - time_support = tsds[0].time_support - for tsd in tsds: - time_support = time_support.union(tsd.time_support) - - kwargs = {"columns": tsds[0].columns} if hasattr(tsds[0], "columns") else {} - - return tsds[0].__class__( - t=new_index, d=new_values, time_support=time_support, **kwargs - ) - - else: - return tsds[0] - else: - raise TypeError - - -class _TsdFrameSliceHelper: - def __init__(self, tsdframe): - self.tsdframe = tsdframe - def __getitem__(self, key): - if hasattr(key, "__iter__") and not isinstance(key, str): - for k in key: - if k not in self.tsdframe.columns: - raise IndexError(str(k)) - index = self.tsdframe.columns.get_indexer(key) - else: - if key not in self.tsdframe.columns: - raise IndexError(str(key)) - index = self.tsdframe.columns.get_indexer([key]) + def __init__(self, t, d, time_units="s", time_support=None): + super().__init__(t, time_units, time_support) - if len(index) == 1: - return self.tsdframe.__getitem__((slice(None, None, None), index[0])) + # Converting d to numpy array + if isinstance(d, Number): + self.values = np.array([d]) + elif isinstance(d, (list, tuple)): + self.values = np.array(d) + elif isinstance(d, np.ndarray): + self.values = d + elif is_array_like(d): + self.values = convert_to_numpy(d, "d") else: - return self.tsdframe.__getitem__( - (slice(None, None, None), index), columns=key + raise RuntimeError( + "Unknown format for d. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." ) + assert len(self.index) == len( + self.values + ), "Length of values {} does not match length of index {}".format( + len(self.values), len(self.index) + ) -class _AbstractTsd(abc.ABC): - """ - Abstract class for Tsd class. - Implement shared functions across concrete classes. - """ - - _initialized = False + if isinstance(time_support, IntervalSet) and len(self.index): + starts = time_support.start.values + ends = time_support.end.values + t, d = jitrestrict(self.index.values, self.values, starts, ends) + self.index = TsIndex(t) + self.values = d + self.rate = self.index.shape[0] / np.sum( + time_support.values[:, 1] - time_support.values[:, 0] + ) - def __init__(self): - self.rate = np.NaN - self.index = TsIndex(np.empty(0)) - self.values = np.empty(0) - self.time_support = IntervalSet(start=[], end=[]) + self.dtype = self.values.dtype - @property - def t(self): - return self.index.values + def __setitem__(self, key, value): + """setter for time series""" + try: + self.values.__setitem__(key, value) + except IndexError: + raise IndexError @property def d(self): return self.values - @property - def start(self): - return self.start_time() - - @property - def end(self): - return self.end_time() - @property def shape(self): return self.values.shape @@ -281,60 +140,9 @@ def ndim(self): def size(self): return self.values.size - def __repr__(self): - return str(self.__class__) - - def __str__(self): - return self.__repr__() - - def __getitem__(self, key, *args, **kwargs): - output = self.values.__getitem__(key) - if isinstance(key, tuple): - index = self.index.__getitem__(key[0]) - else: - index = self.index.__getitem__(key) - - if isinstance(index, Number): - index = np.array([index]) - - if all(isinstance(a, np.ndarray) for a in [index, output]): - if output.shape[0] == index.shape[0]: - if output.ndim == 1: - return Tsd(t=index, d=output, time_support=self.time_support) - elif output.ndim == 2: - return TsdFrame( - t=index, d=output, time_support=self.time_support, **kwargs - ) - else: - return TsdTensor(t=index, d=output, time_support=self.time_support) - else: - return output - else: - return output - - def __setitem__(self, key, value): - try: - self.values.__setitem__(key, value) - except IndexError: - raise IndexError - def __array__(self, dtype=None): return self.values.astype(dtype) - def __len__(self): - return len(self.index) - - def __setattr__(self, name, value): - """Object is immutable""" - if self._initialized: - raise RuntimeError( - "Changing directly attributes is not permitted for {}.".format( - self.nap_class - ) - ) - else: - object.__setattr__(self, name, value) - def __array_ufunc__(self, ufunc, method, *args, **kwargs): # print("In __array_ufunc__") # print(" ufunc = ", ufunc) @@ -362,26 +170,12 @@ def __array_ufunc__(self, ufunc, method, *args, **kwargs): if isinstance(out, np.ndarray): if out.shape[0] == self.index.shape[0]: - if out.ndim == 1: - return Tsd(t=self.index, d=out, time_support=self.time_support) - elif out.ndim == 2: - kwargs = {} - if hasattr(self, "columns"): - kwargs["columns"] = self.columns - return TsdFrame( - t=self.index, - d=out, - time_support=self.time_support, - **kwargs, - ) - # else: - # return TsdFrame( - # t=self.index, d=out, time_support=self.time_support - # ) - else: - return TsdTensor( - t=self.index, d=out, time_support=self.time_support - ) + kwargs = {} + if hasattr(self, "columns"): + kwargs["columns"] = self.columns + return _get_class(out)( + t=self.index, d=out, time_support=self.time_support, **kwargs + ) else: return out else: @@ -390,12 +184,6 @@ def __array_ufunc__(self, ufunc, method, *args, **kwargs): return NotImplemented def __array_function__(self, func, types, args, kwargs): - # print("In __array_function__") - # print(" func = ", func) - # print(" types = ", types) - # print(" args = ", args) - # print(" kwargs = ", kwargs) - if func in [ np.hstack, np.dstack, @@ -434,45 +222,17 @@ def __array_function__(self, func, types, args, kwargs): # if out.ndim > self.ndim: # return out if out.shape[0] == self.index.shape[0]: - if out.ndim == 1: - return Tsd(t=self.index, d=out, time_support=self.time_support) - elif out.ndim == 2: - if hasattr(self, "columns"): - return TsdFrame( - t=self.index, - d=out, - time_support=self.time_support, - columns=self.columns, - ) - else: - return TsdFrame( - t=self.index, d=out, time_support=self.time_support - ) - else: - return TsdTensor( - t=self.index, d=out, time_support=self.time_support - ) + kwargs = {} + if hasattr(self, "columns"): + kwargs["columns"] = self.columns + return _get_class(out)( + t=self.index, d=out, time_support=self.time_support, **kwargs + ) else: return out else: return out - def times(self, units="s"): - """ - The time index of the object, returned as np.double in the desired time units. - - Parameters - ---------- - units : str, optional - ('us', 'ms', 's' [default]) - - Returns - ------- - out: numpy.ndarray - the time indexes - """ - return self.index.in_units(units) - def as_array(self): """ Return the data as a numpy.ndarray @@ -501,262 +261,24 @@ def to_numpy(self): """ return self.values - def start_time(self, units="s"): - """ - The first time index in the time series object - - Parameters - ---------- - units : str, optional - ('us', 'ms', 's' [default]) - - Returns - ------- - out: numpy.float64 - _ - """ - if len(self.index): - return self.times(units=units)[0] - else: - return None - - def end_time(self, units="s"): - """ - The last time index in the time series object - - Parameters - ---------- - units : str, optional - ('us', 'ms', 's' [default]) - - Returns - ------- - out: numpy.float64 - _ - """ - if len(self.index): - return self.times(units=units)[-1] - else: - return None + def copy(self): + """Copy the data, index and time support""" + return self.__class__( + t=self.index.copy(), d=self.values.copy(), time_support=self.time_support + ) def value_from(self, data, ep=None): - """ - Replace the value with the closest value from Tsd/TsdFrame/TsdTensor argument - - Parameters - ---------- - data : Tsd/TsdFrame/TsdTensor - The object holding the values to replace. - ep : IntervalSet (optional) - The IntervalSet object to restrict the operation. - If None, the time support of the tsd input object is used. - - Returns - ------- - out : Tsd/TsdFrame/TsdTensor - Object with the new values + assert isinstance( + data, BaseTsd + ), "First argument should be an instance of Tsd, TsdFrame or TsdTensor" - Examples - -------- - In this example, the ts object will receive the closest values in time from tsd. - - >>> import pynapple as nap - >>> import numpy as np - >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) # random times - >>> ts = nap.Ts(t=t, time_units='s') - >>> tsd = nap.Tsd(t=np.arange(0,1000), d=np.random.rand(1000), time_units='s') - >>> ep = nap.IntervalSet(start = 0, end = 500, time_units = 's') - - The variable ts is a time series object containing only nan. - The tsd object containing the values, for example the tracking data, and the epoch to restrict the operation. - - >>> newts = ts.value_from(tsd, ep) - - newts is the same size as ts restrict to ep. - - >>> print(len(ts.restrict(ep)), len(newts)) - 52 52 - """ - if not isinstance(data, (TsdTensor, TsdFrame, Tsd)): - raise RuntimeError( - "The time series to align to should be Tsd/TsdFrame/TsdTensor." - ) - - if ep is None: - ep = data.time_support - time_array = self.index.values - time_target_array = data.index.values - data_target_array = data.values - starts = ep.start.values - ends = ep.end.values - - if data_target_array.ndim == 1: - t, d, ns, ne = jitvaluefrom( - time_array, time_target_array, data_target_array, starts, ends - ) - else: - t, d, ns, ne = jitvaluefromtensor( - time_array, time_target_array, data_target_array, starts, ends - ) - - time_support = IntervalSet(start=ns, end=ne) - - if isinstance(data, TsdFrame): - return TsdFrame(t=t, d=d, time_support=time_support, columns=data.columns) - else: - return data.__class__(t, d, time_support=time_support) + t, d, time_support, kwargs = super().value_from(data, ep) + return data.__class__(t=t, d=d, time_support=time_support, **kwargs) def count(self, *args, **kwargs): - """ - Count occurences of events within bin_size or within a set of bins defined as an IntervalSet. - You can call this function in multiple ways : - - 1. *tsd.count(bin_size=1, time_units = 'ms')* - -> Count occurence of events within a 1 ms bin defined on the time support of the object. - - 2. *tsd.count(1, ep=my_epochs)* - -> Count occurent of events within a 1 second bin defined on the IntervalSet my_epochs. - - 3. *tsd.count(ep=my_bins)* - -> Count occurent of events within each epoch of the intervalSet object my_bins - - 4. *tsd.count()* - -> Count occurent of events within each epoch of the time support. - - bin_size should be seconds unless specified. - If bin_size is used and no epochs is passed, the data will be binned based on the time support of the object. - - Parameters - ---------- - bin_size : None or float, optional - The bin size (default is second) - ep : None or IntervalSet, optional - IntervalSet to restrict the operation - time_units : str, optional - Time units of bin size ('us', 'ms', 's' [default]) - - Returns - ------- - out: Tsd - A Tsd object indexed by the center of the bins. - - Examples - -------- - This example shows how to count events within bins of 0.1 second. - - >>> import pynapple as nap - >>> import numpy as np - >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) - >>> ts = nap.Ts(t=t, time_units='s') - >>> bincount = ts.count(0.1) - - An epoch can be specified: - - >>> ep = nap.IntervalSet(start = 100, end = 800, time_units = 's') - >>> bincount = ts.count(0.1, ep=ep) - - And bincount automatically inherit ep as time support: - - >>> bincount.time_support - >>> start end - >>> 0 100.0 800.0 - """ - bin_size = None - if "bin_size" in kwargs: - bin_size = kwargs["bin_size"] - if isinstance(bin_size, int): - bin_size = float(bin_size) - if not isinstance(bin_size, float): - raise ValueError("bin_size argument should be float.") - else: - for a in args: - if isinstance(a, (float, int)): - bin_size = float(a) - - time_units = "s" - if "time_units" in kwargs: - time_units = kwargs["time_units"] - if not isinstance(time_units, str): - raise ValueError("time_units argument should be 's', 'ms' or 'us'.") - else: - for a in args: - if isinstance(a, str) and a in ["s", "ms", "us"]: - time_units = a - - ep = self.time_support - if "ep" in kwargs: - ep = kwargs["ep"] - if not isinstance(ep, IntervalSet): - raise ValueError("ep argument should be IntervalSet") - else: - for a in args: - if isinstance(a, IntervalSet): - ep = a - - time_array = self.index.values - starts = ep.start.values - ends = ep.end.values - - if isinstance(bin_size, (float, int)): - bin_size = TsIndex.format_timestamps(np.array([bin_size]), time_units)[0] - t, d = jitcount(time_array, starts, ends, bin_size) - else: - _, d = jittsrestrict_with_count(time_array, starts, ends) - t = starts + (ends - starts) / 2 - + t, d, ep = super().count(*args, **kwargs) return Tsd(t=t, d=d, time_support=ep) - def restrict(self, iset): - """ - Restricts a time series object to a set of time intervals delimited by an IntervalSet object - - Parameters - ---------- - iset : IntervalSet - the IntervalSet object - - Returns - ------- - out: Ts, Tsd, TsdFrame or TsdTensor - Tsd object restricted to ep - - Examples - -------- - The Ts object is restrict to the intervals defined by ep. - - >>> import pynapple as nap - >>> import numpy as np - >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) - >>> ts = nap.Ts(t=t, time_units='s') - >>> ep = nap.IntervalSet(start=0, end=500, time_units='s') - >>> newts = ts.restrict(ep) - - The time support of newts automatically inherit the epochs defined by ep. - - >>> newts.time_support - >>> start end - >>> 0 0.0 500.0 - - """ - assert isinstance(iset, IntervalSet), "Argument should be IntervalSet" - - time_array = self.index.values - starts = iset.start.values - ends = iset.end.values - - if is_array_like(self.values): - - data_array = self.values - t, d = jitrestrict(time_array, data_array, starts, ends) - - if hasattr(self, "columns"): - return TsdFrame(t=t, d=d, time_support=iset, columns=self.columns) - else: - return self.__class__(t=t, d=d, time_support=iset) - else: - t = jittsrestrict(time_array, starts, ends) - return Ts(t, time_support=iset) - def bin_average(self, bin_size, ep=None, time_units="s"): """ Bin the data by averaging points within bin_size @@ -811,88 +333,10 @@ def bin_average(self, bin_size, ep=None, time_units="s"): else: t, d = jitbin(time_array, data_array, starts, ends, bin_size) - if d.ndim == 1: - return Tsd(t=t, d=d, time_support=ep) - elif d.ndim == 2: - kwargs = {} - if hasattr(self, "columns"): - kwargs["columns"] = self.columns - return TsdFrame(t=t, d=d, time_support=ep, **kwargs) - else: - return TsdTensor(t=t, d=d, time_support=ep) - - def copy(self): - """Copy the data, index and time support""" - return self.__class__( - t=self.index.copy(), d=self.values.copy(), time_support=self.time_support - ) - - def find_support(self, min_gap, time_units="s"): - """ - find the smallest (to a min_gap resolution) IntervalSet containing all the times in the Tsd - - Parameters - ---------- - min_gap : float or int - minimal interval between timestamps - time_units : str, optional - Time units of min gap - - Returns - ------- - IntervalSet - Description - """ - assert isinstance(min_gap, Number), "min_gap should be a float or int" - min_gap = TsIndex.format_timestamps(np.array([min_gap]), time_units)[0] - time_array = self.index.values - - starts = [time_array[0]] - ends = [] - for i in range(len(time_array) - 1): - if (time_array[i + 1] - time_array[i]) > min_gap: - ends.append(time_array[i] + 1e-6) - starts.append(time_array[i + 1]) - - ends.append(time_array[-1] + 1e-6) - - return IntervalSet(start=starts, end=ends) - - def get(self, start, end=None, time_units="s"): - """Slice the time series from start to end such that all the timestamps satisfy start<=t<=end. - If end is None, only the timepoint closest to start is returned. - - By default, the time support doesn't change. If you want to change the time support, use the restrict function. - - Parameters - ---------- - start : float or int - The start - end : float or int - The end - """ - assert isinstance(start, Number), "start should be a float or int" - time_array = self.index.values - - if end is None: - start = TsIndex.format_timestamps(np.array([start]), time_units)[0] - idx = int(np.searchsorted(time_array, start)) - if idx == 0: - return self[idx] - elif idx >= self.shape[0]: - return self[-1] - else: - if start - time_array[idx - 1] < time_array[idx] - start: - return self[idx - 1] - else: - return self[idx] - else: - assert isinstance(end, Number), "end should be a float or int" - assert start < end, "Start should not precede end" - start, end = TsIndex.format_timestamps(np.array([start, end]), time_units) - idx_start = np.searchsorted(time_array, start) - idx_end = np.searchsorted(time_array, end, side="right") - return self[idx_start:idx_end] + kwargs = {} + if hasattr(self, "columns"): + kwargs["columns"] = self.columns + return self.__class__(t=t, d=d, time_support=ep, **kwargs) def dropna(self, update_time_support=True): """Drop every rows containing NaNs. By default, the time support is updated to start and end around the time points that are non NaNs. @@ -1037,7 +481,7 @@ def interpolate(self, ts, ep=None, left=None, right=None): Parameters ---------- - ts : Ts, Tsd or TsdFrame + ts : Ts, Tsd, TsdFrame or TsdTensor The object holding the timestamps ep : IntervalSet, optional The epochs to use to interpolate. If None, the time support of Tsd is used. @@ -1046,10 +490,9 @@ def interpolate(self, ts, ep=None, left=None, right=None): right : None, optional Value to return for ts > tsd[-1], default is tsd[-1]. """ - if not isinstance(ts, (Ts, Tsd, TsdFrame, TsdTensor)): - raise RuntimeError( - "First argument should be an instance of Ts, Tsd or TsdFrame" - ) + assert isinstance( + ts, Base + ), "First argument should be an instance of Ts, Tsd, TsdFrame or TsdTensor" if not isinstance(ep, IntervalSet): ep = self.time_support @@ -1096,7 +539,7 @@ def interpolate(self, ts, ep=None, left=None, right=None): return self.__class__(t=new_t, d=new_d, **kwargs_dict) -class TsdTensor(NDArrayOperatorsMixin, _AbstractTsd): +class TsdTensor(BaseTsd): """ TsdTensor @@ -1123,58 +566,13 @@ def __init__(self, t, d, time_units="s", time_support=None, **kwargs): time_support : IntervalSet, optional The time support of the TsdFrame object """ - if is_array_like(t) and d is None: - raise RuntimeError("Missing argument d when initializing TsdTensor") - - if isinstance(t, (list, tuple)): - t = np.array(t) - else: - t = convert_to_numpy(t, "t") - if isinstance(d, (list, tuple)): - d = np.array(d) - else: - d = convert_to_numpy(d, "d") + super().__init__(t, d, time_units, time_support) assert ( - d.ndim >= 3 + self.values.ndim >= 3 ), "Data should have more than 2 dimensions. If ndim < 3, use TsdFrame or Tsd object" - if isinstance(t, TsIndex): - self.index = t - else: - # Checking timestamps - self.index = TsIndex(t, time_units) - - if len(self.index) != len(d): - raise ValueError( - "Length of values " - f"({len(d)}) " - "does not match length of index " - f"({len(self.index)})" - ) - - if len(self.index): - if isinstance(time_support, IntervalSet): - starts = time_support.start.values - ends = time_support.end.values - t, d = jitrestrict(self.index.values, d, starts, ends) - self.index = TsIndex(t) - self.values = d - else: - time_support = IntervalSet(start=self.index[0], end=self.index[-1]) - self.values = d - - self.time_support = time_support - self.rate = self.index.shape[0] / np.sum( - time_support.values[:, 1] - time_support.values[:, 0] - ) - else: - self.rate = np.NaN - self.values = np.empty(0) - self.time_support = IntervalSet(start=[], end=[]) - self.nap_class = self.__class__.__name__ - self.dtype = self.values.dtype self._initialized = True def __repr__(self): @@ -1223,6 +621,32 @@ def create_str(array): else: return tabulate([], headers=headers) + "\n" + bottom + def __getitem__(self, key, *args, **kwargs): + output = self.values.__getitem__(key) + if isinstance(key, tuple): + index = self.index.__getitem__(key[0]) + else: + index = self.index.__getitem__(key) + + if isinstance(index, Number): + index = np.array([index]) + + if all(isinstance(a, np.ndarray) for a in [index, output]): + if output.shape[0] == index.shape[0]: + if output.ndim == 1: + return Tsd(t=index, d=output, time_support=self.time_support) + elif output.ndim == 2: + return TsdFrame( + t=index, d=output, time_support=self.time_support, **kwargs + ) + else: + return TsdTensor(t=index, d=output, time_support=self.time_support) + + else: + return output + else: + return output + def save(self, filename): """ Save TsdTensor object in npz format. The file will contain the timestamps, the @@ -1299,7 +723,7 @@ def save(self, filename): return -class TsdFrame(NDArrayOperatorsMixin, _AbstractTsd): +class TsdFrame(BaseTsd): """ TsdFrame @@ -1336,65 +760,25 @@ def __init__(self, t, d=None, time_units="s", time_support=None, columns=None): d = t.values c = t.columns.values t = t.index.values - - if is_array_like(t) and d is None: - raise RuntimeError("Missing argument d when initializing TsdFrame") - - if isinstance(t, (list, tuple)): - t = np.array(t) - elif is_array_like(t): - t = convert_to_numpy(t, "t") - - if isinstance(d, (list, tuple)): - d = np.array(d) - elif is_array_like(d): - d = convert_to_numpy(d, "d") - - assert d.ndim <= 2, "Data should be 1 or 2 dimensional" - - if d.ndim == 1: - d = d[:, np.newaxis] - - if isinstance(t, TsIndex): - self.index = t else: - # Checking timestamps - self.index = TsIndex(t, time_units) + assert d is not None, "Missing argument d when initializing TsdFrame" - if len(self.index) != len(d): - raise ValueError( - "Length of values " - f"({len(d)}) " - "does not match length of index " - f"({len(self.index)})" - ) + super().__init__(t, d, time_units, time_support) - if c is None or len(c) != d.shape[1]: - c = np.arange(d.shape[1], dtype="int") + assert self.values.ndim <= 2, "Data should be 1 or 2 dimensional." - if len(self.index): - if isinstance(time_support, IntervalSet): - starts = time_support.start.values - ends = time_support.end.values - t, d = jitrestrict(self.index.values, d, starts, ends) - self.index = TsIndex(t) - self.values = d - else: - time_support = IntervalSet(start=self.index[0], end=self.index[-1]) - self.values = d + if self.values.ndim == 1: + self.values = np.expand_dims(self.values, 1) - self.time_support = time_support - self.rate = self.index.shape[0] / np.sum( - time_support.values[:, 1] - time_support.values[:, 0] - ) + if c is None or len(c) != self.values.shape[1]: + c = np.arange(self.values.shape[1], dtype="int") else: - self.rate = np.NaN - self.values = np.empty(0) - self.time_support = IntervalSet(start=[], end=[]) + assert ( + len(c) == self.values.shape[1] + ), "Number of columns should match the second dimension of d" self.columns = pd.Index(c) self.nap_class = self.__class__.__name__ - self.dtype = self.values.dtype self._initialized = True @property @@ -1442,16 +826,6 @@ def __repr__(self): else: return tabulate([], headers=headers) + "\n" + bottom - def __getitem__(self, key, *args, **kwargs): - if ( - isinstance(key, str) - or hasattr(key, "__iter__") - and all([isinstance(k, str) for k in key]) - ): - return self.loc[key] - else: - return super().__getitem__(key, *args, **kwargs) - def __setitem__(self, key, value): try: if isinstance(key, str): @@ -1465,6 +839,34 @@ def __setitem__(self, key, value): except IndexError: raise IndexError + def __getitem__(self, key, *args, **kwargs): + if ( + isinstance(key, str) + or hasattr(key, "__iter__") + and all([isinstance(k, str) for k in key]) + ): + return self.loc[key] + else: + # return super().__getitem__(key, *args, **kwargs) + output = self.values.__getitem__(key) + if isinstance(key, tuple): + index = self.index.__getitem__(key[0]) + else: + index = self.index.__getitem__(key) + + if isinstance(index, Number): + index = np.array([index]) + + if all(isinstance(a, np.ndarray) for a in [index, output]): + if output.shape[0] == index.shape[0]: + return _get_class(output)( + t=index, d=output, time_support=self.time_support, **kwargs + ) + else: + return output + else: + return output + def as_dataframe(self): """ Convert the TsdFrame object to a pandas.DataFrame object. @@ -1582,51 +984,8 @@ def save(self, filename): return - # def interpolate(self, ts, ep=None, left=None, right=None): - # """Wrapper of the numpy linear interpolation method. See https://numpy.org/doc/stable/reference/generated/numpy.interp.html for an explanation of the parameters. - # The argument ts should be Ts, Tsd, TsdFrame, TsdTensor to ensure interpolating from sorted timestamps in the right unit, - - # Parameters - # ---------- - # ts : Ts, Tsd or TsdFrame - # The object holding the timestamps - # ep : IntervalSet, optional - # The epochs to use to interpolate. If None, the time support of Tsd is used. - # left : None, optional - # Value to return for ts < tsd[0], default is tsd[0]. - # right : None, optional - # Value to return for ts > tsd[-1], default is tsd[-1]. - # """ - # if not isinstance(ts, (Ts, Tsd, TsdFrame)): - # raise RuntimeError( - # "First argument should be an instance of Ts, Tsd or TsdFrame" - # ) - - # if not isinstance(ep, IntervalSet): - # ep = self.time_support - - # new_t = ts.restrict(ep).index - # new_d = np.empty((len(new_t), self.shape[1])) - # new_d.fill(np.nan) - - # start = 0 - # for i in range(len(ep)): - # t = ts.restrict(ep.loc[[i]]) - # tmp = self.restrict(ep.loc[[i]]) - # if len(t) and len(tmp): - # interpolated_values = np.apply_along_axis( - # lambda row: np.interp(t.index.values, tmp.index.values, row), - # 0, - # tmp.values, - # ) - # new_d[start : start + len(t), :] = interpolated_values - - # start += len(t) - - # return TsdFrame(t=new_t, d=new_d, columns=self.columns, time_support=ep) - - -class Tsd(NDArrayOperatorsMixin, _AbstractTsd): + +class Tsd(BaseTsd): """ A container around numpy.ndarray specialized for neurophysiology time series. @@ -1640,7 +999,7 @@ class Tsd(NDArrayOperatorsMixin, _AbstractTsd): The time support of the time series """ - def __init__(self, t, d=None, time_units="s", time_support=None): + def __init__(self, t, d=None, time_units="s", time_support=None, **kwargs): """ Tsd Initializer. @@ -1658,58 +1017,14 @@ def __init__(self, t, d=None, time_units="s", time_support=None): if isinstance(t, pd.Series): d = t.values t = t.index.values - - if is_array_like(t) and d is None: - raise RuntimeError("Missing argument d when initializing Tsd") - - if isinstance(t, (list, tuple)): - t = np.array(t) - elif is_array_like(t): - t = convert_to_numpy(t, "t") - - if isinstance(d, (list, tuple)): - d = np.array(d) - elif is_array_like(d): - d = convert_to_numpy(d, "d") - - assert d.ndim == 1, "Data should be 1 dimension" - - if isinstance(t, TsIndex): - self.index = t else: - # Checking timestamps - self.index = TsIndex(t, time_units) - - if len(self.index) != len(d): - raise ValueError( - "Length of values " - f"({len(d)}) " - "does not match length of index " - f"({len(self.index)})" - ) + assert d is not None, "Missing argument d when initializing Tsd" - if len(self.index): - if isinstance(time_support, IntervalSet): - starts = time_support.start.values - ends = time_support.end.values - t, d = jitrestrict(self.index.values, d, starts, ends) - self.index = TsIndex(t) - self.values = d - else: - time_support = IntervalSet(start=self.index[0], end=self.index[-1]) - self.values = d + super().__init__(t, d, time_units, time_support) - self.time_support = time_support - self.rate = self.index.shape[0] / np.sum( - time_support.values[:, 1] - time_support.values[:, 0] - ) - else: - self.rate = np.NaN - self.values = np.empty(0) - self.time_support = IntervalSet(start=[], end=[]) + assert self.values.ndim == 1, "Data should be 1 dimensional" self.nap_class = self.__class__.__name__ - self.dtype = self.values.dtype self._initialized = True def __repr__(self): @@ -1748,6 +1063,26 @@ def __repr__(self): else: return tabulate([], headers=headers) + "\n" + bottom + def __getitem__(self, key, *args, **kwargs): + output = self.values.__getitem__(key) + if isinstance(key, tuple): + index = self.index.__getitem__(key[0]) + else: + index = self.index.__getitem__(key) + + if isinstance(index, Number): + index = np.array([index]) + + if all(isinstance(a, np.ndarray) for a in [index, output]): + if output.shape[0] == index.shape[0]: + return _get_class(output)( + t=index, d=output, time_support=self.time_support, **kwargs + ) + else: + return output + else: + return output + def as_series(self): """ Convert the Ts/Tsd object to a pandas.Series object. @@ -1963,7 +1298,7 @@ def save(self, filename): return -class Ts(_AbstractTsd): +class Ts(Base): """ Timestamps only object for a time series with only time index, @@ -1982,49 +1317,29 @@ def __init__(self, t, time_units="s", time_support=None): Parameters ---------- t : numpy.ndarray or pandas.Series - An object transformable in a time series, or a pandas.Series equivalent (if d is None) + An object transformable in timestamps, or a pandas.Series equivalent (if d is None) time_units : str, optional The time units in which times are specified ('us', 'ms', 's' [default]) time_support : IntervalSet, optional The time support of the Ts object """ - if isinstance(t, Number): - t = np.array([t]) - # convert array-like data to numpy. - # raise a warning to avoid silent conversion if non-numpy array is provided (jax arrays for instance) - elif is_array_like(t): - t = convert_to_numpy(t, "t") - - if isinstance(t, TsIndex): - self.index = t - else: - # Checking timestamps - self.index = TsIndex(t, time_units) - - if len(self.index): - if isinstance(time_support, IntervalSet): - starts = time_support.start.values - ends = time_support.end.values - t = jittsrestrict(self.index.values, starts, ends) - self.index = TsIndex(t) - else: - time_support = IntervalSet(start=t[0], end=t[-1]) + super().__init__(t, time_units, time_support) - self.time_support = time_support + if isinstance(time_support, IntervalSet) and len(self.index): + starts = time_support.start.values + ends = time_support.end.values + t = jittsrestrict(self.index.values, starts, ends) + self.index = TsIndex(t) self.rate = self.index.shape[0] / np.sum( time_support.values[:, 1] - time_support.values[:, 0] ) - else: - self.rate = np.NaN - self.time_support = IntervalSet(start=[], end=[]) - self.values = None self.nap_class = self.__class__.__name__ self._initialized = True def __repr__(self): upper = "Time (s)" - if len(self) < 100: + if len(self) < 50: _str_ = "\n".join([i.__repr__() for i in self.index]) else: _str_ = "\n".join( @@ -2047,9 +1362,6 @@ def __getitem__(self, key): return Ts(t=index, time_support=self.time_support) - def __setitem__(self, key, value): - pass - def as_series(self): """ Convert the Ts/Tsd object to a pandas.Series object. @@ -2082,6 +1394,52 @@ def as_units(self, units="s"): ss.index.name = "Time (" + str(units) + ")" return ss + def value_from(self, data, ep=None): + """ + Replace the value with the closest value from Tsd/TsdFrame/TsdTensor argument + + Parameters + ---------- + data : Tsd, TsdFrame or TsdTensor + The object holding the values to replace. + ep : IntervalSet (optional) + The IntervalSet object to restrict the operation. + If None, the time support of the tsd input object is used. + + Returns + ------- + out : Tsd, TsdFrame or TsdTensor + Object with the new values + + Examples + -------- + In this example, the ts object will receive the closest values in time from tsd. + + >>> import pynapple as nap + >>> import numpy as np + >>> t = np.unique(np.sort(np.random.randint(0, 1000, 100))) # random times + >>> ts = nap.Ts(t=t, time_units='s') + >>> tsd = nap.Tsd(t=np.arange(0,1000), d=np.random.rand(1000), time_units='s') + >>> ep = nap.IntervalSet(start = 0, end = 500, time_units = 's') + + The variable ts is a time series object containing only nan. + The tsd object containing the values, for example the tracking data, and the epoch to restrict the operation. + + >>> newts = ts.value_from(tsd, ep) + + newts is the same size as ts restrict to ep. + + >>> print(len(ts.restrict(ep)), len(newts)) + 52 52 + """ + assert isinstance( + data, BaseTsd + ), "First argument should be an instance of Tsd, TsdFrame or TsdTensor" + + t, d, time_support, kwargs = super().value_from(data, ep) + + return data.__class__(t, d, time_support=time_support, **kwargs) + def fillna(self, value): """ Similar to pandas fillna function. @@ -2174,25 +1532,3 @@ def save(self, filename): ) return - - # def find_gaps(self, min_gap, time_units='s'): - # """ - # finds gaps in a tsd larger than min_gap. Return an IntervalSet. - # Epochs are defined by adding and removing 1 microsecond to the time index. - - # Parameters - # ---------- - # min_gap : float - # The minimum interval size considered to be a gap (default is second). - # time_units : str, optional - # Time units of min_gap ('us', 'ms', 's' [default]) - # """ - # min_gap = format_timestamps(np.array([min_gap]), time_units)[0] - - # time_array = self.index - # starts = self.time_support.start.values - # ends = self.time_support.end.values - - # s, e = jitfind_gaps(time_array, starts, ends, min_gap) - - # return nap.IntervalSet(s, e) diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index e89d2842..9b362b96 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -2,7 +2,7 @@ # @Author: gviejo # @Date: 2022-01-28 15:10:48 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 12:16:24 +# @Last Modified time: 2024-02-13 16:48:21 import os @@ -19,11 +19,10 @@ jitunion, jitunion_isets, ) +from .base_class import Base from .interval_set import IntervalSet - -# from .time_units import format_timestamps from .time_index import TsIndex -from .time_series import Ts, Tsd, TsdFrame, is_array_like +from .time_series import BaseTsd, Ts, Tsd, TsdFrame, is_array_like def union_intervals(i_sets): @@ -105,16 +104,19 @@ def __init__( # Transform elements to Ts/Tsd objects for k in self.index: - if isinstance(data[k], list) or is_array_like(data[k]): - warnings.warn( - "Elements should not be passed as numpy array. Default time units is seconds when creating the Ts object.", - stacklevel=2, - ) - data[k] = Ts( - t=np.asarray(data[k]), - time_support=time_support, - time_units=time_units, - ) + if not isinstance(data[k], Base): + if isinstance(data[k], list) or is_array_like(data[k]): + warnings.warn( + "Elements should not be passed as {}. Default time units is seconds when creating the Ts object.".format( + type(data[k]) + ), + stacklevel=2, + ) + data[k] = Ts( + t=np.asarray(data[k]), + time_support=time_support, + time_units=time_units, + ) # If time_support is passed, all elements of data are restricted prior to init if isinstance(time_support, IntervalSet): @@ -965,13 +967,14 @@ def save(self, filename): nt += len(self[n]) times = np.zeros(nt) - data = np.zeros(nt) + data = np.full(nt, np.nan) index = np.zeros(nt, dtype=np.int64) k = 0 for n in self.index: kl = len(self[n]) times[k : k + kl] = self[n].index - data[k : k + kl] = self[n].values + if isinstance(self[n], BaseTsd): + data[k : k + kl] = self[n].values index[k : k + kl] = int(n) k += kl diff --git a/pynapple/core/utils.py b/pynapple/core/utils.py new file mode 100644 index 00000000..aaaf1f7a --- /dev/null +++ b/pynapple/core/utils.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# @Author: Guillaume Viejo +# @Date: 2024-02-09 11:45:45 +# @Last Modified by: Guillaume Viejo +# @Last Modified time: 2024-02-13 16:55:08 + +""" + Utility functions +""" + +import warnings + +import numpy as np + +from .config import nap_config + + +def is_array_like(obj): + """ + Check if an object is array-like. + + This function determines if an object has array-like properties. + An object is considered array-like if it has attributes typically associated with arrays + (such as `.shape`, `.dtype`, and `.ndim`), supports indexing, and is iterable. + + Parameters + ---------- + obj : object + The object to check for array-like properties. + + Returns + ------- + bool + True if the object is array-like, False otherwise. + + Notes + ----- + This function uses a combination of checks for attributes (`shape`, `dtype`, `ndim`), + indexability, and iterability to determine if the given object behaves like an array. + It is designed to be flexible and work with various types of array-like objects, including + but not limited to NumPy arrays and JAX arrays. However, it may not be full proof for all + possible array-like types or objects that mimic these properties without being suitable for + numerical operations. + + """ + # Check for array-like attributes + has_shape = hasattr(obj, "shape") + has_dtype = hasattr(obj, "dtype") + has_ndim = hasattr(obj, "ndim") + + # Check for indexability (try to access the first element) + try: + obj[0] + is_indexable = True + except (TypeError, IndexError): + is_indexable = False + + # Check for iterable property + try: + iter(obj) + is_iterable = True + except TypeError: + is_iterable = False + + # not_tsd_type = not isinstance(obj, _AbstractTsd) + + return ( + has_shape + and has_dtype + and has_ndim + and is_indexable + and is_iterable + # and not_tsd_type + ) + + +def convert_to_numpy(array, array_name): + """ + Convert an input array-like object to a NumPy array. + + This function attempts to convert an input object to a NumPy array using `np.asarray`. + If the input is not already a NumPy ndarray, it issues a warning indicating that a conversion + has taken place and shows the original type of the input. This function is useful for + ensuring compatibility with Numba operations in cases where the input might come from + various array-like sources (for instance, jax.numpy.Array). + + Parameters + ---------- + array : array_like + The input object to convert. This can be any object that `np.asarray` is capable of + converting to a NumPy array, such as lists, tuples, and other array-like objects, + including those from libraries like JAX or TensorFlow that adhere to the array interface. + array_name : str + The name of the variable that we are converting, printed in the warning message. + + Returns + ------- + ndarray + A NumPy ndarray representation of the input `values`. If `values` is already a NumPy + ndarray, it is returned unchanged. Otherwise, a new NumPy ndarray is created and returned. + + Warnings + -------- + A warning is issued if the input `values` is not already a NumPy ndarray, indicating + that a conversion has taken place and showing the original type of the input. + + """ + if ( + not isinstance(array, np.ndarray) + and not nap_config.suppress_conversion_warnings + ): + original_type = type(array).__name__ + warnings.warn( + f"Converting '{array_name}' to numpy.array. The provided array was of type '{original_type}'.", + UserWarning, + ) + return np.asarray(array) + + +def _split_tsd(func, tsd, indices_or_sections, axis=0): + """ + Wrappers of numpy split functions + """ + if func in [np.split, np.array_split, np.vsplit] and axis == 0: + out = func._implementation(tsd.values, indices_or_sections) + index_list = np.split(tsd.index.values, indices_or_sections) + kwargs = {"columns": tsd.columns.values} if hasattr(tsd, "columns") else {} + return [tsd.__class__(t=t, d=d, **kwargs) for t, d in zip(index_list, out)] + elif func in [np.dsplit, np.hsplit]: + out = func._implementation(tsd.values, indices_or_sections) + kwargs = {"columns": tsd.columns.values} if hasattr(tsd, "columns") else {} + return [tsd.__class__(t=tsd.index, d=d, **kwargs) for d in out] + else: + return func._implementation(tsd.values, indices_or_sections, axis) + + +def _concatenate_tsd(func, tsds): + """ + Wrappers of np.concatenate and np.vstack + """ + if isinstance(tsds, (tuple, list)): + assert all( + [hasattr(tsd, "nap_class") and hasattr(tsd, "values") for tsd in tsds] + ), "Inputs should be Tsd, TsdFrame or TsdTensor" + + nap_type = np.unique([tsd.nap_class for tsd in tsds]) + assert len(nap_type) == 1, "Objects should all be the same." + + if len(tsds) > 1: + new_index = np.hstack([tsd.index.values for tsd in tsds]) + if np.any(np.diff(new_index) <= 0): + raise RuntimeError( + "The order of the Tsd index should be strictly increasing and non overlapping." + ) + + if nap_type == "Tsd": + new_values = func._implementation( + [tsd.values[:, np.newaxis] for tsd in tsds] + ) + new_values = new_values.flatten() + else: + new_values = func._implementation([tsd.values for tsd in tsds]) + + # Joining Time support + time_support = tsds[0].time_support + for tsd in tsds: + time_support = time_support.union(tsd.time_support) + + kwargs = {"columns": tsds[0].columns} if hasattr(tsds[0], "columns") else {} + + return tsds[0].__class__( + t=new_index, d=new_values, time_support=time_support, **kwargs + ) + + else: + return tsds[0] + else: + raise TypeError + + +class _TsdFrameSliceHelper: + def __init__(self, tsdframe): + self.tsdframe = tsdframe + + def __getitem__(self, key): + if hasattr(key, "__iter__") and not isinstance(key, str): + for k in key: + if k not in self.tsdframe.columns: + raise IndexError(str(k)) + index = self.tsdframe.columns.get_indexer(key) + else: + if key not in self.tsdframe.columns: + raise IndexError(str(key)) + index = self.tsdframe.columns.get_indexer([key]) + + if len(index) == 1: + return self.tsdframe.__getitem__((slice(None, None, None), index[0])) + else: + return self.tsdframe.__getitem__( + (slice(None, None, None), index), columns=key + ) diff --git a/tests/__init__.py b/tests/__init__.py index 55f838c8..82dc6660 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +1 @@ """Unit test package for pynapple.""" -from . import mock diff --git a/tests/mock.py b/tests/mock.py deleted file mode 100644 index bb0ba27b..00000000 --- a/tests/mock.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Test configuration script.""" -import numpy as np - - - -class MockArray: - """ - A mock array class designed for testing purposes. It mimics the behavior of array-like objects - by providing necessary attributes and supporting indexing and iteration, but it is not a direct - instance of numpy.ndarray. - """ - - def __init__(self, data): - """ - Initializes the MockArray with data. - - Parameters - ---------- - data : Union[numpy.ndarray, List] - A list of data elements that the MockArray will contain. - """ - self.data = np.asarray(data) - self.shape = self.data.shape # Simplified shape attribute - self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic - self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array - - def __getitem__(self, index): - """ - Supports indexing into the mock array. - - Parameters - ---------- - index : int or slice - The index or slice of the data to access. - - Returns - ------- - The element(s) at the specified index. - """ - return self.data[index] - - def __iter__(self): - """ - Supports iteration over the mock array. - """ - return iter(self.data) - - def __len__(self): - """ - Returns the length of the mock array. - """ - return len(self.data) diff --git a/tests/test_abstract_tsd.py b/tests/test_abstract_tsd.py index ea0a5d39..d48f2340 100644 --- a/tests/test_abstract_tsd.py +++ b/tests/test_abstract_tsd.py @@ -2,16 +2,18 @@ # @Author: Guillaume Viejo # @Date: 2023-09-25 11:53:30 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2023-09-25 12:37:45 +# @Last Modified time: 2024-02-13 17:33:25 import pynapple as nap import numpy as np import pandas as pd import pytest -from pynapple.core.time_series import _AbstractTsd +from pynapple.core.time_series import BaseTsd +from pynapple.core.base_class import Base +from pynapple.core.time_index import TsIndex -class MyClass(_AbstractTsd): +class MyClass(BaseTsd): def __getitem__(self, key): return key @@ -19,25 +21,35 @@ def __getitem__(self, key): def __setitem__(self, key, value): pass + def __str__(self): + return "In str" + + def __repr__(self): + return "In repr" + +class MyClass2(Base): + def __getitem__(self, key): return key + def __setitem__(self, key, value): + pass + def __str__(self): return "In str" def __repr__(self): return "In repr" - def test_create_atsd(): - a = MyClass() + a = MyClass(t=np.arange(10), d=np.arange(10)) assert hasattr(a, "rate") assert hasattr(a, "index") assert hasattr(a, "values") assert hasattr(a, "time_support") - assert a.rate is np.NaN + assert np.isclose(a.rate, 10/9) assert isinstance(a.index, nap.TsIndex) assert isinstance(a.values, np.ndarray) assert isinstance(a.time_support, nap.IntervalSet) @@ -47,10 +59,14 @@ def test_create_atsd(): assert hasattr(a, "start") assert hasattr(a, "end") assert hasattr(a, "__array__") - np.testing.assert_array_equal(a.values, np.empty(0)) - np.testing.assert_array_equal(a.__array__(), np.empty(0)) + assert hasattr(a, "shape") + assert hasattr(a, "ndim") + assert hasattr(a, "size") + + np.testing.assert_array_equal(a.values, np.arange(10)) + np.testing.assert_array_equal(a.__array__(), np.arange(10)) - assert len(a) == 0 + assert len(a) == 10 assert a.__repr__() == "In repr" assert a.__str__() == "In str" @@ -58,10 +74,57 @@ def test_create_atsd(): assert hasattr(a, "__getitem__") assert hasattr(a, "__setitem__") assert a[0] == 0 + + b = a.copy() + np.testing.assert_array_equal(a.values, b.values) + np.testing.assert_array_equal(a.index.values, b.index.values) + +def test_create_ats(): + + a = MyClass2(t=np.arange(10)) + + assert hasattr(a, "rate") + assert hasattr(a, "index") + assert hasattr(a, "time_support") + assert hasattr(a, "shape") + + assert np.isclose(a.rate, 10/9) + assert isinstance(a.index, nap.TsIndex) + assert isinstance(a.time_support, nap.IntervalSet) + assert a.shape == a.index.shape + + assert hasattr(a, "t") + assert a[0] == 0 + +def test_create_ats_from_tsindex(): + + a = MyClass2(t=TsIndex(np.arange(10))) + + assert hasattr(a, "rate") + assert hasattr(a, "index") + assert hasattr(a, "time_support") + assert hasattr(a, "shape") + + assert np.isclose(a.rate, 10/9) + assert isinstance(a.index, nap.TsIndex) + assert isinstance(a.time_support, nap.IntervalSet) + assert a.shape == a.index.shape + + assert hasattr(a, "t") + +@pytest.mark.filterwarnings("ignore") +def test_create_ats_from_number(): + + a = MyClass2(t=1) + + assert hasattr(a, "rate") + assert hasattr(a, "index") + assert hasattr(a, "time_support") + assert hasattr(a, "shape") def test_methods(): - a = MyClass() + a = MyClass(t=[], d=[]) np.testing.assert_array_equal(a.times(), np.empty(0)) np.testing.assert_array_equal(a.as_array(), np.empty(0)) @@ -74,5 +137,15 @@ def test_methods(): assert hasattr(a, "value_from") assert hasattr(a, "count") assert hasattr(a, "restrict") + assert hasattr(a, "as_array") + assert hasattr(a, "data") + assert hasattr(a, "to_numpy") + assert hasattr(a, "copy") + assert hasattr(a, "bin_average") + assert hasattr(a, "dropna") + assert hasattr(a, "convolve") + assert hasattr(a, "smooth") + assert hasattr(a, "interpolate") + diff --git a/tests/test_config.py b/tests/test_config.py index 2c81f58d..7591b582 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,55 @@ import pynapple as nap -from .mock import MockArray +import numpy as np + +class MockArray: + """ + A mock array class designed for testing purposes. It mimics the behavior of array-like objects + by providing necessary attributes and supporting indexing and iteration, but it is not a direct + instance of numpy.ndarray. + """ + + def __init__(self, data): + """ + Initializes the MockArray with data. + + Parameters + ---------- + data : Union[numpy.ndarray, List] + A list of data elements that the MockArray will contain. + """ + self.data = np.asarray(data) + self.shape = self.data.shape # Simplified shape attribute + self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic + self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array + + def __getitem__(self, index): + """ + Supports indexing into the mock array. + + Parameters + ---------- + index : int or slice + The index or slice of the data to access. + + Returns + ------- + The element(s) at the specified index. + """ + return self.data[index] + + def __iter__(self): + """ + Supports iteration over the mock array. + """ + return iter(self.data) + + def __len__(self): + """ + Returns the length of the mock array. + """ + return len(self.data) @pytest.mark.parametrize("param, expectation", @@ -49,7 +97,7 @@ def test_config_restore_default(): pytest.warns(UserWarning, match=f"Converting 't' to numpy.array.")), ]) -def test_config_supress_warining_t(cls, t, d, conf, expectation): +def test_config_supress_warning_t(cls, t, d, conf, expectation): """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" nap.config.nap_config.suppress_conversion_warnings = conf try: @@ -74,7 +122,7 @@ def test_config_supress_warining_t(cls, t, d, conf, expectation): pytest.warns(UserWarning, match=f"Converting 'd' to numpy.array.")), ]) -def test_config_supress_warining_d(cls, t, d, conf, expectation): +def test_config_supress_warning_d(cls, t, d, conf, expectation): """Test if the restore_defaults method correctly resets suppress_conversion_warnings to its default.""" nap.config.nap_config.suppress_conversion_warnings = conf try: diff --git a/tests/test_jitted.py b/tests/test_jitted.py index e06b76e7..5abf8a26 100644 --- a/tests/test_jitted.py +++ b/tests/test_jitted.py @@ -2,7 +2,7 @@ # @Author: gviejo # @Date: 2022-12-02 17:17:03 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 14:48:58 +# @Last Modified time: 2024-02-12 12:54:48 """Tests of jitted core functions for `pynapple` package.""" @@ -65,7 +65,7 @@ def restrict(ep, tsd): ix = ix3[:,0] idx = ~np.isnan(ix) - if tsd.values is None: + if not hasattr(tsd, "values"): return pd.Series(index=tsd.index[idx], dtype="object") else: return pd.Series(index=tsd.index[idx], data=tsd.values[idx]) diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index 17b0519b..d0664e16 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -5,11 +5,58 @@ import pynapple as nap -from .mock import MockArray +class MockArray: + """ + A mock array class designed for testing purposes. It mimics the behavior of array-like objects + by providing necessary attributes and supporting indexing and iteration, but it is not a direct + instance of numpy.ndarray. + """ + + def __init__(self, data): + """ + Initializes the MockArray with data. + + Parameters + ---------- + data : Union[numpy.ndarray, List] + A list of data elements that the MockArray will contain. + """ + self.data = np.asarray(data) + self.shape = self.data.shape # Simplified shape attribute + self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic + self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array + + def __getitem__(self, index): + """ + Supports indexing into the mock array. + + Parameters + ---------- + index : int or slice + The index or slice of the data to access. + + Returns + ------- + The element(s) at the specified index. + """ + return self.data[index] + + def __iter__(self): + """ + Supports iteration over the mock array. + """ + return iter(self.data) + + def __len__(self): + """ + Returns the length of the mock array. + """ + return len(self.data) class TestTsArray: + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, expectation", [ @@ -17,7 +64,7 @@ class TestTsArray: ( "abc", pytest.raises( - AttributeError, match="'str' object has no attribute 'astype'" + RuntimeError, match="Unknown format for t. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." ), ), ], @@ -27,6 +74,7 @@ def test_ts_init(self, time, expectation): with expectation: nap.Ts(t=time) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, expectation", [ @@ -39,6 +87,7 @@ def test_ts_type(self, time, expectation): ts = nap.Ts(t=time) assert isinstance(ts.t, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, expectation", [ @@ -57,6 +106,7 @@ def test_ts_warn(self, time, expectation): class TestTsdArray: + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -66,9 +116,16 @@ class TestTsdArray: MockArray([1, 2, 3]), "abc", pytest.raises( - AttributeError, match="'str' object has no attribute 'ndim'" + RuntimeError, match="Unknown format for d. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." ), ), + ( + "abc", + MockArray([1, 2, 3]), + pytest.raises( + RuntimeError, match="Unknown format for t. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." + ), + ), ], ) def test_tsd_init(self, time, data, expectation): @@ -76,6 +133,7 @@ def test_tsd_init(self, time, data, expectation): with expectation: nap.Tsd(t=time, d=data) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -89,6 +147,7 @@ def test_tsd_type_d(self, time, data, expectation): ts = nap.Tsd(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -106,6 +165,7 @@ def test_tsd_type_t(self, time, data, expectation): ts = nap.Tsd(t=time, d=data) assert isinstance(ts.t, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "data, expectation", [ @@ -124,6 +184,7 @@ def test_tsd_warn(self, data, expectation): class TestTsdFrameArray: + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -132,7 +193,7 @@ class TestTsdFrameArray: MockArray([1, 2, 3]), "abc", pytest.raises( - AttributeError, match="'str' object has no attribute 'ndim'" + RuntimeError, match="Unknown format for d. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." ), ), ], @@ -142,6 +203,7 @@ def test_tsdframe_init(self, time, data, expectation): with expectation: nap.TsdFrame(t=time, d=data) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -155,6 +217,7 @@ def test_tsdframe_type(self, time, data, expectation): ts = nap.TsdFrame(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -172,6 +235,7 @@ def test_tsdframe_type_t(self, time, data, expectation): ts = nap.TsdFrame(t=time, d=data) assert isinstance(ts.t, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "data, expectation", [ @@ -190,6 +254,7 @@ def test_tsdframe_warn(self, data, expectation): class TestTsdTensorArray: + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -201,7 +266,7 @@ class TestTsdTensorArray: ( MockArray([1, 2, 3]), "abc", - pytest.raises(AssertionError, match="Data should have more than"), + pytest.raises(RuntimeError, match="Unknown format for d. Accepted formats are numpy.ndarray, list, tuple or any array-like objects."), ), ], ) @@ -210,6 +275,7 @@ def test_tsdtensor_init(self, time, data, expectation): with expectation: nap.TsdTensor(t=time, d=data) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -227,6 +293,7 @@ def test_tsdtensor_type_d(self, time, data, expectation): ts = nap.TsdTensor(t=time, d=data) assert isinstance(ts.d, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "time, data, expectation", [ @@ -244,6 +311,7 @@ def test_tsdtensor_type_t(self, time, data, expectation): ts = nap.TsdTensor(t=time, d=data) assert isinstance(ts.t, np.ndarray) + @pytest.mark.filterwarnings("ignore") @pytest.mark.parametrize( "data, expectation", [ @@ -257,5 +325,5 @@ def test_tsdtensor_type_t(self, time, data, expectation): def test_tsdtensor_warn(self, data, expectation): """Check for warnings when the data attribute 'd' is automatically converted to numpy.ndarray.""" with expectation: - nap.TsdTensor(t=np.array(data), d=data) + nap.TsdTensor(t=np.ravel(np.array(data)), d=data) diff --git a/tests/test_numpy_compatibility.py b/tests/test_numpy_compatibility.py index 8438019d..9748b714 100644 --- a/tests/test_numpy_compatibility.py +++ b/tests/test_numpy_compatibility.py @@ -2,7 +2,7 @@ # @Author: Guillaume Viejo # @Date: 2023-09-18 18:11:24 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2023-11-19 16:55:26 +# @Last Modified time: 2024-02-13 12:48:55 @@ -126,10 +126,10 @@ def test_funcs(self, tsd): np.testing.assert_array_almost_equal(a.index, tsd.index) np.testing.assert_array_almost_equal(a.values, np.reshape(tsd.values, (tsd.shape[0], np.prod(tsd.shape[1:])))) - a = np.ravel(tsd) + a = np.ravel(tsd.values) np.testing.assert_array_almost_equal(a, np.ravel(tsd)) - a = np.transpose(tsd) + a = np.transpose(tsd.values) np.testing.assert_array_almost_equal(a, np.transpose(tsd)) a = np.expand_dims(tsd, axis=-1) diff --git a/tests/test_time_series.py b/tests/test_time_series.py index e81023c8..698cbca2 100755 --- a/tests/test_time_series.py +++ b/tests/test_time_series.py @@ -2,7 +2,7 @@ # @Author: gviejo # @Date: 2022-04-01 09:57:55 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2023-12-07 14:01:09 +# @Last Modified time: 2024-02-13 17:36:43 #!/usr/bin/env python """Tests of time series for `pynapple` package.""" @@ -12,8 +12,6 @@ import pandas as pd import pytest -from pynapple.core.time_series import TsdTensor - def test_create_tsd(): tsd = nap.Tsd(t=np.arange(100), d=np.arange(100)) @@ -23,6 +21,10 @@ def test_create_empty_tsd(): tsd = nap.Tsd(t=np.array([]), d=np.array([])) assert len(tsd) == 0 +@pytest.mark.filterwarnings("ignore") +def test_create_tsd_from_number(): + tsd = nap.Tsd(t=1, d=2) + def test_create_tsdframe(): tsdframe = nap.TsdFrame(t=np.arange(100), d=np.random.rand(100, 4)) assert isinstance(tsdframe, nap.TsdFrame) @@ -31,12 +33,13 @@ def test_create_tsdframe(): assert isinstance(tsdframe, nap.TsdFrame) assert np.all(tsdframe.columns == np.array(['a', 'b', 'c', 'd'])) +@pytest.mark.filterwarnings("ignore") def test_create_empty_tsdframe(): - tsdframe = nap.TsdFrame(t=np.array([]), d=np.array([])) + tsdframe = nap.TsdFrame(t=np.array([]), d=np.empty(shape=(0, 2))) assert len(tsdframe) == 0 assert isinstance(tsdframe, nap.TsdFrame) - with pytest.raises(RuntimeError): + with pytest.raises(AssertionError): tsdframe = nap.TsdFrame(t=np.arange(100)) def test_create_1d_tsdframe(): @@ -185,6 +188,7 @@ def test_concatenate_tsd(): np.testing.assert_array_equal(new_tsd.values, tsd.values) +@pytest.mark.filterwarnings("ignore") def test_create_tsdtensor(): tsd = nap.TsdTensor(t=np.arange(100), d=np.random.rand(100, 3, 2)) assert isinstance(tsd, nap.TsdTensor) @@ -193,17 +197,18 @@ def test_create_tsdtensor(): assert isinstance(tsd, nap.TsdTensor) def test_create_empty_tsd(): - tsd = nap.TsdTensor(t=np.array([]), d=np.array([[[]]]).T) + tsd = nap.TsdTensor(t=np.array([]), d=np.empty(shape=(0,2,3))) assert len(tsd) == 0 +@pytest.mark.filterwarnings("ignore") def test_raise_error_tsdtensor_init(): - with pytest.raises(RuntimeError, match=r"Missing argument d when initializing TsdTensor"): + with pytest.raises(RuntimeError, match=r"Unknown format for d. Accepted formats are numpy.ndarray, list, tuple or any array-like objects."): nap.TsdTensor(t=np.arange(100), d=None) - with pytest.raises(AssertionError, match=r"Data should have more than 2 dimensions. If ndim < 3, use TsdFrame or Tsd object"): - nap.TsdTensor(t=np.arange(100), d=np.random.rand(100, 10)) + # with pytest.raises(AssertionError, match=r"Data should have more than 2 dimensions. If ndim < 3, use TsdFrame or Tsd object"): + # nap.TsdTensor(t=np.arange(100), d=np.random.rand(100, 10)) - with pytest.raises(ValueError):#, match=r"Length of values (10) does not match length of index (100)"): + with pytest.raises(AssertionError):#, match=r"Length of values (10) does not match length of index (100)"): nap.TsdTensor(t=np.arange(100), d=np.random.rand(10, 10,3)) def test_index_error(): @@ -251,12 +256,15 @@ def test_properties(): with pytest.raises(RuntimeError): tsd.rate = 0 -def test_abstract_class(): - class DummyTsd(nap.core.time_series._AbstractTsd): - def __init__(self): - super().__init__() +def test_base_tsd_class(): + class DummyTsd(nap.core.time_series.BaseTsd): + def __init__(self, t, d): + super().__init__(t, d) + + def __getitem__(self, key): + return self.values.__getitem__(key) - tsd = DummyTsd() + tsd = DummyTsd([], []) assert np.isnan(tsd.rate) assert isinstance(tsd.index, nap.TsIndex) assert isinstance(tsd.values, np.ndarray) @@ -332,7 +340,7 @@ def test_value_from_tsdframe(self, tsd): np.testing.assert_array_almost_equal(tsdframe.values[::10], tsdframe2.values) def test_value_from_value_error(self, tsd): - with pytest.raises(RuntimeError): + with pytest.raises(AssertionError, match=r"First argument should be an instance of Tsd, TsdFrame or TsdTensor"): tsd.value_from(np.arange(10)) def test_value_from_with_restrict(self, tsd): @@ -402,7 +410,7 @@ def test_dropna(self, tsd): np.testing.assert_array_equal(tsd.index.values, new_tsd.index.values) np.testing.assert_array_equal(tsd.values, new_tsd.values) - tsd.values[tsd.values>0.9] = np.NaN + tsd.values[tsd.values>0.9] = np.NaN new_tsd = tsd.dropna() assert not np.all(np.isnan(new_tsd)) tokeep = np.array([~np.any(np.isnan(tsd[i])) for i in range(len(tsd))]) @@ -721,9 +729,9 @@ def test_interpolate(self, tsd): tsd2 = tsd.interpolate(ts) np.testing.assert_array_almost_equal(tsd2.values, y) - with pytest.raises(RuntimeError) as e: + with pytest.raises(AssertionError) as e: tsd.interpolate([0, 1, 2]) - assert str(e.value) == "First argument should be an instance of Ts, Tsd or TsdFrame" + assert str(e.value) == "First argument should be an instance of Ts, Tsd, TsdFrame or TsdTensor" # Right left ep = nap.IntervalSet(start=0, end=5) @@ -953,9 +961,9 @@ def test_interpolate(self, tsdframe): tsdframe2 = tsdframe.interpolate(ts) np.testing.assert_array_almost_equal(tsdframe2.values, data_stack) - with pytest.raises(RuntimeError) as e: + with pytest.raises(AssertionError) as e: tsdframe.interpolate([0, 1, 2]) - assert str(e.value) == "First argument should be an instance of Ts, Tsd or TsdFrame" + assert str(e.value) == "First argument should be an instance of Ts, Tsd, TsdFrame or TsdTensor" # Right left ep = nap.IntervalSet(start=0, end=5) @@ -1253,9 +1261,9 @@ def test_interpolate(self, tsdtensor): tsdtensor2 = tsdtensor.interpolate(ts) np.testing.assert_array_almost_equal(tsdtensor2.values, data_stack) - with pytest.raises(RuntimeError) as e: + with pytest.raises(AssertionError) as e: tsdtensor.interpolate([0, 1, 2]) - assert str(e.value) == "First argument should be an instance of Ts, Tsd or TsdFrame" + assert str(e.value) == "First argument should be an instance of Ts, Tsd, TsdFrame or TsdTensor" # Right left ep = nap.IntervalSet(start=0, end=5) diff --git a/tests/test_ts_group.py b/tests/test_ts_group.py index 4ec2c0fa..41d7e7ba 100644 --- a/tests/test_ts_group.py +++ b/tests/test_ts_group.py @@ -2,7 +2,7 @@ # @Author: gviejo # @Date: 2022-03-30 11:14:41 # @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 11:26:22 +# @Last Modified time: 2024-02-10 17:33:54 """Tests of ts group for `pynapple` package.""" @@ -37,7 +37,7 @@ def test_create_ts_group_from_array(self, group): 1: np.arange(0, 200, 0.5), 2: np.arange(0, 300, 0.2), }) - assert str(w[0].message) == "Elements should not be passed as numpy array. Default time units is seconds when creating the Ts object." + assert str(w[0].message) == "Elements should not be passed as . Default time units is seconds when creating the Ts object." def test_create_ts_group_with_time_support(self, group): ep = nap.IntervalSet(start=0, end=100) From a5ba77a1beb0d097108888cf3ddda19a273d71a6 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Tue, 13 Feb 2024 18:04:18 -0500 Subject: [PATCH 11/23] Fixing docstrings --- pynapple/core/_jitted_functions.py | 5 ----- pynapple/core/base_class.py | 6 ------ pynapple/core/interval_set.py | 8 +++----- pynapple/core/time_series.py | 10 +--------- pynapple/core/ts_group.py | 11 +++++------ 5 files changed, 9 insertions(+), 31 deletions(-) diff --git a/pynapple/core/_jitted_functions.py b/pynapple/core/_jitted_functions.py index 48d6839f..7ef8f966 100644 --- a/pynapple/core/_jitted_functions.py +++ b/pynapple/core/_jitted_functions.py @@ -1,8 +1,3 @@ -# -*- coding: utf-8 -*- -# @Author: guillaume -# @Date: 2022-10-31 16:44:31 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-25 16:43:34 import numpy as np from numba import jit, njit, prange diff --git a/pynapple/core/base_class.py b/pynapple/core/base_class.py index 71c8683a..2e9f4d57 100644 --- a/pynapple/core/base_class.py +++ b/pynapple/core/base_class.py @@ -1,9 +1,3 @@ -# -*- coding: utf-8 -*- -# @Author: Guillaume Viejo -# @Date: 2024-02-09 12:09:18 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-13 17:26:35 - """ Abstract class for `core` time series. diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index 62de64ff..6d3ab7b1 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -1,9 +1,7 @@ -# -*- coding: utf-8 -*- -# @Date: 2022-01-25 21:50:48 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 12:16:38 - """ + + The class `IntervalSet` deals with non-overlaping epochs. `IntervalSet` objects can interact with each other or with the time series objects. + """ import importlib diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 33b60eb0..83036c7f 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -1,13 +1,5 @@ -# -*- coding: utf-8 -*- -# @Author: gviejo -# @Date: 2022-01-27 18:33:31 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-13 17:41:21 - """ - - # pynapple time series - + Pynapple time series are containers specialized for neurophysiological time series. They provides standardized time representation, plus various functions for manipulating times series with identical sampling frequency. diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index 9b362b96..f17624b9 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- -# @Author: gviejo -# @Date: 2022-01-28 15:10:48 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-13 16:48:21 +""" + + The class `TsGroup` helps group objects with different timestamps (i.e. timestamps of spikes of a population of neurons). + +""" import os @@ -244,7 +244,6 @@ def rates(self): def metadata_columns(self): """ Returns list of metadata columns - ------- """ return list(self._metadata.columns) From 2b018c8d4ef406b4574a04a3e79efa97f13c8448 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Tue, 13 Feb 2024 18:11:15 -0500 Subject: [PATCH 12/23] Adding warnig to tsgroup --- pynapple/core/ts_group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index f17624b9..b7ea8f82 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -23,6 +23,7 @@ from .interval_set import IntervalSet from .time_index import TsIndex from .time_series import BaseTsd, Ts, Tsd, TsdFrame, is_array_like +from .utils import convert_to_numpy def union_intervals(i_sets): @@ -113,7 +114,7 @@ def __init__( stacklevel=2, ) data[k] = Ts( - t=np.asarray(data[k]), + t=convert_to_numpy(data[k], "key {}".format(k)), time_support=time_support, time_units=time_units, ) From 5671b614d91111a45ea298a1b13648949598fe4c Mon Sep 17 00:00:00 2001 From: gviejo Date: Mon, 19 Feb 2024 16:02:38 -0500 Subject: [PATCH 13/23] removing pandas from IntervalSet --- pynapple/core/base_class.py | 16 +- pynapple/core/interval_set.py | 360 +++++++++++++++++----------------- pynapple/core/time_series.py | 36 ++-- pynapple/core/ts_group.py | 16 +- pynapple/core/utils.py | 81 +++++++- tests/test_interval_set.py | 103 +++++----- tests/test_jitted.py | 38 ++-- tests/test_ts_group.py | 18 +- 8 files changed, 366 insertions(+), 302 deletions(-) diff --git a/pynapple/core/base_class.py b/pynapple/core/base_class.py index 2e9f4d57..1d647cc4 100644 --- a/pynapple/core/base_class.py +++ b/pynapple/core/base_class.py @@ -210,8 +210,8 @@ def value_from(self, data, ep=None): time_array = self.index.values time_target_array = data.index.values data_target_array = data.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end if data_target_array.ndim == 1: t, d, ns, ne = jitvaluefrom( @@ -318,8 +318,8 @@ def count(self, *args, **kwargs): ep = a time_array = self.index.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end if isinstance(bin_size, (float, int)): bin_size = TsIndex.format_timestamps(np.array([bin_size]), time_units)[0] @@ -365,8 +365,8 @@ def restrict(self, iset): assert isinstance(iset, IntervalSet), "Argument should be IntervalSet" time_array = self.index.values - starts = iset.start.values - ends = iset.end.values + starts = iset.start + ends = iset.end if hasattr(self, "values"): data_array = self.values @@ -468,8 +468,8 @@ def get(self, start, end=None, time_units="s"): # min_gap = format_timestamps(np.array([min_gap]), time_units)[0] # time_array = self.index - # starts = self.time_support.start.values - # ends = self.time_support.end.values + # starts = self.time_support.start + # ends = self.time_support.end # s, e = jitfind_gaps(time_array, starts, ends, min_gap) diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index 6d3ab7b1..18ad188d 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -8,12 +8,15 @@ import os import warnings +from numbers import Number import numpy as np import pandas as pd -from numba import jit +from numpy.lib.mixins import NDArrayOperatorsMixin +from tabulate import tabulate from ._jitted_functions import jitdiff, jitin_interval, jitintersect, jitunion from .time_index import TsIndex +from .utils import is_array_like, _jitfix_iset, convert_to_numpy all_warnings = np.array( [ @@ -25,183 +28,205 @@ ) -@jit(nopython=True) -def jitfix_iset(start, end): +class IntervalSet(NDArrayOperatorsMixin): """ - 0 - > "Some starts and ends are equal. Removing 1 microsecond!", - 1 - > "Some ends precede the relative start. Dropping them!", - 2 - > "Some starts precede the previous end. Joining them!", - 3 - > "Some epochs have no duration" - - Parameters - ---------- - start : numpy.ndarray - Description - end : numpy.ndarray - Description - - Returns - ------- - TYPE - Description - """ - to_warn = np.zeros(4, dtype=np.bool_) - - m = start.shape[0] - - data = np.zeros((m, 2), dtype=np.float64) - - i = 0 - ct = 0 - - while i < m: - newstart = start[i] - newend = end[i] - - while i < m: - if end[i] == start[i]: - to_warn[3] = True - i += 1 - else: - newstart = start[i] - newend = end[i] - break - - while i < m: - if end[i] < start[i]: - to_warn[1] = True - i += 1 - else: - newstart = start[i] - newend = end[i] - break - - while i < m - 1: - if start[i + 1] < end[i]: - to_warn[2] = True - i += 1 - newend = max(end[i - 1], end[i]) - else: - break - - if i < m - 1: - if newend == start[i + 1]: - to_warn[0] = True - newend -= 1.0e-6 - - data[ct, 0] = newstart - data[ct, 1] = newend - - ct += 1 - i += 1 - - data = data[0:ct] - - return (data, to_warn) - - -class IntervalSet(pd.DataFrame): - # class IntervalSet(): - """ - A subclass of pandas.DataFrame representing a (irregular) set of time intervals in elapsed time, with relative operations + A class representing a (irregular) set of time intervals in elapsed time, with relative operations """ def __init__(self, start, end=None, time_units="s", **kwargs): """ IntervalSet initializer - + If start and end and not aligned, meaning that \n 1. len(start) != len(end) 2. end[i] > start[i] 3. start[i+1] > end[i] 4. start and end are not sorted, - + IntervalSet will try to "fix" the data by eliminating some of the start and end data point - + Parameters ---------- - start : numpy.ndarray or number or pandas.DataFrame + start : numpy.ndarray or number or pandas.DataFrame or pandas.Series Beginning of intervals - end : numpy.ndarray or number, optional + end : numpy.ndarray or number or pandas.Series, optional Ends of intervals time_units : str, optional Time unit of the intervals ('us', 'ms', 's' [default]) - **kwargs - Additional parameters passed ot pandas.DataFrame - - Returns - ------- - IntervalSet - _ - + Raises ------ RuntimeError - Description - ValueError - If a pandas.DataFrame is passed, it should contains - a column 'start' and a column 'end'. - + If `start` and `end` arguments are of unknown type + """ - - if end is None: - df = pd.DataFrame(start) - if "start" not in df.columns or "end" not in df.columns: - raise ValueError("wrong columns name") - start = df["start"].values.astype(np.float64) - end = df["end"].values.astype(np.float64) - - start = TsIndex.sort_timestamps( - TsIndex.format_timestamps(start.ravel(), time_units) - ) - end = TsIndex.sort_timestamps( - TsIndex.format_timestamps(end.ravel(), time_units) - ) - - data, to_warn = jitfix_iset(start, end) - if np.any(to_warn): - msg = "\n".join(all_warnings[to_warn]) - warnings.warn(msg, stacklevel=2) - super().__init__(data=data, columns=("start", "end"), **kwargs) - self.r_cache = None - self._metadata = ["nap_class"] - self.nap_class = self.__class__.__name__ - return - - start = np.array(start).astype(np.float64) - end = np.array(end).astype(np.float64) - - start = TsIndex.format_timestamps(np.array(start).ravel(), time_units) - end = TsIndex.format_timestamps(np.array(end).ravel(), time_units) - - if len(start) != len(end): - raise RuntimeError("Starts end ends are not of the same length") + if isinstance(start, pd.DataFrame): + assert ( + "start" in start.columns + and "end" in start.columns + and start.shape[-1] == 2 + ), """ + Wrong dataframe format. Expected format if passing a pandas dataframe is : + - 2 columns + - column names are ["start", "end"] + """ + end = start["end"].values.astype(np.float64) + start = start["start"].values.astype(np.float64) + + else: + assert end is not None, "Missing end argument when initializing IntervalSet" + + args = {"start": start, "end": end} + + for arg, data in args.items(): + if isinstance(data, Number): + args[arg] = np.array([data]) + elif isinstance(data, (list, tuple)): + args[arg] = np.ravel(np.array(data)) + elif isinstance(data, pd.Series): + args[arg] = data.values + elif isinstance(data, np.ndarray): + args[arg] = np.ravel(data) + elif is_array_like(data): + args[arg] = convert_to_numpy(data, arg) + else: + raise RuntimeError( + "Unknown format for {}. Accepted formats are numpy.ndarray, list, tuple or any array-like objects.".format( + arg + ) + ) + + start = args["start"] + end = args["end"] + + assert len(start) == len(end), "Starts end ends are not of the same length" + + start = TsIndex.format_timestamps(start, time_units) + end = TsIndex.format_timestamps(end, time_units) if not (np.diff(start) > 0).all(): - warnings.warn("start is not sorted.", stacklevel=2) + warnings.warn("start is not sorted. Sorting it.", stacklevel=2) start = np.sort(start) if not (np.diff(end) > 0).all(): - warnings.warn("end is not sorted.", stacklevel=2) + warnings.warn("end is not sorted. Sorting it.", stacklevel=2) end = np.sort(end) - data, to_warn = jitfix_iset(start, end) + data, to_warn = _jitfix_iset(start, end) if np.any(to_warn): msg = "\n".join(all_warnings[to_warn]) warnings.warn(msg, stacklevel=2) - super().__init__(data=data, columns=("start", "end"), **kwargs) - self.r_cache = None - # self._metadata = ["nap_class"] + self.values = data + self.index = np.arange(data.shape[0], dtype="int") + self.columns = np.array(["start", "end"]) self.nap_class = self.__class__.__name__ def __repr__(self): - return self.as_units("s").__repr__() + headers = ["start", "end"] + bottom = "shape: {}".format(self.shape) + + return ( + tabulate(self.values, headers=headers, showindex="always", tablefmt="plain") + + "\n" + + bottom + ) def __str__(self): return self.__repr__() + def __len__(self): + return len(self.values) + + def __setitem__(self): + raise RuntimeError( + "IntervalSet is immutable. Starts and ends have been already sorted." + ) + + def __getitem__(self, key, *args, **kwargs): + print(key) + print(args) + print(kwargs) + if isinstance(key, str): + if key == "start": + return self.values[:, 0] + elif key == "end": + return self.values[:, 1] + else: + raise IndexError + else: + output = self.values.__getitem__(key) + + if output.ndim == 2: + if output.shape[1] == 2: + return IntervalSet(start=output[:, 0], end=output[:, 1]) + else: + return output + elif output.ndim == 2: + else: + return output + + def __array__(self, dtype=None): + return self.values.astype(dtype) + + def __array_ufunc__(self, ufunc, method, *args, **kwargs): + # print("In __array_ufunc__") + # print(" ufunc = ", ufunc) + # print(" method = ", method) + # print(" args = ", args) + # for inp in args: + # print(type(inp)) + # print(" kwargs = ", kwargs) + pass + + def __array_function__(self, func, types, args, kwargs): + pass + + @property + def start(self): + return self.values[:, 0] + + @property + def end(self): + return self.values[:, 1] + + @property + def shape(self): + return self.values.shape + + @property + def ndim(self): + return self.values.ndim + + @property + def size(self): + return self.values.size + + @property + def starts(self): + """Return the starts of the IntervalSet as a Ts object + + Returns + ------- + Ts + The starts of the IntervalSet + """ + time_series = importlib.import_module(".time_series", "pynapple.core") + return time_series.Ts(t=self.values[:, 0], time_support=self) + + @property + def ends(self): + """Return the ends of the IntervalSet as a Ts object + + Returns + ------- + Ts + The ends of the IntervalSet + """ + time_series = importlib.import_module(".time_series", "pynapple.core") + return time_series.Ts(t=self.values[:, 1], time_support=self) + def time_span(self): """ Time span of the interval set. @@ -211,8 +236,8 @@ def time_span(self): out: IntervalSet an IntervalSet with a single interval encompassing the whole IntervalSet """ - s = self["start"][0] - e = self["end"].iloc[-1] + s = self.values[0, 0] + e = self.values[-1, 1] return IntervalSet(s, e) def tot_length(self, time_units="s"): @@ -229,12 +254,12 @@ def tot_length(self, time_units="s"): out: float _ """ - tot_l = (self["end"] - self["start"]).sum() + tot_l = np.sum(self.values[:, 1] - self.values[:, 0]) return TsIndex.return_timestamps(np.array([tot_l]), time_units)[0] def intersect(self, a): """ - set intersection of IntervalSet + Set intersection of IntervalSet Parameters ---------- @@ -311,7 +336,7 @@ def in_interval(self, tsd): out: numpy.ndarray an array with the interval index labels for each time stamp (NaN) for timestamps not in IntervalSet """ - times = tsd.index + times = tsd.index.values starts = self.values[:, 0] ends = self.values[:, 1] @@ -319,7 +344,7 @@ def in_interval(self, tsd): def drop_short_intervals(self, threshold, time_units="s"): """ - Drops the short intervals in the interval set. + Drops the short intervals in the interval set with duration shorter than `threshold`. Parameters ---------- @@ -336,13 +361,11 @@ def drop_short_intervals(self, threshold, time_units="s"): threshold = TsIndex.format_timestamps( np.array([threshold], dtype=np.float64), time_units )[0] - return self.loc[(self["end"] - self["start"]) > threshold].reset_index( - drop=True - ) + return self[(self.values[:, 1] - self.values[:, 0]) > threshold] def drop_long_intervals(self, threshold, time_units="s"): """ - Drops the long intervals in the interval set. + Drops the long intervals in the interval set with duration longer than `threshold`. Parameters ---------- @@ -359,13 +382,11 @@ def drop_long_intervals(self, threshold, time_units="s"): threshold = TsIndex.format_timestamps( np.array([threshold], dtype=np.float64), time_units )[0] - return self.loc[(self["end"] - self["start"]) < threshold].reset_index( - drop=True - ) + return self[(self.values[:, 1] - self.values[:, 0]) < threshold] def as_units(self, units="s"): """ - returns a DataFrame with time expressed in the desired unit + returns a pandas DataFrame with time expressed in the desired unit Parameters ---------- @@ -383,7 +404,7 @@ def as_units(self, units="s"): if units == "us": data = data.astype(np.int64) - df = pd.DataFrame(index=self.index.values, data=data, columns=self.columns) + df = pd.DataFrame(index=self.index, data=data, columns=self.columns) return df @@ -410,8 +431,8 @@ def merge_close_intervals(self, threshold, time_units="s"): threshold = TsIndex.format_timestamps( np.array((threshold,), dtype=np.float64).ravel(), time_units )[0] - start = self["start"].values - end = self["end"].values + start = self.values[:, 0] + end = self.values[:, 1] tojoin = (start[1:] - end[0:-1]) > threshold start = np.hstack((start[0], start[1:][tojoin])) end = np.hstack((end[0:-1][tojoin], end[-1])) @@ -510,37 +531,10 @@ def save(self, filename): np.savez( filename, - start=self.start.values, - end=self.end.values, + start=self.values[:, 0], + end=self.values[:, 1], type=np.array(["IntervalSet"], dtype=np.str_), ) return - @property - def _constructor(self): - return IntervalSet - - @property - def starts(self): - """Return the starts of the IntervalSet as a Ts object - - Returns - ------- - Ts - The starts of the IntervalSet - """ - time_series = importlib.import_module(".time_series", "pynapple.core") - return time_series.Ts(t=self.values[:, 0], time_support=self) - - @property - def ends(self): - """Return the ends of the IntervalSet as a Ts object - - Returns - ------- - Ts - The ends of the IntervalSet - """ - time_series = importlib.import_module(".time_series", "pynapple.core") - return time_series.Ts(t=self.values[:, 1], time_support=self) diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 83036c7f..7f7cfaa9 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -98,8 +98,8 @@ def __init__(self, t, d, time_units="s", time_support=None): ) if isinstance(time_support, IntervalSet) and len(self.index): - starts = time_support.start.values - ends = time_support.end.values + starts = time_support.start + ends = time_support.end t, d = jitrestrict(self.index.values, self.values, starts, ends) self.index = TsIndex(t) self.values = d @@ -318,8 +318,8 @@ def bin_average(self, bin_size, ep=None, time_units="s"): time_array = self.index.values data_array = self.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end if data_array.ndim > 1: t, d = jitbin_array(time_array, data_array, starts, ends, bin_size) else: @@ -410,8 +410,8 @@ def convolve(self, array, ep=None, trim="both"): time_array = self.index.values data_array = self.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end if data_array.ndim == 1: new_data_array = np.zeros(data_array.shape) @@ -707,8 +707,8 @@ def save(self, filename): filename, t=self.index.values, d=self.values, - start=self.time_support.start.values, - end=self.time_support.end.values, + start=self.time_support.start, + end=self.time_support.end, type=np.array([self.nap_class], dtype=np.str_), ) @@ -968,8 +968,8 @@ def save(self, filename): filename, t=self.index.values, d=self.values, - start=self.time_support.start.values, - end=self.time_support.end.values, + start=self.time_support.start, + end=self.time_support.end, columns=cols_name, type=np.array(["TsdFrame"], dtype=np.str_), ) @@ -1152,8 +1152,8 @@ def threshold(self, thr, method="above"): """ time_array = self.index.values data_array = self.values - starts = self.time_support.start.values - ends = self.time_support.end.values + starts = self.time_support.start + ends = self.time_support.end if method not in ["above", "below", "aboveequal", "belowequal"]: raise ValueError( "Method {} for thresholding is not accepted.".format(method) @@ -1282,8 +1282,8 @@ def save(self, filename): filename, t=self.index.values, d=self.values, - start=self.time_support.start.values, - end=self.time_support.end.values, + start=self.time_support.start, + end=self.time_support.end, type=np.array([self.nap_class], dtype=np.str_), ) @@ -1318,8 +1318,8 @@ def __init__(self, t, time_units="s", time_support=None): super().__init__(t, time_units, time_support) if isinstance(time_support, IntervalSet) and len(self.index): - starts = time_support.start.values - ends = time_support.end.values + starts = time_support.start + ends = time_support.end t = jittsrestrict(self.index.values, starts, ends) self.index = TsIndex(t) self.rate = self.index.shape[0] / np.sum( @@ -1518,8 +1518,8 @@ def save(self, filename): np.savez( filename, t=self.index.values, - start=self.time_support.start.values, - end=self.time_support.end.values, + start=self.time_support.start, + end=self.time_support.end, type=np.array(["Ts"], dtype=np.str_), ) diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index b7ea8f82..4a321125 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -40,10 +40,10 @@ def union_intervals(i_sets): if n == 2: new_start, new_end = jitunion( - i_sets[0].start.values, - i_sets[0].end.values, - i_sets[1].start.values, - i_sets[1].end.values, + i_sets[0].start, + i_sets[0].end, + i_sets[1].start, + i_sets[1].end, ) if n > 2: @@ -532,8 +532,8 @@ def count(self, *args, **kwargs): if isinstance(a, IntervalSet): ep = a - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end if isinstance(bin_size, (float, int)): bin_size = float(bin_size) @@ -987,8 +987,8 @@ def save(self, filename): if not np.all(np.isnan(data)): dicttosave["d"] = data[idx] - dicttosave["start"] = self.time_support.start.values - dicttosave["end"] = self.time_support.end.values + dicttosave["start"] = self.time_support.start + dicttosave["end"] = self.time_support.end np.savez(filename, **dicttosave) diff --git a/pynapple/core/utils.py b/pynapple/core/utils.py index aaaf1f7a..e51f41ad 100644 --- a/pynapple/core/utils.py +++ b/pynapple/core/utils.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: Guillaume Viejo # @Date: 2024-02-09 11:45:45 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-13 16:55:08 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-19 11:43:39 """ Utility functions @@ -14,6 +14,8 @@ from .config import nap_config +from numba import jit + def is_array_like(obj): """ @@ -178,6 +180,81 @@ def _concatenate_tsd(func, tsds): raise TypeError +@jit(nopython=True) +def _jitfix_iset(start, end): + """ + 0 - > "Some starts and ends are equal. Removing 1 microsecond!", + 1 - > "Some ends precede the relative start. Dropping them!", + 2 - > "Some starts precede the previous end. Joining them!", + 3 - > "Some epochs have no duration" + + Parameters + ---------- + start : numpy.ndarray + Description + end : numpy.ndarray + Description + + Returns + ------- + TYPE + Description + """ + to_warn = np.zeros(4, dtype=np.bool_) + + m = start.shape[0] + + data = np.zeros((m, 2), dtype=np.float64) + + i = 0 + ct = 0 + + while i < m: + newstart = start[i] + newend = end[i] + + while i < m: + if end[i] == start[i]: + to_warn[3] = True + i += 1 + else: + newstart = start[i] + newend = end[i] + break + + while i < m: + if end[i] < start[i]: + to_warn[1] = True + i += 1 + else: + newstart = start[i] + newend = end[i] + break + + while i < m - 1: + if start[i + 1] < end[i]: + to_warn[2] = True + i += 1 + newend = max(end[i - 1], end[i]) + else: + break + + if i < m - 1: + if newend == start[i + 1]: + to_warn[0] = True + newend -= 1.0e-6 + + data[ct, 0] = newstart + data[ct, 1] = newend + + ct += 1 + i += 1 + + data = data[0:ct] + + return (data, to_warn) + + class _TsdFrameSliceHelper: def __init__(self, tsdframe): self.tsdframe = tsdframe diff --git a/tests/test_interval_set.py b/tests/test_interval_set.py index b48afc19..690828ea 100644 --- a/tests/test_interval_set.py +++ b/tests/test_interval_set.py @@ -2,8 +2,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-03-30 11:15:02 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2023-09-18 10:24:22 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-19 14:37:19 """Tests for IntervalSet of `pynapple` package.""" @@ -18,9 +18,9 @@ def test_create_iset(): start = [0, 10, 16, 25] end = [5, 15, 20, 40] ep = nap.IntervalSet(start=start, end=end) - assert isinstance(ep, pd.DataFrame) - np.testing.assert_array_almost_equal(start, ep.start.values) - np.testing.assert_array_almost_equal(end, ep.end.values) + assert isinstance(ep, nap.core.interval_set.IntervalSet) + np.testing.assert_array_almost_equal(start, ep.start) + np.testing.assert_array_almost_equal(end, ep.end) def test_iset_properties(): start = [0, 10, 16, 25] @@ -57,32 +57,32 @@ def test_create_iset_from_scalars(): def test_create_iset_from_df(): df = pd.DataFrame(data=[[16, 100]], columns=["start", "end"]) ep = nap.IntervalSet(df) - np.testing.assert_array_almost_equal(df.start.values, ep.start.values) - np.testing.assert_array_almost_equal(df.end.values, ep.end.values) + np.testing.assert_array_almost_equal(df.start.values, ep.start) + np.testing.assert_array_almost_equal(df.end.values, ep.end) def test_create_iset_from_s(): start = np.array([0, 10, 16, 25]) end = np.array([5, 15, 20, 40]) ep = nap.IntervalSet(start=start, end=end, time_units="s") - np.testing.assert_array_almost_equal(start, ep.start.values) - np.testing.assert_array_almost_equal(end, ep.end.values) + np.testing.assert_array_almost_equal(start, ep.start) + np.testing.assert_array_almost_equal(end, ep.end) def test_create_iset_from_ms(): start = np.array([0, 10, 16, 25]) end = np.array([5, 15, 20, 40]) ep = nap.IntervalSet(start=start, end=end, time_units="ms") - np.testing.assert_array_almost_equal(start * 1e-3, ep.start.values) - np.testing.assert_array_almost_equal(end * 1e-3, ep.end.values) + np.testing.assert_array_almost_equal(start * 1e-3, ep.start) + np.testing.assert_array_almost_equal(end * 1e-3, ep.end) def test_create_iset_from_us(): start = np.array([0, 10, 16, 25]) end = np.array([5, 15, 20, 40]) ep = nap.IntervalSet(start=start, end=end, time_units="us") - np.testing.assert_array_almost_equal(start * 1e-6, ep.start.values) - np.testing.assert_array_almost_equal(end * 1e-6, ep.end.values) + np.testing.assert_array_almost_equal(start * 1e-6, ep.start) + np.testing.assert_array_almost_equal(end * 1e-6, ep.end) def test_timespan(): @@ -91,8 +91,8 @@ def test_timespan(): ep = nap.IntervalSet(start=start, end=end) ep2 = ep.time_span() assert len(ep2) == 1 - np.testing.assert_array_almost_equal(np.array([0]), ep2.start.values) - np.testing.assert_array_almost_equal(np.array([40]), ep2.end.values) + np.testing.assert_array_almost_equal(np.array([0]), ep2.start) + np.testing.assert_array_almost_equal(np.array([40]), ep2.end) def test_tot_length(): @@ -107,9 +107,10 @@ def test_tot_length(): def test_as_units(): ep = nap.IntervalSet(start=0, end=100) - pd.testing.assert_frame_equal(ep, ep.as_units("s")) - pd.testing.assert_frame_equal(ep * 1e3, ep.as_units("ms")) - tmp = ep * 1e6 + df = pd.DataFrame(data=np.array([[0.0, 100.0]]), columns=["start", "end"]) + pd.testing.assert_frame_equal(df, ep.as_units("s")) + pd.testing.assert_frame_equal(df * 1e3, ep.as_units("ms")) + tmp = df * 1e6 np.testing.assert_array_almost_equal(tmp.values, ep.as_units("us").values) @@ -117,25 +118,25 @@ def test_intersect(): ep = nap.IntervalSet(start=[0, 30], end=[10, 70]) ep2 = nap.IntervalSet(start=40, end=100) ep3 = nap.IntervalSet(start=40, end=70) - pd.testing.assert_frame_equal(ep.intersect(ep2), ep3) - pd.testing.assert_frame_equal(ep2.intersect(ep), ep3) + np.testing.assert_array_almost_equal(ep.intersect(ep2), ep3) + np.testing.assert_array_almost_equal(ep2.intersect(ep), ep3) def test_union(): ep = nap.IntervalSet(start=[0, 30], end=[10, 70]) ep2 = nap.IntervalSet(start=40, end=100) ep3 = nap.IntervalSet(start=[0, 30], end=[10, 100]) - pd.testing.assert_frame_equal(ep.union(ep2), ep3) - pd.testing.assert_frame_equal(ep2.union(ep), ep3) + np.testing.assert_array_almost_equal(ep.union(ep2), ep3) + np.testing.assert_array_almost_equal(ep2.union(ep), ep3) def test_set_diff(): ep = nap.IntervalSet(start=[0, 30], end=[10, 70]) ep2 = nap.IntervalSet(start=40, end=100) ep3 = nap.IntervalSet(start=[0, 30], end=[10, 40]) - pd.testing.assert_frame_equal(ep.set_diff(ep2), ep3) + np.testing.assert_array_almost_equal(ep.set_diff(ep2), ep3) ep4 = nap.IntervalSet(start=[70], end=[100]) - pd.testing.assert_frame_equal(ep2.set_diff(ep), ep4) + np.testing.assert_array_almost_equal(ep2.set_diff(ep), ep4) def test_in_interval(): @@ -149,11 +150,11 @@ def test_in_interval(): def test_drop_short_intervals(): ep = nap.IntervalSet(start=np.array([0, 10, 16, 25]), end=np.array([5, 15, 20, 40])) ep2 = nap.IntervalSet(start=25, end=40) - pd.testing.assert_frame_equal(ep.drop_short_intervals(5.0), ep2) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal(ep.drop_short_intervals(5.0), ep2) + np.testing.assert_array_almost_equal( ep.drop_short_intervals(5.0 * 1e3, time_units="ms"), ep2 ) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal( ep.drop_short_intervals(5.0 * 1e6, time_units="us"), ep2 ) @@ -161,11 +162,11 @@ def test_drop_short_intervals(): def test_drop_long_intervals(): ep = nap.IntervalSet(start=np.array([0, 10, 16, 25]), end=np.array([5, 15, 20, 40])) ep2 = nap.IntervalSet(start=16, end=20) - pd.testing.assert_frame_equal(ep.drop_long_intervals(5.0), ep2) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal(ep.drop_long_intervals(5.0), ep2) + np.testing.assert_array_almost_equal( ep.drop_long_intervals(5.0 * 1e3, time_units="ms"), ep2 ) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal( ep.drop_long_intervals(5.0 * 1e6, time_units="us"), ep2 ) @@ -173,12 +174,12 @@ def test_drop_long_intervals(): def test_merge_close_intervals(): ep = nap.IntervalSet(start=np.array([0, 10, 16]), end=np.array([5, 15, 20])) ep2 = nap.IntervalSet(start=np.array([0, 10]), end=np.array([5, 20])) - pd.testing.assert_frame_equal(ep.merge_close_intervals(4.0), ep2) - pd.testing.assert_frame_equal(ep.merge_close_intervals(4.0, time_units="s"), ep2) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal(ep.merge_close_intervals(4.0), ep2) + np.testing.assert_array_almost_equal(ep.merge_close_intervals(4.0, time_units="s"), ep2) + np.testing.assert_array_almost_equal( ep.merge_close_intervals(4.0 * 1e3, time_units="ms"), ep2 ) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal( ep.merge_close_intervals(4.0 * 1e6, time_units="us"), ep2 ) @@ -192,7 +193,7 @@ def test_jitfix_iset(): starts = np.array([0, 10, 16]) ends = np.array([5, 15, 20]) - ep, to_warn = nap.core.interval_set.jitfix_iset(starts, ends) + ep, to_warn = nap.core.utils._jitfix_iset(starts, ends) np.testing.assert_array_almost_equal(starts, ep[:,0]) np.testing.assert_array_almost_equal(ends, ep[:,1]) np.testing.assert_array_almost_equal(to_warn, np.zeros(4)) @@ -201,7 +202,7 @@ def test_jitfix_iset_error0(): start = np.around(np.array([0, 10, 15], dtype=np.float64), 9) end = np.around(np.array([10, 15, 20], dtype=np.float64), 9) - ep, to_warn = nap.core.interval_set.jitfix_iset(start, end) + ep, to_warn = nap.core.utils._jitfix_iset(start, end) end[1:] -= 1e-6 @@ -220,7 +221,7 @@ def test_jitfix_iset_error1(): start = np.around(np.array([0, 15, 16], dtype=np.float64), 9) end = np.around(np.array([5, 10, 20], dtype=np.float64), 9) - ep, to_warn = nap.core.interval_set.jitfix_iset(start, end) + ep, to_warn = nap.core.utils._jitfix_iset(start, end) np.testing.assert_array_almost_equal(start[[0,2]], ep[:,0]) np.testing.assert_array_almost_equal(end[[0,2]], ep[:,1]) @@ -237,7 +238,7 @@ def test_jitfix_iset_error2(): start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) end = np.around(np.array([11, 15, 20], dtype=np.float64), 9) - ep, to_warn = nap.core.interval_set.jitfix_iset(start, end) + ep, to_warn = nap.core.utils._jitfix_iset(start, end) np.testing.assert_array_almost_equal(start[[0,2]], ep[:,0]) np.testing.assert_array_almost_equal(end[[1,2]], ep[:,1]) @@ -254,7 +255,7 @@ def test_jitfix_iset_error3(): start = np.around(np.array([0, 15, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) - ep, to_warn = nap.core.interval_set.jitfix_iset(start, end) + ep, to_warn = nap.core.utils._jitfix_iset(start, end) np.testing.assert_array_almost_equal(start[[0,2]], ep[:,0]) np.testing.assert_array_almost_equal(end[[0,2]], ep[:,1]) @@ -264,57 +265,49 @@ def test_jitfix_iset_error3(): nap.IntervalSet(start=start, end=end) assert str(w[0].message) == "Some epochs have no duration" -@pytest.mark.filterwarnings("ignore") def test_raise_warning(): start = np.around(np.array([0, 15, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) - ep = nap.IntervalSet(start=start,end=end) + with pytest.warns(UserWarning, match=r"Some epochs have no duration"): + nap.IntervalSet(start=start,end=end) def test_iset_wrong_columns(): df = pd.DataFrame(data=[[16, 100]], columns=["start", "endssss"]) with pytest.raises(Exception) as e_info: nap.IntervalSet(df) - assert str(e_info.value) == "wrong columns name" def test_iset_diff_length(): with pytest.raises(Exception) as e_info: nap.IntervalSet(start=np.array([0, 10, 16]), end=np.array([5, 15, 20, 40])) assert str(e_info.value) == "Starts end ends are not of the same length" -@pytest.mark.filterwarnings("ignore") -def test_raise_warning(): - start = np.around(np.array([0, 15, 16], dtype=np.float64), 9) - end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) - ep = nap.IntervalSet(start=start,end=end) -@pytest.mark.filterwarnings("ignore") def test_sort_starts(): start = np.around(np.array([10, 0, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) - ep = nap.IntervalSet(start=start,end=end) + with pytest.warns(UserWarning, match=r"start is not sorted. Sorting it."): + ep = nap.IntervalSet(start=start,end=end) np.testing.assert_array_almost_equal(np.sort(start), ep.values[:,0]) -@pytest.mark.filterwarnings("ignore") def test_sort_ends(): start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) end = np.around(np.array([15, 5, 20], dtype=np.float64), 9) - ep = nap.IntervalSet(start=start,end=end) + with pytest.warns(UserWarning, match=r"end is not sorted. Sorting it."): + ep = nap.IntervalSet(start=start,end=end) np.testing.assert_array_almost_equal(np.sort(end), ep.values[:,1]) def test_repr_(): start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) ep = nap.IntervalSet(start=start,end=end) - - assert pd.DataFrame(ep).__repr__() == ep.__repr__() + assert isinstance(ep.__repr__(), str) def test_str_(): start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) ep = nap.IntervalSet(start=start,end=end) - - assert pd.DataFrame(ep).__str__() == ep.__str__() + assert isinstance(ep.__repr__(), str) def test_save_npz(): import os diff --git a/tests/test_jitted.py b/tests/test_jitted.py index 5abf8a26..14dccdad 100644 --- a/tests/test_jitted.py +++ b/tests/test_jitted.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-12-02 17:17:03 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-12 12:54:48 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-19 15:18:05 """Tests of jitted core functions for `pynapple` package.""" @@ -75,7 +75,7 @@ def test_jitrestrict(): ep, ts, tsd, tsdframe = get_example_dataset() tsd2 = restrict(ep, tsd) - t, d= nap.core._jitted_functions.jitrestrict(tsd.index, tsd.values, ep['start'].values, ep['end'].values) + t, d= nap.core._jitted_functions.jitrestrict(tsd.index, tsd.values, ep.start, ep.end) tsd3 = pd.Series(index=t, data=d) pd.testing.assert_series_equal(tsd2, tsd3) @@ -84,7 +84,7 @@ def test_jittsrestrict(): ep, ts, tsd, tsdframe = get_example_dataset() ts2 = restrict(ep, ts) - t = nap.core._jitted_functions.jittsrestrict(ts.index, ep['start'].values, ep['end'].values) + t = nap.core._jitted_functions.jittsrestrict(ts.index, ep.start, ep.end) ts3 = pd.Series(index=t, dtype="object") pd.testing.assert_series_equal(ts2, ts3) @@ -93,7 +93,7 @@ def test_jitrestrict_with_count(): ep, ts, tsd, tsdframe = get_example_dataset() tsd2 = restrict(ep, tsd) - t, d, count = nap.core._jitted_functions.jitrestrict_with_count(tsd.index, tsd.values, ep['start'].values, ep['end'].values) + t, d, count = nap.core._jitted_functions.jitrestrict_with_count(tsd.index, tsd.values, ep.start, ep.end) tsd3 = pd.Series(index=t, data=d) pd.testing.assert_series_equal(tsd2, tsd3) @@ -114,7 +114,7 @@ def test_jittsrestrict_with_count(): ep, ts, tsd, tsdframe = get_example_dataset() ts2 = restrict(ep, ts) - t, count = nap.core._jitted_functions.jittsrestrict_with_count(ts.index, ep['start'].values, ep['end'].values) + t, count = nap.core._jitted_functions.jittsrestrict_with_count(ts.index, ep.start, ep.end) ts3 = pd.Series(index=t, dtype="object") pd.testing.assert_series_equal(ts2, ts3) @@ -136,25 +136,25 @@ def test_jitthreshold(): thr = np.random.rand() - t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep['start'].values, ep['end'].values, thr) + t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep.start, ep.end, thr) assert len(t) == np.sum(tsd.values > thr) assert len(d) == np.sum(tsd.values > thr) np.testing.assert_array_equal(d, tsd.values[tsd.values > thr]) - t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep['start'].values, ep['end'].values, thr, "below") + t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep.start, ep.end, thr, "below") assert len(t) == np.sum(tsd.values < thr) assert len(d) == np.sum(tsd.values < thr) np.testing.assert_array_equal(d, tsd.values[tsd.values < thr]) - t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep['start'].values, ep['end'].values, thr, "aboveequal") + t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep.start, ep.end, thr, "aboveequal") assert len(t) == np.sum(tsd.values >= thr) assert len(d) == np.sum(tsd.values >= thr) np.testing.assert_array_equal(d, tsd.values[tsd.values >= thr]) - t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep['start'].values, ep['end'].values, thr, "belowequal") + t, d, s, e = nap.core._jitted_functions.jitthreshold(tsd.index, tsd.values, ep.start, ep.end, thr, "belowequal") assert len(t) == np.sum(tsd.values <= thr) assert len(d) == np.sum(tsd.values <= thr) @@ -170,14 +170,14 @@ def test_jitvalue_from(): for i in range(10): ep, ts, tsd, tsdframe = get_example_dataset() - t, d, s, e = nap.core._jitted_functions.jitvaluefrom(ts.index, tsd.index, tsd.values, ep['start'].values, ep['end'].values) + t, d, s, e = nap.core._jitted_functions.jitvaluefrom(ts.index, tsd.index, tsd.values, ep.start, ep.end) tsd3 = pd.Series(index=t, data=d) tsd2 = [] for j in ep.index.values: ix = ts.restrict(ep.loc[[j]]).index if len(ix): - tsd2.append(tsd.restrict(ep.loc[[j]]).as_series().reindex(ix, method="nearest").fillna(0.0)) + tsd2.append(tsd.restrict(ep.loc[[j]]).as_series().reindex(ix, method="nearest").fillna(0.0)) tsd2 = pd.concat(tsd2) @@ -188,8 +188,8 @@ def test_jitcount(): ep, ts, tsd, tsdframe = get_example_dataset() time_array = ts.index - starts = ep['start'].values - ends = ep['end'].values + starts = ep.start + ends = ep.end bin_size = 1.0 t, d = nap.core._jitted_functions.jitcount(time_array, starts, ends, bin_size) tsd3 = nap.Tsd(t=t, d=d, time_support = ep) @@ -216,8 +216,8 @@ def test_jitbin(): time_array = tsd.index data_array = tsd.values - starts = ep['start'].values - ends = ep['end'].values + starts = ep.start + ends = ep.end bin_size = 1.0 t, d = nap.core._jitted_functions.jitbin(time_array, data_array, starts, ends, bin_size) # tsd3 = nap.Tsd(t=t, d=d, time_support = ep) @@ -253,8 +253,8 @@ def test_jitbin_array(): time_array = tsdframe.index data_array = tsdframe.values - starts = ep['start'].values - ends = ep['end'].values + starts = ep.start + ends = ep.end bin_size = 1.0 t, d = nap.core._jitted_functions.jitbin_array(time_array, data_array, starts, ends, bin_size) tsd3 = pd.DataFrame(index=t, data=d) @@ -426,7 +426,7 @@ def test_jitin_interval(): for i in range(10): ep, ts, tsd, tsdframe = get_example_dataset() - inep = nap.core._jitted_functions.jitin_interval(tsd.index, ep['start'].values, ep['end'].values) + inep = nap.core._jitted_functions.jitin_interval(tsd.index, ep.start, ep.end) inep[np.isnan(inep)] = -1 bins = ep.values.ravel() diff --git a/tests/test_ts_group.py b/tests/test_ts_group.py index 41d7e7ba..72b1a144 100644 --- a/tests/test_ts_group.py +++ b/tests/test_ts_group.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-03-30 11:14:41 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-10 17:33:54 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-19 15:11:43 """Tests of ts group for `pynapple` package.""" @@ -42,11 +42,11 @@ def test_create_ts_group_from_array(self, group): def test_create_ts_group_with_time_support(self, group): ep = nap.IntervalSet(start=0, end=100) tsgroup = nap.TsGroup(group, time_support=ep) - pd.testing.assert_frame_equal(tsgroup.time_support, ep) + np.testing.assert_array_almost_equal(tsgroup.time_support, ep) first = [tsgroup[i].index[0] for i in tsgroup] last = [tsgroup[i].index[-1] for i in tsgroup] - assert np.all(first >= ep.loc[0, "start"]) - assert np.all(last <= ep.loc[0, "end"]) + assert np.all(first >= ep[0, 0]) + assert np.all(last <= ep[0, 1]) def test_create_ts_group_with_empty_time_support(self, group): with pytest.raises(RuntimeError) as e_info: @@ -209,8 +209,8 @@ def test_restrict(self, group): tsgroup2 = tsgroup.restrict(ep) first = [tsgroup2[i].index[0] for i in tsgroup2] last = [tsgroup2[i].index[-1] for i in tsgroup2] - assert np.all(first >= ep.loc[0, "start"]) - assert np.all(last <= ep.loc[0, "end"]) + assert np.all(first >= ep[0, 0]) + assert np.all(last <= ep[0, 1]) def test_value_from(self, group): tsgroup = nap.TsGroup(group) @@ -503,8 +503,8 @@ def test_save_npz(self, group): data = data[idx] index = index[idx] - np.testing.assert_array_almost_equal(file['start'], tsgroup.time_support.start.values) - np.testing.assert_array_almost_equal(file['end'], tsgroup.time_support.end.values) + np.testing.assert_array_almost_equal(file['start'], tsgroup.time_support.start) + np.testing.assert_array_almost_equal(file['end'], tsgroup.time_support.end) np.testing.assert_array_almost_equal(file['t'], times) np.testing.assert_array_almost_equal(file['d'], data) np.testing.assert_array_almost_equal(file['index'], index) From 7b1fc83b9cbd0919c92a1cb8e8d536d908cc0af9 Mon Sep 17 00:00:00 2001 From: gviejo Date: Tue, 20 Feb 2024 22:58:45 -0500 Subject: [PATCH 14/23] Fixing tests for new IntervalSet --- pynapple/core/interval_set.py | 44 ++++++++++++++----------- pynapple/core/time_series.py | 4 +-- pynapple/core/utils.py | 3 +- pynapple/process/perievent.py | 12 +++---- tests/test_jitted.py | 51 ++++++++++++++++------------- tests/test_spike_trigger_average.py | 8 ++--- tests/test_time_series.py | 48 +++++++++++++-------------- 7 files changed, 89 insertions(+), 81 deletions(-) diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index 18ad188d..cfdcbaa0 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -7,8 +7,8 @@ import importlib import os import warnings - from numbers import Number + import numpy as np import pandas as pd from numpy.lib.mixins import NDArrayOperatorsMixin @@ -16,7 +16,7 @@ from ._jitted_functions import jitdiff, jitin_interval, jitintersect, jitunion from .time_index import TsIndex -from .utils import is_array_like, _jitfix_iset, convert_to_numpy +from .utils import _jitfix_iset, convert_to_numpy, is_array_like all_warnings = np.array( [ @@ -36,15 +36,15 @@ class IntervalSet(NDArrayOperatorsMixin): def __init__(self, start, end=None, time_units="s", **kwargs): """ IntervalSet initializer - + If start and end and not aligned, meaning that \n 1. len(start) != len(end) 2. end[i] > start[i] 3. start[i+1] > end[i] 4. start and end are not sorted, - + IntervalSet will try to "fix" the data by eliminating some of the start and end data point - + Parameters ---------- start : numpy.ndarray or number or pandas.DataFrame or pandas.Series @@ -53,12 +53,12 @@ def __init__(self, start, end=None, time_units="s", **kwargs): Ends of intervals time_units : str, optional Time unit of the intervals ('us', 'ms', 's' [default]) - + Raises ------ RuntimeError If `start` and `end` arguments are of unknown type - + """ if isinstance(start, pd.DataFrame): assert ( @@ -88,7 +88,7 @@ def __init__(self, start, end=None, time_units="s", **kwargs): elif isinstance(data, np.ndarray): args[arg] = np.ravel(data) elif is_array_like(data): - args[arg] = convert_to_numpy(data, arg) + args[arg] = convert_to_numpy(data, arg) else: raise RuntimeError( "Unknown format for {}. Accepted formats are numpy.ndarray, list, tuple or any array-like objects.".format( @@ -145,27 +145,32 @@ def __setitem__(self): ) def __getitem__(self, key, *args, **kwargs): - print(key) - print(args) - print(kwargs) if isinstance(key, str): if key == "start": return self.values[:, 0] elif key == "end": return self.values[:, 1] else: - raise IndexError - else: + raise IndexError("Unknown string argument. Should be 'start' or 'end'") + elif isinstance(key, Number): output = self.values.__getitem__(key) - - if output.ndim == 2: - if output.shape[1] == 2: + return IntervalSet(start=output[0], end=output[1]) + elif isinstance(key, (list, slice, np.ndarray)): + output = self.values.__getitem__(key) + return IntervalSet(start=output[:, 0], end=output[:, 1]) + elif isinstance(key, tuple): + if len(key) == 2: + if isinstance(key[1], Number): + return self.values.__getitem__(key) + elif key == slice(None, None, None) or key == slice(0, 2, None): + output = self.values.__getitem__(key) return IntervalSet(start=output[:, 0], end=output[:, 1]) else: - return output - elif output.ndim == 2: + return self.values.__getitem__(key) else: - return output + raise IndexError( + "too many indices for IntervalSet: IntervalSet is 2-dimensional" + ) def __array__(self, dtype=None): return self.values.astype(dtype) @@ -537,4 +542,3 @@ def save(self, filename): ) return - diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index 7f7cfaa9..ee3ee9c0 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -498,8 +498,8 @@ def interpolate(self, ts, ep=None, left=None, right=None): start = 0 for i in range(len(ep)): - t = ts.get(ep.loc[i, "start"], ep.loc[i, "end"]) - tmp = self.get(ep.loc[i, "start"], ep.loc[i, "end"]) + t = ts.get(ep[i, 0], ep[i, 1]) + tmp = self.get(ep[i, 0], ep[i, 1]) if len(t) and len(tmp): if self.values.ndim == 1: diff --git a/pynapple/core/utils.py b/pynapple/core/utils.py index e51f41ad..41984d41 100644 --- a/pynapple/core/utils.py +++ b/pynapple/core/utils.py @@ -11,11 +11,10 @@ import warnings import numpy as np +from numba import jit from .config import nap_config -from numba import jit - def is_array_like(obj): """ diff --git a/pynapple/process/perievent.py b/pynapple/process/perievent.py index d418512b..ac68a7ee 100644 --- a/pynapple/process/perievent.py +++ b/pynapple/process/perievent.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-01-30 22:59:00 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-29 12:47:56 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-20 22:27:23 import numpy as np @@ -170,8 +170,8 @@ def compute_perievent_continuous(data, tref, minmax, ep=None, time_unit="s"): time_array = data.index.values data_array = data.values time_target_array = tref.index.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end binsize = time_array[1] - time_array[0] idx1 = -np.arange(0, window[0] + binsize, binsize)[::-1][:-1] @@ -282,8 +282,8 @@ def compute_event_trigger_average( time_array = np.round(count.index.values - (binsize / 2), 9) count_array = count.values - starts = ep.start.values - ends = ep.end.values + starts = ep.start + ends = ep.end time_target_array = feature.index.values data_target_array = feature.values diff --git a/tests/test_jitted.py b/tests/test_jitted.py index 14dccdad..0528b884 100644 --- a/tests/test_jitted.py +++ b/tests/test_jitted.py @@ -2,7 +2,7 @@ # @Author: gviejo # @Date: 2022-12-02 17:17:03 # @Last Modified by: gviejo -# @Last Modified time: 2024-02-19 15:18:05 +# @Last Modified time: 2024-02-20 22:43:28 """Tests of jitted core functions for `pynapple` package.""" @@ -174,10 +174,10 @@ def test_jitvalue_from(): tsd3 = pd.Series(index=t, data=d) tsd2 = [] - for j in ep.index.values: - ix = ts.restrict(ep.loc[[j]]).index + for j in ep.index: + ix = ts.restrict(ep[j]).index if len(ix): - tsd2.append(tsd.restrict(ep.loc[[j]]).as_series().reindex(ix, method="nearest").fillna(0.0)) + tsd2.append(tsd.restrict(ep[j]).as_series().reindex(ix, method="nearest").fillna(0.0)) tsd2 = pd.concat(tsd2) @@ -195,12 +195,12 @@ def test_jitcount(): tsd3 = nap.Tsd(t=t, d=d, time_support = ep) tsd2 = [] - for j in ep.index.values: - bins = np.arange(ep.loc[j,'start'], ep.loc[j,'end']+1.0, 1.0) - idx = np.digitize(ts.restrict(ep.loc[[j]]).index, bins)-1 + for j in ep.index: + bins = np.arange(ep[j,0], ep[j,1]+1.0, 1.0) + idx = np.digitize(ts.restrict(ep[j]).index, bins)-1 tmp = np.array([np.sum(idx==j) for j in range(len(bins)-1)]) tmp = nap.Tsd(t = bins[0:-1] + np.diff(bins)/2, d = tmp) - tmp = tmp.restrict(ep.loc[[j]]) + tmp = tmp.restrict(ep[j]) # pd.testing.assert_series_equal(tmp, tsd3.restrict(ep.loc[[j]])) @@ -225,9 +225,9 @@ def test_jitbin(): tsd3 = tsd3.fillna(0.0) tsd2 = [] - for j in ep.index.values: - bins = np.arange(ep.loc[j,'start'], ep.loc[j,'end']+1.0, 1.0) - aa = tsd.restrict(ep.loc[[j]]) + for j in ep.index: + bins = np.arange(ep[j,0], ep[j,1]+1.0, 1.0) + aa = tsd.restrict(ep[j]) tmp = np.zeros((len(bins)-1)) if len(aa): idx = np.digitize(aa.index, bins)-1 @@ -235,7 +235,7 @@ def test_jitbin(): tmp[k] = np.mean(aa.values[idx==k]) tmp = nap.Tsd(t = bins[0:-1] + np.diff(bins)/2, d = tmp) - tmp = tmp.restrict(ep.loc[[j]]) + tmp = tmp.restrict(ep[j]) # pd.testing.assert_series_equal(tmp, tsd3.restrict(ep.loc[[j]])) @@ -263,9 +263,9 @@ def test_jitbin_array(): tsd2 = [] - for j in ep.index.values: - bins = np.arange(ep.loc[j,'start'], ep.loc[j,'end']+1.0, 1.0) - aa = tsdframe.restrict(ep.loc[[j]]) + for j in ep.index: + bins = np.arange(ep[j,0], ep[j,1]+1.0, 1.0) + aa = tsdframe.restrict(ep[j]) tmp = np.zeros((len(bins)-1, tsdframe.shape[1])) if len(aa): idx = np.digitize(aa.index, bins)-1 @@ -273,7 +273,7 @@ def test_jitbin_array(): tmp[k] = np.mean(aa.values[idx==k], 0) tmp = nap.TsdFrame(t = bins[0:-1] + np.diff(bins)/2, d = tmp) - tmp = tmp.restrict(ep.loc[[j]]) + tmp = tmp.restrict(ep[j]) # pd.testing.assert_series_equal(tmp, tsd3.restrict(ep.loc[[j]])) @@ -288,7 +288,7 @@ def test_jitintersect(): for i in range(10): ep1, ep2 = get_example_isets() - s, e = nap.core._jitted_functions.jitintersect(ep1.start.values, ep1.end.values, ep2.start.values, ep2.end.values) + s, e = nap.core._jitted_functions.jitintersect(ep1.start, ep1.end, ep2.start, ep2.end) ep3 = nap.IntervalSet(s, e) @@ -314,13 +314,14 @@ def test_jitintersect(): ep4 = nap.IntervalSet(start, end) - pd.testing.assert_frame_equal(ep3, ep4) + # pd.testing.assert_frame_equal(ep3, ep4) + np.testing.assert_array_almost_equal(ep3, ep4) def test_jitunion(): for i in range(10): ep1, ep2 = get_example_isets() - s, e = nap.core._jitted_functions.jitunion(ep1.start.values, ep1.end.values, ep2.start.values, ep2.end.values) + s, e = nap.core._jitted_functions.jitunion(ep1.start, ep1.end, ep2.start, ep2.end) ep3 = nap.IntervalSet(s, e) @@ -347,13 +348,14 @@ def test_jitunion(): ep4 = nap.IntervalSet(start, stop) - pd.testing.assert_frame_equal(ep3, ep4) + # pd.testing.assert_frame_equal(ep3, ep4) + np.testing.assert_array_almost_equal(ep3, ep4) def test_jitdiff(): for i in range(10): ep1, ep2 = get_example_isets() - s, e = nap.core._jitted_functions.jitdiff(ep1.start.values, ep1.end.values, ep2.start.values, ep2.end.values) + s, e = nap.core._jitted_functions.jitdiff(ep1.start, ep1.end, ep2.start, ep2.end) ep3 = nap.IntervalSet(s, e) i_sets = (ep1, ep2) @@ -386,7 +388,8 @@ def test_jitdiff(): ep4 = nap.IntervalSet(start[idx], end[idx]) - pd.testing.assert_frame_equal(ep3, ep4) + # pd.testing.assert_frame_equal(ep3, ep4) + np.testing.assert_array_almost_equal(ep3, ep4) def test_jitunion_isets(): for i in range(10): @@ -420,7 +423,9 @@ def test_jitunion_isets(): ep5 = nap.IntervalSet(start, stop) - pd.testing.assert_frame_equal(ep5, ep6) + # pd.testing.assert_frame_equal(ep5, ep6) + np.testing.assert_array_almost_equal(ep5, ep6) + def test_jitin_interval(): for i in range(10): diff --git a/tests/test_spike_trigger_average.py b/tests/test_spike_trigger_average.py index f422d255..56ee193a 100644 --- a/tests/test_spike_trigger_average.py +++ b/tests/test_spike_trigger_average.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-08-29 17:27:02 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-01-25 11:39:01 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-20 22:45:51 #!/usr/bin/env python """Tests of spike trigger average for `pynapple` package.""" @@ -265,8 +265,8 @@ def test_compute_spike_trigger_average_multiple_epochs(): sta2 = [] for i in range(2): - count = group.count(binsize, ep.loc[[i]]) - tmp = feature.bin_average(binsize, ep.loc[[i]]) + count = group.count(binsize, ep[i]) + tmp = feature.bin_average(binsize, ep[i]) # Build the Hankel matrix pad_tmp = np.pad(tmp.values, (n_p, n_f)) diff --git a/tests/test_time_series.py b/tests/test_time_series.py index 698cbca2..8e0edc2e 100755 --- a/tests/test_time_series.py +++ b/tests/test_time_series.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-04-01 09:57:55 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-13 17:36:43 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-20 22:54:15 #!/usr/bin/env python """Tests of time series for `pynapple` package.""" @@ -223,14 +223,14 @@ def test_index_error(): def test_find_support(): tsd = nap.Tsd(t=np.arange(100), d=np.arange(100)) ep = tsd.find_support(1.0) - assert ep.loc[0, 'start'] == 0 - assert ep.loc[0, 'end'] == 99.0 + 1e-6 + assert ep[0, 0] == 0 + assert ep[0, 1] == 99.0 + 1e-6 t = np.hstack((np.arange(10), np.arange(20, 30))) tsd = nap.Tsd(t=t, d=np.arange(20)) ep = tsd.find_support(1.0) - np.testing.assert_array_equal(ep.start.values, np.array([0.0, 20.0])) - np.testing.assert_array_equal(ep.end.values, np.array([9.0+1e-6, 29+1e-6])) + np.testing.assert_array_equal(ep.start, np.array([0.0, 20.0])) + np.testing.assert_array_equal(ep.end, np.array([9.0+1e-6, 29+1e-6])) def test_properties(): t = np.arange(100) @@ -424,7 +424,7 @@ def test_dropna(self, tsd): new_tsd = tsd.dropna(update_time_support=False) np.testing.assert_array_equal(tsd.index.values[tokeep], new_tsd.index.values) np.testing.assert_array_equal(tsd.values[tokeep], new_tsd.values) - pd.testing.assert_frame_equal(new_tsd.time_support, tsd.time_support) + np.testing.assert_array_equal(new_tsd.time_support, tsd.time_support) tsd.values[:] = np.NaN new_tsd = tsd.dropna() @@ -456,13 +456,13 @@ def test_convolve(self, tsd): tsd3 = tsd.convolve(array, ep) for i in range(len(ep)): - tmp2 = tsd.restrict(ep.loc[[i]]).values + tmp2 = tsd.restrict(ep[i]).values tmp2 = tmp2.reshape(tmp2.shape[0], -1) for j in range(tmp2.shape[-1]): tmp2[:,j] = np.convolve(tmp2[:,j], array, mode='full')[5:-4] np.testing.assert_array_almost_equal( tmp2, - tsd3.restrict(ep.loc[[i]]).values.reshape(tmp2.shape[0], -1) + tsd3.restrict(ep[i]).values.reshape(tmp2.shape[0], -1) ) # Trim @@ -525,7 +525,7 @@ def test__getitems__(self, tsd): assert isinstance(a, nap.Tsd) np.testing.assert_array_almost_equal(a.index, b.index) np.testing.assert_array_almost_equal(a.values, b.values) - pd.testing.assert_frame_equal( + np.testing.assert_array_almost_equal( a.time_support, tsd.time_support ) @@ -702,8 +702,8 @@ def test_save_npz(self, tsd): np.testing.assert_array_almost_equal(file['t'], tsd.index) np.testing.assert_array_almost_equal(file['d'], tsd.values) - np.testing.assert_array_almost_equal(file['start'], tsd.time_support.start.values) - np.testing.assert_array_almost_equal(file['end'], tsd.time_support.end.values) + np.testing.assert_array_almost_equal(file['start'], tsd.time_support.start) + np.testing.assert_array_almost_equal(file['end'], tsd.time_support.end) os.remove("tsd.npz") os.remove("tsd2.npz") @@ -773,18 +773,18 @@ def test_horizontal_slicing(self, tsdframe): assert isinstance(tsdframe[:,0], nap.Tsd) np.testing.assert_array_almost_equal(tsdframe[:,0].values, tsdframe.values[:,0]) assert isinstance(tsdframe[:,0].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdframe.time_support, tsdframe[:,0].time_support) + np.testing.assert_array_almost_equal(tsdframe.time_support, tsdframe[:,0].time_support) assert isinstance(tsdframe[:,[0,2]], nap.TsdFrame) np.testing.assert_array_almost_equal(tsdframe.values[:,[0,2]], tsdframe[:,[0,2]].values) assert isinstance(tsdframe[:,[0,2]].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdframe.time_support, tsdframe[:,[0,2]].time_support) + np.testing.assert_array_almost_equal(tsdframe.time_support, tsdframe[:,[0,2]].time_support) def test_vertical_slicing(self, tsdframe): assert isinstance(tsdframe[0:10], nap.TsdFrame) np.testing.assert_array_almost_equal(tsdframe.values[0:10], tsdframe[0:10].values) assert isinstance(tsdframe[0:10].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdframe[0:10].time_support, tsdframe.time_support) + np.testing.assert_array_almost_equal(tsdframe[0:10].time_support, tsdframe.time_support) def test_str_indexing(self, tsdframe): tsdframe = nap.TsdFrame(t=np.arange(100), d=np.random.rand(100, 3), time_units="s", columns=['a', 'b', 'c']) @@ -932,8 +932,8 @@ def test_save_npz(self, tsdframe): np.testing.assert_array_almost_equal(file['t'], tsdframe.index) np.testing.assert_array_almost_equal(file['d'], tsdframe.values) - np.testing.assert_array_almost_equal(file['start'], tsdframe.time_support.start.values) - np.testing.assert_array_almost_equal(file['end'], tsdframe.time_support.end.values) + np.testing.assert_array_almost_equal(file['start'], tsdframe.time_support.start) + np.testing.assert_array_almost_equal(file['end'], tsdframe.time_support.end) np.testing.assert_array_almost_equal(file['columns'], tsdframe.columns) os.remove("tsdframe.npz") @@ -1044,8 +1044,8 @@ def test_save_npz(self, ts): assert 'end' in keys np.testing.assert_array_almost_equal(file['t'], ts.index) - np.testing.assert_array_almost_equal(file['start'], ts.time_support.start.values) - np.testing.assert_array_almost_equal(file['end'], ts.time_support.end.values) + np.testing.assert_array_almost_equal(file['start'], ts.time_support.start) + np.testing.assert_array_almost_equal(file['end'], ts.time_support.end) os.remove("ts.npz") os.remove("ts2.npz") @@ -1093,18 +1093,18 @@ def test_horizontal_slicing(self, tsdtensor): assert isinstance(tsdtensor[:,0], nap.TsdFrame) np.testing.assert_array_almost_equal(tsdtensor[:,0].values, tsdtensor.values[:,0]) assert isinstance(tsdtensor[:,0].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdtensor.time_support, tsdtensor[:,0].time_support) + np.testing.assert_array_almost_equal(tsdtensor.time_support, tsdtensor[:,0].time_support) assert isinstance(tsdtensor[:,[0,2]], nap.TsdTensor) np.testing.assert_array_almost_equal(tsdtensor.values[:,[0,2]], tsdtensor[:,[0,2]].values) assert isinstance(tsdtensor[:,[0,2]].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdtensor.time_support, tsdtensor[:,[0,2]].time_support) + np.testing.assert_array_almost_equal(tsdtensor.time_support, tsdtensor[:,[0,2]].time_support) def test_vertical_slicing(self, tsdtensor): assert isinstance(tsdtensor[0:10], nap.TsdTensor) np.testing.assert_array_almost_equal(tsdtensor.values[0:10], tsdtensor[0:10].values) assert isinstance(tsdtensor[0:10].time_support, nap.IntervalSet) - pd.testing.assert_frame_equal(tsdtensor[0:10].time_support, tsdtensor.time_support) + np.testing.assert_array_almost_equal(tsdtensor[0:10].time_support, tsdtensor.time_support) def test_operators(self, tsdtensor): v = tsdtensor.values @@ -1233,8 +1233,8 @@ def test_save_npz(self, tsdtensor): np.testing.assert_array_almost_equal(file['t'], tsdtensor.index) np.testing.assert_array_almost_equal(file['d'], tsdtensor.values) - np.testing.assert_array_almost_equal(file['start'], tsdtensor.time_support.start.values) - np.testing.assert_array_almost_equal(file['end'], tsdtensor.time_support.end.values) + np.testing.assert_array_almost_equal(file['start'], tsdtensor.time_support.start) + np.testing.assert_array_almost_equal(file['end'], tsdtensor.time_support.end) os.remove("tsdtensor.npz") os.remove("tsdtensor2.npz") From e72ba4d970479e6a4fe762e1133b556e9ccd79a8 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Wed, 21 Feb 2024 18:15:58 -0500 Subject: [PATCH 15/23] Adding tests for IntervalSet --- pynapple/core/config.py | 2 +- pynapple/core/interval_set.py | 46 +++++++++--- tests/__init__.py | 1 + tests/test_interval_set.py | 134 +++++++++++++++++++++++++++++++++- tests/test_non_numpy_array.py | 48 +----------- 5 files changed, 169 insertions(+), 62 deletions(-) diff --git a/pynapple/core/config.py b/pynapple/core/config.py index 4548403e..f7c11f1e 100644 --- a/pynapple/core/config.py +++ b/pynapple/core/config.py @@ -54,7 +54,7 @@ def __init__(self): def suppress_conversion_warnings(self): """ Gets or sets the suppression state for conversion warnings. When set to True, - warnings for automatic conversions of non-NumPy array-like objects to NumPy arrays + warnings for automatic conversions of non-NumPy array-like objects or pynapple objects to NumPy arrays are suppressed. Ensures that only boolean values are assigned. """ return self._suppress_conversion_warnings diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index cfdcbaa0..924800ef 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -15,6 +15,7 @@ from tabulate import tabulate from ._jitted_functions import jitdiff, jitin_interval, jitintersect, jitunion +from .config import nap_config from .time_index import TsIndex from .utils import _jitfix_iset, convert_to_numpy, is_array_like @@ -139,7 +140,7 @@ def __str__(self): def __len__(self): return len(self.values) - def __setitem__(self): + def __setitem__(self, key, value): raise RuntimeError( "IntervalSet is immutable. Starts and ends have been already sorted." ) @@ -162,7 +163,7 @@ def __getitem__(self, key, *args, **kwargs): if len(key) == 2: if isinstance(key[1], Number): return self.values.__getitem__(key) - elif key == slice(None, None, None) or key == slice(0, 2, None): + elif key[1] == slice(None, None, None) or key[1] == slice(0, 2, None): output = self.values.__getitem__(key) return IntervalSet(start=output[:, 0], end=output[:, 1]) else: @@ -171,22 +172,45 @@ def __getitem__(self, key, *args, **kwargs): raise IndexError( "too many indices for IntervalSet: IntervalSet is 2-dimensional" ) + else: + return self.values.__getitem__(key) def __array__(self, dtype=None): return self.values.astype(dtype) def __array_ufunc__(self, ufunc, method, *args, **kwargs): - # print("In __array_ufunc__") - # print(" ufunc = ", ufunc) - # print(" method = ", method) - # print(" args = ", args) - # for inp in args: - # print(type(inp)) - # print(" kwargs = ", kwargs) - pass + new_args = [] + for a in args: + if isinstance(a, self.__class__): + new_args.append(a.values) + else: + new_args.append(a) + + out = ufunc(*new_args, **kwargs) + + if not nap_config.suppress_conversion_warnings: + warnings.warn( + "Converting IntervalSet to numpy.array", + UserWarning, + ) + return out def __array_function__(self, func, types, args, kwargs): - pass + new_args = [] + for a in args: + if isinstance(a, self.__class__): + new_args.append(a.values) + else: + new_args.append(a) + + out = func._implementation(*new_args, **kwargs) + + if not nap_config.suppress_conversion_warnings: + warnings.warn( + "Converting IntervalSet to numpy.array", + UserWarning, + ) + return out @property def start(self): diff --git a/tests/__init__.py b/tests/__init__.py index 82dc6660..55f838c8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,2 @@ """Unit test package for pynapple.""" +from . import mock diff --git a/tests/test_interval_set.py b/tests/test_interval_set.py index 690828ea..08b356c1 100644 --- a/tests/test_interval_set.py +++ b/tests/test_interval_set.py @@ -2,8 +2,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-03-30 11:15:02 -# @Last Modified by: gviejo -# @Last Modified time: 2024-02-19 14:37:19 +# @Last Modified by: Guillaume Viejo +# @Last Modified time: 2024-02-21 18:12:04 """Tests for IntervalSet of `pynapple` package.""" @@ -12,6 +12,7 @@ import pandas as pd import pytest import warnings +from .mock import MockArray def test_create_iset(): @@ -31,6 +32,10 @@ def test_iset_properties(): np.testing.assert_array_almost_equal(np.array(start), ep.starts.index) np.testing.assert_array_almost_equal(np.array(end), ep.ends.index) + assert ep.shape == ep.values.shape + assert ep.ndim == ep.values.ndim + assert ep.size == ep.values.size + def test_iset_centers(): start = np.array([0, 10, 16, 25]) end = np.array([5, 15, 20, 40]) @@ -60,6 +65,26 @@ def test_create_iset_from_df(): np.testing.assert_array_almost_equal(df.start.values, ep.start) np.testing.assert_array_almost_equal(df.end.values, ep.end) +def test_create_iset_from_mock_array(): + start = np.array([0, 200]) + end = np.array([100, 300]) + + with warnings.catch_warnings(record=True) as w: + ep = nap.IntervalSet(MockArray(start), MockArray(end)) + + assert str(w[0].message) == "Converting 'start' to numpy.array. The provided array was of type 'MockArray'." + assert str(w[1].message) == "Converting 'end' to numpy.array. The provided array was of type 'MockArray'." + + np.testing.assert_array_almost_equal(ep.start, start) + np.testing.assert_array_almost_equal(ep.end, end) + +def test_create_iset_from_unknown_format(): + with pytest.raises(RuntimeError) as e: + nap.IntervalSet(start="abc", end=[1, 2]) + assert str(e.value) == "Unknown format for start. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." + with pytest.raises(RuntimeError) as e: + nap.IntervalSet(start=[1,2], end="abc") + assert str(e.value) == "Unknown format for end. Accepted formats are numpy.ndarray, list, tuple or any array-like objects." def test_create_iset_from_s(): start = np.array([0, 10, 16, 25]) @@ -84,6 +109,109 @@ def test_create_iset_from_us(): np.testing.assert_array_almost_equal(start * 1e-6, ep.start) np.testing.assert_array_almost_equal(end * 1e-6, ep.end) +def test_modify_iset(): + start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) + end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) + ep = nap.IntervalSet(start=start,end=end) + + with pytest.raises(RuntimeError) as e: + ep[0,0] = 1 + assert str(e.value) == "IntervalSet is immutable. Starts and ends have been already sorted." + +def test_get_iset(): + start = np.array([0, 10, 16], dtype=np.float64) + end = np.array([5, 15, 20], dtype=np.float64) + ep = nap.IntervalSet(start=start,end=end) + + assert isinstance(ep['start'], np.ndarray) + assert isinstance(ep['end'], np.ndarray) + np.testing.assert_array_almost_equal(ep['start'], start) + np.testing.assert_array_almost_equal(ep['end'], end) + + with pytest.raises(IndexError) as e: + ep['a'] + assert str(e.value) == "Unknown string argument. Should be 'start' or 'end'" + + # Get a new IntervalSet + ep2 = ep[0] + assert isinstance(ep2, nap.IntervalSet) + np.testing.assert_array_almost_equal(ep2, np.array([[0., 5.]])) + + ep2 = ep[0:2] + assert isinstance(ep2, nap.IntervalSet) + np.testing.assert_array_almost_equal(ep2, ep.values[0:2]) + + ep2 = ep[[0,2]] + assert isinstance(ep2, nap.IntervalSet) + np.testing.assert_array_almost_equal(ep2, ep.values[[0,2]]) + + ep2 = ep[0:2,:] + assert isinstance(ep2, nap.IntervalSet) + np.testing.assert_array_almost_equal(ep2, ep.values[0:2]) + + ep2 = ep[0:2,0:2] + assert isinstance(ep2, nap.IntervalSet) + np.testing.assert_array_almost_equal(ep2, ep.values[0:2]) + + ep2 = ep[:,0] + np.testing.assert_array_almost_equal(ep2, ep.start) + ep2 = ep[:,1] + np.testing.assert_array_almost_equal(ep2, ep.end) + + with pytest.raises(IndexError) as e: + ep[:,0,3] + assert str(e.value) == "too many indices for IntervalSet: IntervalSet is 2-dimensional" + +def test_array_ufunc(): + start = np.array([0, 10, 16], dtype=np.float64) + end = np.array([5, 15, 20], dtype=np.float64) + ep = nap.IntervalSet(start=start,end=end) + + with warnings.catch_warnings(record=True) as w: + out = np.exp(ep) + assert str(w[0].message) == "Converting IntervalSet to numpy.array" + np.testing.assert_array_almost_equal(out, np.exp(ep.values)) + + with warnings.catch_warnings(record=True) as w: + out = ep*2 + assert str(w[0].message) == "Converting IntervalSet to numpy.array" + np.testing.assert_array_almost_equal(out, ep.values*2) + + with warnings.catch_warnings(record=True) as w: + out = ep + ep + assert str(w[0].message) == "Converting IntervalSet to numpy.array" + np.testing.assert_array_almost_equal(out, ep.values*2) + + # test warning + from contextlib import nullcontext as does_not_raise + nap.config.nap_config.suppress_conversion_warnings = True + with does_not_raise(): + np.exp(ep) + + nap.config.nap_config.suppress_conversion_warnings = False + +def test_array_func(): + start = np.array([0, 10, 16], dtype=np.float64) + end = np.array([5, 15, 20], dtype=np.float64) + ep = nap.IntervalSet(start=start,end=end) + + with warnings.catch_warnings(record=True) as w: + out = np.vstack((ep, ep)) + assert str(w[0].message) == "Converting IntervalSet to numpy.array" + np.testing.assert_array_almost_equal(out, np.vstack((ep.values, ep.values))) + + with warnings.catch_warnings(record=True) as w: + out = np.ravel(ep) + assert str(w[0].message) == "Converting IntervalSet to numpy.array" + np.testing.assert_array_almost_equal(out, np.ravel(ep.values)) + + # test warning + from contextlib import nullcontext as does_not_raise + nap.config.nap_config.suppress_conversion_warnings = True + with does_not_raise(): + out = np.ravel(ep) + + nap.config.nap_config.suppress_conversion_warnings = False def test_timespan(): start = [0, 10, 16, 25] @@ -307,7 +435,7 @@ def test_str_(): start = np.around(np.array([0, 10, 16], dtype=np.float64), 9) end = np.around(np.array([5, 15, 20], dtype=np.float64), 9) ep = nap.IntervalSet(start=start,end=end) - assert isinstance(ep.__repr__(), str) + assert isinstance(ep.__str__(), str) def test_save_npz(): import os diff --git a/tests/test_non_numpy_array.py b/tests/test_non_numpy_array.py index d0664e16..9f6d8c62 100644 --- a/tests/test_non_numpy_array.py +++ b/tests/test_non_numpy_array.py @@ -5,53 +5,7 @@ import pynapple as nap -class MockArray: - """ - A mock array class designed for testing purposes. It mimics the behavior of array-like objects - by providing necessary attributes and supporting indexing and iteration, but it is not a direct - instance of numpy.ndarray. - """ - - def __init__(self, data): - """ - Initializes the MockArray with data. - - Parameters - ---------- - data : Union[numpy.ndarray, List] - A list of data elements that the MockArray will contain. - """ - self.data = np.asarray(data) - self.shape = self.data.shape # Simplified shape attribute - self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic - self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array - - def __getitem__(self, index): - """ - Supports indexing into the mock array. - - Parameters - ---------- - index : int or slice - The index or slice of the data to access. - - Returns - ------- - The element(s) at the specified index. - """ - return self.data[index] - - def __iter__(self): - """ - Supports iteration over the mock array. - """ - return iter(self.data) - - def __len__(self): - """ - Returns the length of the mock array. - """ - return len(self.data) +from .mock import MockArray class TestTsArray: From 717629f40f8418d02b976392596bc662292aae82 Mon Sep 17 00:00:00 2001 From: gviejo Date: Wed, 21 Feb 2024 21:32:44 -0500 Subject: [PATCH 16/23] Adding loc to intervalset --- pynapple/core/interval_set.py | 14 +++++++++++++- pynapple/core/utils.py | 26 +++++++++++++++++++++++++- tests/test_interval_set.py | 16 ++++++++++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index 924800ef..febd0021 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -17,7 +17,12 @@ from ._jitted_functions import jitdiff, jitin_interval, jitintersect, jitunion from .config import nap_config from .time_index import TsIndex -from .utils import _jitfix_iset, convert_to_numpy, is_array_like +from .utils import ( + _IntervalSetSliceHelper, + _jitfix_iset, + convert_to_numpy, + is_array_like, +) all_warnings = np.array( [ @@ -256,6 +261,13 @@ def ends(self): time_series = importlib.import_module(".time_series", "pynapple.core") return time_series.Ts(t=self.values[:, 1], time_support=self) + @property + def loc(self): + """ + Slicing function to add compatibility with pandas DataFrame after removing it as a super class of IntervalSet + """ + return _IntervalSetSliceHelper(self) + def time_span(self): """ Time span of the interval set. diff --git a/pynapple/core/utils.py b/pynapple/core/utils.py index 41984d41..91f88092 100644 --- a/pynapple/core/utils.py +++ b/pynapple/core/utils.py @@ -2,13 +2,14 @@ # @Author: Guillaume Viejo # @Date: 2024-02-09 11:45:45 # @Last Modified by: gviejo -# @Last Modified time: 2024-02-19 11:43:39 +# @Last Modified time: 2024-02-21 21:27:04 """ Utility functions """ import warnings +from numbers import Number import numpy as np from numba import jit @@ -275,3 +276,26 @@ def __getitem__(self, key): return self.tsdframe.__getitem__( (slice(None, None, None), index), columns=key ) + + +class _IntervalSetSliceHelper: + def __init__(self, intervalset): + self.intervalset = intervalset + + def __getitem__(self, key): + if key in ["start", "end"]: + return self.intervalset[key] + elif isinstance(key, list): + return self.intervalset[key] + elif isinstance(key, Number): + return self.intervalset.values[key] + else: + if isinstance(key, tuple): + if len(key) == 2: + if key[1] not in ["start", "end"]: + raise IndexError + return self.intervalset[key[0]][key[1]] + else: + raise IndexError + else: + raise IndexError diff --git a/tests/test_interval_set.py b/tests/test_interval_set.py index 08b356c1..e9d495aa 100644 --- a/tests/test_interval_set.py +++ b/tests/test_interval_set.py @@ -2,8 +2,8 @@ # -*- coding: utf-8 -*- # @Author: gviejo # @Date: 2022-03-30 11:15:02 -# @Last Modified by: Guillaume Viejo -# @Last Modified time: 2024-02-21 18:12:04 +# @Last Modified by: gviejo +# @Last Modified time: 2024-02-21 21:30:58 """Tests for IntervalSet of `pynapple` package.""" @@ -162,6 +162,18 @@ def test_get_iset(): ep[:,0,3] assert str(e.value) == "too many indices for IntervalSet: IntervalSet is 2-dimensional" +def test_iset_loc(): + start = np.array([0, 10, 16], dtype=np.float64) + end = np.array([5, 15, 20], dtype=np.float64) + ep = nap.IntervalSet(start=start,end=end) + + np.testing.assert_array_almost_equal(ep.loc[0], ep.values[0]) + assert isinstance(ep.loc[[0]], nap.IntervalSet) + np.testing.assert_array_almost_equal(ep.loc[[0]], ep[0]) + np.testing.assert_array_almost_equal(ep.loc['start'], start) + np.testing.assert_array_almost_equal(ep.loc['end'], start) + + def test_array_ufunc(): start = np.array([0, 10, 16], dtype=np.float64) end = np.array([5, 15, 20], dtype=np.float64) From 3ecc7ce55cd101caa12755ae6c87325ce63ab7f9 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Wed, 21 Feb 2024 21:34:30 -0500 Subject: [PATCH 17/23] readding mock.py for tests --- tests/mock.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/mock.py diff --git a/tests/mock.py b/tests/mock.py new file mode 100644 index 00000000..bd09021f --- /dev/null +++ b/tests/mock.py @@ -0,0 +1,49 @@ +"""Test configuration script.""" +import numpy as np + + + +class MockArray: + """ + A mock array class designed for testing purposes. It mimics the behavior of array-like objects + by providing necessary attributes and supporting indexing and iteration, but it is not a direct + instance of numpy.ndarray. + """ + + def __init__(self, data): + """ + Initializes the MockArray with data. + Parameters + ---------- + data : Union[numpy.ndarray, List] + A list of data elements that the MockArray will contain. + """ + self.data = np.asarray(data) + self.shape = self.data.shape # Simplified shape attribute + self.dtype = 'float64' # Simplified dtype; in real scenarios, this should be more dynamic + self.ndim = self.data.ndim # Simplified ndim for a 1-dimensional array + + def __getitem__(self, index): + """ + Supports indexing into the mock array. + Parameters + ---------- + index : int or slice + The index or slice of the data to access. + Returns + ------- + The element(s) at the specified index. + """ + return self.data[index] + + def __iter__(self): + """ + Supports iteration over the mock array. + """ + return iter(self.data) + + def __len__(self): + """ + Returns the length of the mock array. + """ + return len(self.data) \ No newline at end of file From 537d368511f22c122fd3fb84747d9cfdd363c83b Mon Sep 17 00:00:00 2001 From: gviejo Date: Wed, 21 Feb 2024 22:35:38 -0500 Subject: [PATCH 18/23] Fixing notebooks --- docs/examples/tutorial_pynapple_process.py | 6 +++--- pynapple/core/interval_set.py | 4 +--- tests/test_interval_set.py | 4 ++-- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/examples/tutorial_pynapple_process.py b/docs/examples/tutorial_pynapple_process.py index fdd5e05d..e6165276 100644 --- a/docs/examples/tutorial_pynapple_process.py +++ b/docs/examples/tutorial_pynapple_process.py @@ -49,7 +49,7 @@ ts2_time_array = ts2.as_units("s").index.values binsize = 0.1 # second -cc12, xt = nap.cross_correlogram( +cc12, xt = nap.process.correlograms.cross_correlogram( t1=ts1_time_array, t2=ts2_time_array, binsize=binsize, windowsize=1 # second ) @@ -152,7 +152,7 @@ # To check the accuracy of the tuning curves, we will display the spikes aligned to the features with the function `value_from` which assign to each spikes the corresponding feature value for neuron 0. tcurves2d, binsxy = nap.compute_2d_tuning_curves( - group=ts_group, feature=features, nb_bins=10 + group=ts_group, features=features, nb_bins=10 ) ts_to_features = ts_group[1].value_from(features) @@ -203,7 +203,7 @@ tcurves2d, binsxy = nap.compute_2d_tuning_curves( group=ts_group, - feature=features, + features=features, nb_bins=10, ep=epoch, minmax=(-1.0, 1.0, -1.0, 1.0), diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index febd0021..c997ddaa 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -1,7 +1,5 @@ -""" - +""" The class `IntervalSet` deals with non-overlaping epochs. `IntervalSet` objects can interact with each other or with the time series objects. - """ import importlib diff --git a/tests/test_interval_set.py b/tests/test_interval_set.py index e9d495aa..a3f7cadc 100644 --- a/tests/test_interval_set.py +++ b/tests/test_interval_set.py @@ -3,7 +3,7 @@ # @Author: gviejo # @Date: 2022-03-30 11:15:02 # @Last Modified by: gviejo -# @Last Modified time: 2024-02-21 21:30:58 +# @Last Modified time: 2024-02-21 21:39:07 """Tests for IntervalSet of `pynapple` package.""" @@ -171,7 +171,7 @@ def test_iset_loc(): assert isinstance(ep.loc[[0]], nap.IntervalSet) np.testing.assert_array_almost_equal(ep.loc[[0]], ep[0]) np.testing.assert_array_almost_equal(ep.loc['start'], start) - np.testing.assert_array_almost_equal(ep.loc['end'], start) + np.testing.assert_array_almost_equal(ep.loc['end'], end) def test_array_ufunc(): From 090b0ce3a64a3a4ca3b01fa249b1896141b39546 Mon Sep 17 00:00:00 2001 From: Guillaume Viejo Date: Thu, 22 Feb 2024 12:10:47 -0500 Subject: [PATCH 19/23] Fixing docs of Intervalset --- docs/examples/tutorial_HD_dataset.py | 2 +- docs/examples/tutorial_pynapple_core.py | 2 +- pynapple/core/interval_set.py | 32 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/examples/tutorial_HD_dataset.py b/docs/examples/tutorial_HD_dataset.py index 10c006b9..e1dcfa05 100644 --- a/docs/examples/tutorial_HD_dataset.py +++ b/docs/examples/tutorial_HD_dataset.py @@ -236,7 +236,7 @@ def smoothAngularTuningCurves(tuning_curves, sigma=2): np.transpose(p_feature.restrict(ep).values), aspect="auto", interpolation="bilinear", - extent=[ep["start"].values[0], ep["end"].values[0], 0, 2 * np.pi], + extent=[ep["start"][0], ep["end"][0], 0, 2 * np.pi], origin="lower", cmap="viridis", ) diff --git a/docs/examples/tutorial_pynapple_core.py b/docs/examples/tutorial_pynapple_core.py index 59fdf7d5..c9a842c3 100644 --- a/docs/examples/tutorial_pynapple_core.py +++ b/docs/examples/tutorial_pynapple_core.py @@ -85,7 +85,7 @@ # Multiple operations are available for IntervalSet. For example, IntervalSet can be merged. See the full documentation of the class [here](https://peyrachelab.github.io/pynapple/core.interval_set/#pynapple.core.interval_set.IntervalSet.intersect) for a list of all the functions that can be used to manipulate IntervalSets. -epoch1 = nap.IntervalSet(start=[0], end=[10]) # no time units passed. Default is us. +epoch1 = nap.IntervalSet(start=0, end=10) # no time units passed. Default is us. epoch2 = nap.IntervalSet(start=[5, 30], end=[20, 45]) epoch = epoch1.union(epoch2) diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index c997ddaa..51075da0 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -1,5 +1,37 @@ """ The class `IntervalSet` deals with non-overlaping epochs. `IntervalSet` objects can interact with each other or with the time series objects. + + The `IntervalSet` object behaves like a numpy ndarray with the limitation that the object is not mutable. + + You can still apply any numpy array function to it : + + >>> import pynapple as nap + >>> import numpy as np + >>> ep = nap.IntervalSet(start=[0, 10], end=[5,20]) + start end + 0 0 5 + 1 10 20 + shape: (1, 2) + >>> np.diff(ep, 1) + UserWarning: Converting IntervalSet to numpy.array + array([[ 5.], + [10.]]) + + You can slice : + + >>> ep[:,0] + array([ 0., 10.]) + >>> ep[0] + start end + 0 0 5 + shape: (1, 2) + + But modifying the `IntervalSet` with raise an error: + + >>> ep[0,0] = 1 + RuntimeError: IntervalSet is immutable. Starts and ends have been already sorted. + + """ import importlib From e248f8e9fe82d020887542ec0af1f5cf334966a3 Mon Sep 17 00:00:00 2001 From: gviejo Date: Thu, 22 Feb 2024 15:26:55 -0500 Subject: [PATCH 20/23] edit --- docs/index.md | 22 ++++++++++++++++++++-- pynapple/core/interval_set.py | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 52f2a840..b16737fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,10 +27,28 @@ Community To ask any questions or get support for using pynapple, please consider joining our slack. Please send an email to thepynapple[at]gmail[dot]com to receive an invitation link. -New release :fire: +New releases :fire: ------------------ -Starting with 0.4, pynapple rely on the [numpy array container](https://numpy.org/doc/stable/user/basics.dispatch.html) approach instead of Pandas. Pynapple builtin functions will remain the same except for functions inherited from Pandas. Typically this line of code in `pynapple<=0.3.6` : +### pynapple >= 0.6 + +Starting with 0.6, [`IntervalSet`](https://pynapple-org.github.io/pynapple/reference/core/interval_set/) objects are behaving as immutable numpy ndarray. Before 0.6, you could select an interval within an `IntervalSet` object with: + +```python +new_intervalset = intervalset.loc[[0]] # Selecting first interval +``` + +With pynapple>=0.6, the slicing is similar to numpy and it returns an `IntervalSet` + +```python +new_intervalset = intervalset[0] +``` + + + +### pynapple >= 0.4 + +Starting with 0.4, pynapple rely on the [numpy array container](https://numpy.org/doc/stable/user/basics.dispatch.html) approach instead of Pandas for the time series. Pynapple builtin functions will remain the same except for functions inherited from Pandas. Typically this line of code in `pynapple<=0.3.6` : ```python meantsd = tsdframe.mean(1) ``` diff --git a/pynapple/core/interval_set.py b/pynapple/core/interval_set.py index 51075da0..ec91f311 100644 --- a/pynapple/core/interval_set.py +++ b/pynapple/core/interval_set.py @@ -161,7 +161,7 @@ def __init__(self, start, end=None, time_units="s", **kwargs): def __repr__(self): headers = ["start", "end"] - bottom = "shape: {}".format(self.shape) + bottom = "shape: {}, time unit: sec.".format(self.shape) return ( tabulate(self.values, headers=headers, showindex="always", tablefmt="plain") From ff6b540bb58590232e420c58a5e84c94d0c3dafe Mon Sep 17 00:00:00 2001 From: gviejo Date: Fri, 23 Feb 2024 04:15:07 -0500 Subject: [PATCH 21/23] Preparing for bumping 0.6 --- README.md | 26 +++++++++++++++++++++++--- docs/HISTORY.md | 6 ++++++ docs/index.md | 2 +- pynapple/__init__.py | 2 +- pyproject.toml | 2 +- setup.py | 4 ++-- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ef22693c..0fb37f25 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,32 @@ pynapple is a light-weight python library for neurophysiological data analysis. ------------------------------------------------------------------------ New release :fire: ---------------- -Starting with 0.4, pynapple rely on the [numpy array container](https://numpy.org/doc/stable/user/basics.dispatch.html) approach instead of Pandas. Pynapple builtin functions will remain the same except for functions inherited from Pandas. Typically this line of code in `pynapple<=0.3.6` : +------------------ + +### pynapple >= 0.6 + +Starting with 0.6, [`IntervalSet`](https://pynapple-org.github.io/pynapple/reference/core/interval_set/) objects are behaving as immutable numpy ndarray. Before 0.6, you could select an interval within an `IntervalSet` object with: + +```python +new_intervalset = intervalset.loc[[0]] # Selecting first interval +``` + +With pynapple>=0.6, the slicing is similar to numpy and it returns an `IntervalSet` + +```python +new_intervalset = intervalset[0] +``` + +See the [documentation](https://pynapple-org.github.io/pynapple/reference/core/interval_set/) for more details. + + +### pynapple >= 0.4 + +Starting with 0.4, pynapple rely on the [numpy array container](https://numpy.org/doc/stable/user/basics.dispatch.html) approach instead of Pandas for the time series. Pynapple builtin functions will remain the same except for functions inherited from Pandas. Typically this line of code in `pynapple<=0.3.6` : ```python meantsd = tsdframe.mean(1) ``` -is now: +is now : ```python meantsd = np.mean(tsdframe, 1) ``` diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 97d05bfd..5c7f205b 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -9,6 +9,12 @@ In 2018, Francesco started neuroseries, a Python package built on Pandas. It was In 2021, Guillaume and other trainees in Adrien's lab decided to fork from neuroseries and started *pynapple*. The core of pynapple is largely built upon neuroseries. Some of the original changes to TSToolbox made by Luke were included in this package, especially the *time_support* property of all ts/tsd objects. +0.6.0 (coming) +------------------ + +- Refactoring `IntervalSet` to pure numpy ndarray. + + 0.5.1 (2024-01-29) ------------------ diff --git a/docs/index.md b/docs/index.md index b16737fa..f0cb18a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,7 +44,7 @@ With pynapple>=0.6, the slicing is similar to numpy and it returns an `IntervalS new_intervalset = intervalset[0] ``` - +See the [documentation](https://pynapple-org.github.io/pynapple/reference/core/interval_set/) for more details. ### pynapple >= 0.4 diff --git a/pynapple/__init__.py b/pynapple/__init__.py index 1ea30652..989e5ccc 100644 --- a/pynapple/__init__.py +++ b/pynapple/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.5.1" +__version__ = "0.6.0" from .core import IntervalSet, Ts, Tsd, TsdFrame, TsdTensor, TsGroup, TsIndex, config from .io import * from .process import * diff --git a/pyproject.toml b/pyproject.toml index 4e0e9bc5..d452afbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pynapple" -version = "0.5.1" +version = "0.6.0" description = "PYthon Neural Analysis Package Pour Laboratoires d’Excellence" readme = "README.md" authors = [{ name = "Guillaume Viejo", email = "guillaume.viejo@gmail.com" }] diff --git a/setup.py b/setup.py index 700b4858..ed2e8f42 100644 --- a/setup.py +++ b/setup.py @@ -59,8 +59,8 @@ test_suite='tests', tests_require=test_requirements, url='https://github.com/pynapple-org/pynapple', - version='v0.5.1', + version='v0.6.0', zip_safe=False, long_description_content_type='text/markdown', - download_url='https://github.com/pynapple-org/pynapple/archive/refs/tags/v0.5.1.tar.gz' + download_url='https://github.com/pynapple-org/pynapple/archive/refs/tags/v0.6.0.tar.gz' ) From dfbc57efc115f57b52ceb04f2c9abe8e7069c3ff Mon Sep 17 00:00:00 2001 From: gviejo Date: Fri, 23 Feb 2024 04:43:55 -0500 Subject: [PATCH 22/23] Updating history.md --- docs/HISTORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 5c7f205b..83299fe7 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -13,6 +13,7 @@ In 2021, Guillaume and other trainees in Adrien's lab decided to fork from neuro ------------------ - Refactoring `IntervalSet` to pure numpy ndarray. +- Implementing new chain of inheritance for time series with abstract base class. `base_class.Base` holds the temporal methods for all time series and `Ts`. `time_series.BaseTsd` inherit `Base` and implements the common methods for `Tsd`, `TsdFrame` and `Tsd`. 0.5.1 (2024-01-29) From 51152649647775cd1ded7653a7fc6e975f409eae Mon Sep 17 00:00:00 2001 From: gviejo Date: Fri, 23 Feb 2024 04:54:10 -0500 Subject: [PATCH 23/23] fixing black linting --- docs/HISTORY.md | 1 + pynapple/core/_jitted_functions.py | 6 +++--- pynapple/core/time_series.py | 1 + pynapple/core/ts_group.py | 1 - pyproject.toml | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 83299fe7..5ed8642f 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -14,6 +14,7 @@ In 2021, Guillaume and other trainees in Adrien's lab decided to fork from neuro - Refactoring `IntervalSet` to pure numpy ndarray. - Implementing new chain of inheritance for time series with abstract base class. `base_class.Base` holds the temporal methods for all time series and `Ts`. `time_series.BaseTsd` inherit `Base` and implements the common methods for `Tsd`, `TsdFrame` and `Tsd`. +- Automatic conversion to numpy ndarray for all objects that are numpy-like (typically jax). 0.5.1 (2024-01-29) diff --git a/pynapple/core/_jitted_functions.py b/pynapple/core/_jitted_functions.py index 7ef8f966..f7815765 100644 --- a/pynapple/core/_jitted_functions.py +++ b/pynapple/core/_jitted_functions.py @@ -886,9 +886,9 @@ def jitcontinuous_perievent( left = np.minimum(windowsize[0], t_pos - start_t[k, 0]) right = np.minimum(windowsize[1], maxt - t_pos - 1) center = windowsize[0] + 1 - new_data_array[ - center - left - 1 : center + right, cnt_i - ] = data_array[t_pos - left : t_pos + right + 1] + new_data_array[center - left - 1 : center + right, cnt_i] = ( + data_array[t_pos - left : t_pos + right + 1] + ) t -= 1 i += 1 diff --git a/pynapple/core/time_series.py b/pynapple/core/time_series.py index ee3ee9c0..3c39f148 100644 --- a/pynapple/core/time_series.py +++ b/pynapple/core/time_series.py @@ -14,6 +14,7 @@ Most of the same functions are available through all classes. Objects behaves like numpy.ndarray. Slicing can be done the same way for example `tsd[0:10]` returns the first 10 rows. Similarly, you can call any numpy functions like `np.mean(tsd, 1)`. """ + import abc import importlib import os diff --git a/pynapple/core/ts_group.py b/pynapple/core/ts_group.py index 4a321125..bd1a62f3 100644 --- a/pynapple/core/ts_group.py +++ b/pynapple/core/ts_group.py @@ -4,7 +4,6 @@ """ - import os import warnings from collections import UserDict diff --git a/pyproject.toml b/pyproject.toml index d452afbf..08e570f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ repository = "https://github.com/pynapple-org/pynapple" ########################################################################## [project.optional-dependencies] dev = [ - "black>=24.1.0", # Code formatter + "black>=24.2.0", # Code formatter "isort", # Import sorter "pip-tools", # Dependency management "pytest", # Testing framework