irregularlysampledsignal.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. '''
  2. This module implements :class:`IrregularlySampledSignal`, an array of analog
  3. signals with samples taken at arbitrary time points.
  4. :class:`IrregularlySampledSignal` inherits from :class:`basesignal.BaseSignal`
  5. which derives from :class:`BaseNeo`, from :module:`neo.core.baseneo`,
  6. and from :class:`quantities.Quantity`, which in turn inherits from
  7. :class:`numpy.ndarray`.
  8. Inheritance from :class:`numpy.array` is explained here:
  9. http://docs.scipy.org/doc/numpy/user/basics.subclassing.html
  10. In brief:
  11. * Initialization of a new object from constructor happens in :meth:`__new__`.
  12. This is where user-specified attributes are set.
  13. * :meth:`__array_finalize__` is called for all new objects, including those
  14. created by slicing. This is where attributes are copied over from
  15. the old object.
  16. '''
  17. from copy import deepcopy, copy
  18. try:
  19. import scipy.signal
  20. except ImportError as err:
  21. HAVE_SCIPY = False
  22. else:
  23. HAVE_SCIPY = True
  24. import numpy as np
  25. import quantities as pq
  26. from neo.core.baseneo import MergeError, merge_annotations, intersect_annotations
  27. from neo.core.basesignal import BaseSignal
  28. from neo.core.analogsignal import AnalogSignal
  29. from neo.core.channelindex import ChannelIndex
  30. from neo.core.dataobject import DataObject
  31. def _new_IrregularlySampledSignal(cls, times, signal, units=None, time_units=None, dtype=None,
  32. copy=True, name=None, file_origin=None, description=None,
  33. array_annotations=None, annotations=None, segment=None,
  34. channel_index=None):
  35. '''
  36. A function to map IrregularlySampledSignal.__new__ to a function that
  37. does not do the unit checking. This is needed for pickle to work.
  38. '''
  39. iss = cls(times=times, signal=signal, units=units, time_units=time_units, dtype=dtype,
  40. copy=copy, name=name, file_origin=file_origin, description=description,
  41. array_annotations=array_annotations, **annotations)
  42. iss.segment = segment
  43. iss.channel_index = channel_index
  44. return iss
  45. class IrregularlySampledSignal(BaseSignal):
  46. '''
  47. An array of one or more analog signals with samples taken at arbitrary time points.
  48. A representation of one or more continuous, analog signals acquired at time
  49. :attr:`t_start` with a varying sampling interval. Each channel is sampled
  50. at the same time points.
  51. Inherits from :class:`quantities.Quantity`, which in turn inherits from
  52. :class:`numpy.ndarray`.
  53. *Usage*::
  54. >>> from neo.core import IrregularlySampledSignal
  55. >>> from quantities import s, nA
  56. >>>
  57. >>> irsig0 = IrregularlySampledSignal([0.0, 1.23, 6.78], [1, 2, 3],
  58. ... units='mV', time_units='ms')
  59. >>> irsig1 = IrregularlySampledSignal([0.01, 0.03, 0.12]*s,
  60. ... [[4, 5], [5, 4], [6, 3]]*nA)
  61. *Required attributes/properties*:
  62. :times: (quantity array 1D, numpy array 1D, or list)
  63. The time of each data point. Must have the same size as :attr:`signal`.
  64. :signal: (quantity array 2D, numpy array 2D, or list (data, channel))
  65. The data itself.
  66. :units: (quantity units)
  67. Required if the signal is a list or NumPy array, not if it is
  68. a :class:`Quantity`.
  69. :time_units: (quantity units) Required if :attr:`times` is a list or
  70. NumPy array, not if it is a :class:`Quantity`.
  71. *Recommended attributes/properties*:.
  72. :name: (str) A label for the dataset
  73. :description: (str) Text description.
  74. :file_origin: (str) Filesystem path or URL of the original data file.
  75. *Optional attributes/properties*:
  76. :dtype: (numpy dtype or str) Override the dtype of the signal array.
  77. (times are always floats).
  78. :copy: (bool) True by default.
  79. :array_annotations: (dict) Dict mapping strings to numpy arrays containing annotations \
  80. for all data points
  81. Note: Any other additional arguments are assumed to be user-specific
  82. metadata and stored in :attr:`annotations`.
  83. *Properties available on this object*:
  84. :sampling_intervals: (quantity array 1D) Interval between each adjacent
  85. pair of samples.
  86. (``times[1:] - times[:-1]``)
  87. :duration: (quantity scalar) Signal duration, read-only.
  88. (``times[-1] - times[0]``)
  89. :t_start: (quantity scalar) Time when signal begins, read-only.
  90. (``times[0]``)
  91. :t_stop: (quantity scalar) Time when signal ends, read-only.
  92. (``times[-1]``)
  93. *Slicing*:
  94. :class:`IrregularlySampledSignal` objects can be sliced. When this
  95. occurs, a new :class:`IrregularlySampledSignal` (actually a view) is
  96. returned, with the same metadata, except that :attr:`times` is also
  97. sliced in the same way.
  98. *Operations available on this object*:
  99. == != + * /
  100. '''
  101. _single_parent_objects = ('Segment', 'ChannelIndex')
  102. _single_parent_attrs = ('segment', 'channel_index')
  103. _quantity_attr = 'signal'
  104. _necessary_attrs = (('times', pq.Quantity, 1), ('signal', pq.Quantity, 2))
  105. def __new__(cls, times, signal, units=None, time_units=None, dtype=None, copy=True, name=None,
  106. file_origin=None, description=None, array_annotations=None, **annotations):
  107. '''
  108. Construct a new :class:`IrregularlySampledSignal` instance.
  109. This is called whenever a new :class:`IrregularlySampledSignal` is
  110. created from the constructor, but not when slicing.
  111. '''
  112. signal = cls._rescale(signal, units=units)
  113. if time_units is None:
  114. if hasattr(times, "units"):
  115. time_units = times.units
  116. else:
  117. raise ValueError("Time units must be specified")
  118. elif isinstance(times, pq.Quantity):
  119. # could improve this test, what if units is a string?
  120. if time_units != times.units:
  121. times = times.rescale(time_units)
  122. # should check time units have correct dimensions
  123. obj = pq.Quantity.__new__(cls, signal, units=units, dtype=dtype, copy=copy)
  124. if obj.ndim == 1:
  125. obj = obj.reshape(-1, 1)
  126. if len(times) != obj.shape[0]:
  127. raise ValueError("times array and signal array must "
  128. "have same length")
  129. obj.times = pq.Quantity(times, units=time_units, dtype=float, copy=copy)
  130. obj.segment = None
  131. obj.channel_index = None
  132. return obj
  133. def __init__(self, times, signal, units=None, time_units=None, dtype=None, copy=True,
  134. name=None, file_origin=None, description=None, array_annotations=None,
  135. **annotations):
  136. '''
  137. Initializes a newly constructed :class:`IrregularlySampledSignal`
  138. instance.
  139. '''
  140. DataObject.__init__(self, name=name, file_origin=file_origin, description=description,
  141. array_annotations=array_annotations, **annotations)
  142. def __reduce__(self):
  143. '''
  144. Map the __new__ function onto _new_IrregularlySampledSignal, so that pickle
  145. works
  146. '''
  147. return _new_IrregularlySampledSignal, (self.__class__, self.times, np.array(self),
  148. self.units, self.times.units, self.dtype, True,
  149. self.name, self.file_origin, self.description,
  150. self.array_annotations, self.annotations,
  151. self.segment, self.channel_index)
  152. def _array_finalize_spec(self, obj):
  153. '''
  154. Set default values for attributes specific to :class:`IrregularlySampledSignal`.
  155. Common attributes are defined in
  156. :meth:`__array_finalize__` in :class:`basesignal.BaseSignal`),
  157. which is called every time a new signal is created
  158. and calls this method.
  159. '''
  160. self.times = getattr(obj, 'times', None)
  161. return obj
  162. def __repr__(self):
  163. '''
  164. Returns a string representing the :class:`IrregularlySampledSignal`.
  165. '''
  166. return '<{}({} at times {})>'.format(
  167. self.__class__.__name__, super().__repr__(), self.times)
  168. def __getitem__(self, i):
  169. '''
  170. Get the item or slice :attr:`i`.
  171. '''
  172. if isinstance(i, (int, np.integer)): # a single point in time across all channels
  173. obj = super().__getitem__(i)
  174. obj = pq.Quantity(obj.magnitude, units=obj.units)
  175. elif isinstance(i, tuple):
  176. obj = super().__getitem__(i)
  177. j, k = i
  178. if isinstance(j, (int, np.integer)): # a single point in time across some channels
  179. obj = pq.Quantity(obj.magnitude, units=obj.units)
  180. else:
  181. if isinstance(j, slice):
  182. obj.times = self.times.__getitem__(j)
  183. elif isinstance(j, np.ndarray):
  184. raise NotImplementedError("Arrays not yet supported")
  185. else:
  186. raise TypeError("%s not supported" % type(j))
  187. if isinstance(k, (int, np.integer)):
  188. obj = obj.reshape(-1, 1) # add if channel_index
  189. obj.array_annotations = deepcopy(self.array_annotations_at_index(k))
  190. elif isinstance(i, slice):
  191. obj = super().__getitem__(i)
  192. obj.times = self.times.__getitem__(i)
  193. obj.array_annotations = deepcopy(self.array_annotations)
  194. elif isinstance(i, np.ndarray):
  195. # Indexing of an IrregularlySampledSignal is only consistent if the resulting
  196. # number of samples is the same for each trace. The time axis for these samples is not
  197. # guaranteed to be continuous, so returning a Quantity instead of an
  198. # IrregularlySampledSignal here.
  199. new_time_dims = np.sum(i, axis=0)
  200. if len(new_time_dims) and all(new_time_dims == new_time_dims[0]):
  201. obj = np.asarray(self).T.__getitem__(i.T)
  202. obj = obj.T.reshape(self.shape[1], -1).T
  203. obj = pq.Quantity(obj, units=self.units)
  204. else:
  205. raise IndexError("indexing of an IrregularlySampledSignal needs to keep the same "
  206. "number of sample for each trace contained")
  207. else:
  208. raise IndexError("index should be an integer, tuple, slice or boolean numpy array")
  209. return obj
  210. @property
  211. def duration(self):
  212. '''
  213. Signal duration.
  214. (:attr:`times`[-1] - :attr:`times`[0])
  215. '''
  216. return self.times[-1] - self.times[0]
  217. @property
  218. def t_start(self):
  219. '''
  220. Time when signal begins.
  221. (:attr:`times`[0])
  222. '''
  223. return self.times[0]
  224. @property
  225. def t_stop(self):
  226. '''
  227. Time when signal ends.
  228. (:attr:`times`[-1])
  229. '''
  230. return self.times[-1]
  231. def __eq__(self, other):
  232. '''
  233. Equality test (==)
  234. '''
  235. if (isinstance(other, IrregularlySampledSignal) and not (self.times == other.times).all()):
  236. return False
  237. return super().__eq__(other)
  238. def _check_consistency(self, other):
  239. '''
  240. Check if the attributes of another :class:`IrregularlySampledSignal`
  241. are compatible with this one.
  242. '''
  243. # if not an array, then allow the calculation
  244. if not hasattr(other, 'ndim'):
  245. return
  246. # if a scalar array, then allow the calculation
  247. if not other.ndim:
  248. return
  249. # dimensionality should match
  250. if self.ndim != other.ndim:
  251. raise ValueError('Dimensionality does not match: {} vs {}'.format(
  252. self.ndim, other.ndim))
  253. # if if the other array does not have a times property,
  254. # then it should be okay to add it directly
  255. if not hasattr(other, 'times'):
  256. return
  257. # if there is a times property, the times need to be the same
  258. if not (self.times == other.times).all():
  259. raise ValueError('Times do not match: {} vs {}'.format(self.times, other.times))
  260. def __rsub__(self, other, *args):
  261. '''
  262. Backwards subtraction (other-self)
  263. '''
  264. return self.__mul__(-1) + other
  265. def _repr_pretty_(self, pp, cycle):
  266. '''
  267. Handle pretty-printing the :class:`IrregularlySampledSignal`.
  268. '''
  269. pp.text("{cls} with {channels} channels of length {length}; "
  270. "units {units}; datatype {dtype} ".format(cls=self.__class__.__name__,
  271. channels=self.shape[1],
  272. length=self.shape[0],
  273. units=self.units.dimensionality.string,
  274. dtype=self.dtype))
  275. if self._has_repr_pretty_attrs_():
  276. pp.breakable()
  277. self._repr_pretty_attrs_(pp, cycle)
  278. def _pp(line):
  279. pp.breakable()
  280. with pp.group(indent=1):
  281. pp.text(line)
  282. for line in ["sample times: {}".format(self.times)]:
  283. _pp(line)
  284. @property
  285. def sampling_intervals(self):
  286. '''
  287. Interval between each adjacent pair of samples.
  288. (:attr:`times[1:]` - :attr:`times`[:-1])
  289. '''
  290. return self.times[1:] - self.times[:-1]
  291. def mean(self, interpolation=None):
  292. '''
  293. Calculates the mean, optionally using interpolation between sampling
  294. times.
  295. If :attr:`interpolation` is None, we assume that values change
  296. stepwise at sampling times.
  297. '''
  298. if interpolation is None:
  299. return (self[:-1] * self.sampling_intervals.reshape(-1, 1)).sum() / self.duration
  300. else:
  301. raise NotImplementedError
  302. def resample(self, sample_count, **kwargs):
  303. """
  304. Resample the data points of the signal.
  305. This method interpolates the signal and returns a new signal with a fixed number of
  306. samples defined by `sample_count`.
  307. This function is a wrapper of scipy.signal.resample and accepts the same set of keyword
  308. arguments, except for specifying the axis of resampling which is fixed to the first axis
  309. here, and the sample positions. .
  310. Parameters:
  311. -----------
  312. sample_count: integer
  313. Number of desired samples. The resulting signal starts at the same sample as the
  314. original and is sampled regularly.
  315. Returns:
  316. --------
  317. resampled_signal: :class:`AnalogSignal`
  318. New instance of a :class:`AnalogSignal` object containing the resampled data points.
  319. The original :class:`AnalogSignal` is not modified.
  320. """
  321. if not HAVE_SCIPY:
  322. raise ImportError('Resampling requires availability of scipy.signal')
  323. # Resampling is only permitted along the time axis (axis=0)
  324. if 'axis' in kwargs:
  325. kwargs.pop('axis')
  326. if 't' in kwargs:
  327. kwargs.pop('t')
  328. resampled_data, resampled_times = scipy.signal.resample(self.magnitude, sample_count,
  329. t=self.times.magnitude,
  330. axis=0, **kwargs)
  331. new_sampling_rate = (sample_count - 1) / self.duration
  332. resampled_signal = AnalogSignal(resampled_data, units=self.units, dtype=self.dtype,
  333. t_start=self.t_start,
  334. sampling_rate=new_sampling_rate,
  335. array_annotations=self.array_annotations.copy(),
  336. **self.annotations.copy())
  337. # since the number of channels stays the same, we can also copy array annotations here
  338. resampled_signal.array_annotations = self.array_annotations.copy()
  339. return resampled_signal
  340. def time_slice(self, t_start, t_stop):
  341. '''
  342. Creates a new :class:`IrregularlySampledSignal` corresponding to the time slice of
  343. the original :class:`IrregularlySampledSignal` between times
  344. `t_start` and `t_stop`. Either parameter can also be None
  345. to use infinite endpoints for the time interval.
  346. '''
  347. _t_start = t_start
  348. _t_stop = t_stop
  349. if t_start is None:
  350. _t_start = -np.inf
  351. if t_stop is None:
  352. _t_stop = np.inf
  353. indices = (self.times >= _t_start) & (self.times <= _t_stop)
  354. count = 0
  355. id_start = None
  356. id_stop = None
  357. for i in indices:
  358. if id_start is None:
  359. if i:
  360. id_start = count
  361. else:
  362. if not i:
  363. id_stop = count
  364. break
  365. count += 1
  366. # Time slicing should create a deep copy of the object
  367. new_st = deepcopy(self[id_start:id_stop])
  368. return new_st
  369. def time_shift(self, t_shift):
  370. """
  371. Shifts a :class:`IrregularlySampledSignal` to start at a new time.
  372. Parameters:
  373. -----------
  374. t_shift: Quantity (time)
  375. Amount of time by which to shift the :class:`IrregularlySampledSignal`.
  376. Returns:
  377. --------
  378. new_sig: :class:`SpikeTrain`
  379. New instance of a :class:`IrregularlySampledSignal` object
  380. starting at t_shift later than the original :class:`IrregularlySampledSignal`
  381. (the original :class:`IrregularlySampledSignal` is not modified).
  382. """
  383. new_sig = deepcopy(self)
  384. new_sig.times += t_shift
  385. return new_sig
  386. def merge(self, other):
  387. '''
  388. Merge another signal into this one.
  389. The signal objects are concatenated horizontally
  390. (column-wise, :func:`np.hstack`).
  391. If the attributes of the two signals are not
  392. compatible, an Exception is raised.
  393. Required attributes of the signal are used.
  394. '''
  395. if not np.array_equal(self.times, other.times):
  396. raise MergeError("Cannot merge these two signals as the sample times differ.")
  397. if self.segment != other.segment:
  398. raise MergeError(
  399. "Cannot merge these two signals as they belong to different segments.")
  400. if hasattr(self, "lazy_shape"):
  401. if hasattr(other, "lazy_shape"):
  402. if self.lazy_shape[0] != other.lazy_shape[0]:
  403. raise MergeError("Cannot merge signals of different length.")
  404. merged_lazy_shape = (self.lazy_shape[0], self.lazy_shape[1] + other.lazy_shape[1])
  405. else:
  406. raise MergeError("Cannot merge a lazy object with a real object.")
  407. if other.units != self.units:
  408. other = other.rescale(self.units)
  409. stack = np.hstack((self.magnitude, other.magnitude))
  410. kwargs = {}
  411. for name in ("name", "description", "file_origin"):
  412. attr_self = getattr(self, name)
  413. attr_other = getattr(other, name)
  414. if attr_self == attr_other:
  415. kwargs[name] = attr_self
  416. else:
  417. kwargs[name] = "merge({}, {})".format(attr_self, attr_other)
  418. merged_annotations = merge_annotations(self.annotations, other.annotations)
  419. kwargs.update(merged_annotations)
  420. signal = self.__class__(self.times, stack, units=self.units, dtype=self.dtype,
  421. copy=False, **kwargs)
  422. signal.segment = self.segment
  423. signal.array_annotate(**self._merge_array_annotations(other))
  424. if hasattr(self, "lazy_shape"):
  425. signal.lazy_shape = merged_lazy_shape
  426. # merge channel_index (move to ChannelIndex.merge()?)
  427. if self.channel_index and other.channel_index:
  428. signal.channel_index = ChannelIndex(index=np.arange(signal.shape[1]),
  429. channel_ids=np.hstack(
  430. [self.channel_index.channel_ids,
  431. other.channel_index.channel_ids]),
  432. channel_names=np.hstack(
  433. [self.channel_index.channel_names,
  434. other.channel_index.channel_names]))
  435. else:
  436. signal.channel_index = ChannelIndex(index=np.arange(signal.shape[1]))
  437. return signal
  438. def concatenate(self, other, allow_overlap=False):
  439. '''
  440. Combine this and another signal along the time axis.
  441. The signal objects are concatenated vertically
  442. (row-wise, :func:`np.vstack`). Patching can be
  443. used to combine signals across segments.
  444. Note: Only array annotations common to
  445. both signals are attached to the concatenated signal.
  446. If the attributes of the two signal are not
  447. compatible, an Exception is raised.
  448. Required attributes of the signal are used.
  449. Parameters
  450. ----------
  451. other : neo.BaseSignal
  452. The object that is merged into this one.
  453. allow_overlap : bool
  454. If false, overlapping samples between the two
  455. signals are not permitted and an ValueError is raised.
  456. If true, no check for overlapping samples is
  457. performed and all samples are combined.
  458. Returns
  459. -------
  460. signal : neo.IrregularlySampledSignal
  461. Signal containing all non-overlapping samples of
  462. both source signals.
  463. Raises
  464. ------
  465. MergeError
  466. If `other` object has incompatible attributes.
  467. '''
  468. for attr in self._necessary_attrs:
  469. if not (attr[0] in ['signal', 'times', 't_start', 't_stop', 'times']):
  470. if getattr(self, attr[0], None) != getattr(other, attr[0], None):
  471. raise MergeError(
  472. "Cannot concatenate these two signals as the %s differ." % attr[0])
  473. if hasattr(self, "lazy_shape"):
  474. if hasattr(other, "lazy_shape"):
  475. if self.lazy_shape[-1] != other.lazy_shape[-1]:
  476. raise MergeError("Cannot concatenate signals as they contain"
  477. " different numbers of traces.")
  478. merged_lazy_shape = (self.lazy_shape[0] + other.lazy_shape[0], self.lazy_shape[-1])
  479. else:
  480. raise MergeError("Cannot concatenate a lazy object with a real object.")
  481. if other.units != self.units:
  482. other = other.rescale(self.units)
  483. new_times = np.hstack((self.times, other.times))
  484. sorting = np.argsort(new_times)
  485. new_samples = np.vstack((self.magnitude, other.magnitude))
  486. kwargs = {}
  487. for name in ("name", "description", "file_origin"):
  488. attr_self = getattr(self, name)
  489. attr_other = getattr(other, name)
  490. if attr_self == attr_other:
  491. kwargs[name] = attr_self
  492. else:
  493. kwargs[name] = "merge({}, {})".format(attr_self, attr_other)
  494. merged_annotations = merge_annotations(self.annotations, other.annotations)
  495. kwargs.update(merged_annotations)
  496. kwargs['array_annotations'] = intersect_annotations(self.array_annotations,
  497. other.array_annotations)
  498. if not allow_overlap:
  499. if max(self.t_start, other.t_start) <= min(self.t_stop, other.t_stop):
  500. raise ValueError('Can not combine signals that overlap in time. Allow for '
  501. 'overlapping samples using the "no_overlap" parameter.')
  502. t_start = min(self.t_start, other.t_start)
  503. t_stop = max(self.t_start, other.t_start)
  504. signal = IrregularlySampledSignal(signal=new_samples[sorting], times=new_times[sorting],
  505. units=self.units, dtype=self.dtype, copy=False,
  506. t_start=t_start, t_stop=t_stop, **kwargs)
  507. signal.segment = None
  508. signal.channel_index = None
  509. if hasattr(self, "lazy_shape"):
  510. signal.lazy_shape = merged_lazy_shape
  511. return signal