# -*- coding: utf-8 -*- """ RegularlySampledAnalogSignalArray ----------------- Core object definition and implementation for RegularlySampledAnalogSignalArray. """ __all__ = ['RegularlySampledAnalogSignalArray', 'AnalogSignalArray', 'PositionArray', # Trajectory? 'IMUSensorArray', 'MinimalExampleArray'] import logging import numpy as np import copy import numbers from sys import float_info from functools import wraps from scipy import interpolate from scipy.stats import zscore from sys import float_info from collections import namedtuple from .. import core from .. import filtering from .. import auxiliary from .. import utils from .. import version from ..utils_.decorators import keyword_deprecation, keyword_equivalence class IntervalSignalSlicer(object): def __init__(self, obj): self.obj = obj def __getitem__(self, *args): """intervals, signals""" # by default, keep all signals signalslice = slice(None, None, None) if isinstance(*args, int): intervalslice = args[0] elif isinstance(*args, core.IntervalArray): intervalslice = args[0] else: try: slices = np.s_[args]; slices = slices[0] if len(slices) > 2: raise IndexError("only [intervals, signal] slicing is supported at this time!") elif len(slices) == 2: intervalslice, signalslice = slices else: intervalslice = slices[0] except TypeError: # only interval to slice: intervalslice = slices return intervalslice, signalslice class DataSlicer(object): def __init__(self, parent): self._parent = parent def _data_generator(self, interval_indices, signalslice): for start, stop in interval_indices: try: yield self._parent._data[signalslice, start: stop] except StopIteration: return def __getitem__(self, idx): intervalslice, signalslice = self._parent._intervalsignalslicer[idx] interval_indices = self._parent._data_interval_indices() interval_indices = np.atleast_2d(interval_indices[intervalslice]) if len(interval_indices) < 2: start, stop = interval_indices[0] return self._parent._data[signalslice, start: stop] else: return self._data_generator(interval_indices, signalslice) def plot_generator(self): interval_indices = self._parent._data_interval_indices() for start, stop in interval_indices: try: yield self._parent._data[:, start: stop] except StopIteration: return def __iter__(self): self._index = 0 return self def __next__(self): index = self._index if index > self._parent.n_intervals - 1: raise StopIteration interval_indices = self._parent._data_interval_indices() interval_indices = interval_indices[index] start, stop = interval_indices self._index +=1 return self._parent._data[:, start: stop] class AbscissaSlicer(object): def __init__(self, parent): self._parent = parent def _abscissa_vals_generator(self, interval_indices): for start, stop in interval_indices: try: yield self._parent._abscissa_vals[start: stop] except StopIteration: return def __getitem__(self, idx): intervalslice, signalslice = self._parent._intervalsignalslicer[idx] interval_indices = self._parent._data_interval_indices() interval_indices = np.atleast_2d(interval_indices[intervalslice]) if len(interval_indices) < 2: start, stop = interval_indices[0] return self._parent._abscissa_vals[start: stop] else: return self._abscissa_vals_generator(interval_indices) def plot_generator(self): interval_indices = self._parent._data_interval_indices() for start, stop in interval_indices: try: yield self._parent._abscissa_vals[start: stop] except StopIteration: return def __iter__(self): self._index = 0 return self def __next__(self): index = self._index if index > self._parent.n_intervals - 1: raise StopIteration interval_indices = self._parent._data_interval_indices() interval_indices = interval_indices[index] start, stop = interval_indices self._index +=1 return self._parent._abscissa_vals[start: stop] def rsasa_init_wrapper(func): """Decorator that helps figure out abscissa_vals, fs, and sample numbers""" @wraps(func) def wrapper(*args, **kwargs): if kwargs.get('empty', False): func(*args, **kwargs) return if len(args) > 2: raise TypeError("__init__() takes 1 positional arguments but {} positional arguments (and {} keyword-only arguments) were given".format(len(args)-1, len(kwargs.items()))) data = kwargs.get('data', []) if data == []: data = args[1] if data == []: logging.warning('No ordinate data! Returning empty RegularlySampledAnalogSignalArray.') func(*args, **kwargs) return # handle casting other nelpy objects to RegularlySampledAnalogSignalArrays: if isinstance(data, core.BinnedEventArray): abscissa_vals = data.bin_centers kwargs['abscissa_vals'] = abscissa_vals # support = data.support # kwargs['support'] = support abscissa = data._abscissa kwargs['abscissa'] = abscissa fs = 1/data.ds kwargs['fs'] = fs if list(data.series_labels): labels = data.series_labels else: labels = data.series_ids kwargs['labels'] = labels data = data.data.astype(float) # elif isinstance(data, auxiliary.PositionArray): elif isinstance(data, RegularlySampledAnalogSignalArray): kwargs['data'] = data func(args[0], **kwargs) return #check if single AnalogSignal or multiple AnalogSignals in array #and standardize data to 2D if not np.any(np.iscomplex(data)): data = np.squeeze(data).astype(float) try: if(data.shape[0] == data.size): data = np.array(data,ndmin=2) except ValueError: raise TypeError("Unsupported data type!") re_estimate_fs = False no_fs = True fs = kwargs.get('fs', None) if fs is not None: no_fs = False try: if(fs <= 0): raise ValueError("fs must be positive") except TypeError: raise TypeError("fs must be a scalar!") else: fs = 1 re_estimate_fs = True tdata = kwargs.get('tdata', None) if tdata is not None: logging.warning("'tdata' has been deprecated! Use 'abscissa_vals' instead. 'tdata' will be interpreted as 'abscissa_vals' in seconds.") abscissa_vals = tdata else: abscissa_vals = kwargs.get('abscissa_vals', None) if abscissa_vals is None: abscissa_vals = np.linspace(0, data.shape[1]/fs, data.shape[1]+1) abscissa_vals = abscissa_vals[:-1] else: if re_estimate_fs: logging.warning('fs was not specified, so we try to estimate it from the data...') fs = 1.0/np.median(np.diff(abscissa_vals)) logging.warning('fs was estimated to be {} Hz'.format(fs)) else: if no_fs: logging.warning('fs was not specified, so we will assume default of 1 Hz...') fs = 1 kwargs['fs'] = fs kwargs['data'] = data kwargs['abscissa_vals'] = np.squeeze(abscissa_vals) func(args[0], **kwargs) return return wrapper ######################################################################## # class RegularlySampledAnalogSignalArray ######################################################################## class RegularlySampledAnalogSignalArray: """Continuous analog signal(s) with regular sampling rates (irregular sampling rates can be corrected with operations on the support) and same support. NOTE: data that is not equal dimensionality will NOT work and error/warning messages may/may not be sent out. Assumes abscissa_vals are identical for all signals passed through and are therefore expected to be 1-dimensional. Parameters ---------- data : np.ndarray, with shape (n_signals, n_samples). Data samples. abscissa_vals : np.ndarray, with shape (n_samples, ). The abscissa coordinate values. Currently we assume that (1) these values are timestamps, and (2) the timestamps are sampled regularly (we rely on these assumptions to generate intervals). Irregular sampling rates can be corrected with operations on the support. fs : float, optional The sampling rate. abscissa_vals are still expected to be in units of time and fs is expected to be in the corresponding sampling rate (e.g. abscissa_vals in seconds, fs in Hz). Default is 1 Hz. step : float, optional The sampling interval of the data, in seconds. Default is None. specifies step size of samples passed as tdata if fs is given, default is None. If not passed it is inferred by the minimum difference in between samples of tdata passed in (based on if FS is passed). e.g. decimated data would have sample numbers every ten samples so step=10 merge_sample_gap : float, optional Optional merging of gaps between support intervals. If intervals are within a certain amount of time, gap, they will be merged as one interval. Example use case is when there is a dropped sample support : nelpy.IntervalArray, optional Where the data are defined. Default is [0, last abscissa value] inclusive. in_core : bool, optional Whether the abscissa values should be treated as residing in core memory. During RSASA construction, np.diff() is called, so for large data, passing in in_core=True might help. In that case, a slower but much smaller memory footprint function is used. labels : np.array, dtype=np.str Labels for each of the signals. If fewer labels than signals are passed in, labels are padded with None's to match the number of signals. If more labels than signals are passed in, labels are truncated to match the number of signals. Default is None. empty : bool, optional Return an empty RegularlySampledAnalogSignalArray if true else false. Default is false. abscissa : optional The object handling the abscissa values. It is recommended to leave this parameter alone and let nelpy take care of this. Default is a nelpy.core.Abscissa object. ordinate : optional The object handling the ordinate values. It is recommended to leave this parameter alone and let nelpy take care of this. Default is a nelpy.core.Ordinate object. Attributes ---------- data : np.ndarray, with shape (n_signals, n_samples) The underlying data. abscissa_vals : np.ndarray, with shape (n_samples, ) The values of the abscissa coordinate. is1d : bool Whether there is only 1 signal in the RSASA iswrapped : bool Whether the RSASA's data is wrapping. base_unit : string Base unit of the abscissa. signals : list A list of RegularlySampledAnalogSignalArrays, each RSASA containing a single signal (channel). WARNING: this method creates a copy of each signal, so is not particularly efficient at this time. isreal : bool Whether ALL of the values in the RSASA's data are real. iscomplex : bool Whether ANY values in the data are complex. abs : nelpy.RegularlySampledAnalogSignalArray A copy of the RSASA, whose data is the absolute value of the original original RSASA's (potentially complex) data. phase : nelpy.RegularlySampledAnalogSignalArray A copy of the RSASA, whose data is just the phase angle (in radians) of the original RSASA's data. real : nelpy.RegularlySampledAnalogSignalArray A copy of the RSASA, whose data is just the real part of the original RSASA's data. imag : nelpy.RegularlySampledAnalogSignalArray A copy of the RSASA, whose data is just the imaginary part of the original RSASA's data. lengths : list The number of samples in each interval. labels : list The labels corresponding to each signal. n_signals : int The number of signals in the RSASA. support : nelpy.IntervalArray The support of the RSASA. domain : nelpy.IntervalArray The domain of the RSASA. range : nelpy.IntervalArray The range of the RSASA's data. step : float The sampling interval of the RSASA. Currently the units are in seconds. fs : float The sampling frequency of the RSASA. Currently the units are in Hz. isempty : bool Whether the underlying data has zero length, i.e. 0 samples n_bytes : int Approximate number of bytes taken up by the RSASA. n_intervals : int The number of underlying intervals in the RSASA. n_samples : int The number of abscissa values in the RSASA. """ __aliases__ = {} __attributes__ = ['_data','_abscissa_vals', '_fs', '_support', \ '_interp', '_step', '_labels'] @rsasa_init_wrapper def __init__(self, data=[], *, abscissa_vals=None, fs=None, step=None, merge_sample_gap=0, support=None, in_core=True, labels=None, empty=False, abscissa=None, ordinate=None): self._intervalsignalslicer = IntervalSignalSlicer(self) self._intervaldata = DataSlicer(self) self._intervaltime = AbscissaSlicer(self) self.type_name = self.__class__.__name__ if abscissa is None: abscissa = core.Abscissa() #TODO: integrate into constructor? if ordinate is None: ordinate = core.Ordinate() #TODO: integrate into constructor? self._abscissa = abscissa self._ordinate = ordinate # TODO: #FIXME abscissa and ordinate domain, range, and supports should be integrated and/or coerced with support self.__version__ = version.__version__ # cast derivatives of RegularlySampledAnalogSignalArray back into RegularlySampledAnalogSignalArray: # if isinstance(data, auxiliary.PositionArray): if isinstance(data, RegularlySampledAnalogSignalArray): self.__dict__ = copy.deepcopy(data.__dict__) # if self._has_changed: # self.__renew__() self.__renew__() return if (empty): for attr in self.__attributes__: exec("self." + attr + " = None") self._abscissa.support = type(self._abscissa.support)(empty=True) self._data = np.array([]) self._abscissa_vals = np.array([]) self.__bake__() return self._step = step self._fs = fs # Note; if we have an empty array of data with no dimension, # then calling len(data) will return a TypeError try: # if no data are given return empty RegularlySampledAnalogSignalArray if data.size == 0: self.__init__(empty=True) return except TypeError: logging.warning("unsupported type; creating empty RegularlySampledAnalogSignalArray") self.__init__(empty=True) return # Note: if both abscissa_vals and data are given and dimensionality does not # match, then TypeError! abscissa_vals = np.squeeze(abscissa_vals).astype(float) if(abscissa_vals.shape[0] != data.shape[1]): # self.__init__([],empty=True) raise TypeError("abscissa_vals and data size mismatch! Note: data " "is expected to have rows containing signals") #data is not sorted and user wants it to be # TODO: use faster is_sort from jagular if not utils.is_sorted(abscissa_vals): logging.warning("Data is _not_ sorted! Data will be sorted "\ "automatically.") ind = np.argsort(abscissa_vals) abscissa_vals = abscissa_vals[ind] data = np.take(a=data, indices=ind, axis=-1) self._data = data self._abscissa_vals = abscissa_vals #handle labels if labels is not None: labels = np.asarray(labels,dtype=np.str) #label size doesn't match if labels.shape[0] > data.shape[0]: logging.warning("More labels than data! Labels are truncated to " "size of data") labels = labels[0:data.shape[0]] elif labels.shape[0] < data.shape[0]: logging.warning("Fewer labels than abscissa_vals! Labels are filled with " "None to match data shape") for i in range(labels.shape[0],data.shape[0]): labels.append(None) self._labels = labels # Alright, let's handle all the possible parameter cases! if support is not None: self._restrict_to_interval_array_fast(intervalarray=support) else: logging.warning("creating support from abscissa_vals and " "sampling rate, fs!") self._abscissa.support =type(self._abscissa.support)( utils.get_contiguous_segments( self._abscissa_vals, step=self._step, fs=fs, in_core=in_core)) if merge_sample_gap > 0: self._abscissa.support = self._abscissa.support.merge(gap=merge_sample_gap) if np.abs((self.fs - self._estimate_fs())/self.fs) > 0.01: logging.warning("estimated fs and provided fs differ by more than 1%") def __bake__(self): """Fix object as-is, and bake a new hash. For example, if a label has changed, or if an interp has been attached, then the object's hash will change, and it needs to be baked again for efficiency / consistency. """ self._stored_hash_ = self.__hash__() # def _has_changed_data(self): # """Compute hash on abscissa_vals and data and compare to cached hash.""" # return self.data.__hash__ elf._data_hash_ def _has_changed(self): """Compute hash on current object, and compare to previously stored hash""" return self.__hash__() == self._stored_hash_ def __renew__(self): """Re-attach data slicers.""" self._intervalsignalslicer = IntervalSignalSlicer(self) self._intervaldata = DataSlicer(self) self._intervaltime = AbscissaSlicer(self) self._interp = None self.__bake__() def __call__(self, x): """RegularlySampledAnalogSignalArray callable method. Returns interpolated data at requested points. Note that points falling outside the support will not be interpolated. Parameters ---------- x : np.ndarray, list, or tuple, with length n_requested_samples Points at which to interpolate the RSASA's data Returns ------- A np.ndarray with shape (n_signals, n_samples). If all the requested points lie in the support, then n_samples = n_requested_samples. Otherwise n_samples < n_requested_samples. """ return self.asarray(at=x).yvals def center(self, inplace=False): """Center data (zero mean).""" if inplace: out = self else: out = self.copy() out._data = (out._data.T - out.mean()).T return out def normalize(self, inplace=False): """Normalize data (unit standard deviation).""" if inplace: out = self else: out = self.copy() std = out.std() std[std==0] = 1 out._data = (out._data.T / std).T return out def standardize(self, inplace=False): """Standardize data (zero mean and unit std deviation).""" if inplace: out = self else: out = self.copy() out._data = (out._data.T - out.mean()).T std = out.std() std[std==0] = 1 out._data = (out._data.T / std).T return out @property def is_1d(self): try: return self.n_signals == 1 except IndexError: return False @property def is_wrapped(self): if np.any(self.max() > self._ordinate.range.stop) | np.any(self.min() < self._ordinate.range.min): self._ordinate._is_wrapped = False else: self._ordinate._is_wrapped = True # if self._ordinate._is_wrapped is None: # if np.any(self.max() > self._ordinate.range.stop) | np.any(self.min() < self._ordinate.range.min): # self._ordinate._is_wrapped = False # else: # self._ordinate._is_wrapped = True return self._ordinate._is_wrapped def _wrap(self, arr, vmin, vmax): """Wrap array within finite range.""" if np.isinf(vmax - vmin): raise ValueError('range has to be finite!') return ((arr - vmin) % (vmax-vmin)) + vmin def wrap(self, inplace=False): """Wrap oridnate within finite range.""" if inplace: out = self else: out = self.copy() out.data = np.atleast_2d(out._wrap(out.data, out._ordinate.range.min, out._ordinate.range.max )) # out._is_wrapped = True return out def _unwrap(self, arr, vmin, vmax): """Unwrap 2D array (with one signal per row) by minimizing total displacement.""" d = vmax - vmin dh = d/2 lin = copy.deepcopy(arr) - vmin n_signals, n_samples = arr.shape for ii in range(1, n_samples): h1 = lin[:,ii] - lin[:,ii-1] >= dh lin[h1,ii:] = lin[h1,ii:] - d h2 = lin[:,ii] - lin[:,ii-1] < - dh lin[h2,ii:] = lin[h2,ii:] + d return np.atleast_2d(lin + vmin) def unwrap(self, inplace=False): """Unwrap ordinate by minimizing total displacement.""" if inplace: out = self else: out = self.copy() out.data = np.atleast_2d(out._unwrap(out._data, out._ordinate.range.min, out._ordinate.range.max)) # out._is_wrapped = False return out def _crossvals(self): """Return all abscissa values where the orinate crosses. Note that this can return multiple values close in succession if the signal oscillates around the maximum or minimum range. """ raise NotImplementedError @property def base_unit(self): """Base unit of the abscissa.""" return self._abscissa.base_unit def _data_interval_indices(self): """Docstring goes here. We use this to get the indices of samples / abscissa_vals within intervals """ tmp = np.insert(np.cumsum(self.lengths),0,0) indices = np.vstack((tmp[:-1], tmp[1:])).T return indices def ddt(self, rectify=False): """Returns the derivative of each signal in the RegularlySampledAnalogSignalArray. asa.data = f(t) asa.ddt = d/dt (asa.data) Parameters ---------- rectify : boolean, optional If True, the absolute value of the derivative will be returned. Default is False. Returns ------- ddt : RegularlySampledAnalogSignalArray Time derivative of each signal in the RegularlySampledAnalogSignalArray. Note ---- Second order central differences are used here, and it is assumed that the signals are sampled uniformly. If the signals are not uniformly sampled, it is recommended to resample the signal before computing the derivative. """ ddt = utils.ddt_asa(self, rectify=rectify) return ddt @property def signals(self): """Returns a list of RegularlySampledAnalogSignalArrays, each array containing a single signal (channel). WARNING: this method creates a copy of each signal, so is not particularly efficient at this time. Example ======= >>> for channel in lfp.signals: print(channel) """ signals = [] for ii in range(self.n_signals): signals.append(self[:,ii]) return signals # return np.asanyarray(signals).squeeze() @property def isreal(self): """Returns True if entire signal is real.""" return np.all(np.isreal(self.data)) # return np.isrealobj(self._data) @property def iscomplex(self): """Returns True if any part of the signal is complex.""" return np.any(np.iscomplex(self.data)) # return np.iscomplexobj(self._data) @property def abs(self): """RegularlySampledAnalogSignalArray with absolute value of (potentially complex) data.""" out = self.copy() out._data = np.abs(self.data) return out @property def angle(self): """RegularlySampledAnalogSignalArray with only phase angle (in radians) of data.""" out = self.copy() out._data = np.angle(self.data) return out @property def imag(self): """RegularlySampledAnalogSignalArray with only imaginary part of data.""" out = self.copy() out._data = self.data.imag return out @property def real(self): """RegularlySampledAnalogSignalArray with only real part of data.""" out = self.copy() out._data = self.data.real return out def __mul__(self, other): """overloaded * operator.""" if isinstance(other, numbers.Number): newasa = self.copy() newasa._data = self.data * other return newasa elif isinstance(other, np.ndarray): newasa = self.copy() newasa._data = (self.data.T * other).T return newasa else: raise TypeError("unsupported operand type(s) for *: 'RegularlySampledAnalogSignalArray' and '{}'".format(str(type(other)))) def __add__(self, other): """overloaded + operator.""" if isinstance(other, numbers.Number): newasa = self.copy() newasa._data = self.data + other return newasa elif isinstance(other, np.ndarray): newasa = self.copy() newasa._data = (self.data.T + other).T return newasa else: raise TypeError("unsupported operand type(s) for +: 'RegularlySampledAnalogSignalArray' and '{}'".format(str(type(other)))) def __sub__(self, other): """overloaded - operator.""" if isinstance(other, numbers.Number): newasa = self.copy() newasa._data = self.data - other return newasa elif isinstance(other, np.ndarray): newasa = self.copy() newasa._data = (self.data.T - other).T return newasa else: raise TypeError("unsupported operand type(s) for -: 'RegularlySampledAnalogSignalArray' and '{}'".format(str(type(other)))) def zscore(self): """Returns an object where each signal has been normalized using z scores.""" out = self.copy() out._data = zscore(out._data, axis=1) return out def __rmul__(self, other): return self.__mul__(other) def __truediv__(self, other): """overloaded / operator.""" if isinstance(other, numbers.Number): newasa = self.copy() newasa._data = self.data / other return newasa elif isinstance(other, np.ndarray): newasa = self.copy() newasa._data = (self.data.T / other).T return newasa else: raise TypeError("unsupported operand type(s) for /: 'RegularlySampledAnalogSignalArray' and '{}'".format(str(type(other)))) def __lshift__(self, val): """shift abscissa and support to left (<<)""" if isinstance(val, numbers.Number): new = self.copy() new._abscissa_vals -= val new._abscissa.support = new._abscissa.support << val return new else: raise TypeError("unsupported operand type(s) for <<: {} and {}".format(str(type(self)), str(type(val)))) def __rshift__(self, val): """shift abscissa and support to right (>>)""" if isinstance(val, numbers.Number): new = self.copy() new._abscissa_vals += val new._abscissa.support = new._abscissa.support >> val return new else: raise TypeError("unsupported operand type(s) for >>: {} and {}".format(str(type(self)), str(type(val)))) def __len__(self): return self.n_intervals def _drop_empty_intervals(self): """Drops empty intervals from support. In-place.""" keep_interval_ids = np.argwhere(self.lengths).squeeze().tolist() self._abscissa.support = self._abscissa.support[keep_interval_ids] return self def _estimate_fs(self, abscissa_vals=None): """Estimate the sampling rate of the data.""" if abscissa_vals is None: abscissa_vals = self._abscissa_vals return 1.0/np.median(np.diff(abscissa_vals)) def downsample(self, *, fs_out, aafilter=True, inplace=False, **kwargs): """Downsamples the RegularlySampledAnalogSignalArray Parameters ---------- fs_out : float, optional Desired output sampling rate in Hz aafilter : boolean, optional Whether to apply an anti-aliasing filter before performing the actual downsampling. Default is True inplace : boolean, optional If True, the output ASA will replace the input ASA. Default is False kwargs : Other keyword arguments are passed to sosfiltfilt() in the `filtering` module Returns ------- out : RegularlySampledAnalogSignalArray The downsampled RegularlySampledAnalogSignalArray """ if not fs_out < self._fs: raise ValueError("fs_out must be less than current sampling rate!") if aafilter: fh = fs_out/2.0 out = filtering.sosfiltfilt(self, fl=None, fh=fh, inplace=inplace, **kwargs) downsampled = out.simplify(ds=1/fs_out) out._data = downsampled._data out._abscissa_vals = downsampled._abscissa_vals out._fs = fs_out out.__renew__() return out def add_signal(self, signal, label=None): """Docstring goes here. Basically we add a signal, and we add a label. THIS HAPPENS IN PLACE? """ # TODO: add functionality to check that supports are the same, etc. if isinstance(signal, RegularlySampledAnalogSignalArray): signal = signal.data signal = np.squeeze(signal) if signal.ndim > 1: raise TypeError("Can only add one signal at a time!") if self.data.ndim==1: self._data = np.vstack([np.array(self.data, ndmin=2), np.array(signal, ndmin=2)]) else: self._data = np.vstack([self.data, np.array(signal, ndmin=2)]) if label == None: logging.warning("None label appended") self._labels = np.append(self._labels,label) return self def _restrict_to_interval_array_fast(self, *, intervalarray=None, update=True): """Restrict self._abscissa_vals and self._data to an IntervalArray. If no IntervalArray is specified, self._abscissa.support is used. Parameters ---------- intervalarray : IntervalArray, optional IntervalArray on which to restrict AnalogSignal. Default is self._abscissa.support update : bool, optional Overwrite self._abscissa.support with intervalarray if True (default). """ if intervalarray is None: intervalarray = self._abscissa.support update = False # support did not change; no need to update try: if intervalarray.isempty: logging.warning("Support specified is empty") # self.__init__([],empty=True) exclude = ['_support','_data','_fs','_step'] attrs = (x for x in self.__attributes__ if x not in exclude) logging.disable(logging.CRITICAL) for attr in attrs: exec("self." + attr + " = None") logging.disable(0) self._data = np.zeros([0,self.data.shape[0]]) self._data[:] = np.nan self._abscissa.support = intervalarray return except AttributeError: raise AttributeError("IntervalArray expected") indices = [] for interval in intervalarray.merge().data: a_start = interval[0] a_stop = interval[1] frm, to = np.searchsorted(self._abscissa_vals, (a_start, a_stop)) indices.append((frm, to)) indices = np.array(indices, ndmin=2) if np.diff(indices).sum() < len(self._abscissa_vals): logging.warning( 'ignoring signal outside of support') try: data_list = [] for start, stop in indices: data_list.append(self._data[:,start:stop]) self._data = np.hstack(data_list) except IndexError: self._data = np.zeros([0,self.data.shape[0]]) self._data[:] = np.nan time_list = [] for start, stop in indices: time_list.extend(self._abscissa_vals[start:stop]) self._abscissa_vals = np.array(time_list) if update: self._abscissa.support = intervalarray def _restrict_to_interval_array(self, *, intervalarray=None, update=True): """Restrict self._abscissa_vals and self._data to an IntervalArray. If no IntervalArray is specified, self._abscissa.support is used. This function is quite slow, as it checks each sample for inclusion. It does this in a vectorized form, which is fast for small or moderately sized objects, but the memory penalty can be large, and it becomes very slow for large objects. Consequently, _restrict_to_interval_array_fast should be used when possible. Parameters ---------- intervalarray : IntervalArray, optional IntervalArray on which to restrict AnalogSignal. Default is self._abscissa.support update : bool, optional Overwrite self._abscissa.support with intervalarray if True (default). """ if intervalarray is None: intervalarray = self._abscissa.support update = False # support did not change; no need to update try: if intervalarray.isempty: logging.warning("Support specified is empty") # self.__init__([],empty=True) exclude = ['_support','_data','_fs','_step'] attrs = (x for x in self.__attributes__ if x not in exclude) logging.disable(logging.CRITICAL) for attr in attrs: exec("self." + attr + " = None") logging.disable(0) self._data = np.zeros([0,self.data.shape[0]]) self._data[:] = np.nan self._abscissa.support = intervalarray return except AttributeError: raise AttributeError("IntervalArray expected") indices = [] for interval in intervalarray.merge().data: a_start = interval[0] a_stop = interval[1] indices.append((self._abscissa_vals >= a_start) & (self._abscissa_vals < a_stop)) indices = np.any(np.column_stack(indices), axis=1) if np.count_nonzero(indices) < len(self._abscissa_vals): logging.warning( 'ignoring signal outside of support') try: self._data = self.data[:,indices] except IndexError: self._data = np.zeros([0,self.data.shape[0]]) self._data[:] = np.nan self._abscissa_vals = self._abscissa_vals[indices] if update: self._abscissa.support = intervalarray @keyword_deprecation(replace_x_with_y={'bw':'truncate'}) def smooth(self, *, fs=None, sigma=None, truncate=None, inplace=False, mode=None, cval=None, within_intervals=False): """Smooths the regularly sampled RegularlySampledAnalogSignalArray with a Gaussian kernel. Smoothing is applied along the abscissa, and the same smoothing is applied to each signal in the RegularlySampledAnalogSignalArray, or to each unit in a BinnedSpikeTrainArray. Smoothing is applied ACROSS intervals, but smoothing WITHIN intervals is also supported. Parameters ---------- obj : RegularlySampledAnalogSignalArray or BinnedSpikeTrainArray. fs : float, optional Sampling rate (in obj.base_unit^-1) of obj. If not provided, it will be inferred. sigma : float, optional Standard deviation of Gaussian kernel, in obj.base_units. Default is 0.05 (50 ms if base_unit=seconds). truncate : float, optional Bandwidth outside of which the filter value will be zero. Default is 4.0. inplace : bool If True the data will be replaced with the smoothed data. Default is False. mode : {‘reflect’, ‘constant’, ‘nearest’, ‘mirror’, ‘wrap’}, optional The mode parameter determines how the array borders are handled, where cval is the value when mode is equal to ‘constant’. Default is ‘reflect’. cval : scalar, optional Value to fill past edges of input if mode is ‘constant’. Default is 0.0. within_intervals : boolean, optional If True, then smooth within each epoch. Otherwise smooth across epochs. Default is False. Note that when mode = 'wrap', then smoothing within epochs aren't affected by wrapping. Returns ------- out : same type as obj An object with smoothed data is returned. """ if sigma is None: sigma = 0.05 if truncate is None: truncate=4 kwargs = {'inplace' : inplace, 'fs' : fs, 'sigma' : sigma, 'truncate' : truncate, 'mode': mode, 'cval' : cval, 'within_intervals': within_intervals} if inplace: out = self else: out = self.copy() if self._ordinate.is_wrapping: ord_is_wrapped = self.is_wrapped if ord_is_wrapped: out = out.unwrap() # case 1: abs.wrapping=False, ord.linking=False, ord.wrapping=False if not self._abscissa.is_wrapping and not self._ordinate.is_linking and not self._ordinate.is_wrapping: pass # case 2: abs.wrapping=False, ord.linking=False, ord.wrapping=True elif not self._abscissa.is_wrapping and not self._ordinate.is_linking and self._ordinate.is_wrapping: pass # case 3: abs.wrapping=False, ord.linking=True, ord.wrapping=False elif not self._abscissa.is_wrapping and self._ordinate.is_linking and not self._ordinate.is_wrapping: raise NotImplementedError # case 4: abs.wrapping=False, ord.linking=True, ord.wrapping=True elif not self._abscissa.is_wrapping and self._ordinate.is_linking and self._ordinate.is_wrapping: raise NotImplementedError # case 5: abs.wrapping=True, ord.linking=False, ord.wrapping=False elif self._abscissa.is_wrapping and not self._ordinate.is_linking and not self._ordinate.is_wrapping: if mode is None: kwargs['mode'] = 'wrap' # case 6: abs.wrapping=True, ord.linking=False, ord.wrapping=True elif self._abscissa.is_wrapping and not self._ordinate.is_linking and self._ordinate.is_wrapping: # (1) unwrap ordinate (abscissa wrap=False) # (2) smooth unwrapped ordinate (absissa wrap=False) # (3) repeat unwrapped signal based on conditions from (2): # if smoothed wrapped ordinate samples # HH ==> SSS (this must be done on a per-signal basis!!!) H = high; L = low; S = same # LL ==> SSS (the vertical offset must be such that neighbors have smallest displacement) # LH ==> LSH # HL ==> HSL # (4) smooth expanded and unwrapped ordinate (abscissa wrap=False) # (5) cut out orignal signal # (1) kwargs['mode'] = 'reflect' L = out._ordinate.range.max - out._ordinate.range.min D = out.domain.length tmp = utils.gaussian_filter(out.unwrap(), **kwargs) # (2) (3) n_reps = int(np.ceil((sigma*truncate)/float(D))) smooth_data = [] for ss, signal in enumerate(tmp.signals): # signal = signal.wrap() offset = float((signal._data[:,-1] - signal._data[:,0]) // (L/2))*L # print(offset) # left_high = signal._data[:,0] >= out._ordinate.range.min + L/2 # right_high = signal._data[:,-1] >= out._ordinate.range.min + L/2 # signal = signal.unwrap() expanded = signal.copy() for nn in range(n_reps): expanded = expanded.join((signal << D*(nn+1))-offset).join((signal >> D*(nn+1))+offset) # print(expanded) # if left_high == right_high: # print('extending flat! signal {}'.format(ss)) # expanded = expanded.join(signal << D*(nn+1)).join(signal >> D*(nn+1)) # elif left_high < right_high: # print('extending LSH! signal {}'.format(ss)) # # LSH # expanded = expanded.join((signal << D*(nn+1))-L).join((signal >> D*(nn+1))+L) # else: # # HSL # print('extending HSL! signal {}'.format(ss)) # expanded = expanded.join((signal << D*(nn+1))+L).join((signal >> D*(nn+1))-L) # (4) smooth_signal = utils.gaussian_filter(expanded, **kwargs) smooth_data.append(smooth_signal._data[:,n_reps*tmp.n_samples:(n_reps+1)*(tmp.n_samples)].squeeze()) # (5) out._data = np.array(smooth_data) out.__renew__() if self._ordinate.is_wrapping: if ord_is_wrapped: out = out.wrap() return out # case 7: abs.wrapping=True, ord.linking=True, ord.wrapping=False elif self._abscissa.is_wrapping and self._ordinate.is_linking and not self._ordinate.is_wrapping: raise NotImplementedError # case 8: abs.wrapping=True, ord.linking=True, ord.wrapping=True elif self._abscissa.is_wrapping and self._ordinate.is_linking and self._ordinate.is_wrapping: raise NotImplementedError out = utils.gaussian_filter(out, **kwargs) out.__renew__() if self._ordinate.is_wrapping: if ord_is_wrapped: out = out.wrap() return out @property def lengths(self): """(list) The number of samples in each interval.""" indices = [] for interval in self.support.data: a_start = interval[0] a_stop = interval[1] frm, to = np.searchsorted(self._abscissa_vals, (a_start, a_stop)) indices.append((frm, to)) indices = np.array(indices, ndmin=2) lengths = np.atleast_1d(np.diff(indices).squeeze()) return lengths @property def labels(self): """(list) The labels corresponding to each signal.""" # TODO: make this faster and better! return self._labels @property def n_signals(self): """(int) The number of signals.""" try: return utils.PrettyInt(self.data.shape[0]) except AttributeError: return 0 def __repr__(self): address_str = " at " + str(hex(id(self))) if self.isempty: return "<empty " + self.type_name + address_str + ">" if self.n_intervals > 1: epstr = " ({} segments)".format(self.n_intervals) else: epstr = "" try: if(self.n_signals > 0): nstr = " %s signals%s" % (self.n_signals, epstr) except IndexError: nstr = " 1 signal%s" % epstr dstr = " for a total of {}".format(self._abscissa.formatter(self.support.length)) return "<%s%s:%s>%s" % (self.type_name, address_str, nstr, dstr) @keyword_equivalence(this_or_that={'n_intervals':'n_epochs'}) def partition(self, ds=None, n_intervals=None): """Returns an RegularlySampledAnalogSignalArray whose support has been partitioned. # Irrespective of whether 'ds' or 'n_intervals' are used, the exact # underlying support is propagated, and the first and last points # of the supports are always included, even if this would cause # n_samples or ds to be violated. Parameters ---------- ds : float, optional Maximum duration (in seconds), for each interval. n_samples : int, optional Number of intervals. If ds is None and n_intervals is None, then default is to use n_intervals = 100 Returns ------- out : RegularlySampledAnalogSignalArray RegularlySampledAnalogSignalArray that has been partitioned. """ out = self.copy() out._abscissa.support = out.support.partition(ds=ds, n_intervals=n_intervals) return out # @property # def ydata(self): # """(np.array N-Dimensional) data with shape (n_signals, n_samples).""" # # LEGACY # return self.data @property def data(self): """(np.array N-Dimensional) data with shape (n_signals, n_samples).""" return self._data @data.setter def data(self, val): """(np.array N-Dimensional) data with shape (n_signals, n_samples).""" self._data = val # print('data was modified, so clearing interp, etc.') self.__renew__() @property def support(self): """(nelpy.IntervalArray) The support of the underlying RegularlySampledAnalogSignalArray.""" return self._abscissa.support @support.setter def support(self, val): """(nelpy.IntervalArray) The support of the underlying RegularlySampledAnalogSignalArray.""" # modify support if isinstance(val, type(self._abscissa.support)): self._abscissa.support = val elif isinstance(val, (tuple, list)): prev_domain = self._abscissa.domain self._abscissa.support = type(self._abscissa.support)([val[0], val[1]]) self._abscissa.domain = prev_domain else: raise TypeError('support must be of type {}'.format(str(type(self._abscissa.support)))) # restrict data to new support self._restrict_to_interval_array_fast(intervalarray=self._abscissa.support) @property def domain(self): """(nelpy.IntervalArray) The domain of the underlying RegularlySampledAnalogSignalArray.""" return self._abscissa.domain @domain.setter def domain(self, val): """(nelpy.IntervalArray) The domain of the underlying RegularlySampledAnalogSignalArray.""" # modify domain if isinstance(val, type(self._abscissa.support)): self._abscissa.domain = val elif isinstance(val, (tuple, list)): self._abscissa.domain = type(self._abscissa.support)([val[0], val[1]]) else: raise TypeError('support must be of type {}'.format(str(type(self._abscissa.support)))) # restrict data to new support self._restrict_to_interval_array_fast(intervalarray=self._abscissa.support) @property def range(self): """(nelpy.IntervalArray) The range of the underlying RegularlySampledAnalogSignalArray.""" return self._ordinate.range @range.setter def range(self, val): """(nelpy.IntervalArray) The range of the underlying RegularlySampledAnalogSignalArray.""" # modify range self._ordinate.range = val @property def step(self): """ steps per sample Example 1: sample_numbers = np.array([1,2,3,4,5,6]) #aka time Steps per sample in the above case would be 1 Example 2: sample_numbers = np.array([1,3,5,7,9]) #aka time Steps per sample in Example 2 would be 2 """ return self._step @property def abscissa_vals(self): """(np.array 1D) Time in seconds.""" return self._abscissa_vals @abscissa_vals.setter def abscissa_vals(self, vals): """(np.array 1D) Time in seconds.""" self._abscissa_vals = vals @property def fs(self): """(float) Sampling frequency.""" if self._fs is None: logging.warning("No sampling frequency has been specified!") return self._fs @property def isempty(self): """(bool) checks length of data input""" try: return self.data.shape[1] == 0 except IndexError: #IndexError should happen if _data = [] return True @property def n_bytes(self): """Approximate number of bytes taken up by object.""" return utils.PrettyBytes(self.data.nbytes + self._abscissa_vals.nbytes) @property def n_intervals(self): """(int) number of intervals in RegularlySampledAnalogSignalArray""" return self._abscissa.support.n_intervals @property def n_samples(self): """(int) number of abscissa samples where signal is defined.""" if self.isempty: return 0 return utils.PrettyInt(len(self._abscissa_vals)) def __iter__(self): """AnalogSignal iterator initialization""" # initialize the internal index to zero when used as iterator self._index = 0 return self def __next__(self): """AnalogSignal iterator advancer.""" index = self._index if index > self.n_intervals - 1: raise StopIteration logging.disable(logging.CRITICAL) intervalarray = type(self.support)(empty=True) exclude = ["_abscissa_vals"] attrs = (x for x in self._abscissa.support.__attributes__ if x not in exclude) for attr in attrs: exec("intervalarray." + attr + " = self._abscissa.support." + attr) try: intervalarray._data = self._abscissa.support.data[tuple([index]), :] # use np integer indexing! Cool! except IndexError: # index is out of bounds, so return an empty IntervalArray pass logging.disable(0) self._index += 1 asa = type(self)([],empty=True) exclude = ['_interp','_support'] attrs = (x for x in self.__attributes__ if x not in exclude) logging.disable(logging.CRITICAL) for attr in attrs: exec("asa." + attr + " = self." + attr) logging.disable(0) asa._restrict_to_interval_array_fast(intervalarray=intervalarray) if(asa.support.isempty): logging.warning("Support is empty. Empty RegularlySampledAnalogSignalArray returned") asa = type(self)([],empty=True) asa.__renew__() return asa def empty(self, inplace=True): """Remove data (but not metadata) from RegularlySampledAnalogSignalArray. Attributes 'data', 'abscissa_vals', and 'support' are all emptied. Note: n_signals is preserved. """ n_signals = self.n_signals if not inplace: out = self._copy_without_data() else: out = self out._data = np.zeros((n_signals,0)) out._abscissa.support = type(self.support)(empty=True) out._abscissa_vals = [] out.__renew__() return out def __getitem__(self, idx): """RegularlySampledAnalogSignalArray index access. Parameters ---------- idx : IntervalArray, int, slice intersect passed intervalarray with support, index particular a singular interval or multiple intervals with slice """ intervalslice, signalslice = self._intervalsignalslicer[idx] asa = self._subset(signalslice) if asa.isempty: asa.__renew__() return asa if isinstance(intervalslice, slice): if intervalslice.start == None and intervalslice.stop == None and intervalslice.step == None: asa.__renew__() return asa newintervals = self._abscissa.support[intervalslice] # TODO: this needs to change so that n_signals etc. are preserved ################################################################ if newintervals.isempty: logging.warning("Index resulted in empty interval array") return self.empty(inplace=False) ################################################################ asa._restrict_to_interval_array_fast(intervalarray=newintervals) asa.__renew__() return asa def _subset(self, idx): asa = self.copy() try: asa._data = np.atleast_2d(self.data[idx,:]) except IndexError: raise IndexError("index {} is out of bounds for n_signals with size {}".format(idx, self.n_signals)) asa.__renew__() return asa def _copy_without_data(self): """Return a copy of self, without data and abscissa_vals. Note: the support is left unchanged. """ out = copy.copy(self) # shallow copy out._abscissa_vals = None out._data = np.zeros((self.n_signals, 0)) out = copy.deepcopy(out) # just to be on the safe side, but at least now we are not copying the data! out.__renew__() return out def copy(self): """Return a copy of the current object.""" out = copy.deepcopy(self) out.__renew__() return out def median(self,*,axis=1): """Returns the median of each signal in RegularlySampledAnalogSignalArray.""" try: medians = np.nanmedian(self.data, axis=axis).squeeze() if medians.size == 1: return np.asscalar(medians) return medians except IndexError: raise IndexError("Empty RegularlySampledAnalogSignalArray cannot calculate median") def mean(self, *, axis=1): """Returns the mean of each signal in RegularlySampledAnalogSignalArray.""" try: means = np.nanmean(self.data, axis=axis).squeeze() if means.size == 1: return np.asscalar(means) return means except IndexError: raise IndexError("Empty RegularlySampledAnalogSignalArray cannot calculate mean") def std(self, *, axis=1): """Returns the standard deviation of each signal in RegularlySampledAnalogSignalArray.""" try: stds = np.nanstd(self.data,axis=axis).squeeze() if stds.size == 1: return np.asscalar(stds) return stds except IndexError: raise IndexError("Empty RegularlySampledAnalogSignalArray cannot calculate standard deviation") def max(self,*,axis=1): """Returns the maximum of each signal in RegularlySampledAnalogSignalArray""" try: maxes = np.amax(self.data,axis=axis).squeeze() if maxes.size == 1: return np.asscalar(maxes) return maxes except ValueError: raise ValueError("Empty RegularlySampledAnalogSignalArray cannot calculate maximum") def min(self,*,axis=1): """Returns the minimum of each signal in RegularlySampledAnalogSignalArray""" try: mins = np.amin(self.data,axis=axis).squeeze() if mins.size == 1: return np.asscalar(mins) return mins except ValueError: raise ValueError("Empty RegularlySampledAnalogSignalArray cannot calculate minimum") def clip(self, min, max): """Clip (limit) the values of each signal to min and max as specified. Parameters ---------- min : scalar Minimum value max : scalar Maximum value Returns ---------- clipped_analogsignalarray : RegularlySampledAnalogSignalArray RegularlySampledAnalogSignalArray with the signal clipped with the elements of data, but where the values < min are replaced with min and the values > max are replaced with max. """ new_data = np.clip(self.data, min, max) newasa = self.copy() newasa._data = new_data return newasa def trim(self, start, stop=None, *, fs=None): """Trim the RegularlySampledAnalogSignalArray to a single interval. Parameters ---------- start : float or two element array-like (float) Left boundary of interval in time (seconds) if fs=None, otherwise left boundary is start / fs. (2 elements) Left and right boundaries in time (seconds) if fs=None, otherwise boundaries are left / fs. Stop must be None if 2 element start is used. stop : float, optional Right boundary of interval in time (seconds) if fs=None, otherwise right boundary is stop / fs. fs : float, optional Sampling rate in Hz. Returns ------- trim : RegularlySampledAnalogSignalArray The RegularlySampledAnalogSignalArray on the interval [start, stop]. Examples -------- >>> as.trim([0, 3], fs=1) # recommended for readability >>> as.trim(start=0, stop=3, fs=1) >>> as.trim(start=[0, 3]) >>> as.trim(0, 3) >>> as.trim((0, 3)) >>> as.trim([0, 3]) >>> as.trim(np.array([0, 3])) """ logging.warning("RegularlySampledAnalogSignalArray: Trim may not work!") # TODO: do comprehensive input validation if stop is not None: try: start = np.array(start, ndmin=1) if len(start) != 1: raise TypeError("start must be a scalar float") except TypeError: raise TypeError("start must be a scalar float") try: stop = np.array(stop, ndmin=1) if len(stop) != 1: raise TypeError("stop must be a scalar float") except TypeError: raise TypeError("stop must be a scalar float") else: # start must have two elements try: if len(np.array(start, ndmin=1)) > 2: raise TypeError( "unsupported input to RegularlySampledAnalogSignalArray.trim()") stop = np.array(start[1], ndmin=1) start = np.array(start[0], ndmin=1) if len(start) != 1 or len(stop) != 1: raise TypeError( "start and stop must be scalar floats") except TypeError: raise TypeError( "start and stop must be scalar floats") logging.disable(logging.CRITICAL) interval = self._abscissa.support.intersect( type(self.support)( [start, stop], fs=fs)) if not interval.isempty: analogsignalarray = self[interval] else: analogsignalarray = type(self)([],empty=True) logging.disable(0) analogsignalarray.__renew__() return analogsignalarray @property def _ydata_rowsig(self): """returns wide-format data s.t. each row is a signal.""" # LEGACY return self.data @property def _ydata_colsig(self): # LEGACY """returns skinny-format data s.t. each column is a signal.""" return self.data.T @property def _data_rowsig(self): """returns wide-format data s.t. each row is a signal.""" return self.data @property def _data_colsig(self): """returns skinny-format data s.t. each column is a signal.""" return self.data.T def _get_interp1d(self,* , kind='linear', copy=True, bounds_error=False, fill_value=np.nan, assume_sorted=None): """returns a scipy interp1d object, extended to have values at all interval boundaries! """ if assume_sorted is None: assume_sorted = utils.is_sorted(self._abscissa_vals) if self.n_signals > 1: axis = 1 else: axis = -1 abscissa_vals = self._abscissa_vals if self._ordinate.is_wrapping: yvals = self._unwrap(self._data_rowsig, self._ordinate.range.min, self._ordinate.range.max) # always interpolate on the unwrapped data! else: yvals = self._data_rowsig lengths = self.lengths empty_interval_ids = np.argwhere(lengths==0).squeeze().tolist() first_abscissavals_per_interval_idx = np.insert(np.cumsum(lengths[:-1]),0,0) first_abscissavals_per_interval_idx[empty_interval_ids] = 0 last_abscissavals_per_interval_idx = np.cumsum(lengths)-1 last_abscissavals_per_interval_idx[empty_interval_ids] = 0 first_abscissavals_per_interval = self._abscissa_vals[first_abscissavals_per_interval_idx] last_abscissavals_per_interval = self._abscissa_vals[last_abscissavals_per_interval_idx] boundary_abscissa_vals = [] boundary_vals = [] for ii, (start, stop) in enumerate(self.support.data): if lengths[ii] == 0: continue if first_abscissavals_per_interval[ii] > start: boundary_abscissa_vals.append(start) boundary_vals.append(yvals[:,first_abscissavals_per_interval_idx[ii]]) # print('adding {} at abscissa_vals {}'.format(yvals[:,first_abscissavals_per_interval_idx[ii]], start)) if last_abscissavals_per_interval[ii] < stop: boundary_abscissa_vals.append(stop) boundary_vals.append(yvals[:,last_abscissavals_per_interval_idx[ii]]) if boundary_abscissa_vals: insert_locs = np.searchsorted(abscissa_vals, boundary_abscissa_vals) abscissa_vals = np.insert(abscissa_vals, insert_locs, boundary_abscissa_vals) yvals = np.insert(yvals, insert_locs, np.array(boundary_vals).T, axis=1) abscissa_vals, unique_idx = np.unique(abscissa_vals, return_index=True) yvals = yvals[:,unique_idx] f = interpolate.interp1d(x=abscissa_vals, y=yvals, kind=kind, axis=axis, copy=copy, bounds_error=bounds_error, fill_value=fill_value, assume_sorted=assume_sorted) return f def asarray(self,*, where=None, at=None, kind='linear', copy=True, bounds_error=False, fill_value=np.nan, assume_sorted=None, recalculate=False, store_interp=True, n_samples=None, split_by_interval=False): """returns a data_like array at requested points. Parameters ---------- where : array_like or tuple, optional array corresponding to np where condition e.g., where=(data[1,:]>5) or tuple where=(speed>5,tspeed) at : array_like, optional Array of points to evaluate array at. If none given, use self._abscissa_vals together with 'where' if applicable. n_samples: int, optional Number of points to interplate at. These points will be distributed uniformly from self.support.start to stop. split_by_interval: bool If True, separate arrays by intervals and return in a list. Returns ------- out : (array, array) namedtuple tuple (xvals, yvals) of arrays, where xvals is an array of abscissa values for which (interpolated) data are returned. yvals has shape (n_signals, n_samples) """ # TODO: implement splitting by interval if split_by_interval: raise NotImplementedError("split_by_interval not yet implemented...") XYArray = namedtuple('XYArray', ['xvals', 'yvals']) if at is None and where is None and split_by_interval is False and n_samples is None: xyarray = XYArray(self._abscissa_vals, self._data_rowsig.squeeze()) return xyarray if where is not None: assert at is None and n_samples is None, "'where', 'at', and 'n_samples' cannot be used at the same time" if isinstance(where, tuple): y = np.array(where[1]).squeeze() x = where[0] assert len(x) == len(y), "'where' condition and array must have same number of elements" at = y[x] else: x = np.asanyarray(where).squeeze() assert len(x) == len(self._abscissa_vals), "'where' condition must have same number of elements as self._abscissa_vals" at = self._abscissa_vals[x] elif at is not None: assert n_samples is None, "'at' and 'n_samples' cannot be used at the same time" else: at = np.linspace(self.support.start, self.support.stop, n_samples) at = np.atleast_1d(at) if at.ndim > 1: raise ValueError("Requested points must be one-dimensional!") if at.shape[0] == 0: raise ValueError("No points were requested to interpolate") # if we made it this far, either at or where has been specified, and at is now well defined. kwargs = {'kind':kind, 'copy':copy, 'bounds_error':bounds_error, 'fill_value':fill_value, 'assume_sorted':assume_sorted} # retrieve an existing, or construct a new interpolation object if recalculate: interpobj = self._get_interp1d(**kwargs) else: try: interpobj = self._interp if interpobj is None: interpobj = self._get_interp1d(**kwargs) except AttributeError: # does not exist yet interpobj = self._get_interp1d(**kwargs) # store interpolation object, if desired if store_interp: self._interp = interpobj # do not interpolate points that lie outside the support interval_data = self.support.data[:, :, None] # use broadcasting to check in a vectorized manner if # each sample falls within the support, haha aren't we clever? # (n_intervals, n_requested_samples) valid = np.logical_and(at >= interval_data[:, 0, :], at <= interval_data[:, 1, :]) valid_mask = np.any(valid, axis=0) n_invalid = at.size - np.sum(valid_mask) if n_invalid > 0: logging.warning("{} values outside the support were removed".format(n_invalid)) at = at[valid_mask] # do the actual interpolation if self._ordinate.is_wrapping: try: if self.is_wrapped: out = self._wrap(interpobj(at), self._ordinate.range.min, self._ordinate.range.max) else: out = interpobj(at) except SystemError: interpobj = self._get_interp1d(**kwargs) if store_interp: self._interp = interpobj if self.is_wrapped: out = self._wrap(interpobj(at),self._ordinate.range.min, self._ordinate.range.max) else: out = interpobj(at) else: try: out = interpobj(at) except SystemError: interpobj = self._get_interp1d(**kwargs) if store_interp: self._interp = interpobj out = interpobj(at) xyarray = XYArray(xvals=np.asanyarray(at), yvals=np.asanyarray(out)) return xyarray def subsample(self, *, fs): """Subsamples a RegularlySampledAnalogSignalArray WARNING! Aliasing can occur! It is better to use downsample when lowering the sampling rate substantially. Parameters ---------- fs : float, optional Desired output sampling rate, in Hz Returns ------- out : RegularlySampledAnalogSignalArray Copy of RegularlySampledAnalogSignalArray where data is only stored at the new subset of points. """ return self.simplify(ds=1/fs) def simplify(self, *, ds=None, n_samples=None, **kwargs): """Returns an RegularlySampledAnalogSignalArray where the data has been simplified / subsampled. This function is primarily intended to be used for plotting and saving vector graphics without having too large file sizes as a result of too many points. Irrespective of whether 'ds' or 'n_samples' are used, the exact underlying support is propagated, and the first and last points of the supports are always included, even if this would cause n_samples or ds to be violated. WARNING! Simplify can create nan samples, when requesting a timestamp within an interval, but outside of the (first, last) abscissa_vals within that interval, since we don't extrapolate, but only interpolate. # TODO: fix Parameters ---------- ds : float, optional Time (in seconds), in which to step points. n_samples : int, optional Number of points at which to intepolate data. If ds is None and n_samples is None, then default is to use n_samples=5,000 Returns ------- out : RegularlySampledAnalogSignalArray Copy of RegularlySampledAnalogSignalArray where data is only stored at the new subset of points. """ if self.isempty: return self # legacy kwarg support: n_points = original_kwargs.pop('n_points', False) if n_points: n_samples = n_points if ds is not None and n_samples is not None: raise ValueError("ds and n_samples cannot be used together") if n_samples is not None: assert float(n_samples).is_integer(), "n_samples must be a positive integer!" assert n_samples > 1, "n_samples must be a positive integer > 1" # determine ds from number of desired points: ds = self.support.length / (n_samples-1) if ds is None: # neither n_samples nor ds was specified, so assume defaults: n_samples = np.min((5000, 250+self.n_samples//2, self.n_samples)) ds = self.support.length / (n_samples-1) # build list of points at which to evaluate the RegularlySampledAnalogSignalArray # we exclude all empty intervals: at = [] lengths = self.lengths empty_interval_ids = np.argwhere(lengths==0).squeeze().tolist() first_abscissavals_per_interval_idx = np.insert(np.cumsum(lengths[:-1]),0,0) first_abscissavals_per_interval_idx[empty_interval_ids] = 0 last_abscissavals_per_interval_idx = np.cumsum(lengths)-1 last_abscissavals_per_interval_idx[empty_interval_ids] = 0 first_abscissavals_per_interval = self._abscissa_vals[first_abscissavals_per_interval_idx] last_abscissavals_per_interval = self._abscissa_vals[last_abscissavals_per_interval_idx] for ii, (start, stop) in enumerate(self.support.data): if lengths[ii] == 0: continue newxvals = utils.frange(first_abscissavals_per_interval[ii], last_abscissavals_per_interval[ii], step=ds).tolist() at.extend(newxvals) try: if newxvals[-1] < last_abscissavals_per_interval[ii]: at.append(last_abscissavals_per_interval[ii]) except IndexError: at.append(first_abscissavals_per_interval[ii]) at.append(last_abscissavals_per_interval[ii]) _, yvals = self.asarray(at=at, recalculate=True, store_interp=False) yvals = np.array(yvals, ndmin=2) asa = self.copy() asa._abscissa_vals = np.asanyarray(at) asa._data = yvals asa._fs = 1/ds return asa def join(self, other, *, mode=None, inplace=False): """Join another RegularlySampledAnalogSignalArray to this one. WARNING! Numerical precision might cause some epochs to be considered non-disjoint even when they really are, so a better check than ep1[ep2].isempty is to check for samples contained in the intersection of ep1 and ep2. Parameters ---------- other : RegularlySampledAnalogSignalArray RegularlySampledAnalogSignalArray (or derived type) to join to the current RegularlySampledAnalogSignalArray. Other must have the same number of signals as the current RegularlySampledAnalogSignalArray. mode : string, optional One of ['max', 'min', 'left', 'right', 'mean']. Specifies how the signals are merged inside overlapping intervals. Default is 'left'. inplace : boolean, optional If True, then current RegularlySampledAnalogSignalArray is modified. If False, then a copy with the joined result is returned. Default is False. Returns ------- out : RegularlySampledAnalogSignalArray Copy of RegularlySampledAnalogSignalArray where the new RegularlySampledAnalogSignalArray has been joined to the current RegularlySampledAnalogSignalArray. """ if mode is None: mode = 'left' asa = self.copy() # copy without data since we change data at the end? times = np.zeros((1,0)) data = np.zeros((asa.n_signals,0)) # if ASAs are disjoint: if not self.support[other.support].length > 50*float_info.epsilon: # do a simple-as-butter join (concat) and sort times = np.append(times, self._abscissa_vals) data = np.hstack((data, self.data)) times = np.append(times, other._abscissa_vals) data = np.hstack((data, other.data)) else: # not disjoint both_eps = self.support[other.support] self_eps = self.support - both_eps - other.support other_eps = other.support - both_eps - self.support if mode=='left': self_eps += both_eps # print(self_eps) tmp = self[self_eps] times = np.append(times, tmp._abscissa_vals) data = np.hstack((data, tmp.data)) if not other_eps.isempty: tmp = other[other_eps] times = np.append(times, tmp._abscissa_vals) data = np.hstack((data, tmp.data)) elif mode=='right': other_eps += both_eps tmp = other[other_eps] times = np.append(times, tmp._abscissa_vals) data = np.hstack((data, tmp.data)) if not self_eps.isempty: tmp = self[self_eps] times = np.append(times, tmp._abscissa_vals) data = np.hstack((data, tmp.data)) else: raise NotImplementedError("asa.join() has not yet been implemented for mode '{}'!".format(mode)) sample_order = np.argsort(times) times = times[sample_order] data = data[:,sample_order] asa._data = data asa._abscissa_vals = times dom1 = self.domain dom2 = other.domain asa._abscissa.support = (self.support + other.support).merge() asa._abscissa.support.domain = (dom1 + dom2).merge() return asa def _pdf(self, bins=None, n_samples=None): """Return the probability distribution function for each signal.""" from scipy import integrate if bins is None: bins = 100 if n_samples is None: n_samples = 100 if self.n_signals > 1: raise NotImplementedError('multiple signals not supported yet!') # fx, bins = np.histogram(self.data.squeeze(), bins=bins, normed=True) fx, bins = np.histogram(self.data.squeeze(), bins=bins) bin_centers = (bins + (bins[1]-bins[0])/2)[:-1] Ifx = integrate.simps(fx, bin_centers) pdf = type(self)(abscissa_vals=bin_centers, data=fx/Ifx, fs=1/(bin_centers[1]-bin_centers[0]), support=type(self.support)(self.data.min(), self.data.max()) ).simplify(n_samples=n_samples) return pdf # data = [] # for signal in self.data: # fx, bins = np.histogram(signal, bins=bins) # bin_centers = (bins + (bins[1]-bins[0])/2)[:-1] def _cdf(self, n_samples=None): """Return the probability distribution function for each signal.""" if n_samples is None: n_samples = 100 if self.n_signals > 1: raise NotImplementedError('multiple signals not supported yet!') X = np.sort(self.data.squeeze()) F = np.array(range(self.n_samples))/float(self.n_samples) logging.disable(logging.CRITICAL) cdf = type(self)(abscissa_vals=X, data=F, support=type(self.support)(self.data.min(), self.data.max()) ).simplify(n_samples=n_samples) logging.disable(0) return cdf def _eegplot(self, ax=None, normalize=False, pad=None, fill=True, color=None): """custom_func docstring goes here.""" import matplotlib.pyplot as plt from ..plotting import utils as plotutils if ax is None: ax = plt.gca() xmin = self.support.min xmax = self.support.max xvals = self._abscissa_vals if pad is None: pad = np.mean(self.data)/2 data = self.data.copy() if normalize: peak_vals = self.max() data = (data.T / peak_vals).T n_traces = self.n_signals for tt, trace in enumerate(data): if color is None: line = ax.plot(xvals, tt*pad + trace, zorder=int(10+2*n_traces-2*tt)) else: line = ax.plot(xvals, tt*pad + trace, zorder=int(10+2*n_traces-2*tt), color=color) if fill: # Get the color from the current curve fillcolor = line[0].get_color() ax.fill_between(xvals, tt*pad, tt*pad + trace, alpha=0.3, color=fillcolor, zorder=int(10+2*n_traces-2*tt-1)) ax.set_xlim(xmin, xmax) if pad != 0: # yticks = np.arange(n_traces)*pad + 0.5*pad yticks = [] ax.set_yticks(yticks) ax.set_xlabel(self._abscissa.label) ax.set_ylabel(self._ordinate.label) plotutils.no_yticks(ax) plotutils.clear_left(ax) plotutils.clear_top(ax) plotutils.clear_right(ax) return ax def __setattr__(self, name, value): # https://stackoverflow.com/questions/4017572/how-can-i-make-an-alias-to-a-non-function-member-attribute-in-a-python-class name = self.__aliases__.get(name, name) object.__setattr__(self, name, value) def __getattr__(self, name): # https://stackoverflow.com/questions/4017572/how-can-i-make-an-alias-to-a-non-function-member-attribute-in-a-python-class if name == "aliases": raise AttributeError # http://nedbatchelder.com/blog/201010/surprising_getattr_recursion.html name = self.__aliases__.get(name, name) #return getattr(self, name) #Causes infinite recursion on non-existent attribute return object.__getattribute__(self, name) def legacyASAkwargs(**kwargs): """Provide support for legacy AnalogSignalArray kwargs. kwarg: time <==> timestamps <==> abscissa_vals kwarg: data <==> ydata Examples -------- asa = nel.AnalogSignalArray(time=..., data=...) asa = nel.AnalogSignalArray(timestamps=..., data=...) asa = nel.AnalogSignalArray(time=..., ydata=...) asa = nel.AnalogSignalArray(ydata=...) asa = nel.AnalogSignalArray(abscissa_vals=..., data=...) """ def only_one_of(*args): num_non_null_args = 0 out = None for arg in args: if arg is not None: num_non_null_args += 1 out = arg if num_non_null_args > 1: raise ValueError ('multiple conflicting arguments received') return out # legacy ASA constructor support for backward compatibility abscissa_vals = kwargs.pop('abscissa_vals', None) timestamps = kwargs.pop('timestamps', None) time = kwargs.pop('time', None) # only one of the above, else raise exception abscissa_vals = only_one_of(abscissa_vals, timestamps, time) if abscissa_vals is not None: kwargs['abscissa_vals'] = abscissa_vals data = kwargs.pop('data', None) ydata = kwargs.pop('ydata', None) # only one of the above, else raise exception data = only_one_of(data, ydata) if data is not None: kwargs['data'] = data return kwargs ######################################################################## # class AnalogSignalArray ######################################################################## class AnalogSignalArray(RegularlySampledAnalogSignalArray): """Custom ASA docstring with kwarg descriptions. TODO: add the ASA docstring here, using the aliases in the constructor. """ # specify class-specific aliases: __aliases__ = { 'time': 'abscissa_vals', '_time': '_abscissa_vals', 'n_epochs': 'n_intervals', 'ydata': 'data', # legacy support '_ydata': '_data' # legacy support } def __init__(self, *args, **kwargs): # add class-specific aliases to existing aliases: self.__aliases__ = {**super().__aliases__, **self.__aliases__} # legacy ASA constructor support for backward compatibility kwargs = legacyASAkwargs(**kwargs) support = kwargs.get('support', core.EpochArray(empty=True)) abscissa = kwargs.get('abscissa', core.AnalogSignalArrayAbscissa(support=support)) ordinate = kwargs.get('ordinate', core.AnalogSignalArrayOrdinate()) kwargs['abscissa'] = abscissa kwargs['ordinate'] = ordinate super().__init__(*args, **kwargs) ######################################################################## # class PositionArray ######################################################################## class PositionArray(AnalogSignalArray): """Custom PositionArray docstring with kwarg descriptions. TODO: add the docstring here, using the aliases in the constructor. """ # specify class-specific aliases: __aliases__ = { 'posdata': 'data' } def __init__(self, *args, **kwargs): # add class-specific aliases to existing aliases: self.__aliases__ = {**super().__aliases__, **self.__aliases__} xlim = kwargs.pop('xlim', None) ylim = kwargs.pop('ylim', None) super().__init__(*args, **kwargs) self._xlim = xlim self._ylim = ylim @property def is_2d(self): try: return self.n_signals == 2 except IndexError: return False @property def is_1d(self): try: return self.n_signals == 1 except IndexError: return False @property def x(self): """return x-values, as numpy array.""" return self.data[0,:] @property def y(self): """return y-values, as numpy array.""" if self.is_2d: return self.data[1,:] raise ValueError("PositionArray is not 2 dimensional, so y-values are undefined!") @property def xlim(self): if self.is_2d: return self._xlim raise ValueError("PositionArray is not 2 dimensional, so xlim is not undefined!") @xlim.setter def xlim(self, val): if self.is_2d: self._xlim = xlim raise ValueError("PositionArray is not 2 dimensional, so xlim cannot be defined!") @property def ylim(self): if self.is_2d: return self._ylim raise ValueError("PositionArray is not 2 dimensional, so ylim is not undefined!") @ylim.setter def ylim(self, val): if self.is_2d: self._ylim = ylim raise ValueError("PositionArray is not 2 dimensional, so ylim cannot be defined!") ######################################################################## # class IMUSensorArray ######################################################################## class IMUSensorArray(RegularlySampledAnalogSignalArray): """Custom IMUSensorArray docstring with kwarg descriptions. TODO: add the docstring here, using the aliases in the constructor. """ # specify class-specific aliases: __aliases__ = {} def __init__(self, *args, **kwargs): # add class-specific aliases to existing aliases: self.__aliases__ = {**super().__aliases__, **self.__aliases__} super().__init__(*args, **kwargs) ######################################################################## # class MinimalExampleArray ######################################################################## class MinimalExampleArray(RegularlySampledAnalogSignalArray): """Custom MinimalExampleArray docstring with kwarg descriptions. TODO: add the docstring here, using the aliases in the constructor. """ # specify class-specific aliases: __aliases__ = {} def __init__(self, *args, **kwargs): # add class-specific aliases to existing aliases: self.__aliases__ = {**super().__aliases__, **self.__aliases__} super().__init__(*args, **kwargs) def custom_func(self): """custom_func docstring goes here.""" print('Woot! We have some special skillz!')