Coverage for emd/_sift_core.py: 78%
229 statements
« prev ^ index » next coverage.py v7.6.11, created at 2025-03-08 15:44 +0000
« prev ^ index » next coverage.py v7.6.11, created at 2025-03-08 15:44 +0000
1#!/usr/bin/python
3# vim: set expandtab ts=4 sw=4:
5"""
6Low level functionality for the sift algorithm.
8 get_padded_extrema
9 compute_parabolic_extrema
10 interp_envelope
11 zero_crossing_count
13"""
15import logging
17import numpy as np
19# Housekeeping for logging
20logger = logging.getLogger(__name__)
23def get_padded_extrema(X, pad_width=2, mode='peaks', parabolic_extrema=False,
24 loc_pad_opts=None, mag_pad_opts=None, method='rilling'):
25 """Identify and pad the extrema in a signal.
27 This function returns a set of extrema from a signal including padded
28 extrema at the edges of the signal. Padding is carried out using numpy.pad.
30 Parameters
31 ----------
32 X : ndarray
33 Input signal
34 pad_width : int >= 0
35 Number of additional extrema to add to the start and end
36 mode : {'peaks', 'troughs', 'abs_peaks', 'both'}
37 Switch between detecting peaks, troughs, peaks in the abs signal or
38 both peaks and troughs
39 method : {'rilling', 'numpypad'}
40 Which padding method to use
41 parabolic_extrema : bool
42 Flag indicating whether extrema positions should be refined by parabolic interpolation
43 loc_pad_opts : dict
44 Optional dictionary of options to be passed to np.pad when padding extrema locations
45 mag_pad_opts : dict
46 Optional dictionary of options to be passed to np.pad when padding extrema magnitudes
48 Returns
49 -------
50 locs : ndarray
51 location of extrema in samples
52 mags : ndarray
53 Magnitude of each extrema
55 See Also
56 --------
57 emd.sift.interp_envelope
58 emd.sift._pad_extrema_numpy
59 emd.sift._pad_extrema_rilling
61 Notes
62 -----
63 The 'abs_peaks' mode is not compatible with the 'rilling' method as rilling
64 must identify all peaks and troughs together.
66 """
67 if (mode == 'abs_peaks') and (method == 'rilling'):
68 msg = "get_padded_extrema mode 'abs_peaks' is incompatible with method 'rilling'"
69 raise ValueError(msg)
71 if X.ndim == 2:
72 X = X[:, 0]
74 if mode == 'both' or method == 'rilling':
75 max_locs, max_ext = _find_extrema(X, parabolic_extrema=parabolic_extrema)
76 min_locs, min_ext = _find_extrema(-X, parabolic_extrema=parabolic_extrema)
77 min_ext = -min_ext
78 logger.debug('found {0} minima and {1} maxima on mode {2}'.format(len(min_locs),
79 len(max_locs),
80 mode))
81 elif mode == 'peaks':
82 max_locs, max_ext = _find_extrema(X, parabolic_extrema=parabolic_extrema)
83 logger.debug('found {0} maxima on mode {1}'.format(len(max_locs),
84 mode))
85 elif mode == 'troughs':
86 max_locs, max_ext = _find_extrema(-X, parabolic_extrema=parabolic_extrema)
87 max_ext = -max_ext
88 logger.debug('found {0} minima on mode {1}'.format(len(max_locs),
89 mode))
90 elif mode == 'abs_peaks':
91 max_locs, max_ext = _find_extrema(np.abs(X), parabolic_extrema=parabolic_extrema)
92 logger.debug('found {0} extrema on mode {1}'.format(len(max_locs),
93 mode))
94 else:
95 raise ValueError('Mode {0} not recognised by get_padded_extrema'.format(mode))
97 # Return nothing if we don't have enough extrema
98 if (len(max_locs) == 0) or (max_locs.size <= 1):
99 logger.debug('Not enough extrema to pad.')
100 return None, None
101 elif (mode == 'both' or method == 'rilling') and len(min_locs) <= 1:
102 logger.debug('Not enough extrema to pad 2.')
103 return None, None
105 # Run the padding by requested method
106 if pad_width == 0:
107 if mode == 'both':
108 ret = (min_locs, min_ext, max_locs, max_ext)
109 elif mode == 'troughs' and method == 'rilling':
110 ret = (min_locs, min_ext)
111 else:
112 ret = (max_locs, max_ext)
113 elif method == 'numpypad':
114 ret = _pad_extrema_numpy(max_locs, max_ext,
115 X.shape[0], pad_width,
116 loc_pad_opts, mag_pad_opts)
117 if mode == 'both':
118 ret2 = _pad_extrema_numpy(min_locs, min_ext,
119 X.shape[0], pad_width,
120 loc_pad_opts, mag_pad_opts)
121 ret = (ret2[0], ret2[1], ret[0], ret[1])
122 elif method == 'rilling':
123 ret = _pad_extrema_rilling(min_locs, max_locs, X, pad_width)
124 # Inefficient to use rilling for just peaks or troughs, but handle it
125 # just in case.
126 if mode == 'peaks':
127 ret = ret[2:]
128 elif mode == 'troughs':
129 ret = ret[:2]
131 return ret
134def _pad_extrema_numpy(locs, mags, lenx, pad_width, loc_pad_opts, mag_pad_opts):
135 """Pad extrema using a direct call to np.pad.
137 Extra paddings are carried out if the padded values do not span the whole
138 range of the original time-series (defined by lenx)
140 Parameters
141 ----------
142 locs : ndarray
143 location of extrema in time
144 mags : ndarray
145 magnitude of each extrema
146 lenx : int
147 length of the time-series from which locs and mags were identified
148 pad_width : int
149 number of extra extrema to pad
150 loc_pad_opts : dict
151 dictionary of argumnents passed to np.pad to generate new extrema locations
152 mag_pad_opts : dict
153 dictionary of argumnents passed to np.pad to generate new extrema magnitudes
155 Returns
156 -------
157 ndarray
158 location of all extrema (including padded and original points) in time
159 ndarray
160 magnitude of each extrema (including padded and original points)
162 """
163 logger.verbose("Padding {0} extrema in signal X {1} using method '{2}'".format(pad_width,
164 lenx,
165 'numpypad'))
167 if not loc_pad_opts: # Empty dict evaluates to False
168 loc_pad_opts = {'mode': 'reflect', 'reflect_type': 'odd'}
169 else:
170 loc_pad_opts = loc_pad_opts.copy() # Don't work in place...
171 loc_pad_mode = loc_pad_opts.pop('mode')
173 if not mag_pad_opts: # Empty dict evaluates to False
174 mag_pad_opts = {'mode': 'median', 'stat_length': 1}
175 else:
176 mag_pad_opts = mag_pad_opts.copy() # Don't work in place...
177 mag_pad_mode = mag_pad_opts.pop('mode')
179 # Determine how much padding to use
180 if locs.size < pad_width:
181 pad_width = locs.size
183 # Return now if we're not padding
184 if (pad_width is None) or (pad_width == 0):
185 return locs, mags
187 # Pad peak locations
188 ret_locs = np.pad(locs, pad_width, loc_pad_mode, **loc_pad_opts)
190 # Pad peak magnitudes
191 ret_mag = np.pad(mags, pad_width, mag_pad_mode, **mag_pad_opts)
193 # Keep padding if the locations don't stretch to the edge
194 count = 0
195 while np.max(ret_locs) < lenx or np.min(ret_locs) >= 0:
196 logger.debug('Padding again - first ext {0}, last ext {1}'.format(np.min(ret_locs), np.max(ret_locs)))
197 logger.debug(ret_locs)
198 ret_locs = np.pad(ret_locs, pad_width, loc_pad_mode, **loc_pad_opts)
199 ret_mag = np.pad(ret_mag, pad_width, mag_pad_mode, **mag_pad_opts)
200 count += 1
201 #if count > 5:
202 # raise ValueError
204 return ret_locs, ret_mag
207def _pad_extrema_rilling(indmin, indmax, X, pad_width):
208 """Pad extrema using the method from Rilling.
210 This is based on original matlab code in boundary_conditions_emd.m
211 downloaded from: https://perso.ens-lyon.fr/patrick.flandrin/emd.html
213 Unlike the numpypad method - this approach pads both the maxima and minima
214 of the signal together.
216 Parameters
217 ----------
218 indmin : ndarray
219 location of minima in time
220 indmax : ndarray
221 location of maxima in time
222 X : ndarray
223 original time-series
224 pad_width : int
225 number of extra extrema to pad
227 Returns
228 -------
229 tmin
230 location of all minima (including padded and original points) in time
231 xmin
232 magnitude of each minima (including padded and original points)
233 tmax
234 location of all maxima (including padded and original points) in time
235 xmax
236 magnitude of each maxima (including padded and original points)
238 """
239 logger.debug("Padding {0} extrema in signal X {1} using method '{2}'".format(pad_width,
240 X.shape,
241 'rilling'))
243 t = np.arange(len(X))
245 # Pad START
246 if indmax[0] < indmin[0]:
247 # First maxima is before first minima
248 if X[0] > X[indmin[0]]:
249 # First value is larger than first minima - reflect about first MAXIMA
250 logger.debug('L: max earlier than min, first val larger than first min')
251 lmax = np.flipud(indmax[1:pad_width+1])
252 lmin = np.flipud(indmin[:pad_width])
253 lsym = indmax[0]
254 else:
255 # First value is smaller than first minima - reflect about first MINIMA
256 logger.debug('L: max earlier than min, first val smaller than first min')
257 lmax = np.flipud(indmax[:pad_width])
258 lmin = np.r_[np.flipud(indmin[:pad_width-1]), 0]
259 lsym = 0
261 else:
262 # First minima is before first maxima
263 if X[0] > X[indmax[0]]:
264 # First value is larger than first minima - reflect about first MINIMA
265 logger.debug('L: max later than min, first val larger than first max')
266 lmax = np.flipud(indmax[:pad_width])
267 lmin = np.flipud(indmin[1:pad_width+1])
268 lsym = indmin[0]
269 else:
270 # First value is smaller than first minima - reflect about first MAXIMA
271 logger.debug('L: max later than min, first val smaller than first max')
272 lmin = np.flipud(indmin[:pad_width])
273 lmax = np.r_[np.flipud(indmax[:pad_width-1]), 0]
274 lsym = 0
276 # Pad STOP
277 if indmax[-1] < indmin[-1]:
278 # Last maxima is before last minima
279 if X[-1] < X[indmax[-1]]:
280 # Last value is larger than last minima - reflect about first MAXIMA
281 logger.debug('R: max earlier than min, last val smaller than last max')
282 rmax = np.flipud(indmax[-pad_width:])
283 rmin = np.flipud(indmin[-pad_width-1:-1])
284 rsym = indmin[-1]
285 else:
286 # First value is smaller than first minima - reflect about first MINIMA
287 logger.debug('R: max earlier than min, last val larger than last max')
288 rmax = np.r_[X.shape[0] - 1, np.flipud(indmax[-(pad_width-2):])]
289 rmin = np.flipud(indmin[-(pad_width-1):])
290 rsym = X.shape[0] - 1
292 else:
293 if X[-1] > X[indmin[-1]]:
294 # Last value is larger than last minima - reflect about first MAXIMA
295 logger.debug('R: max later than min, last val larger than last min')
296 rmax = np.flipud(indmax[-pad_width-1:-1])
297 rmin = np.flipud(indmin[-pad_width:])
298 rsym = indmax[-1]
299 else:
300 # First value is smaller than first minima - reflect about first MINIMA
301 logger.debug('R: max later than min, last val smaller than last min')
302 rmax = np.flipud(indmax[-(pad_width-1):])
303 rmin = np.r_[X.shape[0] - 1, np.flipud(indmin[-(pad_width-2):])]
304 rsym = X.shape[0] - 1
306 # Extrema values are ordered from largest to smallest,
307 # lmin and lmax are the samples of the first {pad_width} extrema
308 # rmin and rmax are the samples of the final {pad_width} extrema
310 # Compute padded samples
311 tlmin = 2 * lsym - lmin
312 tlmax = 2 * lsym - lmax
313 trmin = 2 * rsym - rmin
314 trmax = 2 * rsym - rmax
316 # tlmin and tlmax are the samples of the left/first padded extrema, in ascending order
317 # trmin and trmax are the samples of the right/final padded extrema, in ascending order
319 # Flip again if needed - don't really get what this is doing, will trust the source...
320 if (tlmin[0] >= t[0]) or (tlmax[0] >= t[0]):
321 msg = 'Flipping start again - first min: {0}, first max: {1}, t[0]: {2}'
322 logger.debug(msg.format(tlmin[0], tlmax[0], t[0]))
323 if lsym == indmax[0]:
324 lmax = np.flipud(indmax[:pad_width])
325 else:
326 lmin = np.flipud(indmin[:pad_width])
327 lsym = 0
328 tlmin = 2*lsym-lmin
329 tlmax = 2*lsym-lmax
331 if tlmin[0] >= t[0]:
332 raise ValueError('Left min not padded enough. {0} {1}'.format(tlmin[0], t[0]))
333 if tlmax[0] >= t[0]:
334 raise ValueError('Left max not padded enough. {0} {1}'.format(trmax[0], t[0]))
336 if (trmin[-1] <= t[-1]) or (trmax[-1] <= t[-1]):
337 msg = 'Flipping end again - last min: {0}, last max: {1}, t[-1]: {2}'
338 logger.debug(msg.format(trmin[-1], trmax[-1], t[-1]))
339 if rsym == indmax[-1]:
340 rmax = np.flipud(indmax[-pad_width-1:-1])
341 else:
342 rmin = np.flipud(indmin[-pad_width-1:-1])
343 rsym = len(X)
344 trmin = 2*rsym-rmin
345 trmax = 2*rsym-rmax
347 if trmin[-1] <= t[-1]:
348 raise ValueError('Right min not padded enough. {0} {1}'.format(trmin[-1], t[-1]))
349 if trmax[-1] <= t[-1]:
350 raise ValueError('Right max not padded enough. {0} {1}'.format(trmax[-1], t[-1]))
352 # Stack and return padded values
353 ret_tmin = np.r_[tlmin, t[indmin], trmin]
354 ret_tmax = np.r_[tlmax, t[indmax], trmax]
356 ret_xmin = np.r_[X[lmin], X[indmin], X[rmin]]
357 ret_xmax = np.r_[X[lmax], X[indmax], X[rmax]]
359 # Quick check that interpolation won't explode
360 if np.all(np.diff(ret_tmin) > 0) is False:
361 logger.warning('Minima locations not strictly ascending - interpolation will break')
362 raise ValueError('Extrema locations not strictly ascending!!')
363 if np.all(np.diff(ret_tmax) > 0) is False:
364 logger.warning('Maxima locations not strictly ascending - interpolation will break')
365 raise ValueError('Extrema locations not strictly ascending!!')
367 return ret_tmin, ret_xmin, ret_tmax, ret_xmax
370def _find_extrema(X, peak_prom_thresh=None, parabolic_extrema=False):
371 """Identify extrema within a time-course.
373 This function detects extrema using a scipy.signals.argrelextrema. Extrema
374 locations can be refined by parabolic intpolation and optionally
375 thresholded by peak prominence.
377 Parameters
378 ----------
379 X : ndarray
380 Input signal
381 peak_prom_thresh : {None, float}
382 Only include peaks which have prominences above this threshold or None
383 for no threshold (default is no threshold)
384 parabolic_extrema : bool
385 Flag indicating whether peak estimation should be refined by parabolic
386 interpolation (default is False)
388 Returns
389 -------
390 locs : ndarray
391 Location of extrema in samples
392 extrema : ndarray
393 Value of each extrema
395 """
396 from scipy.signal import argrelextrema
397 ext_locs = argrelextrema(X, np.greater, order=1)[0]
399 if len(ext_locs) == 0:
400 return np.array([]), np.array([])
402 from scipy.signal._peak_finding import peak_prominences
403 if peak_prom_thresh is not None:
404 prom, _, _ = peak_prominences(X, ext_locs, wlen=3)
405 keeps = np.where(prom > peak_prom_thresh)[0]
406 ext_locs = ext_locs[keeps]
408 if parabolic_extrema:
409 y = np.c_[X[ext_locs-1], X[ext_locs], X[ext_locs+1]].T
410 ext_locs, max_pks = compute_parabolic_extrema(y, ext_locs)
411 return ext_locs, max_pks
412 else:
413 return ext_locs, X[ext_locs]
416def compute_parabolic_extrema(y, locs):
417 """Compute a parabolic refinement extrema locations.
419 Parabolic refinement is computed from in triplets of points based on the
420 method described in section 3.2.1 from Rato 2008 [1]_.
422 Parameters
423 ----------
424 y : array_like
425 A [3 x nextrema] array containing the points immediately around the
426 extrema in a time-series.
427 locs : array_like
428 A [nextrema] length vector containing x-axis positions of the extrema
430 Returns
431 -------
432 numpy array
433 The estimated y-axis values of the interpolated extrema
434 numpy array
435 The estimated x-axis values of the interpolated extrema
437 References
438 ----------
439 .. [1] Rato, R. T., Ortigueira, M. D., & Batista, A. G. (2008). On the HHT,
440 its problems, and some solutions. Mechanical Systems and Signal Processing,
441 22(6), 1374–1394. https://doi.org/10.1016/j.ymssp.2007.11.028
443 """
444 # Parabola equation parameters for computing y from parameters a, b and c
445 # w = np.array([[1, 1, 1], [4, 2, 1], [9, 3, 1]])
446 # ... and its inverse for computing a, b and c from y
447 w_inv = np.array([[.5, -1, .5], [-5/2, 4, -3/2], [3, -3, 1]])
448 abc = w_inv.dot(y)
450 # Find co-ordinates of extrema from parameters abc
451 tp = - abc[1, :] / (2*abc[0, :])
452 t = tp - 2 + locs
453 y_hat = tp*abc[1, :]/2 + abc[2, :]
455 return t, y_hat
458def interp_envelope(X, mode='both', interp_method='splrep', extrema_opts=None,
459 ret_extrema=False, trim=True):
460 """Interpolate the amplitude envelope of a signal.
462 Parameters
463 ----------
464 X : ndarray
465 Input signal
466 mode : {'upper','lower','combined'}
467 Flag to set which envelope should be computed (Default value = 'upper')
468 interp_method : {'splrep','pchip','mono_pchip'}
469 Flag to indicate which interpolation method should be used (Default value = 'splrep')
471 Returns
472 -------
473 ndarray
474 Interpolated amplitude envelope
476 """
477 if not extrema_opts: # Empty dict evaluates to False
478 extrema_opts = {'pad_width': 2,
479 'loc_pad_opts': None,
480 'mag_pad_opts': None}
481 else:
482 extrema_opts = extrema_opts.copy() # Don't work in place...
484 logger.debug("Interpolating '{0}' with method '{1}'".format(mode, interp_method))
486 if interp_method not in ['splrep', 'mono_pchip', 'pchip']:
487 raise ValueError("Invalid interp_method value")
489 if mode == 'upper':
490 extr = get_padded_extrema(X, mode='peaks', **extrema_opts)
491 elif mode == 'lower':
492 extr = get_padded_extrema(X, mode='troughs', **extrema_opts)
493 elif (mode == 'both') or (extrema_opts.get('method', '') == 'rilling'):
494 extr = get_padded_extrema(X, mode='both', **extrema_opts)
495 elif mode == 'combined':
496 extr = get_padded_extrema(X, mode='abs_peaks', **extrema_opts)
497 else:
498 raise ValueError('Mode not recognised. Use mode= \'upper\'|\'lower\'|\'combined\'')
500 if extr[0] is None:
501 if mode == 'both':
502 return None, None
503 else:
504 return None
506 if mode == 'both':
507 lower = _run_scipy_interp(extr[0], extr[1],
508 lenx=X.shape[0], trim=trim,
509 interp_method=interp_method)
510 upper = _run_scipy_interp(extr[2], extr[3],
511 lenx=X.shape[0], trim=trim,
512 interp_method=interp_method)
513 env = (upper, lower)
514 else:
515 env = _run_scipy_interp(extr[0], extr[1], lenx=X.shape[0], interp_method=interp_method, trim=trim)
517 if ret_extrema:
518 return env, extr
519 else:
520 return env
523def _run_scipy_interp(locs, pks, lenx, interp_method='splrep', trim=True):
524 from scipy import interpolate as interp
526 # Run interpolation on envelope
527 t = np.arange(locs[0], locs[-1])
528 if interp_method == 'splrep':
529 f = interp.splrep(locs, pks)
530 env = interp.splev(t, f)
531 elif interp_method == 'mono_pchip':
532 pchip = interp.PchipInterpolator(locs, pks)
533 env = pchip(t)
534 elif interp_method == 'pchip':
535 pchip = interp.pchip(locs, pks)
536 env = pchip(t)
538 if trim:
539 t_max = np.arange(locs[0], locs[-1])
540 tinds = np.logical_and((t_max >= 0), (t_max < lenx))
541 env = np.array(env[tinds])
543 if env.shape[0] != lenx:
544 msg = 'Envelope length does not match input data {0} {1}'
545 raise ValueError(msg.format(env.shape[0], lenx))
547 return env
550def zero_crossing_count(X):
551 """Count the number of zero-crossings within a time-course.
553 Zero-crossings are counted through differentiation of the sign of the
554 signal.
556 Parameters
557 ----------
558 X : ndarray
559 Input array
561 Returns
562 -------
563 int
564 Number of zero-crossings
566 """
567 if X.ndim == 2:
568 X = X[:, None]
570 return (np.diff(np.sign(X), axis=0) != 0).sum(axis=0)