Coverage for src/thunderfish/waveanalysis.py: 34%

860 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 20:05 +0000

1""" 

2Analysis of wave-type EODs. 

3 

4## Analysis 

5 

6- `extract_wave()`: retrieve average EOD waveform via Fourier transform. 

7- `condition_wave()`: subtract offset, flip, and shift wave-type EOD waveform. 

8- `analyze_wave_properties()`: characterize basic properties of a wave-type EOD. 

9- `analyze_wave_phases()`: characterize all phases of a wave-type EOD. 

10- `analyze_wave_spectrum()`: analyze the spectrum of a wave-type EOD. 

11 

12### Complete analysis 

13 

14Calls all the functions listed above: 

15 

16- `analyze_wave()`: full analysis the EOD waveform of a wave fish. 

17 

18## Quality assessment 

19 

20- `wave_quality()`: asses quality of EOD waveform of a wave fish. 

21 

22## Visualization 

23 

24- `plot_wave_eod()`: plot and annotate a wave-type EOD waveform. 

25- `plot_wave_spectrum()`: plot and annotate spectrum of wave-type EODs. 

26 

27## Storage 

28 

29- `save_wave_eodfs()`: save frequencies of wave EODs to file. 

30- `load_wave_eodfs()`: load frequencies of wave EODs from file. 

31- `save_wave_fish()`: save properties of wave EODs to file. 

32- `load_wave_fish()`: load properties of wave EODs from file. 

33- `save_wave_phases()`: save phase properties of wave-type EOD to file. 

34- `load_wave_phases()`: load phase properties of wave-type EOD from file. 

35- `save_wave_spectrum()`: save amplitude and phase spectrum of wave EOD to file. 

36- `load_wave_spectrum()`: load amplitude and phase spectrum of wave EOD from file. 

37 

38## Configuration 

39 

40- `add_extract_wave_config()`: add parameters for `extract_wave()` to configuration. 

41- `extract_wave_args()`: retrieve parameters for `extract_wave()` from configuration. 

42- `add_analyze_wave_config()`: add parameters for `analyze_wave()` to configuration. 

43- `analyze_wave_args()`: retrieve parameters for `analyze_wave()` from configuration. 

44- `wave_quality_args()`: retrieve parameters for `wave_quality()` from configuration. 

45 

46""" 

47 

48import numpy as np 

49 

50try: 

51 import matplotlib.pyplot as plt 

52 from matplotlib.ticker import MultipleLocator, FuncFormatter 

53 from matplotlib.artist import setp 

54except ImportError: 

55 pass 

56 

57from pathlib import Path 

58from scipy.stats import linregress 

59from numba import jit 

60from thunderlab.eventdetection import detect_peaks 

61from thunderlab.eventdetection import threshold_crossings, threshold_crossing_times, merge_events 

62from thunderlab.fourier import fourier_coeffs, normalize_fourier_coeffs 

63from thunderlab.fourier import fourier_synthesis 

64from thunderlab.powerspectrum import decibel 

65from thunderlab.tabledata import TableData 

66 

67from .files import open_from_zip, close_zip 

68from .harmonics import fundamental_freqs_and_power 

69 

70 

71def extract_wave(data, rate, freq, deltaf, 

72 win_fac='auto', min_segments=6, periods=5, 

73 frate=1e6, max_harmonics=20, 

74 min_corr=0.99, min_ampl_frac=0.5, 

75 verbose=0, plot_level=0): 

76 """Retrieve average EOD waveform via Fourier decomposition. 

77 

78 Fourier series are extracted for \\(n\\) frequencies 

79 from `freq` \\(\\pm\\) `deltaf` with `n = 1 + 4*win_fac` 

80 in multiple data segments of `1/(deltaf*win_fac)` duration. 

81 

82 For each data segment, the frequency for which the corresponding 

83 Fourier decomposition results in the largest peak-peak amplitude 

84 is taken. 

85 

86 Then a correlation matrix between the waveform estimates of each 

87 data segments is computed and the segments that are most similar 

88 to each other are selected. From these the ones whose standard 

89 deviation is smaller than `min_ampl_fac` of the maximum one are 

90 also discarded. The returned waveform and Fourier series is the 

91 average of the remaining data segments. 

92 

93 Parameters 

94 ---------- 

95 data: 1-D array of float 

96 The data to be analysed. 

97 rate: float 

98 Sampling rate of the data in Hertz. 

99 freq: float 

100 Estimated EOD frequency. 

101 deltaf: float 

102 Frequency resolution in Hertz. Ususally the resolution of the power spectrum 

103 from which `freq` was deduced. 

104 win_fac: int or 'auto' 

105 Integer factor by which the time window and 

106 thus the frequency resolution is reduced. 

107 Reducing the time window increases the chance for 

108 stable waveforms, but might miss the right frequency. 

109 If 'auto' then it is set to `max(1, int(np.round(freq/400)))`, 

110 i.e. for higher EOD frequencies thisfactor gets larger. 

111 min_segments: int 

112 Minimum required data segments that fit into the data with 

113 an overlap of three quarters. If necessary, the segment sizes 

114 and thus also the frequency resolution is reduced by 

115 powers of two. 

116 periods: float 

117 The duration of the returned waveform estimate is the 

118 EOD period (`1/freq`) times `periods`. 

119 frate: float 

120 Sampling rate used for the returned waveform estimates. 

121 max_harmonics: int 

122 Highest harmonics used for the Fourier decomposition. 

123 min_corr: float 

124 Minimum required correlation between two waveform estimates. 

125 min_ampl_fac: float 

126 Minimum required standard deviation of waveform estimate 

127 relative to the largest one. 

128 verbose: int 

129 Verbosity level. 

130 plot_level: int 

131 When larger than zero, plot some debug information. 

132  

133 Returns 

134 ------- 

135 mean_coeffs: 1-D array of complex 

136 The averaged Fourier coefficients of the extracted EOD waveform. 

137 They correspond to the returnd `mean_eod`. 

138 mean_eod: 2-D array of float 

139 Average of the EOD snippets. First column is time in seconds, 

140 second column the mean eod, third column the standard error. 

141 eod_freq: float 

142 Refined EOD frequency. 

143 times: 1-D array 

144 Start times of data segments in which Fourier coefficients 

145 have been extracted. 

146 n_eods: int 

147 Number of EODs that went into the estimate of the mean waveform. 

148 skip_reason: str 

149 An empty string if the waveform is good, otherwise a string 

150 indicating the failure. 

151 

152 """ 

153 

154 def fourier_freq_range(data, rate, frange, nh, n): 

155 """ Extract Fourier coefficients for some frequencies and 

156 return waveform with largest p-p amplitude. 

157 """ 

158 wave = np.zeros(1) 

159 freq = 0.0 

160 twave = np.arange(n)/rate 

161 for f in frange: 

162 w = np.zeros(len(twave)) 

163 t = np.arange(len(data))/rate 

164 for k in range(nh): 

165 Xk = np.trapz(data*np.exp(-2j*np.pi*k*f*t), t)*2/t[-1] 

166 w += np.real(Xk*np.exp(2j*np.pi*k*f*twave)) 

167 if np.max(w) - np.min(w) > np.max(wave) - np.min(wave): 

168 wave = w 

169 freq = f 

170 return wave, freq 

171 

172 # reduce frequency resolution and time window for high frequency fish: 

173 if win_fac == 'auto': 

174 win_fac = max(1, int(np.round(freq/400))) 

175 deltaf *= win_fac 

176 t_segment = min(len(data)/rate, 1/deltaf) 

177 nfreqs = 1 + 2*2*win_fac # twice the frequency resolution is necessary and sufficient! 

178 step = max(16, int(t_segment*rate)) 

179 # extract Fourier series from data segments: 

180 n = int(periods/freq*rate) 

181 freqs = [] 

