Coverage for emd/tests/test_sift.py: 98%
300 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 10:07 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-01-09 10:07 +0000
1"""Tests for single-cycle analyses in emd.cycles."""
3import unittest
5import numpy as np
6import pytest
8from ..imftools import is_imf
9from ..sift import (complete_ensemble_sift, ensemble_sift, get_config,
10 iterated_mask_sift, mask_sift, sift)
11from ..simulate import abreu2010
14class TestSiftDefaults(unittest.TestCase):
15 """Ensure that all sift variants actually run with default options."""
17 def setUp(self):
18 """Set up data for testing."""
19 # Create core signal
20 seconds = 5.1
21 sample_rate = 2000
22 f1 = 2
23 f2 = 18
24 time_vect = np.linspace(0, seconds, int(seconds * sample_rate))
26 x = abreu2010(f1, .2, 0, sample_rate, seconds)
27 self.x = x + np.cos(2.3 * np.pi * f2 * time_vect) + np.linspace(-.5, 1, len(time_vect))
29 def test_sift_default(self):
30 """Check basic sift runs with some simple settings."""
31 imf = sift(self.x)
32 assert(imf.shape[0] == self.x.shape[0]) # just checking that it ran
34 def test_ensemble_sift_default(self):
35 """Check ensemble sift runs with some simple settings."""
36 imf = ensemble_sift(self.x[:500], max_imfs=3)
37 assert(imf.shape[0] == self.x[:500].shape[0]) # just checking that it ran
39 imf = ensemble_sift(self.x[:500], max_imfs=3, noise_mode='flip')
40 assert(imf.shape[0] == self.x[:500].shape[0]) # just checking that it ran
42 def test_complete_ensemble_sift_default(self):
43 """Check complete ensemble sift runs with some simple settings."""
44 imf = complete_ensemble_sift(self.x[:200])
45 assert(imf.shape[0] == self.x[:200].shape[0]) # just checking that it ran
47 def test_mask_sift_default(self):
48 """Check mask sift runs with some simple settings."""
49 imf = mask_sift(self.x[:200], max_imfs=5, mask_freqs='zc')
50 assert(imf.shape[0] == self.x[:200].shape[0]) # just checking that it ran
52 imf = mask_sift(self.x[:200], max_imfs=5, mask_freqs='if')
53 assert(imf.shape[0] == self.x[:200].shape[0]) # just checking that it ran
55 imf = mask_sift(self.x[:200], max_imfs=5, mask_freqs=0.4)
56 assert(imf.shape[0] == self.x[:200].shape[0]) # just checking that it ran
58 def test_iterated_mask_sift_default(self):
59 """Check mask sift runs with some simple settings."""
60 imf = iterated_mask_sift(self.x[:200], max_imfs=5)
61 assert(imf.shape[0] == self.x[:200].shape[0]) # just checking that it ran
64class TestSiftEnsurance(unittest.TestCase):
65 """Check that different inputs to sift work ok."""
67 def setUp(self):
68 """Set up signal for testing."""
69 # Create core signal
70 seconds = 5.1
71 sample_rate = 2000
72 f1 = 2
73 f2 = 18
74 time_vect = np.linspace(0, seconds, int(seconds * sample_rate))
76 x = abreu2010(f1, .2, 0, sample_rate, seconds)
77 self.x = x + np.cos(2.3 * np.pi * f2 * time_vect) + np.linspace(-.5, 1, len(time_vect))
79 def test_get_next_imf_ensurance(self):
80 """Ensure that get_next_imf works with expected inputs and errors."""
81 from ..sift import get_next_imf
83 # Check that various inputs to get_next_imf work or don't work
84 # check 1d input ok
85 imf, _ = get_next_imf(self.x)
86 assert(imf.shape == (self.x.shape[0], 1))
88 # check 1d+singleton is ok
89 imf, _ = get_next_imf(self.x[:, np.newaxis])
90 assert(imf.shape == (self.x.shape[0], 1))
92 # check nd trailing singletons is ok
93 imf, _ = get_next_imf(self.x[:, np.newaxis, np.newaxis])
94 assert(imf.shape == (self.x.shape[0], 1))
96 # check 2d raises error
97 with pytest.raises(ValueError):
98 xx = np.tile(self.x[:, np.newaxis], (1, 2))
99 imf, _ = get_next_imf(xx)
101 # check 3d raises error
102 with pytest.raises(ValueError):
103 xx = np.tile(self.x[:, np.newaxis, np.newaxis], (1, 2, 3))
104 imf, _ = get_next_imf(xx)
107class BaseTestSiftBehaviour(object):
108 """Base class for testing basic sift behaviour.
110 Base class doesn't inherit from unittest so tests within it aren't run -
111 use multiple inheritance in child classes to add unittest
113 This class implements 5 tests (four from: https://doi.org/10.1016/j.ymssp.2007.11.028)
114 1) The sift should be a complete decomposition
115 2) The sift of a signal multiplied by a constant should just scale the IMFs by that constant
116 3) The sift of a signal with a constant added should only affect the final
117 IMF, and should only increase it by the scalar
118 4) The sift of an IMF should just return the IMF
119 5) The sift of a time-reversed signal should return time-reversed but otherwise identical IMFs
121 """
123 def setUpClass(self):
124 """Create signals for testing.
126 This should create at least:
128 self.x
129 self.imf1
130 self.imf_kwargs
131 self.envelope_kwargs
132 self.extrema_kwargs
133 """
134 raise NotImplementedError
136 def test_complete_decomposition(self):
137 """Ensure complete decomposition."""
138 assert(np.allclose(self.imf1.sum(axis=1), self.x))
140 def test_sift_multiplied_by_constant(self):
141 """Ensure constant scaling only scales IMFs."""
142 const = 3
143 imf2 = sift(self.x*const, imf_opts=self.imf_kwargs,
144 envelope_opts=self.envelope_kwargs,
145 extrema_opts=self.extrema_kwargs)
146 assert(np.allclose(self.imf1, imf2/const))
147 assert(np.allclose(imf2.sum(axis=1)/const, self.x))
149 def test_sift_plus_constant(self):
150 """Ensure adding constant only affects final IMF."""
151 const = 3
152 imf2 = sift(self.x+const, imf_opts=self.imf_kwargs,
153 envelope_opts=self.envelope_kwargs,
154 extrema_opts=self.extrema_kwargs)
155 assert(np.allclose(self.imf1[:, :2], imf2[:, :2]))
156 assert(np.allclose(self.imf1[:, 2]+const, imf2[:, 2]))
158 def test_sift_of_imf(self):
159 """Ensure sift of an IMF is that same IMF."""
160 # Test sift of an IMF - Need to relax criteria as edges effects can be
161 # amplified by repeated sifting
162 imf2 = sift(self.imf1[:, 0], imf_opts=self.imf_kwargs,
163 envelope_opts=self.envelope_kwargs,
164 extrema_opts=self.extrema_kwargs)
165 assert(np.allclose(imf2[:, 0], self.imf1[:, 0], atol=0.02))
167 imf2 = sift(self.imf1[:, 1], imf_opts=self.imf_kwargs,
168 envelope_opts=self.envelope_kwargs,
169 extrema_opts=self.extrema_kwargs)
170 assert(np.allclose(imf2[:, 0], self.imf1[:, 1], atol=0.02))
172 def test_sift_of_reversed_signal(self):
173 """Ensure sift of time-reversed signal returns reversed-but-identical IMFs."""
174 # Test sift of reversed signal - very much increased criteria
175 extrema_kwargs = self.extrema_kwargs.copy()
176 extrema_kwargs['method'] = 'numpypad' # Rilling not working here for some reason
177 imf2 = sift(self.x[::-1], imf_opts=self.imf_kwargs,
178 envelope_opts=self.envelope_kwargs,
179 extrema_opts=extrema_kwargs)
180 assert(np.allclose(self.imf1, imf2[::-1, :], atol=0.1))
182 # Should be fine in the middle though...
183 assert(np.allclose(self.imf1[3000:7000, :],
184 imf2[::-1, :][3000:7000, :],
185 atol=0.02))
188class TestSiftBehaviour(unittest.TestCase, BaseTestSiftBehaviour):
189 """Test sift behaviour on simple signal."""
191 @classmethod
192 def setUpClass(cls):
193 """Set up data and IMFs for testing."""
194 # Create core signal
195 seconds = 5.1
196 sample_rate = 2000
197 f1 = 2
198 f2 = 18
199 time_vect = np.linspace(0, seconds, int(seconds * sample_rate))
201 x = abreu2010(f1, .2, 0, sample_rate, seconds)
202 cls.x = x + np.cos(2.3 * np.pi * f2 * time_vect) + np.linspace(-.5, 1, len(time_vect))
204 cls.imf_kwargs = {}
205 cls.envelope_kwargs = {'interp_method': 'splrep'}
206 cls.extrema_kwargs = {}
207 cls.imf1 = sift(cls.x, imf_opts=cls.imf_kwargs,
208 envelope_opts=cls.envelope_kwargs,
209 extrema_opts=cls.extrema_kwargs)
212class TestMaskSiftBehaviour(unittest.TestCase):
213 """Ensure that sifted IMFs meet certain criteria."""
215 def get_resid(self, x, x_bar):
216 """Get residual from signal and IMF."""
217 ss_orig = np.power(x, 2).sum()
218 ss_resid = np.power(x - x_bar, 2).sum()
219 return (ss_orig - ss_resid) / ss_orig
221 def check_diff(self, val, target, eta=1e-3):
222 """Assess difference between two signals."""
223 return np.abs(val - target) < eta
225 @classmethod
226 def setUpClass(cls):
227 """Set up data and IMFs for testing."""
228 # Create core signal
229 seconds = 5.1
230 cls.sample_rate = 2000
231 f1 = 2
232 f2 = 17 # Has exact division into 5.1 seconds
233 time_vect = np.linspace(0, seconds, int(seconds * cls.sample_rate))
235 x = abreu2010(f1, .2, 0, cls.sample_rate, seconds)
236 cls.x = x + np.cos(2 * np.pi * f2 * time_vect) + np.linspace(-.5, 1, len(time_vect))
238 cls.imf_kwargs = {}
239 cls.envelope_opts = {'interp_method': 'splrep'}
240 cls.imf = sift(cls.x, imf_opts=cls.imf_kwargs, envelope_opts=cls.envelope_opts)
242 # Test mask sifts
243 def test_get_next_imf_mask(self):
244 """Test that get_next_imf_mask works as expected."""
245 from ..sift import get_next_imf_mask
247 # sift with mask above signal should return zeros
248 # mask has to be waaay above signal in a noiseless time-series
249 next_imf, continue_flag = get_next_imf_mask(self.imf[:, 0, None], 0.25, 1)
250 mask_power = np.sum(np.power(next_imf, 2))
252 assert(mask_power < 1)
254 # sift with mask below signal should return original signal
255 next_imf, continue_flag = get_next_imf_mask(self.imf[:, 0, None], 0.0001, 1)
256 power = np.sum(np.power(self.imf[:, 0], 2))
257 mask_power = np.sum(np.power(next_imf, 2))
259 assert(power - mask_power < 1)
261 def test_get_mask_freqs(self):
262 """Test API of get_mask_freqs."""
263 from ..sift import get_mask_freqs
265 # Values outside of 0 <= x < 0.5 raise an error
266 self.assertRaises(ValueError, get_mask_freqs, self.x, 0.55)
267 self.assertRaises(ValueError, get_mask_freqs, self.x, 5)
268 self.assertRaises(ValueError, get_mask_freqs, self.x, -1)
270 # Values within 0 <= x < 0.5 return themselves
271 assert(get_mask_freqs(self.x, first_mask_mode=0.1) == 0.1)
272 assert(get_mask_freqs(self.x, first_mask_mode=0.45894) == 0.45894)
274 # ZC of sinusoid should return pretty much sinusoid freq
275 target = 2 / self.sample_rate
276 assert(np.allclose(get_mask_freqs(self.imf[:, 1], 'zc'), target, atol=1e-3))
278 target = 17 / self.sample_rate
279 assert(np.allclose(get_mask_freqs(self.imf[:, 0], 'zc'), target, atol=1e-3))
281 # IF of sinusoid should return pretty much sinusoid freq
282 target = 2 / self.sample_rate
283 assert(np.allclose(get_mask_freqs(self.imf[:, 1], 'if'), target, atol=1e-3))
285 target = 17 / self.sample_rate
286 assert(np.allclose(get_mask_freqs(self.imf[:, 0], 'if'), target, atol=1e-3))
289class TestSecondLayerSift(unittest.TestCase):
290 """Check second layer sifting is behaving itself."""
292 @classmethod
293 def setUpClass(cls):
294 """Housekeeping and preparation."""
295 cls.seconds = 10
296 cls.sample_rate = 200
297 cls.t = np.linspace(0, cls.seconds, cls.seconds*cls.sample_rate)
299 cls.f_slow = 5 # Frequency of slow oscillation
300 cls.f_slow_am = 0.5 # Frequency of slow amplitude modulation
301 cls.a_slow_am = 0.5 # Amplitude of slow amplitude modulation
303 cls.f_fast = 37 # Frequency of fast oscillation
304 cls.a_fast = 0.5 # Amplitude of fast oscillation
305 cls.f_fast_am = 5 # Frequency of fast amplitude modulation
306 cls.a_fast_am = 0.5 # Amplitude of fast amplitude modulation
308 # First we create a slow 4.25Hz oscillation with a 0.5Hz amplitude modulation
309 cls.slow_am = (cls.a_slow_am+(np.cos(2*np.pi*cls.f_slow_am*cls.t)/2))
310 cls.slow = cls.slow_am * np.sin(2*np.pi*cls.f_slow*cls.t)
312 # Second, we create a faster 37Hz oscillation that is amplitude modulated by the first.
313 cls.fast_am = (cls.a_fast_am+(np.cos(2*np.pi*cls.f_fast_am*cls.t)/2))
314 cls.fast = cls.fast_am * np.sin(2*np.pi*cls.f_fast*cls.t)
316 # We create our signal by summing the oscillation and adding some noise
317 cls.x = cls.slow+cls.fast
319 def test_second_layer_mask_sift_slow(self):
320 """Check that carrier and am frequencies of slower component can be found."""
321 from ..sift import mask_sift, mask_sift_second_layer
322 from ..spectra import frequency_transform
324 # Just checking slow component for now
325 imf, masks = mask_sift(self.slow, max_imfs=4, ret_mask_freq=True)
326 IP, IF, IA = frequency_transform(imf, self.sample_rate, 'hilbert')
328 # Only checking for ballpark accuracy here
329 assert(np.allclose(np.average(IF[:, 0], weights=IA[:, 0]), self.f_slow, atol=1))
331 # Sift the first level IMFs
332 self.imf2 = mask_sift_second_layer(IA, masks, sift_args={'max_imfs': 3})
333 self.IP2, self.IF2, self.IA2 = frequency_transform(self.imf2, self.sample_rate, 'hilbert')
335 assert(np.allclose(np.average(self.IF2[:, 0], weights=self.IA2[:, 0]), self.f_slow_am, atol=1))
337 def test_second_layer_mask_sift_fast(self):
338 """Check that carrier and am frequencies of faster component can be found."""
339 from ..sift import mask_sift, mask_sift_second_layer
340 from ..spectra import frequency_transform
342 # Just checking fast component for now
343 imf, masks = mask_sift(self.fast, max_imfs=4, ret_mask_freq=True, mask_amp_mode='ratio_imf')
344 IP, IF, IA = frequency_transform(imf, self.sample_rate, 'hilbert')
346 # Only checking for ballpark accuracy here
347 assert(np.allclose(np.average(IF[:, 0], weights=IA[:, 0]), self.f_fast, atol=1))
349 # Sift the first level IMFs
350 self.imf2 = mask_sift_second_layer(IA, masks, sift_args={'max_imfs': 3, 'mask_amp_mode': 'ratio_imf'})
351 self.IP2, self.IF2, self.IA2 = frequency_transform(self.imf2, self.sample_rate, 'hilbert')
353 assert(np.allclose(np.average(self.IF2[:, 0], weights=self.IA2[:, 0]), self.f_fast_am, atol=1))
356class TestSiftConfig(unittest.TestCase):
357 """Ensure that sift configs work properly."""
359 def test_config(self):
360 """Check SiftConfig creation and editing."""
361 # Get sift config
362 conf = get_config('sift')
363 # Check a couple of options
364 assert(conf['max_imfs'] is None)
365 assert(conf['extrema_opts/pad_width'] == 2)
366 assert(conf['extrema_opts/loc_pad_opts/mode'] == 'reflect')
368 # Get ensemble sift config
369 conf = get_config('ensemble_sift')
370 # Check a couple of options
371 assert(conf['max_imfs'] is None)
372 assert(conf['extrema_opts/pad_width'] == 2)
373 assert(conf['extrema_opts/loc_pad_opts/mode'] == 'reflect')
375 # Get mask sift config
376 conf = get_config('ensemble_sift')
377 # Check a couple of options
378 assert(conf['nensembles'] == 4)
379 assert(conf['max_imfs'] is None)
380 assert(conf['extrema_opts/pad_width'] == 2)
381 assert(conf['extrema_opts/loc_pad_opts/mode'] == 'reflect')
383 def test_sift_config_saveload_yaml(self):
384 """Check SiftConfig saving and loading."""
385 import tempfile
387 from ..sift import SiftConfig
389 # Get sift config
390 config = get_config('mask_sift')
392 config_file = tempfile.NamedTemporaryFile(prefix="ExampleSiftConfig_").name
394 # Save the config into yaml format
395 config.to_yaml_file(config_file)
397 # Load the config back into a SiftConfig object for use in a script
398 new_config = SiftConfig.from_yaml_file(config_file)
400 assert(new_config.sift_type == 'mask_sift')
403class TestIsIMF(unittest.TestCase):
404 """Ensure that we can validate IMFs."""
406 def setUp(self):
407 """Set up data for testing."""
408 # Create core signal
409 seconds = 5.1
410 sample_rate = 2000
411 f1 = 2
412 f2 = 18
413 time_vect = np.linspace(0, seconds, int(seconds * sample_rate))
415 x = abreu2010(f1, .2, 0, sample_rate, seconds)
416 self.x = x + np.cos(2.3 * np.pi * f2 * time_vect) + np.linspace(-.5, 1, len(time_vect))
418 self.y = np.sin(2 * np.pi * 5 * time_vect)
420 def test_is_imf_on_sinusoid(self):
421 """Make sure a pure sinusoid is an IMF."""
422 out = is_imf(self.y)
424 # Should be true on both criteria
425 assert(np.all(out))
427 def test_is_imf_on_abreu(self):
428 """Make sure the Abreu signal is an IMF."""
429 imf = sift(self.x)
430 out = is_imf(imf)
432 # Should be true on both criteria
433 assert(np.all(out[0, :]))
435 # Should be true on both criteria
436 assert(np.all(out[1, :]))
438 # Trend is not an IMF, should be false on both criteria
439 assert(np.all(out[2, :] == False)) # noqa: E712
442class TestSiftUtils(unittest.TestCase):
443 """Ensure envelopes and extrema are behaving."""
445 def setUp(self):
446 """Set up data for testing."""
447 sample_rate = 1280
448 seconds = 10
450 time_vect = np.linspace(0, seconds, seconds*sample_rate)
451 f = 5
453 self.X = np.zeros((len(time_vect), 5))
454 self.X[:, 0] = np.sin(2*np.pi*f*time_vect)
455 self.X[:, 1] = np.sin(2*np.pi*f*time_vect+np.pi/3)
456 self.X[:, 2] = np.sin(2*np.pi*f*time_vect+np.pi*1.63)
457 self.X[:, 3] = np.cos(2*np.pi*f*2*time_vect+np.pi/2)
458 self.X[:, 4] = np.sin(2*np.pi*f*5*time_vect)
460 def test_num_extrema(self):
461 """Check that various methods find correct number of extrema."""
462 from ..sift import get_padded_extrema
464 # Check extrema without padding
465 extr = [50, 50, 50, 100, 250]
466 for ii in range(5):
467 l, m = get_padded_extrema(self.X[:, ii], pad_width=0, mode='peaks', method='numpypad')
468 assert(len(l) == extr[ii])
469 assert(len(m) == extr[ii])
471 for ii in range(5):
472 l, m = get_padded_extrema(self.X[:, ii], pad_width=0, mode='troughs', method='numpypad')
473 assert(len(l) == extr[ii])
474 assert(len(m) == extr[ii])
476 # Check extrema without padding - both at once
477 extr = [50, 50, 50, 100, 250]
478 for ii in range(5):
479 l, m, l2, m2 = get_padded_extrema(self.X[:, ii], pad_width=0, mode='both', method='numpypad')
480 assert(len(l) == extr[ii])
481 assert(len(m) == extr[ii])
482 assert(len(l2) == extr[ii])
483 assert(len(m2) == extr[ii])
485 def test_numpypad_padding(self):
486 """Check that numpypad options are working."""
487 from ..sift import get_padded_extrema
489 pads = [0, 1, 5, 10]
490 extr = 50
491 for ii in range(len(pads)):
492 l, m = get_padded_extrema(self.X[:, 0], pad_width=pads[ii], mode='peaks', method='numpypad')
493 assert(len(l) == extr+2*pads[ii])
494 assert(len(m) == extr+2*pads[ii])
496 def test_rilling_padding(self):
497 """Check that numpypad options are working."""
498 from ..sift import get_padded_extrema
500 # Rilling method returns peaks and troughs in both mode
501 out = get_padded_extrema(self.X[:, 0], pad_width=3, mode='both', method='rilling')
502 assert(len(out) == 4)
504 # Rilling method returns only peaks when only peaks are requested
505 out = get_padded_extrema(self.X[:, 0], pad_width=3, mode='peaks', method='rilling')
506 assert(len(out) == 2)
508 pads = [0, 1, 5, 10]
509 extr = 50
510 for ii in range(len(pads)):
511 lp, mp, lt, mt = get_padded_extrema(self.X[:, 0], pad_width=pads[ii], mode='both', method='rilling')
512 assert(len(lp) == extr+2*pads[ii])
513 assert(len(mp) == extr+2*pads[ii])
515 def test_envelope_interpolation(self):
516 """Ensure envelope interpolation is sensible."""
517 from ..sift import interp_envelope
519 env = interp_envelope(self.X[:, 2])
520 # Envelope shapes match input
521 assert(env[0].shape[0] == self.X.shape[0])
522 assert(env[1].shape[0] == self.X.shape[0])
524 # Envelopes are sufficiently close to +/-1
525 assert(np.sum((1-env[0])**2) < 1e-3)
526 assert(np.sum((1+env[1])**2) < 1e-3)
528 def test_zero_crossing_count(self):
529 """Ensure we're finding right number of zero crossings."""
530 # Use different sinusoids for this one
531 from ..sift import zero_crossing_count
532 seconds = 5.1
533 sample_rate = 1000
534 time_vect = np.linspace(0, seconds, int(seconds*sample_rate))
536 x = np.sin(2*np.pi*2*time_vect)
537 assert(zero_crossing_count(x) == 21)
539 x = np.sin(2*np.pi*17*time_vect)
540 assert(zero_crossing_count(x) == 174)
543class TestSiftStopping(unittest.TestCase):
544 """Ensure that sift stopping methods behave."""
546 @classmethod
547 def setUpClass(cls):
548 """Set up data for testing."""
549 # Create core signal
550 #cls = cls()
551 seconds = 5.1
552 sample_rate = 2000
553 cls.time_vect = np.linspace(0, seconds, int(seconds * sample_rate))
555 f1 = 2
556 cls.x = abreu2010(f1, .2, 0, sample_rate, seconds)
557 cls.y = np.sin(2*np.pi*5*cls.time_vect)
558 cls.y2 = cls.y + np.sin(2*np.pi*21*cls.time_vect)
559 cls.z = np.random.randn(*cls.x.shape)
561 def test_max_imfs_stop(self):
562 from ..sift import check_sift_continue
564 assert(check_sift_continue(self.x, self.x, 3, max_imfs=5))