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

1#!/usr/bin/python 

2 

3# vim: set expandtab ts=4 sw=4: 

4 

5""" 

6Low level functionality for the sift algorithm. 

7 

8 get_padded_extrema 

9 compute_parabolic_extrema 

10 interp_envelope 

11 zero_crossing_count 

12 

13""" 

14 

15import logging 

16 

17import numpy as np 

18 

19# Housekeeping for logging 

20logger = logging.getLogger(__name__) 

21 

22 

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. 

26 

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. 

29 

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 

47 

48 Returns 

49 ------- 

50 locs : ndarray 

51 location of extrema in samples 

52 mags : ndarray 

53 Magnitude of each extrema 

54 

55 See Also 

56 -------- 

57 emd.sift.interp_envelope 

58 emd.sift._pad_extrema_numpy 

59 emd.sift._pad_extrema_rilling 

60 

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. 

65 

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) 

70 

71 if X.ndim == 2: 

72 X = X[:, 0] 

73 

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)) 

96 

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 

104 

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] 

130 

131 return ret 

132 

133 

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. 

136 

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) 

139 

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 

154 

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) 

161 

162 """ 

163 logger.verbose("Padding {0} extrema in signal X {1} using method '{2}'".format(pad_width, 

164 lenx, 

165 'numpypad')) 

166 

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') 

172 

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') 

178 

179 # Determine how much padding to use 

180 if locs.size < pad_width: 

181 pad_width = locs.size 

182 

183 # Return now if we're not padding 

184 if (pad_width is None) or (pad_width == 0): 

185 return locs, mags 

186 

187 # Pad peak locations 

188 ret_locs = np.pad(locs, pad_width, loc_pad_mode, **loc_pad_opts) 

189 

190 # Pad peak magnitudes 

191 ret_mag = np.pad(mags, pad_width, mag_pad_mode, **mag_pad_opts) 

192 

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 

203 

204 return ret_locs, ret_mag 

205 

206 

207def _pad_extrema_rilling(indmin, indmax, X, pad_width): 

208 """Pad extrema using the method from Rilling. 

209 

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 

212 

213 Unlike the numpypad method - this approach pads both the maxima and minima 

214 of the signal together. 

215 

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 

226 

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) 

237 

238 """ 

239 logger.debug("Padding {0} extrema in signal X {1} using method '{2}'".format(pad_width, 

240 X.shape, 

241 'rilling')) 

242 

243 t = np.arange(len(X)) 

244 

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 

260 

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 

275 

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 

291 

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 

305 

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 

309 

310 # Compute padded samples 

311 tlmin = 2 * lsym - lmin 

312 tlmax = 2 * lsym - lmax 

313 trmin = 2 * rsym - rmin 

314 trmax = 2 * rsym - rmax 

315 

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 

318 

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 

330 

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])) 

335 

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 

346 

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])) 

351 

352 # Stack and return padded values 

353 ret_tmin = np.r_[tlmin, t[indmin], trmin] 

354 ret_tmax = np.r_[tlmax, t[indmax], trmax] 

355 

356 ret_xmin = np.r_[X[lmin], X[indmin], X[rmin]] 

357 ret_xmax = np.r_[X[lmax], X[indmax], X[rmax]] 

358 

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!!') 

366 

367 return ret_tmin, ret_xmin, ret_tmax, ret_xmax 

368 

369 

370def _find_extrema(X, peak_prom_thresh=None, parabolic_extrema=False): 

371 """Identify extrema within a time-course. 

372 

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. 

376 

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) 

387 

388 Returns 

389 ------- 

390 locs : ndarray 

391 Location of extrema in samples 

392 extrema : ndarray 

393 Value of each extrema 

394 

395 """ 

396 from scipy.signal import argrelextrema 

397 ext_locs = argrelextrema(X, np.greater, order=1)[0] 

398 

399 if len(ext_locs) == 0: 

400 return np.array([]), np.array([]) 

401 

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] 

407 

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] 

414 

415 

416def compute_parabolic_extrema(y, locs): 

417 """Compute a parabolic refinement extrema locations. 

418 

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]_. 

421 

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 

429 

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 

436 

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 

442 

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) 

449 

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, :] 

454 

455 return t, y_hat 

456 

457 

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. 

461 

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') 

470 

471 Returns 

472 ------- 

473 ndarray 

474 Interpolated amplitude envelope 

475 

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... 

483 

484 logger.debug("Interpolating '{0}' with method '{1}'".format(mode, interp_method)) 

485 

486 if interp_method not in ['splrep', 'mono_pchip', 'pchip']: 

487 raise ValueError("Invalid interp_method value") 

488 

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\'') 

499 

500 if extr[0] is None: 

501 if mode == 'both': 

502 return None, None 

503 else: 

504 return None 

505 

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) 

516 

517 if ret_extrema: 

518 return env, extr 

519 else: 

520 return env 

521 

522 

523def _run_scipy_interp(locs, pks, lenx, interp_method='splrep', trim=True): 

524 from scipy import interpolate as interp 

525 

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) 

537 

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]) 

542 

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)) 

546 

547 return env 

548 

549 

550def zero_crossing_count(X): 

551 """Count the number of zero-crossings within a time-course. 

552 

553 Zero-crossings are counted through differentiation of the sign of the 

554 signal. 

555 

556 Parameters 

557 ---------- 

558 X : ndarray 

559 Input array 

560 

561 Returns 

562 ------- 

563 int 

564 Number of zero-crossings 

565 

566 """ 

567 if X.ndim == 2: 

568 X = X[:, None] 

569 

570 return (np.diff(np.sign(X), axis=0) != 0).sum(axis=0)