irregularlysampledsignal.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. # -*- coding: utf-8 -*-
  2. '''
  3. This module implements :class:`IrregularlySampledSignal`, an array of analog
  4. signals with samples taken at arbitrary time points.
  5. :class:`IrregularlySampledSignal` inherits from :class:`basesignal.BaseSignal`
  6. which derives from :class:`BaseNeo`, from :module:`neo.core.baseneo`,
  7. and from :class:`quantities.Quantity`, which in turn inherits from
  8. :class:`numpy.ndarray`.
  9. Inheritance from :class:`numpy.array` is explained here:
  10. http://docs.scipy.org/doc/numpy/user/basics.subclassing.html
  11. In brief:
  12. * Initialization of a new object from constructor happens in :meth:`__new__`.
  13. This is where user-specified attributes are set.
  14. * :meth:`__array_finalize__` is called for all new objects, including those
  15. created by slicing. This is where attributes are copied over from
  16. the old object.
  17. '''
  18. # needed for Python 3 compatibility
  19. from __future__ import absolute_import, division, print_function
  20. from copy import deepcopy
  21. import numpy as np
  22. import quantities as pq
  23. from neo.core.baseneo import BaseNeo, MergeError, merge_annotations
  24. from neo.core.basesignal import BaseSignal
  25. from neo.core.channelindex import ChannelIndex
  26. from neo.core.dataobject import DataObject
  27. def _new_IrregularlySampledSignal(cls, times, signal, units=None, time_units=None, dtype=None,
  28. copy=True, name=None, file_origin=None, description=None,
  29. array_annotations=None, annotations=None, segment=None,
  30. channel_index=None):
  31. '''
  32. A function to map IrregularlySampledSignal.__new__ to a function that
  33. does not do the unit checking. This is needed for pickle to work.
  34. '''
  35. iss = cls(times=times, signal=signal, units=units, time_units=time_units, dtype=dtype,
  36. copy=copy, name=name, file_origin=file_origin, description=description,
  37. array_annotations=array_annotations, **annotations)
  38. iss.segment = segment
  39. iss.channel_index = channel_index
  40. return iss
  41. class IrregularlySampledSignal(BaseSignal):
  42. '''
  43. An array of one or more analog signals with samples taken at arbitrary time points.
  44. A representation of one or more continuous, analog signals acquired at time
  45. :attr:`t_start` with a varying sampling interval. Each channel is sampled
  46. at the same time points.
  47. Inherits from :class:`quantities.Quantity`, which in turn inherits from
  48. :class:`numpy.ndarray`.
  49. *Usage*::
  50. >>> from neo.core import IrregularlySampledSignal
  51. >>> from quantities import s, nA
  52. >>>
  53. >>> irsig0 = IrregularlySampledSignal([0.0, 1.23, 6.78], [1, 2, 3],
  54. ... units='mV', time_units='ms')
  55. >>> irsig1 = IrregularlySampledSignal([0.01, 0.03, 0.12]*s,
  56. ... [[4, 5], [5, 4], [6, 3]]*nA)
  57. *Required attributes/properties*:
  58. :times: (quantity array 1D, numpy array 1D, or list)
  59. The time of each data point. Must have the same size as :attr:`signal`.
  60. :signal: (quantity array 2D, numpy array 2D, or list (data, channel))
  61. The data itself.
  62. :units: (quantity units)
  63. Required if the signal is a list or NumPy array, not if it is
  64. a :class:`Quantity`.
  65. :time_units: (quantity units) Required if :attr:`times` is a list or
  66. NumPy array, not if it is a :class:`Quantity`.
  67. *Recommended attributes/properties*:.
  68. :name: (str) A label for the dataset
  69. :description: (str) Text description.
  70. :file_origin: (str) Filesystem path or URL of the original data file.
  71. *Optional attributes/properties*:
  72. :dtype: (numpy dtype or str) Override the dtype of the signal array.
  73. (times are always floats).
  74. :copy: (bool) True by default.
  75. :array_annotations: (dict) Dict mapping strings to numpy arrays containing annotations \
  76. for all data points
  77. Note: Any other additional arguments are assumed to be user-specific
  78. metadata and stored in :attr:`annotations`.
  79. *Properties available on this object*:
  80. :sampling_intervals: (quantity array 1D) Interval between each adjacent
  81. pair of samples.
  82. (``times[1:] - times[:-1]``)
  83. :duration: (quantity scalar) Signal duration, read-only.
  84. (``times[-1] - times[0]``)
  85. :t_start: (quantity scalar) Time when signal begins, read-only.
  86. (``times[0]``)
  87. :t_stop: (quantity scalar) Time when signal ends, read-only.
  88. (``times[-1]``)
  89. *Slicing*:
  90. :class:`IrregularlySampledSignal` objects can be sliced. When this
  91. occurs, a new :class:`IrregularlySampledSignal` (actually a view) is
  92. returned, with the same metadata, except that :attr:`times` is also
  93. sliced in the same way.
  94. *Operations available on this object*:
  95. == != + * /
  96. '''
  97. _single_parent_objects = ('Segment', 'ChannelIndex')
  98. _quantity_attr = 'signal'
  99. _necessary_attrs = (('times', pq.Quantity, 1), ('signal', pq.Quantity, 2))
  100. def __new__(cls, times, signal, units=None, time_units=None, dtype=None, copy=True, name=None,
  101. file_origin=None, description=None, array_annotations=None, **annotations):
  102. '''
  103. Construct a new :class:`IrregularlySampledSignal` instance.
  104. This is called whenever a new :class:`IrregularlySampledSignal` is
  105. created from the constructor, but not when slicing.
  106. '''
  107. signal = cls._rescale(signal, units=units)
  108. if time_units is None:
  109. if hasattr(times, "units"):
  110. time_units = times.units
  111. else:
  112. raise ValueError("Time units must be specified")
  113. elif isinstance(times, pq.Quantity):
  114. # could improve this test, what if units is a string?
  115. if time_units != times.units:
  116. times = times.rescale(time_units)
  117. # should check time units have correct dimensions
  118. obj = pq.Quantity.__new__(cls, signal, units=units, dtype=dtype, copy=copy)
  119. if obj.ndim == 1:
  120. obj = obj.reshape(-1, 1)
  121. if len(times) != obj.shape[0]:
  122. raise ValueError("times array and signal array must "
  123. "have same length")
  124. obj.times = pq.Quantity(times, units=time_units, dtype=float, copy=copy)
  125. obj.segment = None
  126. obj.channel_index = None
  127. return obj
  128. def __init__(self, times, signal, units=None, time_units=None, dtype=None, copy=True,
  129. name=None, file_origin=None, description=None, array_annotations=None,
  130. **annotations):
  131. '''
  132. Initializes a newly constructed :class:`IrregularlySampledSignal`
  133. instance.
  134. '''
  135. DataObject.__init__(self, name=name, file_origin=file_origin, description=description,
  136. array_annotations=array_annotations, **annotations)
  137. def __reduce__(self):
  138. '''
  139. Map the __new__ function onto _new_IrregularlySampledSignal, so that pickle
  140. works
  141. '''
  142. return _new_IrregularlySampledSignal, (self.__class__, self.times, np.array(self),
  143. self.units, self.times.units, self.dtype, True,
  144. self.name, self.file_origin, self.description,
  145. self.array_annotations, self.annotations,
  146. self.segment, self.channel_index)
  147. def _array_finalize_spec(self, obj):
  148. '''
  149. Set default values for attributes specific to :class:`IrregularlySampledSignal`.
  150. Common attributes are defined in
  151. :meth:`__array_finalize__` in :class:`basesignal.BaseSignal`),
  152. which is called every time a new signal is created
  153. and calls this method.
  154. '''
  155. self.times = getattr(obj, 'times', None)
  156. return obj
  157. def __deepcopy__(self, memo):
  158. cls = self.__class__
  159. new_signal = cls(self.times, np.array(self), units=self.units,
  160. time_units=self.times.units, dtype=self.dtype,
  161. t_start=self.t_start, name=self.name,
  162. file_origin=self.file_origin, description=self.description)
  163. new_signal.__dict__.update(self.__dict__)
  164. memo[id(self)] = new_signal
  165. for k, v in self.__dict__.items():
  166. try:
  167. setattr(new_signal, k, deepcopy(v, memo))
  168. except TypeError:
  169. setattr(new_signal, k, v)
  170. return new_signal
  171. def __repr__(self):
  172. '''
  173. Returns a string representing the :class:`IrregularlySampledSignal`.
  174. '''
  175. return '<%s(%s at times %s)>' % (
  176. self.__class__.__name__, super(IrregularlySampledSignal, self).__repr__(), self.times)
  177. def __getitem__(self, i):
  178. '''
  179. Get the item or slice :attr:`i`.
  180. '''
  181. if isinstance(i, (int, np.integer)): # a single point in time across all channels
  182. obj = super(IrregularlySampledSignal, self).__getitem__(i)
  183. obj = pq.Quantity(obj.magnitude, units=obj.units)
  184. elif isinstance(i, tuple):
  185. obj = super(IrregularlySampledSignal, self).__getitem__(i)
  186. j, k = i
  187. if isinstance(j, (int, np.integer)): # a single point in time across some channels
  188. obj = pq.Quantity(obj.magnitude, units=obj.units)
  189. else:
  190. if isinstance(j, slice):
  191. obj.times = self.times.__getitem__(j)
  192. elif isinstance(j, np.ndarray):
  193. raise NotImplementedError("Arrays not yet supported")
  194. else:
  195. raise TypeError("%s not supported" % type(j))
  196. if isinstance(k, (int, np.integer)):
  197. obj = obj.reshape(-1, 1) # add if channel_index
  198. obj.array_annotations = deepcopy(self.array_annotations_at_index(k))
  199. elif isinstance(i, slice):
  200. obj = super(IrregularlySampledSignal, self).__getitem__(i)
  201. obj.times = self.times.__getitem__(i)
  202. obj.array_annotations = deepcopy(self.array_annotations)
  203. elif isinstance(i, np.ndarray):
  204. # Indexing of an IrregularlySampledSignal is only consistent if the resulting
  205. # number of samples is the same for each trace. The time axis for these samples is not
  206. # guaranteed to be continuous, so returning a Quantity instead of an
  207. # IrregularlySampledSignal here.
  208. new_time_dims = np.sum(i, axis=0)
  209. if len(new_time_dims) and all(new_time_dims == new_time_dims[0]):
  210. obj = np.asarray(self).T.__getitem__(i.T)
  211. obj = obj.T.reshape(self.shape[1], -1).T
  212. obj = pq.Quantity(obj, units=self.units)
  213. else:
  214. raise IndexError("indexing of an IrregularlySampledSignal needs to keep the same "
  215. "number of sample for each trace contained")
  216. else:
  217. raise IndexError("index should be an integer, tuple, slice or boolean numpy array")
  218. return obj
  219. @property
  220. def duration(self):
  221. '''
  222. Signal duration.
  223. (:attr:`times`[-1] - :attr:`times`[0])
  224. '''
  225. return self.times[-1] - self.times[0]
  226. @property
  227. def t_start(self):
  228. '''
  229. Time when signal begins.
  230. (:attr:`times`[0])
  231. '''
  232. return self.times[0]
  233. @property
  234. def t_stop(self):
  235. '''
  236. Time when signal ends.
  237. (:attr:`times`[-1])
  238. '''
  239. return self.times[-1]
  240. def __eq__(self, other):
  241. '''
  242. Equality test (==)
  243. '''
  244. if (isinstance(other, IrregularlySampledSignal) and not (self.times == other.times).all()):
  245. return False
  246. return super(IrregularlySampledSignal, self).__eq__(other)
  247. def _check_consistency(self, other):
  248. '''
  249. Check if the attributes of another :class:`IrregularlySampledSignal`
  250. are compatible with this one.
  251. '''
  252. # if not an array, then allow the calculation
  253. if not hasattr(other, 'ndim'):
  254. return
  255. # if a scalar array, then allow the calculation
  256. if not other.ndim:
  257. return
  258. # dimensionality should match
  259. if self.ndim != other.ndim:
  260. raise ValueError('Dimensionality does not match: %s vs %s' % (self.ndim, other.ndim))
  261. # if if the other array does not have a times property,
  262. # then it should be okay to add it directly
  263. if not hasattr(other, 'times'):
  264. return
  265. # if there is a times property, the times need to be the same
  266. if not (self.times == other.times).all():
  267. raise ValueError('Times do not match: %s vs %s' % (self.times, other.times))
  268. def __rsub__(self, other, *args):
  269. '''
  270. Backwards subtraction (other-self)
  271. '''
  272. return self.__mul__(-1) + other
  273. def _repr_pretty_(self, pp, cycle):
  274. '''
  275. Handle pretty-printing the :class:`IrregularlySampledSignal`.
  276. '''
  277. pp.text("{cls} with {channels} channels of length {length}; "
  278. "units {units}; datatype {dtype} ".format(cls=self.__class__.__name__,
  279. channels=self.shape[1],
  280. length=self.shape[0],
  281. units=self.units.dimensionality.string,
  282. dtype=self.dtype))
  283. if self._has_repr_pretty_attrs_():
  284. pp.breakable()
  285. self._repr_pretty_attrs_(pp, cycle)
  286. def _pp(line):
  287. pp.breakable()
  288. with pp.group(indent=1):
  289. pp.text(line)
  290. for line in ["sample times: {0}".format(self.times)]:
  291. _pp(line)
  292. @property
  293. def sampling_intervals(self):
  294. '''
  295. Interval between each adjacent pair of samples.
  296. (:attr:`times[1:]` - :attr:`times`[:-1])
  297. '''
  298. return self.times[1:] - self.times[:-1]
  299. def mean(self, interpolation=None):
  300. '''
  301. Calculates the mean, optionally using interpolation between sampling
  302. times.
  303. If :attr:`interpolation` is None, we assume that values change
  304. stepwise at sampling times.
  305. '''
  306. if interpolation is None:
  307. return (self[:-1] * self.sampling_intervals.reshape(-1, 1)).sum() / self.duration
  308. else:
  309. raise NotImplementedError
  310. def resample(self, at=None, interpolation=None):
  311. '''
  312. Resample the signal, returning either an :class:`AnalogSignal` object
  313. or another :class:`IrregularlySampledSignal` object.
  314. Arguments:
  315. :at: either a :class:`Quantity` array containing the times at
  316. which samples should be created (times must be within the
  317. signal duration, there is no extrapolation), a sampling rate
  318. with dimensions (1/Time) or a sampling interval
  319. with dimensions (Time).
  320. :interpolation: one of: None, 'linear'
  321. '''
  322. # further interpolation methods could be added
  323. raise NotImplementedError
  324. def time_slice(self, t_start, t_stop):
  325. '''
  326. Creates a new :class:`IrregularlySampledSignal` corresponding to the time slice of
  327. the original :class:`IrregularlySampledSignal` between times
  328. `t_start` and `t_stop`. Either parameter can also be None
  329. to use infinite endpoints for the time interval.
  330. '''
  331. _t_start = t_start
  332. _t_stop = t_stop
  333. if t_start is None:
  334. _t_start = -np.inf
  335. if t_stop is None:
  336. _t_stop = np.inf
  337. indices = (self.times >= _t_start) & (self.times <= _t_stop)
  338. count = 0
  339. id_start = None
  340. id_stop = None
  341. for i in indices:
  342. if id_start is None:
  343. if i:
  344. id_start = count
  345. else:
  346. if not i:
  347. id_stop = count
  348. break
  349. count += 1
  350. new_st = self[id_start:id_stop]
  351. return new_st
  352. def merge(self, other):
  353. '''
  354. Merge another signal into this one.
  355. The signal objects are concatenated horizontally
  356. (column-wise, :func:`np.hstack`).
  357. If the attributes of the two signals are not
  358. compatible, an Exception is raised.
  359. Required attributes of the signal are used.
  360. '''
  361. if not np.array_equal(self.times, other.times):
  362. raise MergeError("Cannot merge these two signals as the sample times differ.")
  363. if self.segment != other.segment:
  364. raise MergeError(
  365. "Cannot merge these two signals as they belong to different segments.")
  366. if hasattr(self, "lazy_shape"):
  367. if hasattr(other, "lazy_shape"):
  368. if self.lazy_shape[0] != other.lazy_shape[0]:
  369. raise MergeError("Cannot merge signals of different length.")
  370. merged_lazy_shape = (self.lazy_shape[0], self.lazy_shape[1] + other.lazy_shape[1])
  371. else:
  372. raise MergeError("Cannot merge a lazy object with a real object.")
  373. if other.units != self.units:
  374. other = other.rescale(self.units)
  375. stack = np.hstack(map(np.array, (self, other)))
  376. kwargs = {}
  377. for name in ("name", "description", "file_origin"):
  378. attr_self = getattr(self, name)
  379. attr_other = getattr(other, name)
  380. if attr_self == attr_other:
  381. kwargs[name] = attr_self
  382. else:
  383. kwargs[name] = "merge(%s, %s)" % (attr_self, attr_other)
  384. merged_annotations = merge_annotations(self.annotations, other.annotations)
  385. kwargs.update(merged_annotations)
  386. signal = self.__class__(self.times, stack, units=self.units, dtype=self.dtype,
  387. copy=False, **kwargs)
  388. signal.segment = self.segment
  389. signal.array_annotate(**self._merge_array_annotations(other))
  390. if hasattr(self, "lazy_shape"):
  391. signal.lazy_shape = merged_lazy_shape
  392. # merge channel_index (move to ChannelIndex.merge()?)
  393. if self.channel_index and other.channel_index:
  394. signal.channel_index = ChannelIndex(index=np.arange(signal.shape[1]),
  395. channel_ids=np.hstack(
  396. [self.channel_index.channel_ids,
  397. other.channel_index.channel_ids]),
  398. channel_names=np.hstack(
  399. [self.channel_index.channel_names,
  400. other.channel_index.channel_names]))
  401. else:
  402. signal.channel_index = ChannelIndex(index=np.arange(signal.shape[1]))
  403. return signal