182 indices = np.arange(0, max(1, len(data) - step + 1), step//4) 

183 while len(indices) < min_segments and step > 8: 

184 t_segment /= 2 

185 step = max(8, int(t_segment*rate)) 

186 indices = np.arange(0, max(1, len(data) - step + 1), step//4) 

187 times = indices/rate 

188 frange = np.linspace(freq - deltaf, freq + deltaf, nfreqs) 

189 for i in indices: 

190 w, f = fourier_freq_range(data[i:i + step], rate, frange, 6, n) 

191 freqs.append(f) 

192 freqs = np.array(freqs) 

193 mean_coeffs = np.zeros(0, dtype=complex) 

194 mean_eod = np.zeros((0, 3)) 

195 if len(freqs) == 0: 

196 # TODO: Why??? How can indices be empty? 

197 return mean_coeffs, mean_eod, freq, np.array([]), 0, f'no frequencies detected ({len(indicies)} indices, freqs={freqs})' 

198 # refined Fourier series and waveforms: 

199 n = int(periods/np.mean(freqs)*frate) 

200 coeffs = np.zeros((len(indices), max_harmonics + 1), dtype=complex) 

201 waves = np.zeros((len(indices), n)) 

202 for k in range(len(indices)): 

203 i = indices[k] 

204 c = fourier_coeffs(data[i:i + step], rate, freqs[k], max_harmonics) 

205 c = normalize_fourier_coeffs(c) 

206 w = fourier_synthesis(freqs[k], c, frate, n) 

207 coeffs[k] = c 

208 waves[k] = w 

209 if plot_level > 0: 

210 fig, axs = plt.subplots(2, 3, width_ratios=[8, 8, 1], 

211 layout='constrained') 

212 axs[1, 2].set_visible(False) 

213 axs[0, 0].set_title(f'EODf={np.mean(freqs):.2f}Hz') 

214 t = np.arange(waves.shape[1])*1000/frate 

215 for w in waves: 

216 axs[0, 0].plot(t, w) 

217 axs[0, 0].set_xlabel('time [ms]') 

218 # only snippets that are most similar: 

219 if len(waves) <= 1: 

220 eodf = np.mean(freqs) if len(freqs) > 0 else freq 

221 print(f'extract {freq:7.2f}Hz wave fish: {len(waves)} segments, EODf={eodf:.2f}Hz') 

222 # TODO: what to do with single segment? 

223 else: 

224 corr = np.corrcoef(waves) 

225 np.fill_diagonal(corr, 0.0) 

226 corr_vals = np.sort(corr[corr > min_corr]) 

227 if len(corr_vals) == 0: 

228 if plot_level > 0: 

229 plt.show() 

230 return mean_coeffs, mean_eod, freq, times, 0, f'waveforms not stable (max_corr={np.max(corr):.4f} SMALLER than {min_corr:.4f})' 

231 # higher correlation threshold: 

232 min_c = corr_vals[len(corr_vals)//2] 

233 # number of high correlations for each segment: 

234 num_c = np.sum(corr > min_c, axis=1) 

235 num_cmax = np.max(num_c) 

236 num_cthresh = max(1, num_cmax//2) 

237 # collect pairs with high correlations, 

238 # to stay within a single cluster in the correlation matrix. 

239 mask = [] 

240 sub_corr = np.array(corr) 

241 # pair with maximum correlation: 

242 while not np.all(np.isnan(sub_corr)): 

243 i_max_corr = np.nanargmax(sub_corr) 

244 max_corr_a = i_max_corr//len(corr) 

245 max_corr_b = i_max_corr%len(corr) 

246 if corr[max_corr_a, max_corr_b] < max(min_c, 1 - 0.25*(1 - min_corr)): 

247 break 

248 sub_corr[:, max_corr_a] = np.nan 

249 sub_corr[max_corr_a, :] = np.nan 

250 sub_corr[:, max_corr_b] = np.nan 

251 sub_corr[max_corr_b, :] = np.nan 

252 if num_c[max_corr_a] > num_cthresh: 

253 mask.append(max_corr_a) 

254 if num_c[max_corr_b] > num_cthresh: 

255 mask.append(max_corr_b) 

256 for k in range(len(corr)): 

257 if k >= len(mask): 

258 break 

259 corr_col = corr[:, mask[k]] 

260 idx = np.argsort(corr_col)[::-1] 

261 for i in idx: 

262 if corr_col[i] > min_c and num_c[i] > num_cthresh and \ 

263 i not in mask: 

264 mask.append(i) 

265 break 

266 # if only one was selected, add pair: 

267 if len(mask) == 1: 

268 mask.append(np.argmax(corr[:, mask[0]])) 

269 if len(mask) > 0: 

270 break 

271 

272 waves = waves[mask] 

273 coeffs = coeffs[mask] 

274 freqs = freqs[mask] 

275 times = times[mask] 

276 if plot_level > 0: 

277 axs[0, 1].set_title('Correlations') 

278 m = axs[0, 1].pcolormesh(corr, vmin=min_corr, vmax=1) 

279 axs[0, 1].plot(i_max_corr%len(corr) + 0.5, 

280 i_max_corr//len(corr) + 0.5, 'ok') 

281 axs[0, 1].plot(i_max_corr//len(corr) + 0.5, 

282 i_max_corr%len(corr) + 0.5, 'ok') 

283 axs[0, 1].set_aspect('equal') 

284 axs[0, 1].set_xlabel('segment') 

285 axs[0, 1].set_ylabel('segment') 

286 plt.colorbar(m, axs[0, 2], label='correlation coefficient') 

287 selected = np.zeros(len(corr)) 

288 selected[mask] = 1 

289 axs[1, 1].axhline(num_cthresh + 0.5, color='k', 

290 label='num_cthresh') 

291 axs[1, 1].plot(np.arange(len(num_c)), num_c, '-o', 

292 label='num_c') 

293 axs[1, 1].plot(np.arange(len(num_c))[mask], num_c[mask], 'o', 

294 label='selected') 

295 axs[1, 1].plot(np.arange(len(selected)), selected, '-o', 

296 label='mask') 

297 axs[1, 1].set_ylim(bottom=0) 

298 axs[1, 1].set_xlabel('segment') 

299 axs[1, 1].set_ylabel('num_c') 

300 if len(num_c) < 10: 

301 axs[1, 1].xaxis.set_major_locator(MultipleLocator(1)) 

302 if np.max(num_c) < 10: 

303 axs[1, 1].yaxis.set_major_locator(MultipleLocator(1)) 

304 axs[1, 1].legend() 

305 if verbose > 0: 

306 eodf = np.mean(freqs) if len(freqs) > 0 else np.nan 

307 kwargs = dict(legacy='1.25') 

308 while True: 

309 try: 

310 with np.printoptions(formatter={'float': lambda x: f'{x:.2f}'}, 

311 linewidth=10000, **kwargs): 

312 print(f'extract {freq:7.2f}Hz wave fish: min_corr={min_c:.4f}, max_corr={corr_vals[-1]:.4f}, num_cmax={num_cmax}, segments={len(corr)}, num_selected={len(mask)}, selected={mask}, EODfs={freqs}, EODf={eodf:.2f}Hz') 

313 break 

314 except KeyError: 

315 # old numpy versions do not have the legacy='1.25' option: 

316 kwargs = dict() 

317 if len(waves) == 0: 

318 if plot_level > 0: 

319 plt.show() 

320 return mean_coeffs, mean_eod, freq, times, 0, f'waveforms not stable (min_corr={min_c:.4f}, max_corr={corr_vals[-1]:.4f}, num_cmax={num_cmax})' 

321 # only the largest snippets: 

322 ampls = np.std(waves, axis=1) 

323 mask = ampls >= min_ampl_frac*np.max(ampls) 

324 if verbose > 0 and np.sum(mask) < len(ampls): 

325 print(f' removed {len(ampls) - np.sum(mask)} small amplitude segments') 

326 waves = waves[mask] 

327 coeffs = coeffs[mask] 

328 freqs = freqs[mask] 

329 times = times[mask] 

330 if len(waves) == 0: 

331 if plot_level > 0: 

332 plt.show() 

333 return mean_coeffs, mean_eod, freq, times, 0, 'no large waveform' 

334 if plot_level > 0: 

335 axs[1, 0].set_title(f'selected EODs: EODf={np.mean(freqs):.2f}Hz') 

336 t = np.arange(waves.shape[1])*1000/frate 

337 for w in waves: 

338 axs[1, 0].plot(t, w) 

339 axs[1, 0].set_xlabel('time [ms]') 

340 plt.show() 

341 

342 eod_freq = np.mean(freqs) 

343 mean_coeffs = np.mean(coeffs, 0) 

344 mean_coeffs[0] = 0 # no offset 

345 mean_eod = np.zeros((n, 3)) 

346 mean_eod[:, 0] = np.arange(len(mean_eod))/frate 

347 mean_eod[:, 1] = np.mean(waves, axis=0) 

348 mean_eod[:, 1] = fourier_synthesis(eod_freq, mean_coeffs, frate, n) 

349 mean_eod[:, 2] = np.std(waves, axis=0) 

350 t_covered = 0 

351 t_end = -1 

352 for t in times: 

353 if t_end < t: 

354 t_end = t 

355 t_covered += t + t_segment - t_end 

356 t_end = t + t_segment 

357 n_eods = int(t_covered*eod_freq) 

358 return mean_coeffs, mean_eod, eod_freq, times, n_eods, '' 

359 

360 

361def condition_wave(eod, ratetime, freq, coeffs=None, flip_wave='none'): 

362 """Subtract offset, flip, and shift wave-type EOD waveform. 

363  

364 Parameters 

365 ---------- 

366 eod: 1-D or 2-D array 

367 The eod waveform to be analyzed. If an 1-D array, then this 

368 is the waveform and you need to also pass a time array or 

369 sampling rate in `ratetime`. If a 2-D array, then first 

370 column is time in seconds, second column the EOD 

371 waveform, and the last column - if present - is the waveform 

372 obtained from Fourier decomposition. All other columns are optional 

373 and are not used. 

374 ratetime: None or float or array of float 

375 If a 1-D array is passed on to `eod` then either the sampling 

376 rate in Hertz or the time array corresponding to `eod`. 

377 freq: float 

378 The frequency of the EOD. 

379 coeffs: None or 1-D array of complex 

380 The Fourier coefficients of an EOD waveform. 

381 flip_wave: 'auto', 'none', 'flip' 

382 - 'auto' flip waveform such that the larger extremum is positive. 

383 - 'flip' flip waveform. 

384 - 'none' do not flip waveform. 

385 

386 Returns 

387 ------- 

388 eod: 1-D or 2-D array 

389 Same shape as the input `eod`. 

390 If no `coeffs` are provided, the mean over integer multiples of 

391 the period of the waveform is subtracted. 

392 If waveform is flipped, it is flipped in the second and last column. 

393 If a last column is present, the waveform in the last column 

394 is recomputed from the flipped Fourier coefficients. 

395 In the first time column, time zero is set to the maximum 

396 of the first Fourier component. 

397 time: 1-D array 

398 If `eod` is an 1-D array, then a time array is returned, 

399 with zero set to the maximum of the first Fourier component. 

400 coeffs: None or 1-D array of complex 

401 The Fourier coefficients of an EOD waveform with \\(\\pi\\) 

402 added to the phases if waveform has been flipped. 

403 The Fourier coefficients are then normalized such 

404 that the phase of the fundamental is zero. 

405 This is only returned, if `coeffs` is not None. 

406 flipped: bool 

407 True if waveform was flipped. 

408 """ 

409 if eod.ndim == 2: 

410 time = eod[:, 0] 

411 eodw = eod[:, -1] if eod.shape[1] > 2 else eod[:, 1] 

412 else: 

413 eodw = eod 

414 if isinstance(ratetime, (list, tuple, np.ndarray)): 

415 time = ratetime 

416 else: 

417 time = np.arange(len(eod))/ratetime 

418 

419 # subtract mean: 

420 if coeffs is None: 

421 period = 1/freq 

422 p_time = np.floor((time[-1] - time[0])/period)*periods 

423 mask = (time >= time[-1] - p_time) 

424 if eod.ndim == 2: 

425 eod[:, 1] -= np.mean(eod[mask, 1]) 

426 else: 

427 eod -= np.mean(eod[mask]) 

428 

429 # flip: 

430 flipped = False 

431 if flip_wave.lower() in ['flip', 'true', 'yes']: 

432 flipped = True 

433 elif flip_wave.lower() == 'auto': 

434 if -np.min(eodw) > np.max(eodw): 

435 flipped = True 

436 if flipped: 

437 if eod.ndim == 2: 

438 eod[:, 1] *= -1 

439 if eod.shape[1] > 2: 

440 eod[:, -1] *= -1 

441 else: 

442 eod *= -1 

443 if coeffs is not None: 

444 coeffs *= np.exp(1j*np.pi) 

445 

446 # shift time: 

447 if coeffs is None: 

448 c = fourier_coeffs(eodw, time, freq, 1) 

449 else: 

450 c = coeffs 

451 period = 1/freq 

452 phi1 = np.angle(c[1]) 

453 t_zero = -phi1/(2*np.pi*freq) 

454 min_t = period 

455 if time[-1] - time[0] < 3*period: 

456 min_t = 0.5*period 

457 while t_zero < min_t: 

458 t_zero += period 

459 time -= time[0] + t_zero 

460 if coeffs is not None: 

461 for k in range(1, len(coeffs)): 

462 coeffs[k] *= np.exp(-1j*k*phi1) 

463 if eod.ndim == 2: 

464 eod[:, 0] = time 

465 

466 # return: 

467 r = [eod] 

468 if eod.ndim == 1: 

469 r.append(time) 

470 if coeffs is not None: 

471 r.append(coeffs) 

472 r.append(flipped) 

473 return r 

474 

475 

476def analyze_wave_properties(eod, ratetime, freq): 

477 """Characterize basic properties of a wave-type EOD. 

478  

479 Parameters 

480 ---------- 

481 eod: 1-D or 2-D array 

482 The eod waveform to be analyzed. If an 1-D array, then this 

483 is the waveform and you need to also pass a time array or 

484 sampling rate in `ratetime`. If a 2-D array, then first 

485 column is time in seconds, second column the EOD 

486 waveform, and the last column - if present - is the waveform 

487 obtained from Fourier decomposition. Allother columns are optional 

488 and are not used. 

489 ratetime: None or float or array of float 

490 If a 1-D array is passed on to `eod` then either the sampling 

491 rate in Hertz or the time array corresponding to `eod`. 

492 freq: float 

493 The frequency of the EOD. 

494 

495 Returns 

496 ------- 

497 pp_ampl: float 

498 Peak-to-peak amplitude. 

499 rel_max_ampl: float 

500 Amplitude of minimum or maximum, whichever is larger, relative to p-p amplitude. 

501 distance: float 

502 Temporal distance between largest negative trough and positive peak. 

503 min_distance: float 

504 Temporal distance between largest negative trough and positive peak 

505 normalized to first half of the EOD cycle. 

506 That is, if `distance` is larger than half an EOD cycle, then the 

507 duration of an EOD cycle minus `distance`. 

508 rms_sem: None or float 

509 Root-mean squared standard deviation of the extracted 

510 EOD waveform relative to the p-p amplitude. 

511 rms_error: float 

512 Root-mean-square difference between Fourier decomposition and 

513 EOD waveform relative to the p-p amplitude. 

514 """ 

515 if eod.ndim == 2: 

516 time = eod[:, 0] 

517 eodw = eod[:, -1] if eod.shape[1] > 2 else eod[:, 1] 

518 else: 

519 eodw = eod 

520 if isinstance(ratetime, (list, tuple, np.ndarray)): 

521 time = ratetime 

522 else: 

523 time = np.arange(len(eod))/ratetime 

524 

525 # cut out one period: 

526 period = 1/freq 

527 mask = (time >= 0) & (time <= period) 

528 eodp = eodw[mask] 

529 timep = time[mask] 

530 

531 # amplitudes: 

532 pos_idx = np.argmax(eodp) 

533 pos_ampl = abs(eodp[pos_idx]) 

534 neg_idx = np.argmin(eodp) 

535 neg_ampl = abs(eodp[neg_idx]) 

536 pp_ampl = pos_ampl + neg_ampl 

537 max_ampl = max(pos_ampl, neg_ampl) 

538 rel_max_ampl = max_ampl/pp_ampl 

539 

540 # timing: 

541 distance = abs(timep[neg_idx] - timep[pos_idx]) 

542 min_distance = distance 

543 if distance > period/2: 

544 min_distance = period - distance 

545 

546 # variance and fit error: 

547 rms_sem = None 

548 if eod.ndim == 2 and eod.shape[1] > 2: 

549 rms_sem = np.sqrt(np.mean(eod[mask, 2]**2.0))/pp_ampl 

550 rms_error = np.sqrt(np.mean((eod[mask, 1] - eod[mask, -1])**2.0))/pp_ampl 

551 

552 return pp_ampl, rel_max_ampl, distance, min_distance, rms_sem, rms_error 

553 

554 

555def analyze_wave_phases(eod, ratetime, freq, thresh_frac=0.05): 

556 """Characterize all phases of a wave-type EOD. 

557  

558 Parameters 

559 ---------- 

560 eod: 1-D or 2-D array 

561 The eod waveform to be analyzed. If an 1-D array, then this 

562 is the waveform and you need to also pass a time array or 

563 sampling rate in `ratetime`. If a 2-D array, then first 

564 column is time in seconds, second column the EOD 

565 waveform, and the last column - if present - is the waveform 

566 obtained from Fourier decomposition. Allother columns are optional 

567 and are not used. 

568 ratetime: None or float or array of float 

569 If a 1-D array is passed on to `eod` then either the sampling 

570 rate in Hertz or the time array corresponding to `eod`. 

571 freq: float 

572 The frequency of the EOD. 

573 thresh_frac: float 

574 Threshold for detecting peaks and troughs as a fraction of 

575 the p-p amplitude. 

576  

577 Returns 

578 ------- 

579 phases: dict 

580 Dictionary with 

581  

582 - "indices": indices of each phase 

583 (1 is P1, i.e. the largest positive peak) 

584 - "times": times of each phase within an EOD cycle 

585 - "amplitudes": amplitudes of each phase 

586 - "relamplitudes": amplitudes normalized to amplitude of P1 phase 

587 - "widths": widths of each phase computed from zeros 

588 - "zeros": time point where amplitude between this and the next phase is half their difference. 

589 

590 """ 

591 if eod.ndim == 2: 

592 time = eod[:, 0] 

593 eod = eod[:, -1] if eod.shape[1] > 2 else eod[:, 1] 

594 elif isinstance(ratetime, (list, tuple, np.ndarray)): 

595 time = ratetime 

596 else: 

597 time = np.arange(len(eod))/ratetime 

598 dt = np.mean(np.diff(time)) 

599 period = 1/freq 

600 

601 # threshold: 

602 mask = (time >= 0) & (time <= period) 

603 pp_ampl = np.max(eod[mask]) - np.min(eod[mask]) 

604 thresh = thresh_frac*pp_ampl 

605 

606 # find peaks and troughs: 

607 peak_idx, trough_idx = detect_peaks(eod, thresh) 

608 pt_idx = np.sort(np.concatenate((peak_idx, trough_idx))) 

609 

610 # maximum peak in first period after zero: 

611 pt_pidx = pt_idx[(time[pt_idx] >= -0.25*period) & (time[pt_idx] < 0.75*period)] 

612 pi = np.argmax(eod[pt_pidx]) 

613 max_inx = np.nonzero(pt_idx == pt_pidx[pi])[0][0] 

614 max_time = time[pt_idx[max_inx]] 

615 

616 # analyse phases: 

617 times = [] 

618 amplitudes = [] 

619 widths = [] 

620 zero_times = [] 

621 p_time = 0 

622 sign_fac = 1 

623 i = 0 

624 for k in range(max_inx - 1, len(pt_idx)): 

625 idx = pt_idx[k] 

626 if time[idx] - max_time >= period - dt: 

627 break 

628 n_idx = pt_idx[k + 1] 

629 th = 0.5*(eod[idx] + eod[n_idx]) 

630 snippet = eod[idx:n_idx] - th 

631 stimes = time[idx:n_idx] 

632 zidx = np.nonzero(snippet[:-1]*snippet[1:] < 0)[0] 

633 if len(zidx) == 0: 

634 zero_times,append(np.nan) 

635 else: 

636 zidx = zidx[len(zidx)//2] # reduce to single zero crossing 

637 snippet = snippet[zidx:zidx + 2] 

638 stimes = stimes[zidx:zidx + 2] 

639 if sign_fac > 0: 

640 z_time = np.interp(0, snippet[::-1], stimes[::-1]) 

641 else: 

642 z_time = np.interp(0, snippet, stimes) 

643 if i > 0: 

644 times.append(time[idx]) 

645 amplitudes.append(eod[idx]) 

646 widths.append(z_time - p_time) 

647 zero_times.append(z_time) 

648 p_time = z_time 

649 i += 1 

650 sign_fac *= -1 

651 amplitudes = np.array(amplitudes) 

652 

653 # store phase properties: 

654 phases = dict(indices=np.arange(len(times)) + 1, 

655 times=np.array(times), 

656 amplitudes=amplitudes, 

657 relamplitudes=amplitudes/amplitudes[0], 

658 widths=np.array(widths), 

659 zeros=np.array(zero_times)) 

660 return phases 

661 

662 

663def analyze_wave_spectrum(freq, coeffs, n_phase_harmonics=8): 

664 """Analyze the spectrum of a wave-type EOD. 

665  

666 Parameters 

667 ---------- 

668 freq: float or 2-D array 

669 The frequency of the EOD or the list of harmonics (rows) with 

670 frequency and peak height (columns) as returned from 

671 `harmonics.harmonic_groups()`. 

672 coeffs: None or 1-D array of complex 

673 The Fourier coefficients of an EOD waveform, first one is the offset. 

674 n_phase_harmonics: int 

675 Number of harmonics over which to compute the slope of the phases. 

676  

677 Returns 

678 ------- 

679 spec: 2-D array of float 

680 Amplitudes and phases for each harmonic. 

681 Rows are the harmonics, first row is the fundamental frequency 

682 with multiplier 1, relative amplitude of one, relative power of 0dB, 

683 and phase shift of zero. 

684 First six columns are from the spectrum of the extracted 

685 waveform, optional column 6 is from the spectrum of the recording: 

686 

687 - column 0: multiplier of the harmonics (fundamental is one) 

688 - column 1: frequency in Hertz 

689 - column 2: amplitude 

690 - column 3: amplitude relative to the one of the fundamental 

691 - column 4: power of harmonics relative to fundamental in decibel 

692 - column 5: phase shift relative to the fundamental 

693 - column 6: if `freq` is a list of harmonics, the powers of 

694 the harmonics from the power spectrum of the raw data. 

695 

696 power: float 

697 Total power, i.e. sum of squared Fourier amplitudes. 

698 data_power: float or None 

699 Total power (sum of data powers) in the data, if available. 

700 Only sum over as many harmonics as there are Fourier coefficients. 

701 thd: float 

702 Total harmonic distortion of the amplitudes \\(a_i\\) of the harmonics 

703 is power in the higher harmonics relative to the fundamental: 

704 \\[ \\text{thd} = \\sqrt{\\sum_{i=2}^n \\left(\\frac{a_i}{a_1}\\right)^2} \\] 

705 max_harmonics: int 

706 Harmonics with the largest power. Fundamental is one. 

707 db_diff: float 

708 Standard deviation of the differences of the decibel powers. 

709 A measure of smoothness of the spectrum. 

710 phase_slope: float 

711 Slope of a linear regression between phases and multipliers of 

712 the first `n_phase_harmonics` harmonics. 

713 """ 

714 if hasattr(freq, 'shape'): 

715 freq1 = freq[0][0] 

716 n = len(coeffs) - 1 

717 n += np.sum(freq[:, 0] > (len(coeffs) - 0.5)*freq1) 

718 spec = np.zeros((n, 7)) 

719 spec[:, :] = np.nan 

720 k = 0 

721 for i in range(len(coeffs) - 1): 

722 while k < len(freq) and freq[k, 0] < (i + 0.5)*freq1: 

723 k += 1 

724 if k >= len(freq): 

725 break 

726 if freq[k, 0] < (i + 1.5)*freq1: 

727 spec[i, 6] = freq[k, 1] 

728 k += 1 

729 for i in range(len(coeffs) - 1, n): 

730 if k >= len(freq): 

731 break 

732 spec[i, :2] = [np.round(freq[k, 0]/freq1), freq[k, 0]] 

733 spec[i, 6] = freq[k, 1] 

734 k += 1 

735 else: 

736 freq1 = freq 

737 spec = np.zeros((len(coeffs) - 1, 6)) 

738 ampl1 = np.abs(coeffs[1]) 

739 for i in range(1, min(len(coeffs), len(spec) + 1)): 

740 ampl = np.abs(coeffs[i]) 

741 phase = np.angle(coeffs[i]) 

742 spec[i - 1, :6] = [i, i*freq1, ampl, ampl/ampl1, 

743 decibel((ampl/ampl1)**2.0), phase] 

744 

745 # harmonics, frequency, amplitude, relative amplitude, power, phase, data power 

746 

747 # total power: 

748 pnh = len(coeffs) - 1 

749 power = decibel(np.sum(spec[:pnh, 2]**2)) 

750 data_power = None 

751 if spec.shape[1] > 6: 

752 data_power = decibel(np.nansum(spec[:pnh, 6])) 

753 

754 # total harmonic distortion: 

755 thd = np.sqrt(np.nansum(spec[1:, 3])**2) 

756 

757 # harmonic with maximum power: 

758 db_powers = spec[:len(coeffs) - 1, 4] 

759 max_harmonics = np.argmax(db_powers) + 1 

760 

761 # smoothness of power spectrum: 

762 db_diff = np.std(np.diff(db_powers)) 

763 

764 # slope of unwraped phases: 

765 phases = spec[:n_phase_harmonics, 5] 

766 phases = np.unwrap(phases) 

767 r = linregress(np.arange(len(phases)), phases) 

768 phase_slope = r.slope 

769 

770 return spec, power, data_power, thd, max_harmonics, db_diff, phase_slope 

771 

772 

773def analyze_wave(eod, ratetime, freq, coeffs=None, 

774 max_harmonics=20, flip_wave='none', thresh_frac=0.05, 

775 n_phase_harmonics=8): 

776 """Full analysis of the EOD waveform of a wave fish. 

777  

778 Parameters 

779 ---------- 

780 eod: 1-D or 2-D array 

781 The eod waveform to be analyzed. 

782 If an 1-D array, then this is the waveform and you 

783 need to also pass a time array or sampling rate in `ratetime`. 

784 If a 2-D array, then first column is time in seconds, second 

785 column the EOD waveform, third column, if present, is the 

786 standard error of the EOD waveform. Further columns are 

787 optional and are not used. 

788 ratetime: None or float or array of float 

789 If a 1-D array is passed on to `eod` then either the sampling 

790 rate in Hertz or the time array corresponding to `eod`. 

791 freq: float or 2-D array 

792 The frequency of the EOD or the list of harmonics (rows) with 

793 frequency and peak height (columns) as returned from 

794 `harmonics.harmonic_groups()`. 

795 coeffs: None or 1-D array of complex 

796 The Fourier coefficients of an EOD waveform. 

797 If provided, they are taken for the spectrum and the waveform 

798 is updated from them. 

799 max_harmonics: int 

800 Highest harmonics used for the Fourier decomposition. 

801 flip_wave: 'auto', 'none', 'flip' 

802 - 'auto' flip waveform such that the larger extremum is positive. 

803 - 'flip' flip waveform. 

804 - 'none' do not flip waveform. 

805 thresh_frac: float 

806 Threshold for detecting peaks and troughs as a fraction of 

807 the p-p amplitude. 

808 n_phase_harmonics: int 

809 Number of harmonics over which to compute the slope of the phases. 

810  

811 Returns 

812 ------- 

813 meod: 2-D array of floats 

814 The eod waveform. First column is time in seconds, second 

815 column the eod waveform. Further columns are kept from the 

816 input `eod`. And a column is added with the Fourier series. 

817 props: dict 

818 A dictionary with properties of the analyzed EOD waveform. 

819 

820 - type: set to 'wave'. 

821 - flipped: true if the waveform was flipped. 

822 - EODf: EOD fundamental frequency. 

823 - period: period of the EOD, i.e. 1/EODf. 

824 - ppampl: peak-to-peak amplitude of the Fourier decomposed EOD waveform. 

825 - relmaxampl: amplitude of peak or trough, whichever is larger, 

826 relative to p-p amplitude. 

827 - power: summed power of all harmonics of the extracted 

828 EOD waveform in decibel relative to one. 

829 - datapower: summed power of all harmonics of the original 

830 data in decibel relative to one. 

831 Only if `freq` is a list of harmonics. 

832 - noise: root-mean squared standard deviation of the extracted 

833 EOD waveform relative to the p-p amplitude. 

834 - rmserror: root-mean-square difference between 

835 Fourier-decomposition and EOD waveform relative 

836 to the p-p amplitude. 

837 - ppdist: time between peak and trough relative to EOD period. 

838 - minppdist: p-p-distance or EOD period minus p-p-distance, 

839 whichever is smaller, relative to EOD period. 

840 - peakwidth: width of the maximum peak phase relative 

841 to EOD period. 

842 - troughwidth: width of minimum trough phase relative 

843 to EOD period. 

844 - minwidth: peakwidth or troughwidth, whichever is smaller. 

845 - phasethresh: threshold used for detecting phases as a 

846 factor of the p-p-amplitude. 

847 - nphases: number of phases of one EOD cycle. 

848 - thd: total harmonic distortion, i.e. square root of sum 

849 of squared relative amplitudes of higher harmonics. 

850 - maxharmonics: harmonics with maximum amplitude. 

851 - power2: power of the second harmonics. 

852 - dbdiff: smoothness of power spectrum as standard deviation of 

853 differences in decibel power. 

854 - phaseslope: slope of a linear regression between phases and 

855 multipliers of the harmonics. 

856 

857 phases: dict 

858 Dictionary with 

859  

860 - "indices": indices of each phase 

861 (1 is P1, i.e. the largest positive peak) 

862 - "times": times of each phase within an EOD cycle 

863 - "amplitudes": amplitudes of each phase 

864 - "relamplitudes": amplitudes normalized to amplitude of P1 phase 

865 - "widths": widths of each phase computed from zeros 

866 - "zeros": time point where amplitude between this and the next phase 

867 is half their difference. 

868  

869 spec: 2-D array of floats 

870 spec: 2-D array of float 

871 Amplitudes and phases for each harmonic. 

872 Rows are the harmonics, first row is the fundamental frequency 

873 with multiplier 1, relative amplitude of one, relative power of 0dB, 

874 and phase shift of zero. 

875 First six columns are from the spectrum of the extracted 

876 waveform, optional column 6 is from the spectrum of the recording: 

877 

878 - column 0: multiplier of the harmonics (fundamental is one) 

879 - column 1: frequency in Hertz 

880 - column 2: amplitude 

881 - column 3: amplitude relative to the one of the fundamental 

882 - column 4: power of harmonics relative to fundamental in decibel 

883 - column 5: phase shift relative to the fundamental 

884 - column 6: if `freq` is a list of harmonics, the powers of 

885 the harmonics from the power spectrum of the raw data. 

886 """ 

887 if eod.ndim == 2: 

888 if eod.shape[1] > 2: 

889 eeod = eod 

890 else: 

891 eeod = np.column_stack((eod, np.zeros(len(eod)))) 

892 rate = 1/np.mean(np.diff(eeod[:, 0])) 

893 else: 

894 if isinstance(ratetime, (list, tuple, np.ndarray)): 

895 time = ratetime 

896 rate = 1/np.mean(np.diff(time)) 

897 else: 

898 rate = ratetime 

899 time = np.arange(len(eod))/rate 

900 eeod = np.zeros((len(eod), 3)) 

901 eeod[:, 0] = time 

902 eeod[:, 1] = eod 

903 # storage: 

904 meod = np.zeros((eeod.shape[0], eeod.shape[1] + 1)) 

905 meod[:, :eeod.shape[1]] = eeod 

906 meod[:, -1] = np.nan 

907 

908 freq1 = freq 

909 if hasattr(freq, 'shape'): 

910 freq1 = freq[0][0] 

911 period = 1/freq1 

912 

913 # spectrum: 

914 has_spec = coeffs is not None 

915 if not has_spec: 

916 coeffs = fourier_coeffs(meod[:, 1], meod[:, 0], freq1, max_harmonics) 

917 phase1 = np.angle(coeffs[1]) 

918 deltat = phase1/(2*np.pi*freq1) 

919 meod[:, 0] -= deltat # TODO: test direction of shift 

920 

921 # update waveform: 

922 coeffs = normalize_fourier_coeffs(coeffs) 

923 eodw = fourier_synthesis(freq1, coeffs, meod[:, 0]) 

924 if has_spec: 

925 meod[:, 1] = eodw 

926 meod[:, -1] = eodw 

927 

928 # subtract mean and flip: 

929 meod, coeffs, flipped = condition_wave(meod, None, freq1, coeffs, flip_wave) 

930 if has_spec: 

931 meod[:, 1] = meod[:, -1] 

932 

933 # waveform properties: 

934 pp_ampl, rel_max_ampl, distance, min_distance, rms_sem, rms_error = \ 

935 analyze_wave_properties(meod, None, freq1) 

936 

937 # phases: 

938 phases = analyze_wave_phases(meod, None, freq1, thresh_frac=thresh_frac) 

939 p_inx = np.argmax(phases['amplitudes']) 

940 peak_width = phases['widths'][p_inx] 

941 t_inx = np.argmin(phases['amplitudes']) 

942 trough_width = phases['widths'][t_inx] 

943 

944 # spectral analysis: 

945 spec, power, data_power, thd, max_harmonics, db_diff, phase_slope = \ 

946 analyze_wave_spectrum(freq, coeffs, 

947 n_phase_harmonics=n_phase_harmonics) 

948 

949 # store results: 

950 props = {} 

951 props['type'] = 'wave' 

952 props['flipped'] = flipped 

953 props['EODf'] = freq1 

954 props['period'] = period 

955 props['ppampl'] = pp_ampl 

956 props['relmaxampl'] = rel_max_ampl 

957 props['power'] = power 

958 if data_power is not None: 

959 props['datapower'] = data_power 

960 if rms_sem: 

961 props['noise'] = rms_sem 

962 props['rmserror'] = rms_error 

963 props['ppdist'] = distance/period 

964 props['minppdist'] = min_distance/period 

965 props['peakwidth'] = peak_width/period 

966 props['troughwidth'] = trough_width/period 

967 props['minwidth'] = min(peak_width, trough_width)/period 

968 props['phasethresh'] = thresh_frac 

969 props['nphases'] = len(phases['times']) 

970 props['thd'] = thd 

971 props['maxharmonics'] = max_harmonics 

972 props['power2'] = spec[1, 4] 

973 props['dbdiff'] = db_diff 

974 props['phaseslope'] = phase_slope 

975 

976 return meod, props, phases, spec 

977 

978 

979def wave_quality(props, harm_relampl=None, min_freq=0.0, 

980 max_freq=2000.0, max_clipped_frac=0.1, 

981 max_phases=4, max_rms_sem=0.0, max_rms_error=0.05, 

982 min_power=-100.0, max_thd=0.0, max_db_diff=20.0, 

983 max_relampl_harm2=0.0, max_relampl_harm3=0.0, 

984 max_relampl_harm4=0.0): 

985 """Assess the quality of an EOD waveform of a wave fish. 

986  

987 Parameters 

988 ---------- 

989 props: dict 

990 A dictionary with properties of the analyzed EOD waveform 

991 as returned by `analyze_wave()`. 

992 harm_relampl: 1-D array of floats or None 

993 Relative amplitude of at least the first 3 harmonics without 

994 the fundamental. 

995 min_freq: float 

996 Minimum EOD frequency (`props['EODf']`). 

997 max_freq: float 

998 Maximum EOD frequency (`props['EODf']`). 

999 max_clipped_frac: float 

1000 If larger than zero, maximum allowed fraction of clipped data 

1001 (`props['clipped']`). 

1002 max_phases: int 

1003 If larger than zero, maximum number of phases per EOD period 

1004 (`props['nphases']`). 

1005 max_rms_sem: float 

1006 If larger than zero, maximum allowed standard deviation of the 

1007 data relative to p-p amplitude (`props['noise']`). 

1008 max_rms_error: float 

1009 If larger than zero, maximum allowed root-mean-square difference 

1010 between EOD waveform and Fourier series relative to p-p amplitude 

1011 (`props['rmserror']`). 

1012 min_power: float 

1013 Minimum power of the EOD in dB (`props['power']`). 

1014 max_thd: float 

1015 If larger than zero, then maximum total harmonic distortion 

1016 (`props['thd']`). 

1017 max_db_diff: float 

1018 If larger than zero, maximum standard deviation of differences between 

1019 logarithmic powers of harmonics in decibel (`props['dbdiff']`). 

1020 Low values enforce smoother power spectra. 

1021 max_relampl_harm2: float 

1022 If larger than zero, maximum allowed amplitude of second harmonic 

1023 relative to fundamental (=first harmonics). 

1024 max_relampl_harm3: float 

1025 If larger than zero, maximum allowed amplitude of third harmonic 

1026 relative to fundamental (=first harmonics). 

1027 max_relampl_harm4: float 

1028 If larger than zero, maximum allowed amplitude of fourth harmonic 

1029 relative to fundamental (=first harmonics). 

1030  

1031 Returns 

1032 ------- 

1033 remove: bool 

1034 If True then this is most likely not an electric fish. Remove 

1035 it from both the waveform properties and the list of EOD 

1036 frequencies. If False, keep it in the list of EOD 

1037 frequencies, but remove it from the waveform properties if 

1038 `skip_reason` is not empty. 

1039 skip_reason: str 

1040 An empty string if the waveform is good, otherwise a string 

1041 indicating the failure. 

1042 msg: str 

1043 A textual representation of the values tested. 

1044 """ 

1045 remove = False 

1046 msg = [] 

1047 skip_reason = [] 

1048 # EOD frequency: 

1049 if 'EODf' in props: 

1050 eodf = props['EODf'] 

1051 msg += [f'EODf={eodf:6.1f}Hz'] 

1052 if eodf < min_freq or eodf > max_freq: 

1053 remove = True 

1054 skip_reason += [f'invalid EODf={eodf:6.1f}Hz (minimumFrequency={min_freq:6.1f}Hz, maximumFrequency={max_freq:6.1f}Hz)'] 

1055 # clipped fraction: 

1056 if 'clipped' in props: 

1057 clipped_frac = props['clipped'] 

1058 msg += [f'clipped={100*clipped_frac:3.0f}%'] 

1059 if max_clipped_frac > 0 and clipped_frac >= max_clipped_frac: 

1060 skip_reason += [f'clipped={100*clipped_frac:3.0f}% (maximumClippedFraction={100*max_clipped_frac:3.0f}%)'] 

1061 # too many zero crossings: 

1062 if 'nphases' in props: 

1063 nphases = props['nphases'] 

1064 msg += [f'phases={nphases}'] 

1065 if max_phases > 0 and nphases > max_phases: 

1066 skip_reason += [f'too many phases={nphases} (maximumPhases={max_phases})'] 

1067 # noise: 

1068 rms_sem = None 

1069 if 'rmssem' in props: 

1070 rms_sem = props['rmssem'] 

1071 if 'noise' in props: 

1072 rms_sem = props['noise'] 

1073 if rms_sem is not None: 

1074 msg += [f'rms sem waveform={100*rms_sem:6.2f}%'] 

1075 if max_rms_sem > 0.0 and rms_sem >= max_rms_sem: 

1076 skip_reason += [f'noisy waveform s.e.m.={100*rms_sem:6.2f}% (max {100*max_rms_sem:6.2f}%)'] 

1077 # fit error: 

1078 if 'rmserror' in props: 

1079 rms_error = props['rmserror'] 

1080 msg += [f'rmserror={100*rms_error:6.2f}%'] 

1081 if max_rms_error > 0.0 and rms_error >= max_rms_error: 

1082 skip_reason += [f'noisy rmserror={100*rms_error:6.2f}% (maximumVariance={100*max_rms_error:6.2f}%)'] 

1083 # wave power: 

1084 if 'power' in props: 

1085 power = props['power'] 

1086 msg += [f'power={power:6.1f}dB'] 

1087 if power < min_power: 

1088 skip_reason += [f'small power={power:6.1f}dB (minimumPower={min_power:6.1f}dB)'] 

1089 # total harmonic distortion: 

1090 if 'thd' in props: 

1091 thd = props['thd'] 

1092 msg += [f'thd={100*thd:5.1f}%'] 

1093 if max_thd > 0.0 and thd > max_thd: 

1094 skip_reason += [f'large THD={100*thd:5.1f}% (maxximumTotalHarmonicDistortion={100*max_thd:5.1f}%)'] 

1095 # smoothness of spectrum: 

1096 if 'dbdiff' in props: 

1097 db_diff = props['dbdiff'] 

1098 msg += [f'dBdiff={db_diff:5.1f}dB'] 

1099 if max_db_diff > 0.0 and db_diff > max_db_diff: 

1100 remove = True 

1101 skip_reason += [f'not smooth s.d. diff={db_diff:5.1f}dB (maxximumPowerDifference={max_db_diff:5.1f}dB)'] 

1102 # relative amplitude of harmonics: 

1103 if harm_relampl is not None: 

1104 for k, max_relampl in enumerate([max_relampl_harm2, max_relampl_harm3, max_relampl_harm4]): 

1105 if k >= len(harm_relampl): 

1106 break 

1107 msg += [f'ampl{k + 2}={100*harm_relampl[k]:5.1f}%'] 

1108 if max_relampl > 0.0 and k < len(harm_relampl) and harm_relampl[k] >= max_relampl: 

1109 num_str = ['Second', 'Third', 'Fourth'] 

1110 skip_reason += [f'distorted ampl{k + 2}={100*harm_relampl[k]:5.1f}% (maximum{num_str[k]}HarmonicAmplitude={100*max_relampl:5.1f}%)'] 

1111 return remove, ', '.join(skip_reason), ', '.join(msg) 

1112 

1113 

1114def plot_wave_eod(ax, eod_waveform, props, phases=None, 

1115 unit=None, wave_periods=2, rel_width=True, 

1116 wave_style=dict(lw=1.5, color='tab:red'), 

1117 sem_style=dict(color='0.8'), 

1118 phase_style=dict(zorder=0, ls='', marker='o', color='tab:red', 

1119 markersize=5, mec='white', mew=1), 

1120 zerox_style=dict(zorder=50, ls='', marker='o', color='black', 

1121 markersize=5, mec='white', mew=1), 

1122 zero_style=dict(lw=0.5, color='0.7'), 

1123 fontsize='medium'): 

1124 """Plot and annotate a wave-type EOD waveform. 

1125 

1126 Parameters 

1127 ---------- 

1128 ax: matplotlib axes 

1129 Axes used for plotting. 

1130 eod_waveform: 2-D array 

1131 EOD waveform. First column is time in seconds, second column 

1132 the (mean) eod waveform. The optional third column is the 

1133 standard error. Further columns are ignored. 

1134 props: dict 

1135 A dictionary with properties of the analyzed EOD waveform as 

1136 returned by `analyze_wave()`. 

1137 phases: dict 

1138 Dictionary with phase properties as returned by 

1139 `analyze_wave_phases()`, `analyze_wave()`, and 

1140 `load_wave_phases()`. 

1141 unit: str 

1142 Optional unit of the data used for y-label. 

1143 wave_periods: float 

1144 How many periods of a wave EOD are shown. 

1145 rel_width: bool 

1146 If True annotate width in percent relative to the EOD period, 

1147 otherwise report them in ms. 

1148 wave_style: dict 

1149 Arguments passed on to the plot command for the EOD waveform. 

1150 sem_style: dict 

1151 Arguments passed on to the fill_between command for the 

1152 standard error of the EOD. 

1153 phase_style: dict 

1154 Arguments passed on to the plot command for marking EOD phases. 

1155 zerox_style: dict 

1156 Arguments passed on to the plot command for marking zero crossings. 

1157 zero_style: dict 

1158 Arguments passed on to the plot command for the zero line. 

1159 fontsize: str or float or int 

1160 Fontsize for annotation text. 

1161 

1162 """ 

1163 time = 1000*eod_waveform[:, 0] 

1164 eod = eod_waveform[:, 1] 

1165 # time axis:  

1166 period = 1000/props['EODf'] 

1167 xlim_l = -0.5*wave_periods*period 

1168 xlim_r = +0.5*wave_periods*period + period 

1169 xlim = xlim_r - xlim_l 

1170 ax.set_xlim(xlim_l, xlim_r) 

1171 if xlim < 2: 

1172 ax.xaxis.set_major_locator(MultipleLocator(0.5)) 

1173 elif xlim < 4: 

1174 ax.xaxis.set_major_locator(MultipleLocator(1)) 

1175 elif xlim < 8: 

1176 ax.xaxis.set_major_locator(MultipleLocator(2)) 

1177 ax.set_xlabel('Time [msec]') 

1178 # amplitude axis:  

1179 ylim = np.max(np.abs(eod[(time >= xlim_l) & (time <= xlim_r)])) 

1180 ax.set_ylim(-1.2*ylim, +1.2*ylim) 

1181 if unit: 

1182 ax.set_ylabel(f'Amplitude [{unit}]') 

1183 else: 

1184 ax.set_ylabel('Amplitude') 

1185 # ax height dimensions: 

1186 t = ax.text(0, 0, 'test', fontsize=fontsize) 

1187 fs = t.get_fontsize() 

1188 t.remove() 

1189 pixelx = np.abs(np.diff(ax.get_window_extent().get_points()[:, 0]))[0] 

1190 dxu = 2*xlim/pixelx 

1191 xfs = fs*dxu 

1192 pixely = np.abs(np.diff(ax.get_window_extent().get_points()[:, 1]))[0] 

1193 dyu = 2*ylim/pixely 

1194 yfs = fs*dyu 

1195 # plot zero line: 

1196 ax.axhline(0.0, zorder=10, **zero_style) 

1197 # plot waveform: 

1198 ax.plot(time, eod, zorder=45, **wave_style) 

1199 # plot standard error: 

1200 if eod_waveform.shape[1] > 2: 

1201 std_eod = eod_waveform[:, 2] 

1202 ax.fill_between(time, eod + std_eod, eod - std_eod, 

1203 zorder=20, **sem_style) 

1204 # plot and annotate phases: 

1205 if phases is not None and len(phases) > 0: 

1206 # mark zero crossings: 

1207 zeros = 1000*phases['zeros'] 

1208 zeros_ampls = np.interp(zeros, time, eod) 

1209 ax.plot(zeros, zeros_ampls, **zerox_style) 

1210 ax.plot(zeros[-1] - period, zeros_ampls[-1], **zerox_style) 

1211 # mark phase peak/trough: 

1212 times = 1000*phases['times'] 

1213 amplitudes = phases['amplitudes'] 

1214 ax.plot(times, amplitudes, **phase_style) 

1215 # annotate period: 

1216 ax.plot(times[0] + period, amplitudes[0], **phase_style) 

1217 ax.text(times[0] + period + xfs, amplitudes[0], f'T={period:.3g}ms', 

1218 va='center', zorder=100, fontsize=fontsize) 

1219 # phase peaks and troughs: 

1220 widths = phases['widths'] 

1221 for i in range(len(times)): 

1222 index = phases['indices'][i] 

1223 ptime = times[i] 

1224 if ptime < xlim_l or ptime > xlim_r: 

1225 continue 

1226 pi = np.argmin(np.abs(time - ptime)) 

1227 pampl = amplitudes[i] 

1228 relampl = phases['relamplitudes'][i] 

1229 # text for phase label: 

1230 label = f'P{index:.0f}' 

1231 if np.abs(ptime) < 1: 

1232 ts = f'{1000*ptime:.0f}\u00b5s' 

1233 elif np.abs(ptime) < 10: 

1234 ts = f'{ptime:.2f}ms' 

1235 else: 

1236 ts = f'{ptime:.3g}ms' 

1237 if index == 1: 

1238 label += f'(@ {ts})' 

1239 else: 

1240 if np.abs(relampl) < 0.05: 

1241 ps = f'{100*relampl:.1f}%' 

1242 else: 

1243 ps = f'{100*relampl:.0f}%' 

1244 label += f'({ps} @ {ts})' 

1245 # position of phase label: 

1246 nampl = amplitudes[i - 1 if i > 0 else i + 1] 

1247 local_min = pampl < nampl 

1248 if i == 0: 

1249 ax.text(ptime + xfs, pampl, label, 

1250 ha='left', va='center', zorder=100, fontsize=fontsize) 

1251 else: 

1252 dy = 1.5*yfs 

1253 valign = 'bottom' 

1254 if local_min: 

1255 dy = -dy 

1256 valign = 'top' 

1257 ax.text(ptime, pampl + dy, label, 

1258 ha='center', va=valign, zorder=100, fontsize=fontsize) 

1259 # text for width label: 

1260 pwidth = 1000*widths[i] 

1261 if rel_width: 

1262 ws = f'{100*pwidth/period:.2g}%' 

1263 elif pwidth < 1: 

1264 ws = f'{1000*pwidth:.0f}\u00b5s' 

1265 elif pwidth < 10: 

1266 ws = f'{pwidth:.2f}ms' 

1267 else: 

1268 ws = f'{pwidth:.3g}ms' 

1269 if i > 0: 

1270 x = 0.5*(zeros[i - 1] + zeros[i]) 

1271 else: 

1272 x = 0.5*(zeros[-1] - period + zeros[i]) 

1273 y = 0.5*(zeros_ampls[i - 1] + zeros_ampls[i]) 

1274 if local_min: 

1275 if y > amplitudes[i - 1] - yfs: 

1276 y = amplitudes[i - 1] - yfs 

1277 if y > amplitudes[(i + 1)%len(amplitudes)] - yfs: 

1278 y = amplitudes[(i + 1)%len(amplitudes)] - yfs 

1279 else: 

1280 if y < amplitudes[i - 1] + yfs: 

1281 y = amplitudes[i - 1] + yfs 

1282 if y < amplitudes[(i + 1)%len(amplitudes)] + yfs: 

1283 y = amplitudes[(i + 1)%len(amplitudes)] + yfs 

1284 if pwidth < 0.25*period: 

1285 ax.text(x, y, ws, ha='center', va='center', rotation='vertical', zorder=100, fontsize=fontsize) 

1286 else: 

1287 ax.text(x, y, ws, ha='center', va='center', zorder=100, fontsize=fontsize) 

1288 # annotate plot: 

1289 if props is not None: 

1290 label = '' 

1291 if 'n' in props: 

1292 eods = 'EODs' if props['n'] > 1 else 'EOD' 

1293 segs = '' 

1294 if 'nsegments' in props: 

1295 n_segs = props['nsegments'] 

1296 segs = f' (in {n_segs} segment{"s" if n_segs > 1 else ""})' 

1297 label += f'n = {props["n"]} {eods}{segs}' 

1298 if 'flipped' in props and props['flipped']: 

1299 if len(label) > 0: 

1300 label += '\n' 

1301 label += 'flipped' 

1302 ax.text(0.03, 1.03, label, transform=ax.transAxes, 

1303 va='top', ha='left', zorder=100) 

1304 

1305 

1306def plot_wave_spectrum(axa, axp, spec, props, unit=None, 

1307 ampl_style=dict(ls='', marker='o', color='tab:blue', markersize=6), 

1308 ampl_stem_style=dict(color='tab:blue', alpha=0.5, lw=2), 

1309 phase_style=dict(ls='', marker='p', color='tab:blue', markersize=6), 

1310 phase_stem_style=dict(color='tab:blue', alpha=0.5, lw=2), 

1311 phase_twopi_style=dict(color='k', ls='--', lw=0.5)): 

1312 """Plot and annotate spectrum of wave EOD. 

1313 

1314 Parameters 

1315 ---------- 

1316 axa: matplotlib axes 

1317 Axes for amplitude plot. 

1318 axp: matplotlib axes 

1319 Axes for phase plot. 

1320 spec: 2-D array 

1321 The spectrum of the wave-type EOD as returned by 

1322 `analyze_wave()`. First column is the harmonics, 

1323 second column its frequency, third column its amplitude, 

1324 fourth column its amplitude relative to the fundamental, fifth 

1325 column is power of harmonics relative to fundamental in 

1326 decibel, and sixth column the phase shift relative to the 

1327 fundamental. 

1328 props: dict 

1329 A dictionary with properties of the analyzed EOD waveform as 

1330 returned by `analyze_wave()`. 

1331 unit: string 

1332 Optional unit of the data used for y-label. 

1333 ampl_style: dict 

1334 Properties of the markers of the amplitude plot. 

1335 ampl_stem_style: dict 

1336 Properties of the stems of the amplitude plot. 

1337 phase_style: dict 

1338 Properties of the markers of the phase plot. 

1339 phase_stem_style: dict 

1340 Properties of the stems of the phase plot. 

1341 phase_twopi_style: dict 

1342 Properties of the horizontal lines marking \\(2\\pi\\) 

1343 in the phase plot. 

1344 """ 

1345 def pi_fmt(x, pos): 

1346 i = int(np.round(x/np.pi)) 

1347 if i == 1: 

1348 return '\u03c0' 

1349 elif i == 0: 

1350 return '0' 

1351 elif i == -1: 

1352 return '-\u03c0' 

1353 else: 

1354 return f'{i}\u03c0' 

1355 

1356 n = min(9, np.sum(np.isfinite(spec[:, 2]))) 

1357 # amplitudes: 

1358 markers, stemlines, _ = axa.stem(spec[:n, 0], spec[:n, 2], 

1359 basefmt='none') 

1360 setp(markers, clip_on=False, **ampl_style) 

1361 setp(stemlines, **ampl_stem_style) 

1362 if props and 'thd' in props: 

1363 axa.text(1, 1, f'thd={100*props["thd"]:.0f}%', 

1364 ha='right', va='top', transform=axa.transAxes) 

1365 axa.set_xlim(0.5, n + 0.5) 

1366 axa.set_ylim(bottom=0) 

1367 axa.xaxis.set_major_locator(MultipleLocator(1)) 

1368 axa.tick_params('x', direction='out') 

1369 if unit: 

1370 axa.set_ylabel(f'Amplitude [{unit}]') 

1371 else: 

1372 axa.set_ylabel('Amplitude') 

1373 # phases: 

1374 phases = spec[:n, 5] 

1375 phases = np.unwrap(phases) 

1376 min_p = np.floor(np.min(phases)/np.pi)*np.pi 

1377 max_p = np.ceil(np.max(phases)/np.pi)*np.pi 

1378 if max_p - min_p < 1.5*np.pi: 

1379 max_p += np.pi 

1380 for p in np.arange(min_p + np.pi, max_p, np.pi): 

1381 if np.abs(p - np.round(p/2/np.pi)*2*np.pi) < 1e-8: 

1382 axp.axhline(p, **phase_twopi_style) 

1383 markers, stemlines, _ = axp.stem(spec[:n, 0], phases[:n], 

1384 basefmt='none') 

1385 setp(markers, clip_on=False, **phase_style) 

1386 setp(stemlines, **phase_stem_style) 

1387 axp.set_xlim(0.5, n + 0.5) 

1388 axp.xaxis.set_major_locator(MultipleLocator(1)) 

1389 axp.tick_params('x', direction='out') 

1390 if props and 'phaseslope' in props: 

1391 axp.text(0.03, 1, f'slope={props["phaseslope"]:.2g}', 

1392 va='top', transform=axp.transAxes) 

1393 axp.set_ylim(min_p, max_p) 

1394 if max_p - min_p < 3.5*np.pi: 

1395 axp.yaxis.set_major_locator(MultipleLocator(1*np.pi)) 

1396 else: 

1397 axp.yaxis.set_major_locator(MultipleLocator(2*np.pi)) 

1398 axp.yaxis.set_major_formatter(FuncFormatter(pi_fmt)) 

1399 axp.set_xlabel('Harmonics') 

1400 axp.set_ylabel('Phase') 

1401 

1402 

1403def save_wave_eodfs(wave_eodfs, wave_indices, basename, **kwargs): 

1404 """Save frequencies of wave EODs to file. 

1405 

1406 Parameters 

1407 ---------- 

1408 wave_eodfs: list of 2D arrays 

1409 Each item is a matrix with the frequencies and powers 

1410 (columns) of the fundamental and harmonics (rows) as returned 

1411 by `harmonics.harmonic_groups()`. 

1412 wave_indices: array 

1413 Indices identifying each fish or NaN. 

1414 If None no index column is inserted. 

1415 basename: string or stream 

1416 If string, path and basename of file. 

1417 If `basename` does not have an extension, 

1418 '-waveeodfs' and a file extension according to `kwargs` are appended. 

1419 If stream, write EOD frequencies data into this stream. 

1420 kwargs: 

1421 Arguments passed on to `TableData.write()`. 

1422 

1423 Returns 

1424 ------- 

1425 filename: Path 

1426 Path and full name of the written file in case of `basename` 

1427 being a string. Otherwise, the file name and extension that 

1428 would have been appended to a basename. 

1429 

1430 See Also 

1431 -------- 

1432 load_wave_eodfs() 

1433 

1434 """ 

1435 eodfs = fundamental_freqs_and_power(wave_eodfs) 

1436 td = TableData() 

1437 if wave_indices is not None: 

1438 td.append('index', '', '%d', 

1439 value=[wi if wi >= 0 else np.nan 

1440 for wi in wave_indices]) 

1441 td.append('EODf', 'Hz', '%7.2f', value=eodfs[:, 0]) 

1442 td.append('datapower', 'dB', '%7.2f', value=eodfs[:, 1]) 

1443 ext = Path(basename).suffix if not hasattr(basename, 'write') else '' 

1444 fp = '-waveeodfs' if not ext else '' 

1445 return td.write_file_stream(basename, fp, **kwargs) 

1446 

1447 

1448def load_wave_eodfs(file_handle): 

1449 """Load frequencies of wave EODs from file. 

1450 

1451 Parameters 

1452 ---------- 

1453 file_handle: str, Path, or stream 

1454 The file to be loaded. 

1455 If str or Path and file does not exist, try to open 

1456 possible corresponding zip file and read file from there. 

1457 

1458 Returns 

1459 ------- 

1460 eodfs: 2D array of floats 

1461 EODfs and power of wave type fish. 

1462 indices: array of ints 

1463 Corresponding indices of fish, can contain negative numbers to 

1464 indicate frequencies without fish. 

1465 

1466 Raises 

1467 ------ 

1468 FileNotFoundError: 

1469 If `file_path` does not exist. 

1470 

1471 See Also 

1472 -------- 

1473 save_wave_eodfs() 

1474 """ 

1475 file_handle, zf = open_from_zip(file_handle) 

1476 data = TableData(file_handle) 

1477 close_zip(file_handle, zf) 

1478 eodfs = data.array() 

1479 if 'index' in data: 

1480 indices = data[:, 'index'] 

1481 indices[~np.isfinite(indices)] = -1 

1482 indices = np.array(indices, dtype=int) 

1483 eodfs = eodfs[:, 1:] 

1484 else: 

1485 indices = np.zeros(data.rows(), dtype=int) - 1 

1486 return eodfs, indices 

1487 

1488 

1489def save_wave_fish(eod_props, unit, basename, **kwargs): 

1490 """Save properties of wave EODs to file. 

1491 

1492 Parameters 

1493 ---------- 

1494 eod_props: list of dict 

1495 Properties of EODs as returned by `analyze_wave()` and 

1496 `analyze_pulse()`. Only properties of wave fish are saved. 

1497 unit: string 

1498 Unit of the waveform data. 

1499 basename: string or stream 

1500 If string, path and basename of file. 

1501 If `basename` does not have an extension, 

1502 '-wavefish' and a file extension are appended. 

1503 If stream, write wave fish properties into this stream. 

1504 kwargs: 

1505 Arguments passed on to `TableData.write()`. 

1506 

1507 Returns 

1508 ------- 

1509 filename: Path or None 

1510 Path and full name of the written file in case of `basename` 

1511 being a string. Otherwise, the file name and extension that 

1512 would have been appended to a basename. 

1513 None if no wave fish are contained in eod_props and 

1514 consequently no file was written. 

1515 

1516 See Also 

1517 -------- 

1518 load_wave_fish() 

1519 """ 

1520 wave_props = [p for p in eod_props if p['type'] == 'wave'] 

1521 if len(wave_props) == 0: 

1522 return None 

1523 td = TableData() 

1524 if 'twin' in wave_props[0] or 'samplerate' in wave_props[0] or \ 

1525 'nfft' in wave_props[0]: 

1526 td.append_section('recording') 

1527 if 'twin' in wave_props[0]: 

1528 td.append('twin', 's', '%7.2f', value=wave_props) 

1529 td.append('window', 's', '%7.2f', value=wave_props) 

1530 td.append('channel', '', '%d', value=wave_props) 

1531 td.append('winclipped', '%', '%.2f', 

1532 value=wave_props, fac=100.0) 

1533 if 'nsegments' in wave_props[0]: 

1534 td.append('nsegments', '', '%d', value=wave_props) 

1535 if 'samplerate' in wave_props[0]: 

1536 td.append('samplerate', 'kHz', '%.3f', 

1537 value=wave_props, fac=0.001) 

1538 if 'nfft' in wave_props[0]: 

1539 td.append('nfft', '', '%d', value=wave_props) 

1540 td.append('dfreq', 'Hz', '%.2f', value=wave_props) 

1541 td.append_section('waveform') 

1542 td.append('index', '', '%d', value=wave_props) 

1543 td.append('n', '', '%5d', value=wave_props) 

1544 td.append('flipped', '', '%d', value=wave_props) 

1545 td.append('EODf', 'Hz', '%7.2f', value=wave_props) 

1546 td.append('period', 'ms', '%7.3f', value=wave_props, fac=1000) 

1547 td.append('ppampl', unit, '%.5g', value=wave_props) 

1548 td.append('relmaxampl', '%', '%.2f', value=wave_props, fac=100) 

1549 td.append('power', 'dB', '%7.2f', value=wave_props) 

1550 if 'datapower' in wave_props[0]: 

1551 td.append('datapower', 'dB', '%7.2f', value=wave_props) 

1552 if 'noise' in wave_props[0]: 

1553 td.append('noise', '%', '%.1f', value=wave_props, fac=100) 

1554 td.append('rmserror', '%', '%.2f', value=wave_props, fac=100) 

1555 if 'clipped' in wave_props[0]: 

1556 td.append('clipped', '%', '%.1f', value=wave_props, fac=100) 

1557 td.append_section('timing') 

1558 td.append('ppdist', '%', '%.2f', value=wave_props, fac=100) 

1559 td.append('minppdist', '%', '%.2f', value=wave_props, fac=100) 

1560 td.append('peakwidth', '%', '%.2f', value=wave_props, fac=100) 

1561 td.append('troughwidth', '%', '%.2f', value=wave_props, fac=100) 

1562 td.append('minwidth', '%', '%.2f', value=wave_props, fac=100) 

1563 td.append_section('phases') 

1564 td.append('phasethresh', '%', '%.1f', value=wave_props, fac=100) 

1565 td.append('nphases', '', '%d', value=wave_props) 

1566 td.append_section('spectrum') 

1567 td.append('thd', '%', '%.2f', value=wave_props, fac=100) 

1568 td.append('maxharmonics', '', '%d', value=wave_props) 

1569 td.append('power2', 'dB', '%7.2f', value=wave_props) 

1570 td.append('dbdiff', 'dB', '%7.2f', value=wave_props) 

1571 td.append('phaseslope', '', '%.3f', value=wave_props) 

1572 ext = Path(basename).suffix if not hasattr(basename, 'write') else '' 

1573 fp = '-wavefish' if not ext else '' 

1574 return td.write_file_stream(basename, fp, **kwargs) 

1575 

1576 

1577def load_wave_fish(file_handle): 

1578 """Load properties of wave EODs from file. 

1579 

1580 All times are scaled to seconds, all frequencies to Hertz and all 

1581 percentages to fractions. 

1582 

1583 Parameters 

1584 ---------- 

1585 file_handle: str, Path, or stream 

1586 The file to be loaded. 

1587 If str or Path and file does not exist, try to open 

1588 possible corresponding zip file and read file from there. 

1589 

1590 Returns 

1591 ------- 

1592 eod_props: list of dict 

1593 Properties of EODs. 

1594 

1595 Raises 

1596 ------ 

1597 FileNotFoundError: 

1598 If `file_path` does not exist. 

1599 

1600 See Also 

1601 -------- 

1602 save_wave_fish() 

1603 

1604 """ 

1605 file_handle, zf = open_from_zip(file_handle) 

1606 data = TableData(file_handle) 

1607 close_zip(file_handle, zf) 

1608 eod_props = data.dicts() 

1609 for props in eod_props: 

1610 if 'winclipped' in props: 

1611 props['winclipped'] /= 100 

1612 if 'nsegments' in props: 

1613 props['nsegments'] = int(props['nsegments']) 

1614 if 'samplerate' in props: 

1615 props['samplerate'] *= 1000 

1616 if 'nfft' in props: 

1617 props['nfft'] = int(props['nfft']) 

1618 props['type'] = 'wave' 

1619 props['channel'] = int(props['channel']) 

1620 props['index'] = int(props['index']) 

1621 props['n'] = int(props['n']) 

1622 props['period'] /= 1000 

1623 props['relmaxampl'] /= 100 

1624 props['ppdist'] /= 100 

1625 props['minppdist'] /= 100 

1626 props['phasethresh'] /= 100 

1627 props['nphases'] = int(props['nphases']) 

1628 props['thd'] /= 100 

1629 props['noise'] /= 100 

1630 props['rmserror'] /= 100 

1631 if 'clipped' in props: 

1632 props['clipped'] /= 100 

1633 props['peakwidth'] /= 100 

1634 props['troughwidth'] /= 100 

1635 props['minwidth'] /= 100 

1636 return eod_props 

1637 

1638 

1639def save_wave_phases(phases, unit, idx, basename, **kwargs): 

1640 """Save phase properties of wave-type EOD to file. 

1641 

1642 Parameters 

1643 ---------- 

1644 phases: dict 

1645 Dictionary with 

1646  

1647 - "indices": indices of each phase 

1648 (1 is P1, i.e. the largest positive peak) 

1649 - "times": times of each phase within an EOD cycle 

1650 - "amplitudes": amplitudes of each phase 

1651 - "relamplitudes": amplitudes normalized to amplitude of P1 phase 

1652 - "widths": widths of each phase computed from zeros 

1653 - "zeros": time point where amplitude between this and the next phase is half the difference. 

1654  

1655 as returned by `analyze_wave_phases()` and `analyze_wave()`. 

1656 unit: string 

1657 Unit of the waveform data. 

1658 idx: int or None 

1659 Index of fish. 

1660 basename: string or stream 

1661 If string, path and basename of file. 

1662 If `basename` does not have an extension, 

1663 '-wavephases', the fish index, and a file extension are appended. 

1664 If stream, write wave phases into this stream. 

1665 kwargs: 

1666 Arguments passed on to `TableData.write()`. 

1667 

1668 Returns 

1669 ------- 

1670 filename: Path 

1671 Path and full name of the written file in case of `basename` 

1672 being a string. Otherwise, the file name and extension that 

1673 would have been appended to a basename. 

1674 

1675 See Also 

1676 -------- 

1677 load_wave_phases() 

1678 """ 

1679 if not phases or len(phases['times']) == 0: 

1680 return None 

1681 td = TableData() 

1682 td.append('index', '', '%.0f', value=phases['indices']) 

1683 td.append('time', 'ms', '%.4f', value=phases['times'], fac=1000) 

1684 td.append('amplitude', unit, '%.5f', value=phases['amplitudes']) 

1685 td.append('relampl', '%', '%.2f', value=phases['relamplitudes'], fac=100) 

1686 td.append('width', 'ms', '%.4f', value=phases['widths'], fac=1000) 

1687 td.append('zeros', 'ms', '%.4f', value=phases['zeros'], fac=1000) 

1688 fp = '' 

1689 ext = Path(basename).suffix if not hasattr(basename, 'write') else '' 

1690 if not ext: 

1691 fp = '-wavephases' 

1692 if idx is not None: 

1693 fp += f'-{idx}' 

1694 return td.write_file_stream(basename, fp, **kwargs) 

1695 

1696 

1697def load_wave_phases(file_handle): 

1698 """Load phase properties of wave-type EOD from file. 

1699 

1700 Parameters 

1701 ---------- 

1702 file_handle: str, Path, or stream 

1703 The file to be loaded. 

1704 If str or Path and file does not exist, try to open 

1705 possible corresponding zip file and read file from there. 

1706 

1707 Returns 

1708 ------- 

1709 phases: dict 

1710 Dictionary with 

1711  

1712 - "indices": indices of each phase 

1713 (1 is P1, i.e. the largest positive peak) 

1714 - "times": times of each phase within an EOD cycle 

1715 - "amplitudes": amplitudes of each phase 

1716 - "relamplitudes": amplitudes normalized to amplitude of P1 phase 

1717 - "widths": widths of each phase computed from zeros 

1718 - "zeros": time point where amplitude between this and the next phase is half the difference. 

1719  

1720 unit: string 

1721 Unit of phase amplitudes. 

1722 

1723 Raises 

1724 ------ 

1725 FileNotFoundError: 

1726 If `file_path` does not exist. 

1727 

1728 See Also 

1729 -------- 

1730 save_wave_phases() 

1731 """ 

1732 file_handle, zf = open_from_zip(file_handle) 

1733 data = TableData(file_handle) 

1734 close_zip(file_handle, zf) 

1735 phases = dict(indices=data['index'].astype(int), 

1736 times=data['time']*0.001, 

1737 amplitudes=data['amplitude'], 

1738 relamplitudes=data['relampl']*0.01, 

1739 widths=data['width']*0.001, 

1740 zeros=data['zeros']*0.001) 

1741 return phases, data.unit('amplitude') 

1742 

1743 

1744def save_wave_spectrum(spec_data, unit, idx, basename, **kwargs): 

1745 """Save amplitude and phase spectrum of wave EOD to file. 

1746 

1747 Parameters 

1748 ---------- 

1749 spec_data: 2D array of floats 

1750 Amplitude and phase spectrum of wave EOD as returned by 

1751 `analyze_wave()`. 

1752 unit: string 

1753 Unit of the waveform data. 

1754 idx: int or None 

1755 Index of fish. 

1756 basename: string or stream 

1757 If string, path and basename of file. 

1758 If `basename` does not have an extension, 

1759 '-wavespectrum', the fish index, and a file extension are appended. 

1760 If stream, write wave spectrum into this stream. 

1761 kwargs: 

1762 Arguments passed on to `TableData.write()`. 

1763 

1764 Returns 

1765 ------- 

1766 filename: Path 

1767 Path and full name of the written file in case of `basename` 

1768 being a string. Otherwise, the file name and extension that 

1769 would have been appended to a basename. 

1770 

1771 See Also 

1772 -------- 

1773 load_wave_spectrum() 

1774 

1775 """ 

1776 td = TableData(spec_data[:, :6]*[1, 1, 1, 100, 1, 1], 

1777 ['harmonics', 'frequency', 'amplitude', 

1778 'relampl', 'relpower', 'phase'], 

1779 ['', 'Hz', unit, '%', 'dB', 'rad'], 

1780 ['%.0f', '%.2f', '%.6f', '%10.2f', '%6.2f', '%8.4f']) 

1781 if spec_data.shape[1] > 6: 

1782 td.append('datapower', f'{unit}^2/Hz', '%11.4e', 

1783 value=spec_data[:, 6]) 

1784 fp = '' 

1785 ext = Path(basename).suffix if not hasattr(basename, 'write') else '' 

1786 if not ext: 

1787 fp = '-wavespectrum' 

1788 if idx is not None: 

1789 fp += f'-{idx}' 

1790 return td.write_file_stream(basename, fp, **kwargs) 

1791 

1792 

1793def load_wave_spectrum(file_handle): 

1794 """Load amplitude and phase spectrum of wave EOD from file. 

1795 

1796 Parameters 

1797 ---------- 

1798 file_handle: str, Path, or stream 

1799 The file to be loaded. 

1800 If str or Path and file does not exist, try to open 

1801 possible corresponding zip file and read file from there. 

1802 

1803 Returns 

1804 ------- 

1805 spec: 2D array of floats 

1806 Amplitude and phase spectrum of wave EOD: 

1807 harmonics, frequency, amplitude, relative amplitude in dB, 

1808 relative power in dB, phase, data power in unit squared. 

1809 Can contain NaNs. 

1810 unit: string 

1811 Unit of amplitudes. 

1812 

1813 Raises 

1814 ------ 

1815 FileNotFoundError: 

1816 If `file_path` does not exist. 

1817 

1818 See Also 

1819 -------- 

1820 save_wave_spectrum() 

1821 """ 

1822 file_handle, zf = open_from_zip(file_handle) 

1823 data = TableData(file_handle) 

1824 close_zip(file_handle, zf) 

1825 spec = data.array() 

1826 spec[:, 3] *= 0.01 

1827 return spec, data.unit('amplitude') 

1828 

1829 

1830def add_extract_wave_config(cfg, win_fac='auto', min_segments=6, 

1831 periods=5, frate=1e6, max_harmonics=20, 

1832 min_corr=0.99, min_ampl_frac=0.5): 

1833 """Add all parameters needed for `extract_wave()` as a new 

1834 section to a configuration. 

1835 

1836 Parameters 

1837 ---------- 

1838 cfg: ConfigFile 

1839 The configuration. 

1840  

1841 See `extract_wave()` for details on the remaining arguments. 

1842 """ 

1843 cfg.add_section('Wave-type EOD extraction:') 

1844 cfg.add('dataSegmentDivider', win_fac, '', 'Reduce duration of data segments for EOD waveform estimation by this factor.') 

1845 cfg.add('minimumDataSegments', min_segments, '', 'Minimum number of data segments required for EOD waveform estimation.') 

1846 cfg.add('periodsWaveEOD', periods, '', 'Number of periods computed for an EOD estimate.') 

1847 cfg.add('samplingRateWaveEOD', 0.001*frate, 'kHz', 'Sampling rate of EOD waveform estimates.') 

1848 cfg.add('eodHarmonics', max_harmonics, '', 'Highest harmonics of the Fourier decomposition.') 

1849 cfg.add('minimumEODCorrelations', min_corr, '', 'Minimum correlation between good EOD waveform estimates.') 

1850 cfg.add('minimumEODAmplitude', 100*min_ampl_frac, '%', 'Minimum amplitude of an EOD estimate relative to the largest one.') 

1851 

1852 

1853def extract_wave_args(cfg): 

1854 """Retrieve parameters for `extract_wave()` from configuration. 

1855  

1856 The return value can then be passed as key-word arguments to this 

1857 function. 

1858 

1859 Parameters 

1860 ---------- 

1861 cfg: ConfigFile 

1862 The configuration. 

1863 

1864 Returns 

1865 ------- 

1866 a: dict 

1867 Dictionary with names of arguments of the `extract_wave()` function 

1868 and their values as supplied by `cfg`. 

1869 """ 

1870 a = cfg.map(win_fac='dataSegmentDivider', 

1871 min_segments='minimumDataSegments', 

1872 periods='periodsWaveEOD', 

1873 frate='samplingRateWaveEOD', 

1874 max_harmonics='eodHarmonics', 

1875 min_corr='minimumEODCorrelations', 

1876 min_ampl_frac='minimumEODAmplitude') 

1877 if a['win_fac'] != 'auto': 

1878 a['win_fac'] = int(a['win_fac']) 

1879 a['frate'] *= 1000 

1880 a['min_ampl_frac'] *= 0.01 

1881 return a 

1882 

1883 

1884def add_analyze_wave_config(cfg, max_harmonics=20, flip_wave='none', 

1885 thresh_frac=0.05, n_phase_harmonics=8): 

1886 """Add all parameters needed for `analyze_wave()` as a new 

1887 section to a configuration. 

1888 

1889 Parameters 

1890 ---------- 

1891 cfg: ConfigFile 

1892 The configuration. 

1893  

1894 See `analyze_wave()` for details on the remaining arguments. 

1895 """ 

1896 cfg.add_section('Wave-type EOD analysis:') 

1897 cfg.add('eodHarmonics', max_harmonics, '', 'Highest harmonics of the Fourier decomposition.') 

1898 cfg.add('flipWaveEOD', flip_wave, '', 'Flip EOD of wave fish to make largest extremum positive (flip, none, or auto).') 

1899 cfg.add('waveEODThresholdFraction', 100*thresh_frac, '%', 'Threshold for detecting peaks and troughs in wave-type EODs as a fraction of the p-p amplitude.') 

1900 cfg.add('waveEODPhaseHarmonics', n_phase_harmonics, '', 'Number of harmonics over which to compute the slope of the phases..') 

1901 

1902 

1903def analyze_wave_args(cfg): 

1904 """Retrieve parameters for `analyze_wave()` from configuration. 

1905  

1906 The return value can then be passed as key-word arguments to this 

1907 function. 

1908 

1909 Parameters 

1910 ---------- 

1911 cfg: ConfigFile 

1912 The configuration. 

1913 

1914 Returns 

1915 ------- 

1916 a: dict 

1917 Dictionary with names of arguments of the `analyze_wave()` function 

1918 and their values as supplied by `cfg`. 

1919 """ 

1920 a = cfg.map(max_harmonics='eodHarmonics', 

1921 flip_wave='flipWaveEOD', 

1922 thresh_frac='waveEODThresholdFraction', 

1923 n_phase_harmonics='waveEODPhaseHarmonics') 

1924 a['thresh_frac'] *= 0.01 

1925 return a 

1926 

1927 

1928def wave_quality_args(cfg): 

1929 """Retrieve parameters for `wave_quality()` from configuration. 

1930  

1931 The return value can then be passed as key-word arguments to this 

1932 function. 

1933 

1934 Parameters 

1935 ---------- 

1936 cfg: ConfigFile 

1937 The configuration. 

1938 

1939 Returns 

1940 ------- 

1941 a: dict 

1942 Dictionary with names of arguments of the `wave_quality()` function 

1943 and their values as supplied by `cfg`. 

1944 

1945 See Also 

1946 -------- 

1947 eodanalysis.add_eod_quality_config() 

1948  

1949 """ 

1950 a = cfg.map(min_freq='minimumFrequency', 

1951 max_freq='maximumFrequency', 

1952 max_clipped_frac='maximumClippedFraction', 

1953 max_phases='maximumPhases', 

1954 max_rms_sem='maximumVariance', 

1955 max_rms_error='maximumRMSError', 

1956 min_power='minimumPower', 

1957 max_thd='maximumTotalHarmonicDistortion', 

1958 max_db_diff='maximumPowerDifferences', 

1959 max_relampl_harm2='maximumSecondHarmonicAmplitude', 

1960 max_relampl_harm3='maximumThirdHarmonicAmplitude', 

1961 max_relampl_harm4='maximumFourthHarmonicAmplitude') 

1962 a['max_clipped_frac'] *= 0.01 

1963 return a 

1964 

1965 

1966def main(): 

1967 import matplotlib.pyplot as plt 

1968 from thunderlab.eventdetection import snippets 

1969 from .fakefish import wavefish_eods, export_wavefish 

1970 

1971 print('Analysis of wave-type EOD waveforms.') 

1972 

1973 # data: 

1974 eodf = 456 # Hz 

1975 rate = 96_000 

1976 tmax = 5 # s 

1977 data = wavefish_eods('Eigenmannia', eodf, rate, tmax, noise_std=0.02) 

1978 unit = 'mV' 

1979 eod_times = np.arange(0.5/eodf, tmax - 1/eodf, 1/eodf) 

1980 eod_idx = (eod_times*rate).astype(int) 

1981 

1982 # mean EOD: 

1983 snips = snippets(data, eod_idx, start=-300, stop=300) 

1984 mean_eod = np.mean(snips, 0) 

1985 

1986 # analyse EOD: 

1987 mean_eod, props, power, error_str = \ 

1988 analyze_wave(mean_eod, rate, eodf) 

1989 

1990 # write out as python code: 

1991 export_wavefish(power, '', 'Eigenmannia spec') 

1992 

1993 # plot: 

1994 fig, axs = plt.subplot_mosaic('wa\nwp', layout='constrained') 

1995 plot_wave_eod(axs['w'], mean_eod, props, unit=unit) 

1996 axs['w'].set_title(f'wave fish: EODf = {props["EODf"]:.1f} Hz') 

1997 plot_wave_spectrum(axs['a'], axs['p'], power, props) 

1998 plt.show() 

1999 

2000 

2001if __name__ == '__main__': 

2002 main()