irregularlysampledsignal.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  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` derives from :class:`BaseNeo`, from
  6. :module:`neo.core.baseneo`, and from :class:`quantites.Quantity`, which
  7. inherits from :class:`numpy.array`.
  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. # needed for Python 3 compatibility
  18. from __future__ import absolute_import, division, print_function
  19. import numpy as np
  20. import quantities as pq
  21. from neo.core.baseneo import BaseNeo, MergeError, merge_annotations
  22. def _new_IrregularlySampledSignal(cls, times, signal, units=None, time_units=None, dtype=None,
  23. copy=True, name=None, file_origin=None, description=None,
  24. annotations=None, segment=None, channel_index=None):
  25. '''
  26. A function to map IrregularlySampledSignal.__new__ to function that
  27. does not do the unit checking. This is needed for pickle to work.
  28. '''
  29. iss = cls(times=times, signal=signal, units=units, time_units=time_units,
  30. dtype=dtype, copy=copy, name=name, file_origin=file_origin,
  31. description=description, **annotations)
  32. iss.segment = segment
  33. iss.channel_index = channel_index
  34. return iss
  35. class IrregularlySampledSignal(BaseNeo, pq.Quantity):
  36. '''
  37. An array of one or more analog signals with samples taken at arbitrary time points.
  38. A representation of one or more continuous, analog signals acquired at time
  39. :attr:`t_start` with a varying sampling interval. Each channel is sampled
  40. at the same time points.
  41. *Usage*::
  42. >>> from neo.core import IrregularlySampledSignal
  43. >>> from quantities import s, nA
  44. >>>
  45. >>> irsig0 = IrregularlySampledSignal([0.0, 1.23, 6.78], [1, 2, 3],
  46. ... units='mV', time_units='ms')
  47. >>> irsig1 = IrregularlySampledSignal([0.01, 0.03, 0.12]*s,
  48. ... [[4, 5], [5, 4], [6, 3]]*nA)
  49. *Required attributes/properties*:
  50. :times: (quantity array 1D, numpy array 1D, or list)
  51. The time of each data point. Must have the same size as :attr:`signal`.
  52. :signal: (quantity array 2D, numpy array 2D, or list (data, channel))
  53. The data itself.
  54. :units: (quantity units)
  55. Required if the signal is a list or NumPy array, not if it is
  56. a :class:`Quantity`.
  57. :time_units: (quantity units) Required if :attr:`times` is a list or
  58. NumPy array, not if it is a :class:`Quantity`.
  59. *Recommended attributes/properties*:.
  60. :name: (str) A label for the dataset
  61. :description: (str) Text description.
  62. :file_origin: (str) Filesystem path or URL of the original data file.
  63. *Optional attributes/properties*:
  64. :dtype: (numpy dtype or str) Override the dtype of the signal array.
  65. (times are always floats).
  66. :copy: (bool) True by default.
  67. Note: Any other additional arguments are assumed to be user-specific
  68. metadata and stored in :attr:`annotations`.
  69. *Properties available on this object*:
  70. :sampling_intervals: (quantity array 1D) Interval between each adjacent
  71. pair of samples.
  72. (``times[1:] - times[:-1]``)
  73. :duration: (quantity scalar) Signal duration, read-only.
  74. (``times[-1] - times[0]``)
  75. :t_start: (quantity scalar) Time when signal begins, read-only.
  76. (``times[0]``)
  77. :t_stop: (quantity scalar) Time when signal ends, read-only.
  78. (``times[-1]``)
  79. *Slicing*:
  80. :class:`IrregularlySampledSignal` objects can be sliced. When this
  81. occurs, a new :class:`IrregularlySampledSignal` (actually a view) is
  82. returned, with the same metadata, except that :attr:`times` is also
  83. sliced in the same way.
  84. *Operations available on this object*:
  85. == != + * /
  86. '''
  87. _single_parent_objects = ('Segment', 'ChannelIndex')
  88. _quantity_attr = 'signal'
  89. _necessary_attrs = (('times', pq.Quantity, 1),
  90. ('signal', pq.Quantity, 2))
  91. def __new__(cls, times, signal, units=None, time_units=None, dtype=None,
  92. copy=True, name=None, file_origin=None,
  93. description=None,
  94. **annotations):
  95. '''
  96. Construct a new :class:`IrregularlySampledSignal` instance.
  97. This is called whenever a new :class:`IrregularlySampledSignal` is
  98. created from the constructor, but not when slicing.
  99. '''
  100. if units is None:
  101. if hasattr(signal, "units"):
  102. units = signal.units
  103. else:
  104. raise ValueError("Units must be specified")
  105. elif isinstance(signal, pq.Quantity):
  106. # could improve this test, what if units is a string?
  107. if units != signal.units:
  108. signal = signal.rescale(units)
  109. if time_units is None:
  110. if hasattr(times, "units"):
  111. time_units = times.units
  112. else:
  113. raise ValueError("Time units must be specified")
  114. elif isinstance(times, pq.Quantity):
  115. # could improve this test, what if units is a string?
  116. if time_units != times.units:
  117. times = times.rescale(time_units)
  118. # should check time units have correct dimensions
  119. obj = pq.Quantity.__new__(cls, signal, units=units,
  120. dtype=dtype, copy=copy)
  121. if obj.ndim == 1:
  122. obj = obj.reshape(-1, 1)
  123. if len(times) != obj.shape[0]:
  124. raise ValueError("times array and signal array must "
  125. "have same length")
  126. obj.times = pq.Quantity(times, units=time_units,
  127. dtype=float, copy=copy)
  128. obj.segment = None
  129. obj.channel_index = None
  130. return obj
  131. def __init__(self, times, signal, units=None, time_units=None, dtype=None,
  132. copy=True, name=None, file_origin=None, description=None,
  133. **annotations):
  134. '''
  135. Initializes a newly constructed :class:`IrregularlySampledSignal`
  136. instance.
  137. '''
  138. BaseNeo.__init__(self, name=name, file_origin=file_origin,
  139. description=description, **annotations)
  140. def __reduce__(self):
  141. '''
  142. Map the __new__ function onto _new_IrregularlySampledSignal, so that pickle
  143. works
  144. '''
  145. return _new_IrregularlySampledSignal, (self.__class__,
  146. self.times,
  147. np.array(self),
  148. self.units,
  149. self.times.units,
  150. self.dtype,
  151. True,
  152. self.name,
  153. self.file_origin,
  154. self.description,
  155. self.annotations,
  156. self.segment,
  157. self.channel_index)
  158. def __array_finalize__(self, obj):
  159. '''
  160. This is called every time a new :class:`IrregularlySampledSignal` is
  161. created.
  162. It is the appropriate place to set default values for attributes
  163. for :class:`IrregularlySampledSignal` constructed by slicing or
  164. viewing.
  165. User-specified values are only relevant for construction from
  166. constructor, and these are set in __new__. Then they are just
  167. copied over here.
  168. '''
  169. super(IrregularlySampledSignal, self).__array_finalize__(obj)
  170. self.times = getattr(obj, 'times', None)
  171. # The additional arguments
  172. self.annotations = getattr(obj, 'annotations', None)
  173. # Globally recommended attributes
  174. self.name = getattr(obj, 'name', None)
  175. self.file_origin = getattr(obj, 'file_origin', None)
  176. self.description = getattr(obj, 'description', None)
  177. def __repr__(self):
  178. '''
  179. Returns a string representing the :class:`IrregularlySampledSignal`.
  180. '''
  181. return '<%s(%s at times %s)>' % (self.__class__.__name__,
  182. super(IrregularlySampledSignal,
  183. self).__repr__(), self.times)
  184. def __getslice__(self, i, j):
  185. '''
  186. Get a slice from :attr:`i` to :attr:`j`.
  187. Doesn't get called in Python 3, :meth:`__getitem__` is called instead
  188. '''
  189. return self.__getitem__(slice(i, j))
  190. def __getitem__(self, i):
  191. '''
  192. Get the item or slice :attr:`i`.
  193. '''
  194. obj = super(IrregularlySampledSignal, self).__getitem__(i)
  195. if isinstance(i, int): # a single point in time across all channels
  196. obj = pq.Quantity(obj.magnitude, units=obj.units)
  197. elif isinstance(i, tuple):
  198. j, k = i
  199. if isinstance(j, int): # a single point in time across some channels
  200. obj = pq.Quantity(obj.magnitude, units=obj.units)
  201. else:
  202. if isinstance(j, slice):
  203. obj.times = self.times.__getitem__(j)
  204. elif isinstance(j, np.ndarray):
  205. raise NotImplementedError("Arrays not yet supported")
  206. else:
  207. raise TypeError("%s not supported" % type(j))
  208. if isinstance(k, int):
  209. obj = obj.reshape(-1, 1)
  210. elif isinstance(i, slice):
  211. obj.times = self.times.__getitem__(i)
  212. else:
  213. raise IndexError("index should be an integer, tuple or slice")
  214. return obj
  215. @property
  216. def duration(self):
  217. '''
  218. Signal duration.
  219. (:attr:`times`[-1] - :attr:`times`[0])
  220. '''
  221. return self.times[-1] - self.times[0]
  222. @property
  223. def t_start(self):
  224. '''
  225. Time when signal begins.
  226. (:attr:`times`[0])
  227. '''
  228. return self.times[0]
  229. @property
  230. def t_stop(self):
  231. '''
  232. Time when signal ends.
  233. (:attr:`times`[-1])
  234. '''
  235. return self.times[-1]
  236. def __eq__(self, other):
  237. '''
  238. Equality test (==)
  239. '''
  240. return (super(IrregularlySampledSignal, self).__eq__(other).all() and
  241. (self.times == other.times).all())
  242. def __ne__(self, other):
  243. '''
  244. Non-equality test (!=)
  245. '''
  246. return not self.__eq__(other)
  247. def _apply_operator(self, other, op, *args):
  248. '''
  249. Handle copying metadata to the new :class:`IrregularlySampledSignal`
  250. after a mathematical operation.
  251. '''
  252. self._check_consistency(other)
  253. f = getattr(super(IrregularlySampledSignal, self), op)
  254. new_signal = f(other, *args)
  255. new_signal._copy_data_complement(self)
  256. return new_signal
  257. def _check_consistency(self, other):
  258. '''
  259. Check if the attributes of another :class:`IrregularlySampledSignal`
  260. are compatible with this one.
  261. '''
  262. # if not an array, then allow the calculation
  263. if not hasattr(other, 'ndim'):
  264. return
  265. # if a scalar array, then allow the calculation
  266. if not other.ndim:
  267. return
  268. # dimensionality should match
  269. if self.ndim != other.ndim:
  270. raise ValueError('Dimensionality does not match: %s vs %s' %
  271. (self.ndim, other.ndim))
  272. # if if the other array does not have a times property,
  273. # then it should be okay to add it directly
  274. if not hasattr(other, 'times'):
  275. return
  276. # if there is a times property, the times need to be the same
  277. if not (self.times == other.times).all():
  278. raise ValueError('Times do not match: %s vs %s' %
  279. (self.times, other.times))
  280. def _copy_data_complement(self, other):
  281. '''
  282. Copy the metadata from another :class:`IrregularlySampledSignal`.
  283. '''
  284. for attr in ("times", "name", "file_origin",
  285. "description", "annotations"):
  286. setattr(self, attr, getattr(other, attr, None))
  287. def __add__(self, other, *args):
  288. '''
  289. Addition (+)
  290. '''
  291. return self._apply_operator(other, "__add__", *args)
  292. def __sub__(self, other, *args):
  293. '''
  294. Subtraction (-)
  295. '''
  296. return self._apply_operator(other, "__sub__", *args)
  297. def __mul__(self, other, *args):
  298. '''
  299. Multiplication (*)
  300. '''
  301. return self._apply_operator(other, "__mul__", *args)
  302. def __truediv__(self, other, *args):
  303. '''
  304. Float division (/)
  305. '''
  306. return self._apply_operator(other, "__truediv__", *args)
  307. def __div__(self, other, *args):
  308. '''
  309. Integer division (//)
  310. '''
  311. return self._apply_operator(other, "__div__", *args)
  312. __radd__ = __add__
  313. __rmul__ = __sub__
  314. def __rsub__(self, other, *args):
  315. '''
  316. Backwards subtraction (other-self)
  317. '''
  318. return self.__mul__(-1) + other
  319. def _repr_pretty_(self, pp, cycle):
  320. '''
  321. Handle pretty-printing the :class:`IrregularlySampledSignal`.
  322. '''
  323. pp.text("{cls} with {channels} channels of length {length}; "
  324. "units {units}; datatype {dtype} ".format(
  325. cls=self.__class__.__name__,
  326. channels=self.shape[1],
  327. length=self.shape[0],
  328. units=self.units.dimensionality.string,
  329. dtype=self.dtype))
  330. if self._has_repr_pretty_attrs_():
  331. pp.breakable()
  332. self._repr_pretty_attrs_(pp, cycle)
  333. def _pp(line):
  334. pp.breakable()
  335. with pp.group(indent=1):
  336. pp.text(line)
  337. for line in ["sample times: {0}".format(self.times)]:
  338. _pp(line)
  339. @property
  340. def sampling_intervals(self):
  341. '''
  342. Interval between each adjacent pair of samples.
  343. (:attr:`times[1:]` - :attr:`times`[:-1])
  344. '''
  345. return self.times[1:] - self.times[:-1]
  346. def mean(self, interpolation=None):
  347. '''
  348. Calculates the mean, optionally using interpolation between sampling
  349. times.
  350. If :attr:`interpolation` is None, we assume that values change
  351. stepwise at sampling times.
  352. '''
  353. if interpolation is None:
  354. return (self[:-1]*self.sampling_intervals.reshape(-1, 1)).sum()/self.duration
  355. else:
  356. raise NotImplementedError
  357. def resample(self, at=None, interpolation=None):
  358. '''
  359. Resample the signal, returning either an :class:`AnalogSignal` object
  360. or another :class:`IrregularlySampledSignal` object.
  361. Arguments:
  362. :at: either a :class:`Quantity` array containing the times at
  363. which samples should be created (times must be within the
  364. signal duration, there is no extrapolation), a sampling rate
  365. with dimensions (1/Time) or a sampling interval
  366. with dimensions (Time).
  367. :interpolation: one of: None, 'linear'
  368. '''
  369. # further interpolation methods could be added
  370. raise NotImplementedError
  371. def rescale(self, units):
  372. '''
  373. Return a copy of the :class:`IrregularlySampledSignal` converted to the
  374. specified units
  375. '''
  376. to_dims = pq.quantity.validate_dimensionality(units)
  377. if self.dimensionality == to_dims:
  378. to_u = self.units
  379. signal = np.array(self)
  380. else:
  381. to_u = pq.Quantity(1.0, to_dims)
  382. from_u = pq.Quantity(1.0, self.dimensionality)
  383. try:
  384. cf = pq.quantity.get_conversion_factor(from_u, to_u)
  385. except AssertionError:
  386. raise ValueError('Unable to convert between units of "%s" \
  387. and "%s"' % (from_u._dimensionality,
  388. to_u._dimensionality))
  389. signal = cf * self.magnitude
  390. new = self.__class__(times=self.times, signal=signal, units=to_u)
  391. new._copy_data_complement(self)
  392. new.annotations.update(self.annotations)
  393. return new
  394. def merge(self, other):
  395. '''
  396. Merge another :class:`IrregularlySampledSignal` with this one, and return the
  397. merged signal.
  398. The :class:`IrregularlySampledSignal` objects are concatenated horizontally
  399. (column-wise, :func:`np.hstack`).
  400. If the attributes of the two :class:`IrregularlySampledSignal` are not
  401. compatible, a :class:`MergeError` is raised.
  402. '''
  403. if not np.array_equal(self.times, other.times):
  404. raise MergeError("Cannot merge these two signals as the sample times differ.")
  405. if self.segment != other.segment:
  406. raise MergeError("Cannot merge these two signals as they belong to different segments.")
  407. if hasattr(self, "lazy_shape"):
  408. if hasattr(other, "lazy_shape"):
  409. if self.lazy_shape[0] != other.lazy_shape[0]:
  410. raise MergeError("Cannot merge signals of different length.")
  411. merged_lazy_shape = (self.lazy_shape[0], self.lazy_shape[1] + other.lazy_shape[1])
  412. else:
  413. raise MergeError("Cannot merge a lazy object with a real object.")
  414. if other.units != self.units:
  415. other = other.rescale(self.units)
  416. stack = np.hstack(map(np.array, (self, other)))
  417. kwargs = {}
  418. for name in ("name", "description", "file_origin"):
  419. attr_self = getattr(self, name)
  420. attr_other = getattr(other, name)
  421. if attr_self == attr_other:
  422. kwargs[name] = attr_self
  423. else:
  424. kwargs[name] = "merge(%s, %s)" % (attr_self, attr_other)
  425. merged_annotations = merge_annotations(self.annotations,
  426. other.annotations)
  427. kwargs.update(merged_annotations)
  428. signal = IrregularlySampledSignal(self.times, stack, units=self.units,
  429. dtype=self.dtype, copy=False,
  430. **kwargs)
  431. signal.segment = self.segment
  432. if hasattr(self, "lazy_shape"):
  433. signal.lazy_shape = merged_lazy_shape
  434. return signal
  435. def time_slice (self, t_start, t_stop):
  436. '''
  437. Creates a new :class:`IrregularlySampledSignal` corresponding to the time slice of
  438. the original :class:`IrregularlySampledSignal` between times
  439. `t_start` and `t_stop`. Either parameter can also be None
  440. to use infinite endpoints for the time interval.
  441. '''
  442. _t_start = t_start
  443. _t_stop = t_stop
  444. if t_start is None:
  445. _t_start = -np.inf
  446. if t_stop is None:
  447. _t_stop = np.inf
  448. indices = (self.times >= _t_start) & (self.times <= _t_stop)
  449. count = 0
  450. id_start = None
  451. id_stop = None
  452. for i in indices :
  453. if id_start == None :
  454. if i == True :
  455. id_start = count
  456. else :
  457. if i == False :
  458. id_stop = count
  459. break
  460. count += 1
  461. new_st = self[id_start:id_stop]
  462. return new_st
  463. def as_array(self, units=None):
  464. """
  465. Return the signal as a plain NumPy array.
  466. If `units` is specified, first rescale to those units.
  467. """
  468. if units:
  469. return self.rescale(units).magnitude
  470. else:
  471. return self.magnitude
  472. def as_quantity(self):
  473. """
  474. Return the signal as a quantities array.
  475. """
  476. return self.view(pq.Quantity)