Skip to content

Commit 8375fcf

Browse files
JuliaSprengerapdavison
authored andcommitted
Add resample and decimate utility methods for Analog and IrregularlysampledSignals (#776)
* Add decimate and resampling method for analog and irregular signal * Add decimate and resampling method for analog and irregular signal * Refactor method names and imports and add requirements to unittests * Refactor method names and imports and add requirements to unittests * Remove non-implemented resampling method and refactor * Refactor for PEP8 * Refactor for PEP8 * Improve docstrings and add crossreferences.
1 parent 3e23244 commit 8375fcf

File tree

4 files changed

+252
-17
lines changed

4 files changed

+252
-17
lines changed

neo/core/analogsignal.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323

2424
import logging
2525

26+
try:
27+
import scipy.signal
28+
except ImportError as err:
29+
HAVE_SCIPY = False
30+
else:
31+
HAVE_SCIPY = True
32+
2633
import numpy as np
2734
import quantities as pq
2835

@@ -539,3 +546,92 @@ def splice(self, signal, copy=False):
539546
else:
540547
self[i:j, :] = signal
541548
return self
549+
550+
def downsample(self, downsampling_factor, **kwargs):
551+
"""
552+
Downsample the data of a signal.
553+
This method reduces the number of samples of the AnalogSignal to a fraction of the
554+
original number of samples, defined by `downsampling_factor`.
555+
This method is a wrapper of scipy.signal.decimate and accepts the same set of keyword
556+
arguments, except for specifying the axis of resampling, which is fixed to the first axis
557+
here.
558+
559+
Parameters:
560+
-----------
561+
downsampling_factor: integer
562+
Factor used for decimation of samples. Scipy recommends to call decimate multiple times
563+
for downsampling factors higher than 13 when using IIR downsampling (default).
564+
565+
Returns:
566+
--------
567+
downsampled_signal: :class:`AnalogSignal`
568+
New instance of a :class:`AnalogSignal` object containing the resampled data points.
569+
The original :class:`AnalogSignal` is not modified.
570+
571+
Note:
572+
-----
573+
For resampling the signal with a fixed number of samples, see `resample` method.
574+
"""
575+
576+
if not HAVE_SCIPY:
577+
raise ImportError('Decimating requires availability of scipy.signal')
578+
579+
# Resampling is only permitted along the time axis (axis=0)
580+
if 'axis' in kwargs:
581+
kwargs.pop('axis')
582+
583+
downsampled_data = scipy.signal.decimate(self.magnitude, downsampling_factor, axis=0,
584+
**kwargs)
585+
downsampled_signal = self.duplicate_with_new_data(downsampled_data)
586+
587+
# since the number of channels stays the same, we can also copy array annotations here
588+
downsampled_signal.array_annotations = self.array_annotations.copy()
589+
downsampled_signal.sampling_rate = self.sampling_rate / downsampling_factor
590+
591+
return downsampled_signal
592+
593+
def resample(self, sample_count, **kwargs):
594+
"""
595+
Resample the data points of the signal.
596+
This method interpolates the signal and returns a new signal with a fixed number of
597+
samples defined by `sample_count`.
598+
This method is a wrapper of scipy.signal.resample and accepts the same set of keyword
599+
arguments, except for specifying the axis of resampling which is fixed to the first axis
600+
here, and the sample positions. .
601+
602+
Parameters:
603+
-----------
604+
sample_count: integer
605+
Number of desired samples. The resulting signal starts at the same sample as the
606+
original and is sampled regularly.
607+
608+
Returns:
609+
--------
610+
resampled_signal: :class:`AnalogSignal`
611+
New instance of a :class:`AnalogSignal` object containing the resampled data points.
612+
The original :class:`AnalogSignal` is not modified.
613+
614+
Note:
615+
-----
616+
For reducing the number of samples to a fraction of the original, see `downsample` method
617+
"""
618+
619+
if not HAVE_SCIPY:
620+
raise ImportError('Resampling requires availability of scipy.signal')
621+
622+
# Resampling is only permitted along the time axis (axis=0)
623+
if 'axis' in kwargs:
624+
kwargs.pop('axis')
625+
if 't' in kwargs:
626+
kwargs.pop('t')
627+
628+
resampled_data, resampled_times = scipy.signal.resample(self.magnitude, sample_count,
629+
t=self.times, axis=0, **kwargs)
630+
631+
resampled_signal = self.duplicate_with_new_data(resampled_data)
632+
resampled_signal.sampling_rate = (sample_count / self.shape[0]) * self.sampling_rate
633+
634+
# since the number of channels stays the same, we can also copy array annotations here
635+
resampled_signal.array_annotations = self.array_annotations.copy()
636+
637+
return resampled_signal

neo/core/irregularlysampledsignal.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@
2424
from __future__ import absolute_import, division, print_function
2525

2626
from copy import deepcopy, copy
27+
28+
try:
29+
import scipy.signal
30+
except ImportError as err:
31+
HAVE_SCIPY = False
32+
else:
33+
HAVE_SCIPY = True
34+
2735
import numpy as np
2836
import quantities as pq
2937

3038
from neo.core.baseneo import BaseNeo, MergeError, merge_annotations
3139
from neo.core.basesignal import BaseSignal
40+
from neo.core.analogsignal import AnalogSignal
3241
from neo.core.channelindex import ChannelIndex
3342
from neo.core.dataobject import DataObject
3443

@@ -346,21 +355,51 @@ def mean(self, interpolation=None):
346355
else:
347356
raise NotImplementedError
348357

349-
def resample(self, at=None, interpolation=None):
350-
'''
351-
Resample the signal, returning either an :class:`AnalogSignal` object
352-
or another :class:`IrregularlySampledSignal` object.
358+
def resample(self, sample_count, **kwargs):
359+
"""
360+
Resample the data points of the signal.
361+
This method interpolates the signal and returns a new signal with a fixed number of
362+
samples defined by `sample_count`.
363+
This function is a wrapper of scipy.signal.resample and accepts the same set of keyword
364+
arguments, except for specifying the axis of resampling which is fixed to the first axis
365+
here, and the sample positions. .
353366
354-
Arguments:
355-
:at: either a :class:`Quantity` array containing the times at
356-
which samples should be created (times must be within the
357-
signal duration, there is no extrapolation), a sampling rate
358-
with dimensions (1/Time) or a sampling interval
359-
with dimensions (Time).
360-
:interpolation: one of: None, 'linear'
361-
'''
362-
# further interpolation methods could be added
363-
raise NotImplementedError
367+
Parameters:
368+
-----------
369+
sample_count: integer
370+
Number of desired samples. The resulting signal starts at the same sample as the
371+
original and is sampled regularly.
372+
373+
Returns:
374+
--------
375+
resampled_signal: :class:`AnalogSignal`
376+
New instance of a :class:`AnalogSignal` object containing the resampled data points.
377+
The original :class:`AnalogSignal` is not modified.
378+
"""
379+
380+
if not HAVE_SCIPY:
381+
raise ImportError('Resampling requires availability of scipy.signal')
382+
383+
# Resampling is only permitted along the time axis (axis=0)
384+
if 'axis' in kwargs:
385+
kwargs.pop('axis')
386+
if 't' in kwargs:
387+
kwargs.pop('t')
388+
389+
resampled_data, resampled_times = scipy.signal.resample(self.magnitude, sample_count,
390+
t=self.times.magnitude,
391+
axis=0, **kwargs)
392+
393+
new_sampling_rate = (sample_count - 1) / self.duration
394+
resampled_signal = AnalogSignal(resampled_data, units=self.units, dtype=self.dtype,
395+
t_start=self.t_start,
396+
sampling_rate=new_sampling_rate,
397+
array_annotations=self.array_annotations.copy(),
398+
**self.annotations.copy())
399+
400+
# since the number of channels stays the same, we can also copy array annotations here
401+
resampled_signal.array_annotations = self.array_annotations.copy()
402+
return resampled_signal
364403

365404
def time_slice(self, t_start, t_stop):
366405
'''

neo/test/coretest/test_analogsignal.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
else:
2525
HAVE_IPYTHON = True
2626

27+
try:
28+
import scipy
29+
except ImportError:
30+
HAVE_SCIPY = False
31+
else:
32+
HAVE_SCIPY = True
33+
2734
from numpy.testing import assert_array_equal
2835
from neo.core.analogsignal import AnalogSignal, _get_sampling_rate
2936
from neo.core.channelindex import ChannelIndex
@@ -1158,6 +1165,87 @@ def test_array_annotations_getitem(self):
11581165
assert_arrays_equal(result3.array_annotations['label'], np.array(['abc', 'def']))
11591166
self.assertIsInstance(result3.array_annotations, ArrayDict)
11601167

1168+
@unittest.skipUnless(HAVE_SCIPY, "requires Scipy")
1169+
def test_downsample(self):
1170+
# generate signal long enough for decimating
1171+
data = np.sin(np.arange(1500) / 30).reshape(500, 3) * pq.mV
1172+
signal = AnalogSignal(data, sampling_rate=30000 * pq.Hz)
1173+
1174+
# test decimation using different decimation factors
1175+
factors = [1, 9, 10, 11]
1176+
for factor in factors:
1177+
desired = signal[::factor].magnitude
1178+
result = signal.downsample(factor)
1179+
1180+
self.assertEqual(np.ceil(signal.shape[0] / factor), result.shape[0])
1181+
self.assertEqual(signal.shape[-1],
1182+
result.shape[-1]) # preserve number of recording traces
1183+
self.assertAlmostEqual(signal.sampling_rate, factor * result.sampling_rate)
1184+
# only comparing center values due to border effects
1185+
np.testing.assert_allclose(desired[3:-3], result.magnitude[3:-3], rtol=0.05, atol=0.1)
1186+
1187+
@unittest.skipUnless(HAVE_SCIPY, "requires Scipy")
1188+
def test_resample_less_samples(self):
1189+
# generate signal long enough for resampling
1190+
data = np.sin(np.arange(1500) / 30).reshape(3, 500).T * pq.mV
1191+
signal = AnalogSignal(data, sampling_rate=30000 * pq.Hz)
1192+
1193+
# test resampling using different numbers of desired samples
1194+
sample_counts = [10, 100, 400]
1195+
for sample_count in sample_counts:
1196+
sample_ids = np.linspace(0, signal.shape[0], sample_count, dtype=int, endpoint=False)
1197+
desired = signal.magnitude[sample_ids]
1198+
result = signal.resample(sample_count)
1199+
1200+
self.assertEqual(sample_count, result.shape[0])
1201+
self.assertEqual(signal.shape[-1],
1202+
result.shape[-1]) # preserve number of recording traces
1203+
self.assertAlmostEqual(sample_count / signal.shape[0] * signal.sampling_rate,
1204+
result.sampling_rate)
1205+
# only comparing center values due to border effects
1206+
np.testing.assert_allclose(desired[3:-3], result.magnitude[3:-3], rtol=0.05, atol=0.1)
1207+
1208+
@unittest.skipUnless(HAVE_SCIPY, "requires Scipy")
1209+
def test_resample_more_samples(self):
1210+
# generate signal long enough for resampling
1211+
data = np.sin(np.arange(1500) / 100).T * pq.mV
1212+
signal = AnalogSignal(data, sampling_rate=30000 * pq.Hz)
1213+
1214+
# test resampling using different numbers of desired samples
1215+
factor = 2
1216+
sample_count = factor * signal.shape[0]
1217+
desired = np.interp(np.arange(sample_count) / factor, np.arange(signal.shape[0]),
1218+
signal.magnitude.flatten()).reshape(-1, 1)
1219+
result = signal.resample(sample_count)
1220+
1221+
self.assertEqual(sample_count, result.shape[0])
1222+
self.assertEqual(signal.shape[-1], result.shape[-1]) # preserve number of recording traces
1223+
self.assertAlmostEqual(sample_count / signal.shape[0] * signal.sampling_rate,
1224+
result.sampling_rate)
1225+
# only comparing center values due to border effects
1226+
np.testing.assert_allclose(desired[10:-10], result.magnitude[10:-10], rtol=0.0, atol=0.1)
1227+
1228+
@unittest.skipUnless(HAVE_SCIPY, "requires Scipy")
1229+
def test_compare_resample_and_downsample(self):
1230+
# generate signal long enough for resampling
1231+
data = np.sin(np.arange(1500) / 30).reshape(3, 500).T * pq.mV
1232+
signal = AnalogSignal(data, sampling_rate=30000 * pq.Hz)
1233+
1234+
# test resampling using different numbers of desired samples
1235+
sample_counts = [10, 100, 250]
1236+
for sample_count in sample_counts:
1237+
downsampling_factor = int(signal.shape[0] / sample_count)
1238+
desired = signal.downsample(downsampling_factor=downsampling_factor)
1239+
result = signal.resample(sample_count)
1240+
1241+
self.assertEqual(desired.shape[0], result.shape[0])
1242+
self.assertEqual(desired.shape[-1],
1243+
result.shape[-1]) # preserve number of recording traces
1244+
self.assertAlmostEqual(desired.sampling_rate, result.sampling_rate)
1245+
# only comparing center values due to border effects
1246+
np.testing.assert_allclose(desired.magnitude[3:-3], result.magnitude[3:-3], rtol=0.05,
1247+
atol=0.1)
1248+
11611249

11621250
class TestAnalogSignalEquality(unittest.TestCase):
11631251
def test__signals_with_different_data_complement_should_be_not_equal(self):

neo/test/coretest/test_irregularysampledsignal.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
else:
2424
HAVE_IPYTHON = True
2525

26+
try:
27+
import scipy
28+
except ImportError:
29+
HAVE_SCIPY = False
30+
else:
31+
HAVE_SCIPY = True
32+
2633
from neo.core.irregularlysampledsignal import IrregularlySampledSignal
2734
from neo.core import Segment, ChannelIndex
2835
from neo.core.baseneo import MergeError
@@ -352,9 +359,6 @@ def test_simple_statistics(self):
352359
def test_mean_interpolation_NotImplementedError(self):
353360
self.assertRaises(NotImplementedError, self.signal1.mean, True)
354361

355-
def test_resample_NotImplementedError(self):
356-
self.assertRaises(NotImplementedError, self.signal1.resample, True)
357-
358362
def test__rescale_same(self):
359363
result = self.signal1.copy()
360364
result = result.rescale(pq.mV)
@@ -710,6 +714,14 @@ def test__copy_should_preserve_parent_objects(self):
710714
self.assertIs(result.segment, self.signal1.segment)
711715
self.assertIs(result.channel_index, self.signal1.channel_index)
712716

717+
@unittest.skipUnless(HAVE_SCIPY, "requires Scipy")
718+
def test_resample(self):
719+
factors = [1, 2, 10]
720+
for factor in factors:
721+
result = self.signal1.resample(self.signal1.shape[0] * factor)
722+
np.testing.assert_allclose(self.signal1.magnitude, result.magnitude[::factor],
723+
rtol=1e-7, atol=0)
724+
713725

714726
class TestIrregularlySampledSignalCombination(unittest.TestCase):
715727
def setUp(self):

0 commit comments

Comments
 (0)