Coverage for src/thunderfish/eodanalysis.py: 32%

1310 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-29 16:21 +0000

1""" 

2Analysis of EOD waveforms. 

3 

4## EOD waveform analysis 

5 

6- `eod_waveform()`: compute an averaged EOD waveform. 

7- `analyze_wave()`: analyze the EOD waveform of a wave fish. 

8- `analyze_pulse()`: analyze the EOD waveform of a pulse fish. 

9- `adjust_eodf()`: adjust EOD frequencies to a standard temperature. 

10 

11## Similarity of EOD waveforms 

12 

13- `wave_similarity()`: root-mean squared difference between two wave fish EODs. 

14- `pulse_similarity()`: root-mean squared difference between two pulse fish EODs. 

15- `load_species_waveforms()`: load template EOD waveforms for species matching. 

16 

17## Quality assessment 

18 

19- `clipped_fraction()`: compute fraction of clipped EOD waveform snippets. 

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

21- `pulse_quality()`: asses quality of EOD waveform of a pulse fish. 

22 

23## Visualization 

24 

25- `plot_eod_recording()`: plot a zoomed in range of the recorded trace. 

26- `plot_pulse_eods()`: mark pulse EODs in a plot of an EOD recording. 

27- `plot_eod_snippets()`: plot a few EOD waveform snippets. 

28- `plot_eod_waveform()`: plot and annotate the averaged EOD-waveform with standard error. 

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

30- `plot_pulse_spectrum()`: plot and annotate spectrum of single pulse EOD. 

31 

32## Storage 

33 

34- `save_eod_waveform()`: save mean EOD waveform to file. 

35- `load_eod_waveform()`: load EOD waveform from file. 

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

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

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

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

40- `save_pulse_fish()`: save properties of pulse EODs to file. 

41- `load_pulse_fish()`: load properties of pulse EODs from file. 

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

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

44- `save_pulse_spectrum()`: save power spectrum of pulse EOD to file. 

45- `load_pulse_spectrum()`: load power spectrum of pulse EOD from file. 

46- `save_pulse_peaks()`: save peak properties of pulse EOD to file. 

47- `load_pulse_peaks()`: load peak properties of pulse EOD from file. 

48- `save_pulse_times()`: save times of pulse EOD to file. 

49- `load_pulse_times()`: load times of pulse EOD from file. 

50- `parse_filename()`: parse components of an EOD analysis file name. 

51- `save_analysis(): save EOD analysis results to files. 

52- `load_analysis()`: load EOD analysis files. 

53- `load_recording()`: load recording. 

54 

55## Fit functions 

56 

57- `fourier_series()`: Fourier series of sine waves with amplitudes and phases. 

58- `exp_decay()`: exponential decay. 

59 

60## Filter functions 

61 

62- `unfilter()`: apply inverse low-pass filter on data. 

63 

64## Configuration 

65 

66- `add_eod_analysis_config()`: add parameters for EOD analysis functions to configuration. 

67- `eod_waveform_args()`: retrieve parameters for `eod_waveform()` from configuration. 

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

69- `analyze_pulse_args()`: retrieve parameters for `analyze_pulse()` from configuration. 

70- `add_species_config()`: add parameters needed for assigning EOD waveforms to species. 

71- `add_eod_quality_config()`: add parameters needed for assesing the quality of an EOD waveform. 

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

73- `pulse_quality_args()`: retrieve parameters for `pulse_quality()` from configuration. 

74""" 

75 

76import os 

77import io 

78import glob 

79import zipfile 

80import numpy as np 

81from scipy.optimize import curve_fit 

82import matplotlib.patches as mpatches 

83import matplotlib.pyplot as plt 

84from thunderlab.eventdetection import percentile_threshold, detect_peaks, snippets, peak_width 

85from thunderlab.eventdetection import threshold_crossings, threshold_crossing_times, merge_events 

86from thunderlab.powerspectrum import next_power_of_two, nfft, decibel 

87from thunderlab.tabledata import TableData 

88from thunderlab.dataloader import load_data 

89from .harmonics import fundamental_freqs_and_power 

90 

91 

92def eod_waveform(data, samplerate, eod_times, win_fac=2.0, min_win=0.01, 

93 min_sem=False, max_eods=None, unfilter_cutoff=0.0): 

94 """Detect EODs in the given data, extract data snippets around each EOD, 

95 and compute a mean waveform with standard error. 

96 

97 Retrieving the EOD waveform of a wave fish works under the following 

98 conditions: (i) at a signal-to-noise ratio \\(SNR = P_s/P_n\\), 

99 i.e. the power \\(P_s\\) of the EOD of interest relative to the 

100 largest other EOD \\(P_n\\), we need to average over at least \\(n > 

101 (SNR \\cdot c_s^2)^{-1}\\) snippets to bring the standard error of the 

102 averaged EOD waveform down to \\(c_s\\) relative to its 

103 amplitude. For a s.e.m. less than 5% ( \\(c_s=0.05\\) ) and an SNR of 

104 -10dB (the signal is 10 times smaller than the noise, \\(SNR=0.1\\) ) we 

105 get \\(n > 0.00025^{-1} = 4000\\) data snippets - a recording a 

106 couple of seconds long. (ii) Very important for wave fish is that 

107 they keep their frequency constant. Slight changes in the EOD 

108 frequency will corrupt the average waveform. If the period of the 

109 waveform changes by \\(c_f=\\Delta T/T\\), then after \\(n = 

110 1/c_f\\) periods moved the modified waveform through a whole period. 

111 This is in the range of hundreds or thousands waveforms. 

112 

113 NOTE: we need to take into account a possible error in the estimate 

114 of the EOD period. This will limit the maximum number of snippets to 

115 be averaged. 

116 

117 If `min_sem` is set, the algorithm checks for a global minimum of 

118 the s.e.m. as a function of snippet number. If there is one then 

119 the average is computed for this number of snippets, otherwise all 

120 snippets are taken from the provided data segment. Note that this 

121 check only works for the strongest EOD in a recording. For weaker 

122 EOD the s.e.m. always decays with snippet number (empirical 

123 observation). 

124 

125 TODO: use power spectra to check for changes in EOD frequency! 

126 

127 Parameters 

128 ---------- 

129 data: 1-D array of float 

130 The data to be analysed. 

131 samplerate: float 

132 Sampling rate of the data in Hertz. 

133 eod_times: 1-D array of float 

134 Array of EOD times in seconds over which the waveform should be 

135 averaged. 

136 WARNING: The first data point must be at time zero! 

137 win_fac: float 

138 The snippet size is the EOD period times `win_fac`. The EOD period 

139 is determined as the minimum interval between EOD times. 

140 min_win: float 

141 The minimum size of the snippets in seconds. 

142 min_sem: bool 

143 If set, check for minimum in s.e.m. to set the maximum numbers 

144 of EODs to be used for computing the average waveform. 

145 max_eods: int or None 

146 Maximum number of EODs to be used for averaging. 

147 unfilter_cutoff: float 

148 If not zero, the cutoff frequency for an inverse high-pass filter 

149 applied to the mean EOD waveform. 

150  

151 Returns 

152 ------- 

153 mean_eod: 2-D array 

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

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

156 eod_times: 1-D array 

157 Times of EOD peaks in seconds that have been actually used to calculate the 

158 averaged EOD waveform. 

159 """ 

160 # indices of EOD times: 

161 eod_idx = np.round(eod_times * samplerate).astype(int) 

162 

163 # window size: 

164 period = np.min(np.diff(eod_times)) 

165 win = 0.5*win_fac*period 

166 if 2*win < min_win: 

167 win = 0.5*min_win 

168 win_inx = int(win * samplerate) 

169 

170 # extract snippets: 

171 eod_times = eod_times[(eod_idx >= win_inx) & (eod_idx < len(data)-win_inx)] 

172 eod_idx = eod_idx[(eod_idx >= win_inx) & (eod_idx < len(data)-win_inx)] 

173 if max_eods and max_eods > 0 and len(eod_idx) > max_eods: 

174 dn = (len(eod_idx) - max_eods)//2 

175 eod_times = eod_times[dn:dn+max_eods] 

176 eod_idx = eod_idx[dn:dn+max_eods] 

177 eod_snippets = snippets(data, eod_idx, -win_inx, win_inx) 

178 if len(eod_snippets) == 0: 

179 return np.zeros((0, 3)), eod_times 

180 

181 # optimal number of snippets: 

182 step = 10 

183 if min_sem and len(eod_snippets) > step: 

184 sems = [np.mean(np.std(eod_snippets[:k], axis=0, ddof=1)/np.sqrt(k)) 

185 for k in range(step, len(eod_snippets), step)] 

186 idx = np.argmin(sems) 

187 # there is a local minimum: 

188 if idx > 0 and idx < len(sems)-1: 

189 maxn = step*(idx+1) 

190 eod_snippets = eod_snippets[:maxn] 

191 eod_times = eod_times[:maxn] 

192 

193 # mean and std of snippets: 

194 mean_eod = np.zeros((len(eod_snippets[0]), 3)) 

195 mean_eod[:,1] = np.mean(eod_snippets, axis=0) 

196 if len(eod_snippets) > 1: 

197 mean_eod[:,2] = np.std(eod_snippets, axis=0, ddof=1)/np.sqrt(len(eod_snippets)) 

198 

199 # apply inverse filter: 

200 if unfilter_cutoff and unfilter_cutoff > 0.0: 

201 unfilter(mean_eod[:,1], samplerate, unfilter_cutoff) 

202 

203 # time axis: 

204 mean_eod[:,0] = (np.arange(len(mean_eod)) - win_inx) / samplerate 

205 

206 return mean_eod, eod_times 

207 

208 

209def unfilter(data, samplerate, cutoff): 

210 """Apply inverse high-pass filter on data. 

211 

212 Assumes high-pass filter 

213 \\[ \\tau \\dot y = -y + \\tau \\dot x \\] 

214 has been applied on the original data \\(x\\), where 

215 \\(\\tau=(2\\pi f_{cutoff})^{-1}\\) is the time constant of the 

216 filter. To recover \\(x\\) the ODE 

217 \\[ \\tau \\dot x = y + \\tau \\dot y \\] 

218 is applied on the filtered data \\(y\\). 

219 

220 Parameters 

221 ---------- 

222 data: ndarray 

223 High-pass filtered original data. 

224 samplerate: float 

225 Sampling rate of `data` in Hertz. 

226 cutoff: float 

227 Cutoff frequency \\(f_{cutoff}\\) of the high-pass filter in Hertz. 

228 

229 Returns 

230 ------- 

231 data: ndarray 

232 Recovered original data. 

233 """ 

234 tau = 0.5/np.pi/cutoff 

235 fac = tau*samplerate 

236 data -= np.mean(data) 

237 d0 = data[0] 

238 x = d0 

239 for k in range(len(data)): 

240 d1 = data[k] 

241 x += (d1 - d0) + d0/fac 

242 data[k] = x 

243 d0 = d1 

244 return data 

245 

246 

247def fourier_series(t, freq, *ap): 

248 """Fourier series of sine waves with amplitudes and phases. 

249 

250 x(t) = sum_{i=0}^n ap[2*i]*sin(2 pi (i+1) freq t + ap[2*i+1]) 

251  

252 Parameters 

253 ---------- 

254 t: float or array 

255 Time. 

256 freq: float 

257 Fundamental frequency. 

258 *ap: list of floats 

259 The amplitudes and phases (in rad) of the fundamental and harmonics. 

260  

261 Returns 

262 ------- 

263 x: float or array 

264 The Fourier series evaluated at times `t`. 

265 """ 

266 omega = 2.0*np.pi*freq 

267 x = 0.0 

268 for i, (a, p) in enumerate(zip(ap[0:-1:2], ap[1::2])): 

269 x += a*np.sin((i+1)*omega*t+p) 

270 return x 

271 

272 

273def analyze_wave(eod, freq, n_harm=10, power_n_harmonics=0, 

274 n_harmonics=3, flip_wave='none'): 

275 """Analyze the EOD waveform of a wave fish. 

276  

277 Parameters 

278 ---------- 

279 eod: 2-D array 

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

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

282 standard error of the EOD waveform, Further columns are 

283 optional but not used. 

284 freq: float or 2-D array 

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

286 frequency and peak height (columns) as returned from 

287 `harmonics.harmonic_groups()`. 

288 n_harm: int 

289 Maximum number of harmonics used for the Fourier decomposition. 

290 power_n_harmonics: int 

291 Sum over the first `power_n_harmonics` harmonics for computing 

292 the total power. If 0 sum over all harmonics. 

293 n_harmonics: int 

294 The maximum power of higher harmonics is computed from 

295 harmonics higher than the maximum harmonics within the first 

296 three harmonics plus `n_harmonics`. 

297 flip_wave: 'auto', 'none', 'flip' 

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

299 - 'flip' flip waveform. 

300 - 'none' do not flip waveform. 

301  

302 Returns 

303 ------- 

304 meod: 2-D array of floats 

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

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

307 input `eod`. And a column is added with the fit of the fourier 

308 series to the waveform. 

309 props: dict 

310 A dictionary with properties of the analyzed EOD waveform. 

311 

312 - type: set to 'wave'. 

313 - EODf: is set to the EOD fundamental frequency. 

314 - p-p-amplitude: peak-to-peak amplitude of the Fourier fit. 

315 - flipped: True if the waveform was flipped. 

316 - amplitude: amplitude factor of the Fourier fit. 

317 - noise: root-mean squared standard error mean of the averaged 

318 EOD waveform relative to the p-p amplitude. 

319 - rmserror: root-mean-square error between Fourier-fit and 

320 EOD waveform relative to the p-p amplitude. If larger than 

321 about 0.05 the data are bad. 

322 - ncrossings: number of zero crossings per period 

323 - peakwidth: width of the peak at the averaged amplitude relative 

324 to EOD period. 

325 - troughwidth: width of the trough at the averaged amplitude 

326 relative to EOD period. 

327 - leftpeak: time from positive zero crossing to peak relative 

328 to EOD period. 

329 - rightpeak: time from peak to negative zero crossing relative to 

330 EOD period. 

331 - lefttrough: time from negative zero crossing to trough relative to 

332 EOD period. 

333 - righttrough: time from trough to positive zero crossing relative to 

334 EOD period. 

335 - p-p-distance: time between peak and trough relative to EOD period. 

336 - relpeakampl: amplitude of peak or trough, whichever is larger, relative to p-p amplitude. 

337 - power: summed power of all harmonics of the extracted EOD waveform 

338 in decibel relative to one. 

339 - datapower: summed power of all harmonics of the original data in 

340 decibel relative to one. Only if `freq` is a list of harmonics. 

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

342 amplitudes squared of harmonics relative to amplitude 

343 of fundamental.  

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

345 differences in decibel power. 

346 - maxdb: maximum power of higher harmonics relative to peak power 

347 in decibel. 

348 

349 spec_data: 2-D array of floats 

350 First size columns are from the spectrum of the extracted 

351 waveform. First column is the index of the harmonics, second 

352 column its frequency, third column its amplitude, fourth 

353 column its amplitude relative to the fundamental, fifth column 

354 is power of harmonics relative to fundamental in decibel, and 

355 sixth column the phase shift relative to the fundamental. 

356 If `freq` is a list of harmonics, a seventh column is added to 

357 `spec_data` that contains the powers of the harmonics from the 

358 original power spectrum of the raw data. Rows are the 

359 harmonics, first row is the fundamental frequency with index 

360 0, relative amplitude of one, relative power of 0dB, and phase 

361 shift of zero. 

362 error_str: string 

363 If fitting of the fourier series failed, 

364 this is reported in this string. 

365 

366 Raises 

367 ------ 

368 IndexError: 

369 EOD data is less than one period long. 

370 """ 

371 error_str = '' 

372 

373 freq0 = freq 

374 if hasattr(freq, 'shape'): 

375 freq0 = freq[0][0] 

376 

377 # storage: 

378 meod = np.zeros((eod.shape[0], eod.shape[1]+1)) 

379 meod[:,:-1] = eod 

380 

381 # subtract mean and flip: 

382 period = 1.0/freq0 

383 pinx = int(np.ceil(period/(meod[1,0]-meod[0,0]))) 

384 maxn = (len(meod)//pinx)*pinx 

385 if maxn < pinx: maxn = len(meod) 

386 offs = (len(meod) - maxn)//2 

387 meod[:,1] -= np.mean(meod[offs:offs+pinx,1]) 

388 flipped = False 

389 if 'flip' in flip_wave or ('auto' in flip_wave and -np.min(meod[:,1]) > np.max(meod[:,1])): 

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

391 flipped = True 

392 

393 # move peak of waveform to zero: 

394 offs = len(meod)//4 

395 maxinx = offs+np.argmax(meod[offs:3*offs,1]) 

396 meod[:,0] -= meod[maxinx,0] 

397 

398 # indices of exactly one or two periods around peak: 

399 if len(meod) < pinx: 

400 raise IndexError('data need to contain at least one EOD period') 

401 if len(meod) >= 2*pinx: 

402 i0 = maxinx - pinx if maxinx >= pinx else 0 

403 i1 = i0 + 2*pinx 

404 if i1 > len(meod): 

405 i1 = len(meod) 

406 i0 = i1 - 2*pinx 

407 else: 

408 i0 = maxinx - pinx//2 if maxinx >= pinx//2 else 0 

409 i1 = i0 + pinx 

410 

411 # subtract mean: 

412 meod[:,1] -= np.mean(meod[i0:i1,1]) 

413 

414 # zero crossings: 

415 ui, di = threshold_crossings(meod[:,1], 0.0) 

416 ut, dt = threshold_crossing_times(meod[:,0], meod[:,1], 0.0, ui, di) 

417 ut, dt = merge_events(ut, dt, 0.02/freq0) 

418 ncrossings = int(np.round((len(ut) + len(dt))/(meod[-1,0]-meod[0,0])/freq0)) 

419 if np.any(ut<0.0): 

420 up_time = ut[ut<0.0][-1] 

421 else: 

422 up_time = 0.0 

423 error_str += '%.1f Hz wave fish: no upward zero crossing. ' % freq0 

424 if np.any(dt>0.0): 

425 down_time = dt[dt>0.0][0] 

426 else: 

427 down_time = 0.0 

428 error_str += '%.1f Hz wave fish: no downward zero crossing. ' % freq0 

429 peak_width = down_time - up_time 

430 trough_width = period - peak_width 

431 peak_time = 0.0 

432 trough_time = meod[maxinx+np.argmin(meod[maxinx:maxinx+pinx,1]),0] 

433 phase1 = peak_time - up_time 

434 phase2 = down_time - peak_time 

435 phase3 = trough_time - down_time 

436 phase4 = up_time + period - trough_time 

437 distance = trough_time - peak_time 

438 

439 # fit fourier series: 

440 ampl = 0.5*(np.max(meod[:,1])-np.min(meod[:,1])) 

441 while n_harm > 1: 

442 params = [freq0] 

443 for i in range(1, n_harm+1): 

444 params.extend([ampl/i, 0.0]) 

445 try: 

446 popt, pcov = curve_fit(fourier_series, meod[i0:i1,0], 

447 meod[i0:i1,1], params, maxfev=2000) 

448 break 

449 except (RuntimeError, TypeError): 

450 error_str += '%.1f Hz wave fish: fit of fourier series failed for %d harmonics. ' % (freq0, n_harm) 

451 n_harm //= 2 

452 # store fourier fit: 

453 meod[:,-1] = fourier_series(meod[:,0], *popt) 

454 # make all amplitudes positive: 

455 for i in range(n_harm): 

456 if popt[i*2+1] < 0.0: 

457 popt[i*2+1] *= -1.0 

458 popt[i*2+2] += np.pi 

459 # phases relative to fundamental: 

460 # phi0 = 2*pi*f0*dt <=> dt = phi0/(2*pi*f0) 

461 # phik = 2*pi*i*f0*dt = i*phi0 

462 phi0 = popt[2] 

463 for i in range(n_harm): 

464 popt[i*2+2] -= (i + 1)*phi0 

465 # all phases in the range -pi to pi: 

466 popt[i*2+2] %= 2.0*np.pi 

467 if popt[i*2+2] > np.pi: 

468 popt[i*2+2] -= 2.0*np.pi 

469 # shift time axis: 

470 # meod[:,0] += phi0/2/np.pi/freq0 

471 # store fourier spectrum: 

472 if hasattr(freq, 'shape'): 

473 n = n_harm 

474 n += np.sum(freq[:,0] > (n_harm+0.5)*freq[0,0]) 

475 spec_data = np.zeros((n, 7)) 

476 spec_data[:,:] = np.nan 

477 k = 0 

478 for i in range(n_harm): 

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

480 k += 1 

481 if k >= len(freq): 

482 break 

483 if freq[k,0] < (i+1.5)*freq0: 

484 spec_data[i,6] = freq[k,1] 

485 k += 1 

486 for i in range(n_harm, n): 

487 if k >= len(freq): 

488 break 

489 spec_data[i,:2] = [np.round(freq[k,0]/freq0)-1, freq[k,0]] 

490 spec_data[i,6] = freq[k,1] 

491 k += 1 

492 else: 

493 spec_data = np.zeros((n_harm, 6)) 

494 for i in range(n_harm): 

495 spec_data[i,:6] = [i, (i+1)*freq0, popt[i*2+1], popt[i*2+1]/popt[1], 

496 decibel((popt[i*2+1]/popt[1])**2.0), popt[i*2+2]] 

497 # smoothness of power spectrum: 

498 db_powers = decibel(spec_data[:n_harm,2]**2) 

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

500 # maximum relative power of higher harmonics: 

501 p_max = np.argmax(db_powers[:3]) 

502 db_powers -= db_powers[p_max] 

503 if len(db_powers[p_max+n_harmonics:]) == 0: 

504 max_harmonics_power = -100.0 

505 else: 

506 max_harmonics_power = np.max(db_powers[p_max+n_harmonics:]) 

507 # total harmonic distortion: 

508 thd = np.sqrt(np.nansum(spec_data[1:,3])) 

509 

510 # peak-to-peak and trough amplitudes: 

511 ppampl = np.max(meod[i0:i1,1]) - np.min(meod[i0:i1,1]) 

512 relpeakampl = min(np.max(meod[i0:i1,1]), np.abs(np.min(meod[i0:i1,1])))/ppampl 

513 

514 # variance and fit error: 

515 rmssem = np.sqrt(np.mean(meod[i0:i1,2]**2.0))/ppampl if eod.shape[1] > 2 else None 

516 rmserror = np.sqrt(np.mean((meod[i0:i1,1] - meod[i0:i1,-1])**2.0))/ppampl 

517 

518 # store results: 

519 props = {} 

520 props['type'] = 'wave' 

521 props['EODf'] = freq0 

522 props['p-p-amplitude'] = ppampl 

523 props['flipped'] = flipped 

524 props['amplitude'] = 0.5*ppampl # remove it 

525 props['rmserror'] = rmserror 

526 if rmssem: 

527 props['noise'] = rmssem 

528 props['ncrossings'] = ncrossings 

529 props['peakwidth'] = peak_width/period 

530 props['troughwidth'] = trough_width/period 

531 props['leftpeak'] = phase1/period 

532 props['rightpeak'] = phase2/period 

533 props['lefttrough'] = phase3/period 

534 props['righttrough'] = phase4/period 

535 props['p-p-distance'] = distance/period 

536 props['relpeakampl'] = relpeakampl 

537 pnh = power_n_harmonics if power_n_harmonics > 0 else n_harm 

538 pnh = min(n_harm, pnh) 

539 props['power'] = decibel(np.sum(spec_data[:pnh,2]**2.0)) 

540 if hasattr(freq, 'shape'): 

541 props['datapower'] = decibel(np.sum(freq[:pnh,1])) 

542 props['thd'] = thd 

543 props['dbdiff'] = db_diff 

544 props['maxdb'] = max_harmonics_power 

545 

546 return meod, props, spec_data, error_str 

547 

548 

549def exp_decay(t, tau, ampl, offs): 

550 """Exponential decay function. 

551 

552 x(t) = ampl*exp(-t/tau) + offs 

553 

554 Parameters 

555 ---------- 

556 t: float or array 

557 Time. 

558 tau: float 

559 Time constant of exponential decay. 

560 ampl: float 

561 Amplitude of exponential decay, i.e. initial value minus 

562 steady-state value. 

563 offs: float 

564 Steady-state value. 

565  

566 Returns 

567 ------- 

568 x: float or array 

569 The exponential decay evaluated at times `t`. 

570 

571 """ 

572 return offs + ampl*np.exp(-t/tau) 

573 

574 

575def analyze_pulse(eod, eod_times=None, min_pulse_win=0.001, 

576 peak_thresh_fac=0.01, min_dist=50.0e-6, 

577 width_frac=0.5, fit_frac = 0.5, freq_resolution=1.0, 

578 flip_pulse='none', ipi_cv_thresh=0.5, 

579 ipi_percentile=30.0): 

580 """Analyze the EOD waveform of a pulse fish. 

581  

582 Parameters 

583 ---------- 

584 eod: 2-D array 

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

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

587 standard error of the EOD waveform, Further columns are 

588 optional but not used. 

589 eod_times: 1-D array or None 

590 List of times of detected EOD peaks. 

591 min_pulse_win: float 

592 The minimum size of cut-out EOD waveform. 

593 peak_thresh_fac: float 

594 Set the threshold for peak detection to the maximum pulse 

595 amplitude times this factor. 

596 min_dist: float 

597 Minimum distance between peak and troughs of the pulse. 

598 width_frac: float 

599 The width of a peak is measured at this fraction of a peak's 

600 height (0-1). 

601 fit_frac: float or None 

602 An exponential is fitted to the tail of the last peak/trough 

603 starting where the waveform falls below this fraction of the 

604 peak's height (0-1). 

605 freq_resolution: float 

606 The frequency resolution of the power spectrum of the single pulse. 

607 flip_pulse: 'auto', 'none', 'flip' 

608 - 'auto' flip waveform such that the first large extremum is positive. 

609 - 'flip' flip waveform. 

610 - 'none' do not flip waveform. 

611 ipi_cv_thresh: float 

612 If the coefficient of variation of the interpulse intervals 

613 are smaller than this threshold, then the EOD frequency is 

614 computed as the inverse of the mean of all interpulse 

615 intervals. Otherwise only intervals smaller than a certain 

616 quantile are used. 

617 ipi_percentile: float 

618 When computing the EOD frequency, period, mean and standard 

619 deviation of interpulse intervals from a subset of the 

620 interpulse intervals, only intervals smaller than this 

621 percentile (between 0 and 100) are used. 

622  

623 Returns 

624 ------- 

625 meod: 2-D array of floats 

626 The eod waveform. First column is time in seconds, 

627 second column the eod waveform. 

628 Further columns are kept from the input `eod`. 

629 As a last column the fit to the tail of the last peak is appended. 

630 props: dict 

631 A dictionary with properties of the analyzed EOD waveform. 

632 

633 - type: set to 'pulse'. 

634 - EODf: the inverse of the median interval between `eod_times`, 

635 if provided. 

636 - period: the median interval between `eod_times`, if provided. 

637 - IPI-mean: the mean interval between `eod_times`, if provided. 

638 - IPI-std: the standard deviation of the intervals between 

639 `eod_times`, if provided. 

640 - max-ampl: the amplitude of the largest positive peak (P1). 

641 - min-ampl: the amplitude of the largest negative peak (P2). 

642 - p-p-amplitude: peak-to-peak amplitude of the EOD waveform. 

643 - noise: root-mean squared standard error mean of the averaged 

644 EOD waveform relative to the p-p amplitude. 

645 - tstart: time in seconds where the pulse starts, 

646 i.e. crosses the threshold for the first time. 

647 - tend: time in seconds where the pulse ends, 

648 i.e. crosses the threshold for the last time. 

649 - width: total width of the pulse in seconds (tend-tstart). 

650 - P2-P1-dist: distance between P2 and P1 in seconds. 

651 - tau: time constant of exponential decay of pulse tail in seconds. 

652 - firstpeak: index of the first peak in the pulse (i.e. -1 for P-1) 

653 - lastpeak: index of the last peak in the pulse (i.e. 3 for P3) 

654 - peakfreq: frequency at peak power of the single pulse spectrum 

655 in Hertz. 

656 - peakpower: peak power of the single pulse spectrum in decibel. 

657 - poweratt5: how much the average power below 5 Hz is attenuated 

658 relative to the peak power in decibel. 

659 - poweratt50: how much the average power below 5 Hz is attenuated 

660 relative to the peak power in decibel. 

661 - lowcutoff: frequency at which the power reached half of the 

662 peak power relative to the initial power in Hertz. 

663 - flipped: True if the waveform was flipped. 

664 - n: number of pulses analyzed (i.e. `len(eod_times)`), if provided. 

665 - times: the times of the detected EOD pulses (i.e. `eod_times`), 

666 if provided. 

667 

668 Empty if waveform is not a pulse EOD. 

669 peaks: 2-D array 

670 For each peak and trough (rows) of the EOD waveform 

671 5 columns: the peak index (1 is P1, i.e. the largest positive peak), 

672 time relative to largest positive peak, amplitude, 

673 amplitude normalized to largest postive peak, 

674 and width of peak/trough at half height. 

675 Empty if waveform is not a pulse EOD. 

676 power: 2-D array 

677 The power spectrum of a single pulse. First column are the 

678 frequencies, second column the power in x^2/Hz such that the 

679 integral equals the variance. Empty if waveform is not a 

680 pulse EOD. 

681 

682 """ 

683 # storage: 

684 meod = np.zeros((eod.shape[0], eod.shape[1]+1)) 

685 meod[:,:eod.shape[1]] = eod 

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

687 toffs = 0 

688 

689 # cut out stable estimate if standard deviation is available: 

690 if eod.shape[1] > 2 and np.max(meod[:,2]) > 3*np.min(meod[:,2]): 

691 n = len(meod) 

692 idx0 = np.argmax(np.abs(meod[n//10:9*n//10,1])) + n//10 

693 toffs += meod[idx0,0] 

694 meod[:,0] -= meod[idx0,0] 

695 # minimum in standard deviation: 

696 lstd_idx = np.argmin(meod[:idx0-5,2]) 

697 rstd_idx = np.argmin(meod[idx0+5:,2]) + idx0 

698 # central, left, and right maximum of standard deviation: 

699 max_std = np.max(meod[lstd_idx:rstd_idx,2]) 

700 l_std = np.max(meod[:len(meod)//4,2]) 

701 r_std = np.max(meod[len(meod)*3//4:,2]) 

702 lidx = 0 

703 ridx = len(meod) 

704 if l_std > max_std and lstd_idx > lidx: 

705 lidx = lstd_idx - np.argmax(meod[lstd_idx:0:-1,2] >= 0.25*l_std + 0.75*meod[lstd_idx,2]) 

706 if r_std > max_std and rstd_idx < ridx: 

707 ridx = rstd_idx + np.argmax(meod[rstd_idx:,2] >= 0.25*r_std + 0.75*meod[rstd_idx,2]) 

708 #plt.plot(meod[:,0], meod[:,1]) 

709 #plt.plot(meod[:,0], meod[:,2], '-r') 

710 #plt.plot([meod[lidx,0], meod[lidx,0]], [-0.1, 0.1], '-k') 

711 #plt.plot([meod[ridx-1,0], meod[ridx-1,0]], [-0.1, 0.1], '-b') 

712 #plt.show() 

713 meod = meod[lidx:ridx,:] 

714 

715 # subtract mean computed from the ends of the snippet: 

716 n = len(meod)//20 if len(meod) >= 20 else 1 

717 meod[:,1] -= 0.5*(np.mean(meod[:n,1]) + np.mean(meod[-n:,1])) 

718 

719 # largest positive and negative peak: 

720 flipped = False 

721 max_idx = np.argmax(meod[:,1]) 

722 max_ampl = np.abs(meod[max_idx,1]) 

723 min_idx = np.argmin(meod[:,1]) 

724 min_ampl = np.abs(meod[min_idx,1]) 

725 amplitude = np.max((max_ampl, min_ampl)) 

726 if max_ampl > 0.2*amplitude and min_ampl > 0.2*amplitude: 

727 # two major peaks: 

728 if 'flip' in flip_pulse or ('auto' in flip_pulse and min_idx < max_idx): 

729 # flip: 

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

731 peak_idx = min_idx 

732 min_idx = max_idx 

733 max_idx = peak_idx 

734 flipped = True 

735 elif 'flip' in flip_pulse or ('auto' in flip_pulse and min_ampl > 0.2*amplitude): 

736 # flip: 

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

738 peak_idx = min_idx 

739 min_idx = max_idx 

740 max_idx = peak_idx 

741 flipped = True 

742 max_ampl = np.abs(meod[max_idx,1]) 

743 min_ampl = np.abs(meod[min_idx,1]) 

744 

745 # move peak of waveform to zero: 

746 toffs += meod[max_idx,0] 

747 meod[:,0] -= meod[max_idx,0] 

748 

749 # minimum threshold for peak detection: 

750 n = len(meod[:,1])//10 if len(meod) >= 20 else 2 

751 thl_max = np.max(meod[:n,1]) 

752 thl_min = np.min(meod[:n,1]) 

753 thr_max = np.max(meod[-n:,1]) 

754 thr_min = np.min(meod[-n:,1]) 

755 min_thresh = 2*np.max([thl_max, thr_max]) - np.min([thl_min, thr_min]) 

756 if min_thresh > 0.5*(max_ampl + min_ampl): 

757 min_thresh = 0.5*(max_ampl + min_ampl) 

758 fit_frac = None 

759 # threshold for peak detection: 

760 threshold = max_ampl*peak_thresh_fac 

761 if threshold < min_thresh: 

762 threshold = min_thresh 

763 if threshold <= 0.0: 

764 return meod, {}, [], [] 

765 

766 # cut out relevant signal: 

767 lidx = np.argmax(np.abs(meod[:,1]) > threshold) 

768 ridx = len(meod) - 1 - np.argmax(np.abs(meod[::-1,1]) > threshold) 

769 t0 = meod[lidx,0] 

770 t1 = meod[ridx,0] 

771 width = t1 - t0 

772 if width < min_pulse_win: 

773 width = min_pulse_win 

774 dt = meod[1,0] - meod[0,0] 

775 width_idx = int(np.round(width/dt)) 

776 # expand width: 

777 leidx = lidx - width_idx//2 

778 if leidx < 0: 

779 leidx = 0 

780 reidx = ridx + width_idx//2 

781 if reidx >= len(meod): 

782 reidx = len(meod) 

783 meod = meod[leidx:reidx,:] 

784 lidx -= leidx 

785 ridx -= leidx 

786 max_idx -= leidx 

787 min_idx -= leidx 

788 tau = None 

789 dist = 0.0 

790 peaks = [] 

791 

792 # amplitude and variance: 

793 ppampl = max_ampl + min_ampl 

794 rmssem = np.sqrt(np.mean(meod[:,2]**2.0))/ppampl if eod.shape[1] > 2 else None 

795 

796 # find smaller peaks: 

797 peak_idx, trough_idx = detect_peaks(meod[:,1], threshold) 

798 

799 if len(peak_idx) > 0: 

800 # and their width: 

801 peak_widths = peak_width(meod[:,0], meod[:,1], peak_idx, trough_idx, 

802 peak_frac=width_frac, base='max') 

803 trough_widths = peak_width(meod[:,0], -meod[:,1], trough_idx, peak_idx, 

804 peak_frac=width_frac, base='max') 

805 # combine peaks and troughs: 

806 pt_idx = np.concatenate((peak_idx, trough_idx)) 

807 pt_widths = np.concatenate((peak_widths, trough_widths)) 

808 pts_idx = np.argsort(pt_idx) 

809 peak_list = pt_idx[pts_idx] 

810 width_list = pt_widths[pts_idx] 

811 # remove multiple peaks that are too close: XXX replace by Dexters function that keeps the maximum peak 

812 rmidx = [(k, k+1) for k in np.where(np.diff(meod[peak_list,0]) < min_dist)[0]] 

813 # flatten and keep maximum peak: 

814 rmidx = np.unique([k for kk in rmidx for k in kk if peak_list[k] != max_idx]) 

815 # delete: 

816 if len(rmidx) > 0: 

817 peak_list = np.delete(peak_list, rmidx) 

818 width_list = np.delete(width_list, rmidx) 

819 if len(peak_list) == 0: 

820 return meod, {}, [], [] 

821 # find P1: 

822 p1i = np.argmax(peak_list == max_idx) 

823 # truncate peaks to the left: XXX REALLY? WHY? 

824 offs = 0 if p1i <= 2 else p1i - 2 

825 peak_list = peak_list[offs:] 

826 width_list = width_list[offs:] 

827 p1i -= offs 

828 # store peaks: 

829 peaks = np.zeros((len(peak_list), 5)) 

830 for i, pi in enumerate(peak_list): 

831 peaks[i,:] = [i+1-p1i, meod[pi,0], meod[pi,1], meod[pi,1]/max_ampl, width_list[i]] 

832 # P2 - P1 distance: 

833 dist = peaks[p1i+1,1] - peaks[p1i,1] if p1i+1 < len(peaks) else 0.0 

834 # fit exponential to last peak/trough: 

835 pi = peak_list[-1] 

836 # positive or negative decay: 

837 sign = 1.0 

838 if np.sum(meod[pi:,1] < -0.5*threshold) > np.sum(meod[pi:,1] > 0.5*threshold): 

839 sign = -1.0 

840 if sign*meod[pi,1] < 0.0: 

841 pi += np.argmax(sign*meod[pi:,1]) 

842 pi_ampl = np.abs(meod[pi,1]) 

843 n = len(meod[pi:]) 

844 # no sufficiently large initial value: 

845 if fit_frac and pi_ampl*fit_frac <= 0.5*threshold: 

846 fit_frac = False 

847 # no sufficiently long decay: 

848 if n < 10: 

849 fit_frac = False 

850 # not decaying towards zero: 

851 max_line = pi_ampl - (pi_ampl-threshold)*np.arange(n)/n + 1e-8 

852 if np.any(np.abs(meod[pi+2:,1]) > max_line[2:]): 

853 fit_frac = False 

854 if fit_frac: 

855 thresh = meod[pi,1]*fit_frac 

856 inx = pi + np.argmax(sign*meod[pi:,1] < sign*thresh) 

857 thresh = meod[inx,1]*np.exp(-1.0) 

858 tau_inx = np.argmax(sign*meod[inx:,1] < sign*thresh) 

859 if tau_inx < 2: 

860 tau_inx = 2 

861 rridx = inx + 6*tau_inx 

862 if rridx > len(meod)-1: 

863 tau = None 

864 else: 

865 tau = meod[inx+tau_inx,0]-meod[inx,0] 

866 params = [tau, meod[inx,1]-meod[rridx,1], meod[rridx,1]] 

867 try: 

868 popt, pcov = curve_fit(exp_decay, meod[inx:rridx,0]-meod[inx,0], 

869 meod[inx:rridx,1], params, 

870 bounds=([0.0, -np.inf, -np.inf], np.inf)) 

871 except TypeError: 

872 popt, pcov = curve_fit(exp_decay, meod[inx:rridx,0]-meod[inx,0], 

873 meod[inx:rridx,1], params) 

874 if popt[0] > 1.2*tau: 

875 tau_inx = int(np.round(popt[0]/dt)) 

876 rridx = inx + 6*tau_inx 

877 if rridx > len(meod)-1: 

878 rridx = len(meod)-1 

879 try: 

880 popt, pcov = curve_fit(exp_decay, meod[inx:rridx,0]-meod[inx,0], 

881 meod[inx:rridx,1], popt, 

882 bounds=([0.0, -np.inf, -np.inf], np.inf)) 

883 except TypeError: 

884 popt, pcov = curve_fit(exp_decay, meod[inx:rridx,0]-meod[inx,0], 

885 meod[inx:rridx,1], popt) 

886 tau = popt[0] 

887 meod[inx:rridx,-1] = exp_decay(meod[inx:rridx,0]-meod[inx,0], *popt) 

888 

889 # power spectrum of single pulse: 

890 samplerate = 1.0/(meod[1,0]-meod[0,0]) 

891 n_fft = nfft(samplerate, freq_resolution) 

892 

893 n0 = max_idx 

894 n1 = len(meod)-max_idx 

895 n = 2*max(n0, n1) 

896 if n_fft < n: 

897 n_fft = next_power_of_two(n) 

898 data = np.zeros(n_fft) 

899 data[n_fft//2-n0:n_fft//2+n1] = meod[:,1] 

900 nr = n//4 

901 data[n_fft//2-n0:n_fft//2-n0+nr] *= np.arange(nr)/nr 

902 data[n_fft//2+n1-nr:n_fft//2+n1] *= np.arange(nr)[::-1]/nr 

903 freqs = np.fft.rfftfreq(n_fft, 1.0/samplerate) 

904 fourier = np.fft.rfft(data)/n_fft/freqs[1] 

905 power = np.abs(fourier)**2.0 

906 ppower = np.zeros((len(power), 2)) 

907 

908 ppower[:,0] = freqs 

909 ppower[:,1] = power 

910 maxpower = np.max(power) 

911 att5 = decibel(np.mean(power[freqs<5.0]), maxpower) 

912 att50 = decibel(np.mean(power[freqs<50.0]), maxpower) 

913 lowcutoff = freqs[decibel(power, maxpower) > 0.5*att5][0] 

914 

915 # analyze pulse timing: 

916 if eod_times is not None: 

917 inter_pulse_intervals = np.diff(eod_times) 

918 ipi_cv = np.std(inter_pulse_intervals)/np.mean(inter_pulse_intervals) 

919 if ipi_cv < ipi_cv_thresh: 

920 period = np.median(inter_pulse_intervals) 

921 ipi_mean = np.mean(inter_pulse_intervals) 

922 ipi_std = np.std(inter_pulse_intervals) 

923 else: 

924 intervals = inter_pulse_intervals[inter_pulse_intervals < 

925 np.percentile(inter_pulse_intervals, ipi_percentile)] 

926 period = np.median(intervals) 

927 ipi_mean = np.mean(intervals) 

928 ipi_std = np.std(intervals) 

929 

930 # store properties: 

931 props = {} 

932 props['type'] = 'pulse' 

933 if eod_times is not None: 

934 props['EODf'] = 1.0/period 

935 props['period'] = period 

936 props['IPI-mean'] = ipi_mean 

937 props['IPI-std'] = ipi_std 

938 props['max-ampl'] = max_ampl 

939 props['min-ampl'] = min_ampl 

940 props['p-p-amplitude'] = ppampl 

941 if rmssem: 

942 props['noise'] = rmssem 

943 props['tstart'] = t0 

944 props['tend'] = t1 

945 props['width'] = t1-t0 

946 props['P2-P1-dist'] = dist 

947 if tau: 

948 props['tau'] = tau 

949 props['firstpeak'] = peaks[0, 0] if len(peaks) > 0 else 1 

950 props['lastpeak'] = peaks[-1, 0] if len(peaks) > 0 else 1 

951 props['peakfreq'] = freqs[np.argmax(power)] 

952 props['peakpower'] = decibel(maxpower) 

953 props['poweratt5'] = att5 

954 props['poweratt50'] = att50 

955 props['lowcutoff'] = lowcutoff 

956 props['flipped'] = flipped 

957 if eod_times is not None: 

958 props['n'] = len(eod_times) 

959 props['times'] = eod_times + toffs 

960 

961 return meod, props, peaks, ppower 

962 

963 

964def adjust_eodf(eodf, temp, temp_adjust=25.0, q10=1.62): 

965 """Adjust EOD frequencies to a standard temperature using Q10. 

966 

967 Parameters 

968 ---------- 

969 eodf: float or ndarray 

970 EOD frequencies. 

971 temp: float 

972 Temperature in degree celsisus at which EOD frequencies in 

973 `eodf` were measured. 

974 temp_adjust: float 

975 Standard temperature in degree celsisus to which EOD 

976 frequencies are adjusted. 

977 q10: float 

978 Q10 value describing temperature dependence of EOD 

979 frequencies. The default of 1.62 is from Dunlap, Smith, Yetka 

980 (2000) Brain Behav Evol, measured for Apteronotus 

981 lepthorhynchus in the lab. 

982 

983 Returns 

984 ------- 

985 eodf_corrected: float or array 

986 EOD frequencies adjusted to `temp_adjust` using `q10`. 

987 """ 

988 return eodf*q10**((temp_adjust - temp) / 10.0) 

989 

990 

991def load_species_waveforms(species_file='none'): 

992 """Load template EOD waveforms for species matching. 

993  

994 Parameters 

995 ---------- 

996 species_file: string 

997 Name of file containing species definitions. The content of 

998 this file is as follows: 

999  

1000 - Empty lines and line starting with a hash ('#') are skipped. 

1001 - A line with the key-word 'wavefish' marks the beginning of the 

1002 table for wave fish. 

1003 - A line with the key-word 'pulsefish' marks the beginning of the 

1004 table for pulse fish. 

1005 - Each line in a species table has three fields, 

1006 separated by colons (':'): 

1007  

1008 1. First field is an abbreviation of the species name. 

1009 2. Second field is the filename of the recording containing the 

1010 EOD waveform. 

1011 3. The optional third field is the EOD frequency of the EOD waveform. 

1012 

1013 The EOD frequency is used to normalize the time axis of a 

1014 wave fish EOD to one EOD period. If it is not specified in 

1015 the third field, it is taken from the corresponding 

1016 *-wavespectrum-* file, if present. Otherwise the species is 

1017 excluded and a warning is issued. 

1018 

1019 Example file content: 

1020 ``` plain 

1021 Wavefish 

1022 Aptero : F_91009L20-eodwaveform-0.csv : 823Hz 

1023 Eigen : C_91008L01-eodwaveform-0.csv 

1024 

1025 Pulsefish 

1026 Gymnotus : pulsefish/gymnotus.csv 

1027 Brachy : H_91009L11-eodwaveform-0.csv 

1028 ``` 

1029  

1030 Returns 

1031 ------- 

1032 wave_names: list of strings 

1033 List of species names of wave-type fish. 

1034 wave_eods: list of 2-D arrays 

1035 List of EOD waveforms of wave-type fish corresponding to 

1036 `wave_names`. First column of a waveform is time in seconds, 

1037 second column is the EOD waveform. The waveforms contain 

1038 exactly one EOD period. 

1039 pulse_names: list of strings 

1040 List of species names of pulse-type fish. 

1041 pulse_eods: list of 2-D arrays 

1042 List of EOD waveforms of pulse-type fish corresponding to 

1043 `pulse_names`. First column of a waveform is time in seconds, 

1044 second column is the EOD waveform. 

1045 """ 

1046 if len(species_file) == 0 or species_file == 'none' or \ 

1047 not os.path.isfile(species_file): 

1048 return [], [], [], [] 

1049 wave_names = [] 

1050 wave_eods = [] 

1051 pulse_names = [] 

1052 pulse_eods = [] 

1053 fish_type = 'wave' 

1054 with open(species_file, 'r') as sf: 

1055 for line in sf: 

1056 line = line.strip() 

1057 if len(line) == 0 or line[0] == '#': 

1058 continue 

1059 if line.lower() == 'wavefish': 

1060 fish_type = 'wave' 

1061 elif line.lower() == 'pulsefish': 

1062 fish_type = 'pulse' 

1063 else: 

1064 ls = [s.strip() for s in line.split(':')] 

1065 if len(ls) < 2: 

1066 continue 

1067 name = ls[0] 

1068 waveform_file = ls[1] 

1069 eod = TableData(waveform_file).array() 

1070 eod[:,0] *= 0.001 

1071 if fish_type == 'wave': 

1072 eodf = None 

1073 if len(ls) > 2: 

1074 eodf = float(ls[2].replace('Hz', '').strip()) 

1075 else: 

1076 spectrum_file = waveform_file.replace('eodwaveform', 'wavespectrum') 

1077 try: 

1078 wave_spec = TableData(spectrum_file) 

1079 eodf = wave_spec[0, 1] 

1080 except FileNotFoundError: 

1081 pass 

1082 if eodf is None: 

1083 print('warning: unknown EOD frequency of %s. Skip.' % name) 

1084 continue 

1085 eod[:,0] *= eodf 

1086 wave_names.append(name) 

1087 wave_eods.append(eod[:,:2]) 

1088 elif fish_type == 'pulse': 

1089 pulse_names.append(name) 

1090 pulse_eods.append(eod[:,:2]) 

1091 return wave_names, wave_eods, pulse_names, pulse_eods 

1092 

1093 

1094def wave_similarity(eod1, eod2, eod1f=1.0, eod2f=1.0): 

1095 """Root-mean squared difference between two wave fish EODs. 

1096 

1097 Compute the root-mean squared difference between two wave fish 

1098 EODs over one period. The better sampled signal is down-sampled to 

1099 the worse sampled one. Amplitude are normalized to peak-to-peak 

1100 amplitude before computing rms difference. Also compute the rms 

1101 difference between the one EOD and the other one inverted in 

1102 amplitude. The smaller of the two rms values is returned. 

1103 

1104 Parameters 

1105 ---------- 

1106 eod1: 2-D array 

1107 Time and amplitude of reference EOD. 

1108 eod2: 2-D array 

1109 Time and amplitude of EOD that is to be compared to `eod1`. 

1110 eod1f: float 

1111 EOD frequency of `eod1` used to transform the time axis of `eod1` 

1112 to multiples of the EOD period. If already normalized to EOD period, 

1113 as for example by the `load_species_waveforms()` function, then 

1114 set the EOD frequency to one (default). 

1115 eod2f: float 

1116 EOD frequency of `eod2` used to transform the time axis of `eod2` 

1117 to multiples of the EOD period. If already normalized to EOD period, 

1118 as for example by the `load_species_waveforms()` function, then 

1119 set the EOD frequency to one (default). 

1120 

1121 Returns 

1122 ------- 

1123 rmse: float 

1124 Root-mean-squared difference between the two EOD waveforms relative to 

1125 their standard deviation over one period. 

1126 """ 

1127 # copy: 

1128 eod1 = np.array(eod1[:,:2]) 

1129 eod2 = np.array(eod2[:,:2]) 

1130 # scale to multiples of EOD period: 

1131 eod1[:,0] *= eod1f 

1132 eod2[:,0] *= eod2f 

1133 # make eod1 the waveform with less samples per period: 

1134 n1 = int(1.0/(eod1[1,0]-eod1[0,0])) 

1135 n2 = int(1.0/(eod2[1,0]-eod2[0,0])) 

1136 if n1 > n2: 

1137 eod1, eod2 = eod2, eod1 

1138 n1, n2 = n2, n1 

1139 # one period around time zero: 

1140 i0 = np.argmin(np.abs(eod1[:,0])) 

1141 k0 = i0-n1//2 

1142 if k0 < 0: 

1143 k0 = 0 

1144 k1 = k0 + n1 + 1 

1145 if k1 >= len(eod1): 

1146 k1 = len(eod1) 

1147 # cut out one period from the reference EOD around maximum: 

1148 i = k0 + np.argmax(eod1[k0:k1,1]) 

1149 k0 = i-n1//2 

1150 if k0 < 0: 

1151 k0 = 0 

1152 k1 = k0 + n1 + 1 

1153 if k1 >= len(eod1): 

1154 k1 = len(eod1) 

1155 eod1 = eod1[k0:k1,:] 

1156 # normalize amplitudes of first EOD: 

1157 eod1[:,1] -= np.min(eod1[:,1]) 

1158 eod1[:,1] /= np.max(eod1[:,1]) 

1159 sigma = np.std(eod1[:,1]) 

1160 # set time zero to maximum of second EOD: 

1161 i0 = np.argmin(np.abs(eod2[:,0])) 

1162 k0 = i0-n2//2 

1163 if k0 < 0: 

1164 k0 = 0 

1165 k1 = k0 + n2 + 1 

1166 if k1 >= len(eod2): 

1167 k1 = len(eod2) 

1168 i = k0 + np.argmax(eod2[k0:k1,1]) 

1169 eod2[:,0] -= eod2[i,0] 

1170 # interpolate eod2 to the time base of eod1: 

1171 eod2w = np.interp(eod1[:,0], eod2[:,0], eod2[:,1]) 

1172 # normalize amplitudes of second EOD: 

1173 eod2w -= np.min(eod2w) 

1174 eod2w /= np.max(eod2w) 

1175 # root-mean-square difference: 

1176 rmse1 = np.sqrt(np.mean((eod1[:,1] - eod2w)**2))/sigma 

1177 # root-mean-square difference of the flipped signal: 

1178 i = k0 + np.argmin(eod2[k0:k1,1]) 

1179 eod2[:,0] -= eod2[i,0] 

1180 eod2w = np.interp(eod1[:,0], eod2[:,0], -eod2[:,1]) 

1181 eod2w -= np.min(eod2w) 

1182 eod2w /= np.max(eod2w) 

1183 rmse2 = np.sqrt(np.mean((eod1[:,1] - eod2w)**2))/sigma 

1184 # take the smaller value: 

1185 rmse = min(rmse1, rmse2) 

1186 return rmse 

1187 

1188 

1189def pulse_similarity(eod1, eod2, wfac=10.0): 

1190 """Root-mean squared difference between two pulse fish EODs. 

1191 

1192 Compute the root-mean squared difference between two pulse fish 

1193 EODs over `wfac` times the distance between minimum and maximum of 

1194 the wider EOD. The waveforms are normalized to their maxima prior 

1195 to computing the rms difference. Also compute the rms difference 

1196 between the one EOD and the other one inverted in amplitude. The 

1197 smaller of the two rms values is returned. 

1198 

1199 Parameters 

1200 ---------- 

1201 eod1: 2-D array 

1202 Time and amplitude of reference EOD. 

1203 eod2: 2-D array 

1204 Time and amplitude of EOD that is to be compared to `eod1`. 

1205 wfac: float 

1206 Multiply the distance between minimum and maximum by this factor 

1207 to get the window size over which to compute the rms difference. 

1208 

1209 Returns 

1210 ------- 

1211 rmse: float 

1212 Root-mean-squared difference between the two EOD waveforms 

1213 relative to their standard deviation over the analysis window. 

1214 """ 

1215 # copy: 

1216 eod1 = np.array(eod1[:,:2]) 

1217 eod2 = np.array(eod2[:,:2]) 

1218 # width of the pulses: 

1219 imin1 = np.argmin(eod1[:,1]) 

1220 imax1 = np.argmax(eod1[:,1]) 

1221 w1 = np.abs(eod1[imax1,0]-eod1[imin1,0]) 

1222 imin2 = np.argmin(eod2[:,1]) 

1223 imax2 = np.argmax(eod2[:,1]) 

1224 w2 = np.abs(eod2[imax2,0]-eod2[imin2,0]) 

1225 w = wfac*max(w1, w2) 

1226 # cut out signal from the reference EOD: 

1227 n = int(w/(eod1[1,0]-eod1[0,0])) 

1228 i0 = imax1-n//2 

1229 if i0 < 0: 

1230 i0 = 0 

1231 i1 = imax1+n//2+1 

1232 if i1 >= len(eod1): 

1233 i1 = len(eod1) 

1234 eod1[:,0] -= eod1[imax1,0] 

1235 eod1 = eod1[i0:i1,:] 

1236 # normalize amplitude of first EOD: 

1237 eod1[:,1] /= np.max(eod1[:,1]) 

1238 sigma = np.std(eod1[:,1]) 

1239 # interpolate eod2 to the time base of eod1: 

1240 eod2[:,0] -= eod2[imax2,0] 

1241 eod2w = np.interp(eod1[:,0], eod2[:,0], eod2[:,1]) 

1242 # normalize amplitude of second EOD: 

1243 eod2w /= np.max(eod2w) 

1244 # root-mean-square difference: 

1245 rmse1 = np.sqrt(np.mean((eod1[:,1] - eod2w)**2))/sigma 

1246 # root-mean-square difference of the flipped signal: 

1247 eod2[:,0] -= eod2[imin2,0] 

1248 eod2w = np.interp(eod1[:,0], eod2[:,0], -eod2[:,1]) 

1249 eod2w /= np.max(eod2w) 

1250 rmse2 = np.sqrt(np.mean((eod1[:,1] - eod2w)**2))/sigma 

1251 # take the smaller value: 

1252 rmse = min(rmse1, rmse2) 

1253 return rmse 

1254 

1255 

1256def clipped_fraction(data, samplerate, eod_times, mean_eod, 

1257 min_clip=-np.inf, max_clip=np.inf): 

1258 """Compute fraction of clipped EOD waveform snippets. 

1259 

1260 Cut out snippets at each `eod_times` based on time axis of 

1261 `mean_eod`. Check which fraction of snippets exceeds clipping 

1262 amplitude `min_clip` and `max_clip`. 

1263 

1264 Parameters 

1265 ---------- 

1266 data: 1-D array of float 

1267 The data to be analysed. 

1268 samplerate: float 

1269 Sampling rate of the data in Hertz. 

1270 eod_times: 1-D array of float 

1271 Array of EOD times in seconds. 

1272 mean_eod: 2-D array with time, mean, sem, and fit. 

1273 Averaged EOD waveform of wave fish. Only the time axis is used 

1274 to set width of snippets. 

1275 min_clip: float 

1276 Minimum amplitude that is not clipped. 

1277 max_clip: float 

1278 Maximum amplitude that is not clipped. 

1279  

1280 Returns 

1281 ------- 

1282 clipped_frac: float 

1283 Fraction of snippets that are clipped. 

1284 """ 

1285 # snippets: 

1286 idx0 = np.argmin(np.abs(mean_eod[:,0])) # index of time zero 

1287 w0 = -idx0 

1288 w1 = len(mean_eod[:,0]) - idx0 

1289 eod_idx = np.round(eod_times * samplerate).astype(int) 

1290 eod_snippets = snippets(data, eod_idx, w0, w1) 

1291 # fraction of clipped snippets: 

1292 clipped_frac = np.sum(np.any((eod_snippets > max_clip) | 

1293 (eod_snippets < min_clip), axis=1))\ 

1294 / len(eod_snippets) 

1295 return clipped_frac 

1296 

1297 

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

1299 max_freq=2000.0, max_clipped_frac=0.1, 

1300 max_crossings=4, max_rms_sem=0.0, max_rms_error=0.05, 

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

1302 max_harmonics_db=-5.0, max_relampl_harm1=0.0, 

1303 max_relampl_harm2=0.0, max_relampl_harm3=0.0): 

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

1305  

1306 Parameters 

1307 ---------- 

1308 props: dict 

1309 A dictionary with properties of the analyzed EOD waveform 

1310 as returned by `analyze_wave()`. 

1311 harm_relampl: 1-D array of floats or None 

1312 Relative amplitude of at least the first 3 harmonics without 

1313 the fundamental. 

1314 min_freq: float 

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

1316 max_freq: float 

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

1318 max_clipped_frac: float 

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

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

1321 max_crossings: int 

1322 If larger than zero, maximum number of zero crossings per EOD period 

1323 (`props['ncrossings']`). 

1324 max_rms_sem: float 

1325 If larger than zero, maximum allowed standard error of the 

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

1327 max_rms_error: float 

1328 If larger than zero, maximum allowed root-mean-square error 

1329 between EOD waveform and Fourier fit relative to p-p amplitude 

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

1331 min_power: float 

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

1333 max_thd: float 

1334 If larger than zero, then maximum total harmonic distortion 

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

1336 max_db_diff: float 

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

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

1339 Low values enforce smoother power spectra. 

1340 max_harmonics_db: 

1341 Maximum power of higher harmonics relative to peak power in 

1342 decibel (`props['maxdb']`). 

1343 max_relampl_harm1: float 

1344 If larger than zero, maximum allowed amplitude of first harmonic 

1345 relative to fundamental. 

1346 max_relampl_harm2: float 

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

1348 relative to fundamental. 

1349 max_relampl_harm3: float 

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

1351 relative to fundamental. 

1352  

1353 Returns 

1354 ------- 

1355 remove: bool 

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

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

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

1359 frequencies, but remove it from the waveform properties if 

1360 `skip_reason` is not empty. 

1361 skip_reason: string 

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

1363 indicating the failure. 

1364 msg: string 

1365 A textual representation of the values tested. 

1366 """ 

1367 remove = False 

1368 msg = [] 

1369 skip_reason = [] 

1370 # EOD frequency: 

1371 if 'EODf' in props: 

1372 eodf = props['EODf'] 

1373 msg += ['EODf=%5.1fHz' % eodf] 

1374 if eodf < min_freq or eodf > max_freq: 

1375 remove = True 

1376 skip_reason += ['invalid EODf=%5.1fHz (minimumFrequency=%5.1fHz, maximumFrequency=%5.1f))' % 

1377 (eodf, min_freq, max_freq)] 

1378 # clipped fraction: 

1379 if 'clipped' in props: 

1380 clipped_frac = props['clipped'] 

1381 msg += ['clipped=%3.0f%%' % (100.0*clipped_frac)] 

1382 if max_clipped_frac > 0 and clipped_frac >= max_clipped_frac: 

1383 skip_reason += ['clipped=%3.0f%% (maximumClippedFraction=%3.0f%%)' % 

1384 (100.0*clipped_frac, 100.0*max_clipped_frac)] 

1385 # too many zero crossings: 

1386 if 'ncrossings' in props: 

1387 ncrossings = props['ncrossings'] 

1388 msg += ['zero crossings=%d' % ncrossings] 

1389 if max_crossings > 0 and ncrossings > max_crossings: 

1390 skip_reason += ['too many zero crossings=%d (maximumCrossings=%d)' % 

1391 (ncrossings, max_crossings)] 

1392 # noise: 

1393 rms_sem = None 

1394 if 'rmssem' in props: 

1395 rms_sem = props['rmssem'] 

1396 if 'noise' in props: 

1397 rms_sem = props['noise'] 

1398 if rms_sem is not None: 

1399 msg += ['rms sem waveform=%6.2f%%' % (100.0*rms_sem)] 

1400 if max_rms_sem > 0.0 and rms_sem >= max_rms_sem: 

1401 skip_reason += ['noisy waveform s.e.m.=%6.2f%% (max %6.2f%%)' % 

1402 (100.0*rms_sem, 100.0*max_rms_sem)] 

1403 # fit error: 

1404 if 'rmserror' in props: 

1405 rms_error = props['rmserror'] 

1406 msg += ['rmserror=%6.2f%%' % (100.0*rms_error)] 

1407 if max_rms_error > 0.0 and rms_error >= max_rms_error: 

1408 skip_reason += ['noisy rmserror=%6.2f%% (maximumVariance=%6.2f%%)' % 

1409 (100.0*rms_error, 100.0*max_rms_error)] 

1410 # wave power: 

1411 if 'power' in props: 

1412 power = props['power'] 

1413 msg += ['power=%6.1fdB' % power] 

1414 if power < min_power: 

1415 skip_reason += ['small power=%6.1fdB (minimumPower=%6.1fdB)' % 

1416 (power, min_power)] 

1417 # total harmonic distortion: 

1418 if 'thd' in props: 

1419 thd = props['thd'] 

1420 msg += ['thd=%5.1f%%' % (100.0*thd)] 

1421 if max_thd > 0.0 and thd > max_thd: 

1422 skip_reason += ['large THD=%5.1f%% (maxximumTotalHarmonicDistortion=%5.1f%%)' % 

1423 (100.0*thd, 100.0*max_thd)] 

1424 # smoothness of spectrum: 

1425 if 'dbdiff' in props: 

1426 db_diff = props['dbdiff'] 

1427 msg += ['dBdiff=%5.1fdB' % db_diff] 

1428 if max_db_diff > 0.0 and db_diff > max_db_diff: 

1429 remove = True 

1430 skip_reason += ['not smooth s.d. diff=%5.1fdB (maxximumPowerDifference=%5.1fdB)' % 

1431 (db_diff, max_db_diff)] 

1432 # maximum power of higher harmonics: 

1433 if 'maxdb' in props: 

1434 max_harmonics = props['maxdb'] 

1435 msg += ['max harmonics=%5.1fdB' % max_harmonics] 

1436 if max_harmonics > max_harmonics_db: 

1437 remove = True 

1438 skip_reason += ['maximum harmonics=%5.1fdB too strong (maximumHarmonicsPower=%5.1fdB)' % 

1439 (max_harmonics, max_harmonics_db)] 

1440 # relative amplitude of harmonics: 

1441 if harm_relampl is not None: 

1442 for k, max_relampl in enumerate([max_relampl_harm1, max_relampl_harm2, max_relampl_harm3]): 

1443 if k >= len(harm_relampl): 

1444 break 

1445 msg += ['ampl%d=%5.1f%%' % (k+1, 100.0*harm_relampl[k])] 

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

1447 num_str = ['First', 'Second', 'Third'] 

1448 skip_reason += ['distorted ampl%d=%5.1f%% (maximum%sHarmonicAmplitude=%5.1f%%)' % 

1449 (k+1, 100.0*harm_relampl[k], num_str[k], 100.0*max_relampl)] 

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

1451 

1452 

1453def pulse_quality(props, max_clipped_frac=0.1, max_rms_sem=0.0): 

1454 """Assess the quality of an EOD waveform of a pulse fish. 

1455  

1456 Parameters 

1457 ---------- 

1458 props: dict 

1459 A dictionary with properties of the analyzed EOD waveform 

1460 as returned by `analyze_pulse()`. 

1461 max_clipped_frac: float 

1462 Maximum allowed fraction of clipped data. 

1463 max_rms_sem: float 

1464 If not zero, maximum allowed standard error of the data 

1465 relative to p-p amplitude. 

1466 

1467 Returns 

1468 ------- 

1469 skip_reason: string 

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

1471 indicating the failure. 

1472 msg: string 

1473 A textual representation of the values tested. 

1474 skipped_clipped: bool 

1475 True if waveform was skipped because of clipping. 

1476 """ 

1477 msg = [] 

1478 skip_reason = [] 

1479 skipped_clipped = False 

1480 # clipped fraction: 

1481 if 'clipped' in props: 

1482 clipped_frac = props['clipped'] 

1483 msg += ['clipped=%3.0f%%' % (100.0*clipped_frac)] 

1484 if clipped_frac >= max_clipped_frac: 

1485 skip_reason += ['clipped=%3.0f%% (maximumClippedFraction=%3.0f%%)' % 

1486 (100.0*clipped_frac, 100.0*max_clipped_frac)] 

1487 skipped_clipped = True 

1488 # noise: 

1489 rms_sem = None 

1490 if 'rmssem' in props: 

1491 rms_sem = props['rmssem'] 

1492 if 'noise' in props: 

1493 rms_sem = props['noise'] 

1494 if rms_sem is not None: 

1495 msg += ['rms sem waveform=%6.2f%%' % (100.0*rms_sem)] 

1496 if max_rms_sem > 0.0 and rms_sem >= max_rms_sem: 

1497 skip_reason += ['noisy waveform s.e.m.=%6.2f%% (maximumRMSNoise=%6.2f%%)' % 

1498 (100.0*rms_sem, 100.0*max_rms_sem)] 

1499 return ', '.join(skip_reason), ', '.join(msg), skipped_clipped 

1500 

1501 

1502def plot_eod_recording(ax, data, samplerate, unit=None, width=0.1, 

1503 toffs=0.0, kwargs={'lw': 2, 'color': 'red'}): 

1504 """Plot a zoomed in range of the recorded trace. 

1505 

1506 Parameters 

1507 ---------- 

1508 ax: matplotlib axes 

1509 Axes used for plotting. 

1510 data: 1D ndarray 

1511 Recorded data to be plotted. 

1512 samplerate: float 

1513 Sampling rate of the data in Hertz. 

1514 unit: string 

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

1516 width: float 

1517 Width of data segment to be plotted in seconds. 

1518 toffs: float 

1519 Time of first data value in seconds. 

1520 kwargs: dict 

1521 Arguments passed on to the plot command for the recorded trace. 

1522 """ 

1523 widx2 = int(width*samplerate)//2 

1524 i0 = len(data)//2 - widx2 

1525 i0 = (i0//widx2)*widx2 

1526 i1 = i0 + 2*widx2 

1527 if i0 < 0: 

1528 i0 = 0 

1529 if i1 >= len(data): 

1530 i1 = len(data) 

1531 time = np.arange(len(data))/samplerate + toffs 

1532 tunit = 'sec' 

1533 if np.abs(time[i0]) < 1.0 and np.abs(time[i1]) < 1.0: 

1534 time *= 1000.0 

1535 tunit = 'ms' 

1536 ax.plot(time, data, **kwargs) 

1537 ax.set_xlim(time[i0], time[i1]) 

1538 

1539 ax.set_xlabel('Time [%s]' % tunit) 

1540 ymin = np.min(data[i0:i1]) 

1541 ymax = np.max(data[i0:i1]) 

1542 dy = ymax - ymin 

1543 ax.set_ylim(ymin-0.05*dy, ymax+0.05*dy) 

1544 if len(unit) == 0 or unit == 'a.u.': 

1545 ax.set_ylabel('Amplitude') 

1546 else: 

1547 ax.set_ylabel('Amplitude [%s]' % unit) 

1548 

1549 

1550def plot_pulse_eods(ax, data, samplerate, zoom_window, width, eod_props, 

1551 toffs=0.0, colors=None, markers=None, marker_size=10, 

1552 legend_rows=8, **kwargs): 

1553 """Mark pulse EODs in a plot of an EOD recording. 

1554 

1555 Parameters 

1556 ---------- 

1557 ax: matplotlib axes 

1558 Axes used for plotting. 

1559 data: 1D ndarray 

1560 Recorded data (these are not plotted!). 

1561 samplerate: float 

1562 Sampling rate of the data in Hertz. 

1563 zoom_window: tuple of floats 

1564 Start and end time of the data to be plotted in seconds. 

1565 width: float 

1566 Minimum width of the data to be plotted in seconds. 

1567 eod_props: list of dictionaries 

1568 Lists of EOD properties as returned by `analyze_pulse()` 

1569 and `analyze_wave()`. From the entries with 'type' == 

1570 'pulse' the properties 'EODf' and 'times' are used. 'EODf' 

1571 is the averaged EOD frequency, and 'times' is a list of 

1572 detected EOD pulse times. 

1573 toffs: float 

1574 Time of first data value in seconds that will be added 

1575 to the pulse times in `eod_props`. 

1576 colors: list of colors or None 

1577 If not None list of colors for plotting each cluster 

1578 markers: list of markers or None 

1579 If not None list of markers for plotting each cluster 

1580 marker_size: float 

1581 Size of markers used to mark the pulses. 

1582 legend_rows: int 

1583 Maximum number of rows to be used for the legend. 

1584 kwargs:  

1585 Key word arguments for the legend of the plot. 

1586 """ 

1587 k = 0 

1588 for eod in eod_props: 

1589 if eod['type'] != 'pulse': 

1590 continue 

1591 if 'times' not in eod: 

1592 continue 

1593 

1594 width = np.min([width, np.diff(zoom_window)[0]]) 

1595 while len(eod['peaktimes'][(eod['peaktimes']>(zoom_window[1]-width)) & (eod['peaktimes']<(zoom_window[1]))]) == 0: 

1596 width *= 2 

1597 if zoom_window[1] - width < 0: 

1598 width = width/2 

1599 break 

1600 

1601 x = eod['peaktimes'] + toffs 

1602 pidx = np.round(eod['peaktimes']*samplerate).astype(int) 

1603 y = data[pidx[(pidx>0)&(pidx<len(data))]] 

1604 x = x[(pidx>0)&(pidx<len(data))] 

1605 color_kwargs = {} 

1606 #if colors is not None: 

1607 # color_kwargs['color'] = colors[k%len(colors)] 

1608 if marker_size is not None: 

1609 color_kwargs['ms'] = marker_size 

1610 label = '%6.1f Hz' % eod['EODf'] 

1611 if legend_rows > 5 and k >= legend_rows: 

1612 label = None 

1613 if markers is None: 

1614 ax.plot(x, y, 'o', label=label, zorder=-1, **color_kwargs) 

1615 else: 

1616 ax.plot(x, y, linestyle='none', label=label, 

1617 marker=markers[k%len(markers)], mec=None, mew=0.0, 

1618 zorder=-1, **color_kwargs) 

1619 k += 1 

1620 

1621 # legend: 

1622 if k > 1: 

1623 if legend_rows > 0: 

1624 if legend_rows > 5: 

1625 ncol = 1 

1626 else: 

1627 ncol = (len(idx)-1) // legend_rows + 1 

1628 ax.legend(numpoints=1, ncol=ncol, **kwargs) 

1629 else: 

1630 ax.legend(numpoints=1, **kwargs) 

1631 

1632 # reset window so at least one EOD of each cluster is visible  

1633 if len(zoom_window)>0: 

1634 ax.set_xlim(max(toffs,toffs+zoom_window[1]-width), toffs+zoom_window[1]) 

1635 

1636 i0 = max(0,int((zoom_window[1]-width)*samplerate)) 

1637 i1 = int(zoom_window[1]*samplerate) 

1638 

1639 ymin = np.min(data[i0:i1]) 

1640 ymax = np.max(data[i0:i1]) 

1641 dy = ymax - ymin 

1642 ax.set_ylim(ymin-0.05*dy, ymax+0.05*dy) 

1643 

1644 

1645def plot_eod_snippets(ax, data, samplerate, tmin, tmax, eod_times, 

1646 n_snippets=10, flip=False, 

1647 kwargs={'zorder': -5, 'scaley': False, 

1648 'lw': 0.5, 'color': '#CCCCCC'}): 

1649 """Plot a few EOD waveform snippets. 

1650 

1651 Parameters 

1652 ---------- 

1653 ax: matplotlib axes 

1654 Axes used for plotting. 

1655 data: 1D ndarray 

1656 Recorded data from which the snippets are taken. 

1657 samplerate: float 

1658 Sampling rate of the data in Hertz. 

1659 tmin: float 

1660 Start time of each snippet. 

1661 tmax: float 

1662 End time of each snippet. 

1663 eod_times: 1-D array 

1664 EOD peak times from which a few are selected to be plotted. 

1665 n_snippets: int 

1666 Number of snippets to be plotted. If zero do not plot anything. 

1667 flip: bool 

1668 If True flip the snippets upside down. 

1669 kwargs: dict 

1670 Arguments passed on to the plot command for plotting the snippets. 

1671 """ 

1672 if n_snippets <= 0: 

1673 return 

1674 i0 = int(tmin*samplerate) 

1675 i1 = int(tmax*samplerate) 

1676 time = 1000.0*np.arange(i0, i1)/samplerate 

1677 step = len(eod_times)//n_snippets 

1678 if step < 1: 

1679 step = 1 

1680 for t in eod_times[n_snippets//2::step]: 

1681 idx = int(np.round(t*samplerate)) 

1682 if idx+i0 < 0 or idx+i1 >= len(data): 

1683 continue 

1684 snippet = data[idx+i0:idx+i1] 

1685 if flip: 

1686 snippet *= -1 

1687 ax.plot(time, snippet - np.mean(snippet[:len(snippet)//4]), **kwargs) 

1688 

1689 

1690def plot_eod_waveform(ax, eod_waveform, props, peaks=None, unit=None, 

1691 mkwargs={'zorder': 10, 'lw': 2, 'color': 'red'}, 

1692 skwargs={'zorder': -10, 'color': '#CCCCCC'}, 

1693 fkwargs={'zorder': 5, 'lw': 6, 'color': 'steelblue'}, 

1694 zkwargs={'zorder': -5, 'lw': 1, 'color': '#AAAAAA'}): 

1695 """Plot mean EOD, its standard error, and an optional fit to the EOD. 

1696 

1697 Parameters 

1698 ---------- 

1699 ax: matplotlib axes 

1700 Axes used for plotting. 

1701 eod_waveform: 2-D array 

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

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

1704 standard error, and the optional fourth column is a fit on the 

1705 waveform. 

1706 props: dict 

1707 A dictionary with properties of the analyzed EOD waveform as 

1708 returned by `analyze_wave()` and `analyze_pulse()`. 

1709 peaks: 2_D arrays or None 

1710 List of peak properties (index, time, and amplitude) of a EOD pulse 

1711 as returned by `analyze_pulse()`. 

1712 unit: string 

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

1714 mkwargs: dict 

1715 Arguments passed on to the plot command for the mean EOD. 

1716 skwargs: dict 

1717 Arguments passed on to the fill_between command for the 

1718 standard error of the EOD. 

1719 fkwargs: dict 

1720 Arguments passed on to the plot command for the fitted EOD. 

1721 zkwargs: dict 

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

1723 """ 

1724 ax.autoscale(True) 

1725 time = 1000.0 * eod_waveform[:,0] 

1726 # plot zero line: 

1727 ax.axhline(0.0, **zkwargs) 

1728 # plot fit: 

1729 if eod_waveform.shape[1] > 3: 

1730 ax.plot(time, eod_waveform[:,3], **fkwargs) 

1731 # plot waveform: 

1732 mean_eod = eod_waveform[:,1] 

1733 ax.plot(time, mean_eod, **mkwargs) 

1734 # plot standard error: 

1735 if eod_waveform.shape[1] > 2: 

1736 std_eod = eod_waveform[:,2] 

1737 if np.mean(std_eod)/(np.max(mean_eod) - np.min(mean_eod)) > 0.1: 

1738 ax.autoscale_view(False) 

1739 ax.autoscale(False) 

1740 ax.fill_between(time, mean_eod + std_eod, mean_eod - std_eod, 

1741 **skwargs) 

1742 # ax height dimensions: 

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

1744 ymin, ymax = ax.get_ylim() 

1745 unity = ymax - ymin 

1746 dyu = np.abs(unity)/pixely 

1747 font_size = plt.rcParams['font.size']*dyu 

1748 # annotate fit: 

1749 tau = None if props is None else props.get('tau', None) 

1750 ty = 0.0 

1751 if tau is not None and eod_waveform.shape[1] > 3: 

1752 if tau < 0.001: 

1753 label = u'\u03c4=%.0f\u00b5s' % (1.e6*tau) 

1754 else: 

1755 label = u'\u03c4=%.2fms' % (1.e3*tau) 

1756 inx = np.argmin(np.isnan(eod_waveform[:,3])) 

1757 x = eod_waveform[inx,0] + 1.5*tau 

1758 ty = 0.7*eod_waveform[inx,3] 

1759 if np.abs(ty) < 0.5*font_size: 

1760 ty = 0.5*font_size*np.sign(ty) 

1761 va = 'bottom' if ty > 0.0 else 'top' 

1762 ax.text(1000.0*x, ty, label, ha='left', va=va, zorder=20) 

1763 # annotate peaks: 

1764 if peaks is not None and len(peaks)>0: 

1765 for p in peaks: 

1766 ax.scatter(1000.0*p[1], p[2], s=80, clip_on=False, zorder=0, alpha=0.4, 

1767 c=mkwargs['color'], edgecolors=mkwargs['color']) 

1768 label = u'P%d' % p[0] 

1769 if p[0] != 1: 

1770 if np.abs(p[1]) < 0.001: 

1771 ts = u'%.0f\u00b5s' % (1.0e6*p[1]) 

1772 elif np.abs(p[1]) < 0.01: 

1773 ts = u'%.2fms' % (1.0e3*p[1]) 

1774 else: 

1775 ts = u'%.3gms' % (1.0e3*p[1]) 

1776 if np.abs(p[3]) < 0.05: 

1777 ps = u'%.1f%%' % (100*p[3]) 

1778 else: 

1779 ps = u'%.0f%%' % (100*p[3]) 

1780 label += u'(%s @ %s)' % (ps, ts) 

1781 va = 'bottom' 

1782 dy = 0.4*font_size 

1783 if p[0] % 2 == 0: 

1784 va = 'top' 

1785 dy = -dy 

1786 if p[0] == 1: 

1787 dy = 0.0 

1788 """ 

1789 if p[2] <= np.min(peaks[:,2]): 

1790 dy = -0.8*font_size 

1791 va = 'bottom' 

1792 """ 

1793 if p[2] + dy < ymin + 1.3*font_size: 

1794 dy = ymin + 1.3*font_size - p[2] 

1795 sign = np.sign(p[2]) 

1796 if p[0] == np.max(peaks[:,0]) and ty*p[2] > 0.0 and \ 

1797 sign*p[2]+dy < sign*ty+1.2*font_size: 

1798 dy = ty + sign*1.2*font_size - p[2] 

1799 dx = 0.05*time[-1] 

1800 if p[1] >= 0.0: 

1801 ax.text(1000.0*p[1]+dx, p[2]+dy, label, 

1802 ha='left', va=va, zorder=20) 

1803 else: 

1804 ax.text(1000.0*p[1]-dx, p[2]+dy, label, 

1805 ha='right', va=va, zorder=20) 

1806 # annotate plot: 

1807 if unit is None or len(unit) == 0 or unit == 'a.u.': 

1808 unit = '' 

1809 if props is not None: 

1810 props['unit'] = unit 

1811 label = 'p-p amplitude = {p-p-amplitude:.3g} {unit}\n'.format(**props) 

1812 if 'n' in props: 

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

1814 label += 'n = {n} {eods}\n'.format(**props) 

1815 if props['flipped']: 

1816 label += 'flipped\n' 

1817 if -eod_waveform[0,0] < 0.6*eod_waveform[-1,0]: 

1818 ax.text(0.97, 0.97, label, transform=ax.transAxes, 

1819 va='top', ha='right', zorder=20) 

1820 else: 

1821 ax.text(0.03, 0.97, label, transform=ax.transAxes, 

1822 va='top', zorder=20) 

1823 # axis:  

1824 if props is not None and props['type'] == 'wave': 

1825 lim = 750.0/props['EODf'] 

1826 ax.set_xlim([-lim, +lim]) 

1827 else: 

1828 ax.set_xlim(time[0], time[-1]) 

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

1830 if unit: 

1831 ax.set_ylabel('Amplitude [%s]' % unit) 

1832 else: 

1833 ax.set_ylabel('Amplitude') 

1834 

1835 

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

1837 color='b', lw=2, markersize=10): 

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

1839 

1840 Parameters 

1841 ---------- 

1842 axa: matplotlib axes 

1843 Axes for amplitude plot. 

1844 axp: matplotlib axes 

1845 Axes for phase plot. 

1846 spec: 2-D array 

1847 The amplitude spectrum of a single pulse as returned by 

1848 `analyze_wave()`. First column is the index of the harmonics, 

1849 second column its frequency, third column its amplitude, 

1850 fourth column its amplitude relative to the fundamental, fifth 

1851 column is power of harmonics relative to fundamental in 

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

1853 fundamental. 

1854 props: dict 

1855 A dictionary with properties of the analyzed EOD waveform as 

1856 returned by `analyze_wave()`. 

1857 unit: string 

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

1859 color: 

1860 Color for line and points of spectrum. 

1861 lw: float 

1862 Linewidth for spectrum. 

1863 markersize: float 

1864 Size of points on spectrum. 

1865 """ 

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

1867 # amplitudes: 

1868 markers, stemlines, _ = axa.stem(spec[:n,0]+1, spec[:n,2], basefmt='none') 

1869 plt.setp(markers, color=color, markersize=markersize, clip_on=False) 

1870 plt.setp(stemlines, color=color, lw=lw) 

1871 axa.set_xlim(0.5, n+0.5) 

1872 axa.set_ylim(bottom=0) 

1873 axa.xaxis.set_major_locator(plt.MultipleLocator(1)) 

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

1875 if unit: 

1876 axa.set_ylabel('Amplitude [%s]' % unit) 

1877 else: 

1878 axa.set_ylabel('Amplitude') 

1879 # phases: 

1880 phases = spec[:n,5] 

1881 phases[phases<0.0] = phases[phases<0.0] + 2.0*np.pi 

1882 markers, stemlines, _ = axp.stem(spec[:n,0]+1, phases[:n], basefmt='none') 

1883 plt.setp(markers, color=color, markersize=markersize, clip_on=False) 

1884 plt.setp(stemlines, color=color, lw=lw) 

1885 axp.set_xlim(0.5, n+0.5) 

1886 axp.xaxis.set_major_locator(plt.MultipleLocator(1)) 

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

1888 axp.set_ylim(0, 2.0*np.pi) 

1889 axp.set_yticks([0, np.pi, 2.0*np.pi]) 

1890 axp.set_yticklabels([u'0', u'\u03c0', u'2\u03c0']) 

1891 axp.set_xlabel('Harmonics') 

1892 axp.set_ylabel('Phase') 

1893 

1894 

1895def plot_pulse_spectrum(ax, power, props, min_freq=1.0, max_freq=10000.0, 

1896 color='b', lw=3, markersize=80): 

1897 """Plot and annotate spectrum of single pulse EOD. 

1898 

1899 Parameters 

1900 ---------- 

1901 ax: matplotlib axes 

1902 Axes used for plotting. 

1903 power: 2-D array 

1904 The power spectrum of a single pulse as returned by `analyze_pulse()`. 

1905 First column are the frequencies, second column the power. 

1906 props: dict 

1907 A dictionary with properties of the analyzed EOD waveform as 

1908 returned by `analyze_pulse()`. 

1909 min_freq: float 

1910 Minimun frequency of the spectrum to be plotted (logscale!). 

1911 max_freq: float 

1912 Maximun frequency of the spectrum to be plotted (logscale!). 

1913 color: 

1914 Color for line and points of spectrum. 

1915 lw: float 

1916 Linewidth for spectrum. 

1917 markersize: float 

1918 Size of points on spectrum. 

1919 """ 

1920 box = mpatches.Rectangle((1,-60), 49, 60, linewidth=0, facecolor='#DDDDDD', 

1921 zorder=1) 

1922 ax.add_patch(box) 

1923 att = props['poweratt50'] 

1924 if att < -5.0: 

1925 ax.text(10.0, att+1.0, '%.0f dB' % att, ha='left', va='bottom', zorder=10) 

1926 else: 

1927 ax.text(10.0, att-1.0, '%.0f dB' % att, ha='left', va='top', zorder=10) 

1928 box = mpatches.Rectangle((1,-60), 4, 60, linewidth=0, facecolor='#CCCCCC', 

1929 zorder=2) 

1930 ax.add_patch(box) 

1931 att = props['poweratt5'] 

1932 if att < -5.0: 

1933 ax.text(4.0, att+1.0, '%.0f dB' % att, ha='right', va='bottom', zorder=10) 

1934 else: 

1935 ax.text(4.0, att-1.0, '%.0f dB' % att, ha='right', va='top', zorder=10) 

1936 lowcutoff = props['lowcutoff'] 

1937 if lowcutoff >= min_freq: 

1938 ax.plot([lowcutoff, lowcutoff, 1.0], [-60.0, 0.5*att, 0.5*att], '#BBBBBB', 

1939 zorder=3) 

1940 ax.text(1.2*lowcutoff, 0.5*att-1.0, '%.0f Hz' % lowcutoff, ha='left', va='top', zorder=10) 

1941 db = decibel(power[:,1]) 

1942 smax = np.nanmax(db) 

1943 ax.plot(power[:,0], db - smax, color, lw=lw, zorder=4) 

1944 peakfreq = props['peakfreq'] 

1945 if peakfreq >= min_freq: 

1946 ax.scatter([peakfreq], [0.0], c=color, edgecolors=color, s=markersize, alpha=0.4, zorder=5) 

1947 ax.text(peakfreq*1.2, 1.0, '%.0f Hz' % peakfreq, va='bottom', zorder=10) 

1948 ax.set_xlim(min_freq, max_freq) 

1949 ax.set_xscale('log') 

1950 ax.set_ylim(-60.0, 2.0) 

1951 ax.set_xlabel('Frequency [Hz]') 

1952 ax.set_ylabel('Power [dB]') 

1953 

1954 

1955def save_eod_waveform(mean_eod, unit, idx, basename, **kwargs): 

1956 """Save mean EOD waveform to file. 

1957 

1958 Parameters 

1959 ---------- 

1960 mean_eod: 2D array of floats 

1961 Averaged EOD waveform as returned by `eod_waveform()`, 

1962 `analyze_wave()`, and `analyze_pulse()`. 

1963 unit: string 

1964 Unit of the waveform data. 

1965 idx: int or None 

1966 Index of fish. 

1967 basename: string or stream 

1968 If string, path and basename of file. 

1969 '-eodwaveform', the fish index, and a file extension are appended. 

1970 If stream, write EOD waveform data into this stream. 

1971 kwargs: 

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

1973 

1974 Returns 

1975 ------- 

1976 filename: string 

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

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

1979 would have been appended to a basename. 

1980 

1981 See Also 

1982 -------- 

1983 load_eod_waveform() 

1984 """ 

1985 td = TableData(mean_eod[:,:3]*[1000.0, 1.0, 1.0], ['time', 'mean', 'sem'], 

1986 ['ms', unit, unit], ['%.3f', '%.5f', '%.5f']) 

1987 if mean_eod.shape[1] > 3: 

1988 td.append('fit', unit, '%.5f', mean_eod[:,3]) 

1989 fp = '-eodwaveform' 

1990 if idx is not None: 

1991 fp += '-%d' % idx 

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

1993 

1994 

1995def load_eod_waveform(file_path): 

1996 """Load EOD waveform from file. 

1997 

1998 Parameters 

1999 ---------- 

2000 file_path: string 

2001 Path of the file to be loaded. 

2002 

2003 Returns 

2004 ------- 

2005 mean_eod: 2D array of floats 

2006 Averaged EOD waveform: time in seconds, mean, standard deviation, fit. 

2007 unit: string 

2008 Unit of EOD waveform. 

2009 

2010 Raises 

2011 ------ 

2012 FileNotFoundError: 

2013 If `file_path` does not exist. 

2014 

2015 See Also 

2016 -------- 

2017 save_eod_waveform() 

2018 """ 

2019 data = TableData(file_path) 

2020 mean_eod = data.array() 

2021 mean_eod[:,0] *= 0.001 

2022 return mean_eod, data.unit('mean') 

2023 

2024 

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

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

2027 

2028 Parameters 

2029 ---------- 

2030 wave_eodfs: list of 2D arrays 

2031 Each item is a matrix with the frequencies and powers 

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

2033 by `harmonics.harmonic_groups()`. 

2034 wave_indices: array 

2035 Indices identifying each fish or NaN. 

2036 If None no index column is inserted. 

2037 basename: string or stream 

2038 If string, path and basename of file. 

2039 '-waveeodfs' and a file extension are appended. 

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

2041 kwargs: 

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

2043 

2044 Returns 

2045 ------- 

2046 filename: string 

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

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

2049 would have been appended to a basename. 

2050 

2051 See Also 

2052 -------- 

2053 load_wave_eodfs() 

2054 

2055 """ 

2056 eodfs = fundamental_freqs_and_power(wave_eodfs) 

2057 td = TableData() 

2058 if wave_indices is not None: 

2059 td.append('index', '', '%d', [wi if wi >= 0 else np.nan for wi in wave_indices]) 

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

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

2062 fp = '-waveeodfs' 

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

2064 

2065 

2066def load_wave_eodfs(file_path): 

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

2068 

2069 Parameters 

2070 ---------- 

2071 file_path: string 

2072 Path of the file to be loaded. 

2073 

2074 Returns 

2075 ------- 

2076 eodfs: 2D array of floats 

2077 EODfs and power of wave type fish. 

2078 Indices can contain NaNs. 

2079 indices: array of ints 

2080 Corresponding indices of fish, can contain negative numbers to 

2081 indicate frequencies without fish. 

2082 

2083 Raises 

2084 ------ 

2085 FileNotFoundError: 

2086 If `file_path` does not exist. 

2087 

2088 See Also 

2089 -------- 

2090 save_wave_eodfs() 

2091 """ 

2092 data = TableData(file_path) 

2093 eodfs = data.array() 

2094 if 'index' in data: 

2095 indices = data[:,'index'] 

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

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

2098 eodfs = eodfs[:,1:] 

2099 else: 

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

2101 return eodfs, indices 

2102 

2103 

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

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

2106 

2107 Parameters 

2108 ---------- 

2109 eod_props: list of dict 

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

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

2112 unit: string 

2113 Unit of the waveform data. 

2114 basename: string or stream 

2115 If string, path and basename of file. 

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

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

2118 kwargs: 

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

2120 

2121 Returns 

2122 ------- 

2123 filename: string or None 

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

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

2126 would have been appended to a basename. 

2127 None if no wave fish are contained in eod_props and 

2128 consequently no file was written. 

2129 

2130 See Also 

2131 -------- 

2132 load_wave_fish() 

2133 """ 

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

2135 if len(wave_props) == 0: 

2136 return None 

2137 td = TableData() 

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

2139 'nfft' in wave_props[0]: 

2140 td.append_section('recording') 

2141 if 'twin' in wave_props[0]: 

2142 td.append('twin', 's', '%7.2f', wave_props) 

2143 td.append('window', 's', '%7.2f', wave_props) 

2144 td.append('winclipped', '%', '%.2f', wave_props, 100.0) 

2145 if 'samplerate' in wave_props[0]: 

2146 td.append('samplerate', 'kHz', '%.3f', wave_props, 0.001) 

2147 if 'nfft' in wave_props[0]: 

2148 td.append('nfft', '', '%d', wave_props) 

2149 td.append('dfreq', 'Hz', '%.2f', wave_props) 

2150 td.append_section('waveform') 

2151 td.append('index', '', '%d', wave_props) 

2152 td.append('EODf', 'Hz', '%7.2f', wave_props) 

2153 td.append('p-p-amplitude', unit, '%.5f', wave_props) 

2154 td.append('power', 'dB', '%7.2f', wave_props) 

2155 if 'datapower' in wave_props[0]: 

2156 td.append('datapower', 'dB', '%7.2f', wave_props) 

2157 td.append('thd', '%', '%.2f', wave_props, 100.0) 

2158 td.append('dbdiff', 'dB', '%7.2f', wave_props) 

2159 td.append('maxdb', 'dB', '%7.2f', wave_props) 

2160 if 'noise' in wave_props[0]: 

2161 td.append('noise', '%', '%.1f', wave_props, 100.0) 

2162 td.append('rmserror', '%', '%.2f', wave_props, 100.0) 

2163 if 'clipped' in wave_props[0]: 

2164 td.append('clipped', '%', '%.1f', wave_props, 100.0) 

2165 td.append('flipped', '', '%d', wave_props) 

2166 td.append('n', '', '%5d', wave_props) 

2167 td.append_section('timing') 

2168 td.append('ncrossings', '', '%d', wave_props) 

2169 td.append('peakwidth', '%', '%.2f', wave_props, 100.0) 

2170 td.append('troughwidth', '%', '%.2f', wave_props, 100.0) 

2171 td.append('leftpeak', '%', '%.2f', wave_props, 100.0) 

2172 td.append('rightpeak', '%', '%.2f', wave_props, 100.0) 

2173 td.append('lefttrough', '%', '%.2f', wave_props, 100.0) 

2174 td.append('righttrough', '%', '%.2f', wave_props, 100.0) 

2175 td.append('p-p-distance', '%', '%.2f', wave_props, 100.0) 

2176 td.append('relpeakampl', '%', '%.2f', wave_props, 100.0) 

2177 fp = '-wavefish' 

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

2179 

2180 

2181def load_wave_fish(file_path): 

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

2183 

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

2185 percentages to fractions. 

2186 

2187 Parameters 

2188 ---------- 

2189 file_path: string 

2190 Path of the file to be loaded. 

2191 

2192 Returns 

2193 ------- 

2194 eod_props: list of dict 

2195 Properties of EODs. 

2196 

2197 Raises 

2198 ------ 

2199 FileNotFoundError: 

2200 If `file_path` does not exist. 

2201 

2202 See Also 

2203 -------- 

2204 save_wave_fish() 

2205 

2206 """ 

2207 data = TableData(file_path) 

2208 eod_props = data.dicts() 

2209 for props in eod_props: 

2210 if 'winclipped' in props: 

2211 props['winclipped'] /= 100 

2212 if 'samplerate' in props: 

2213 props['samplerate'] *= 1000 

2214 if 'nfft' in props: 

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

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

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

2218 props['type'] = 'wave' 

2219 props['thd'] /= 100 

2220 props['noise'] /= 100 

2221 props['rmserror'] /= 100 

2222 if 'clipped' in props: 

2223 props['clipped'] /= 100 

2224 props['ncrossings'] = int(props['ncrossings']) 

2225 props['peakwidth'] /= 100 

2226 props['troughwidth'] /= 100 

2227 props['leftpeak'] /= 100 

2228 props['rightpeak'] /= 100 

2229 props['lefttrough'] /= 100 

2230 props['righttrough'] /= 100 

2231 props['p-p-distance'] /= 100 

2232 props['relpeakampl'] /= 100 

2233 return eod_props 

2234 

2235 

2236def save_pulse_fish(eod_props, unit, basename, **kwargs): 

2237 """Save properties of pulse EODs to file. 

2238 

2239 Parameters 

2240 ---------- 

2241 eod_props: list of dict 

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

2243 `analyze_pulse()`. Only properties of pulse fish are saved. 

2244 unit: string 

2245 Unit of the waveform data. 

2246 basename: string or stream 

2247 If string, path and basename of file. 

2248 '-pulsefish' and a file extension are appended. 

2249 If stream, write pulse fish properties into this stream. 

2250 kwargs: 

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

2252 

2253 Returns 

2254 ------- 

2255 filename: string or None 

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

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

2258 would have been appended to a basename. 

2259 None if no pulse fish are contained in eod_props and 

2260 consequently no file was written. 

2261 

2262 See Also 

2263 -------- 

2264 load_pulse_fish() 

2265 """ 

2266 pulse_props = [p for p in eod_props if p['type'] == 'pulse'] 

2267 if len(pulse_props) == 0: 

2268 return None 

2269 td = TableData() 

2270 if 'twin' in pulse_props[0] or 'samplerate' in pulse_props[0] or \ 

2271 'nfft' in pulse_props[0]: 

2272 td.append_section('recording') 

2273 if 'twin' in pulse_props[0]: 

2274 td.append('twin', 's', '%7.2f', pulse_props) 

2275 td.append('window', 's', '%7.2f', pulse_props) 

2276 td.append('winclipped', '%', '%.2f', pulse_props, 100.0) 

2277 if 'samplerate' in pulse_props[0]: 

2278 td.append('samplerate', 'kHz', '%.3f', pulse_props, 0.001) 

2279 if 'nfft' in pulse_props[0]: 

2280 td.append('nfft', '', '%d', pulse_props) 

2281 td.append('dfreq', 'Hz', '%.2f', pulse_props) 

2282 td.append_section('waveform') 

2283 td.append('index', '', '%d', pulse_props) 

2284 td.append('EODf', 'Hz', '%7.2f', pulse_props) 

2285 td.append('period', 'ms', '%7.2f', pulse_props, 1000.0) 

2286 td.append('max-ampl', unit, '%.5f', pulse_props) 

2287 td.append('min-ampl', unit, '%.5f', pulse_props) 

2288 td.append('p-p-amplitude', unit, '%.5f', pulse_props) 

2289 if 'noise' in pulse_props[0]: 

2290 td.append('noise', '%', '%.1f', pulse_props, 100.0) 

2291 if 'clipped' in pulse_props[0]: 

2292 td.append('clipped', '%', '%.1f', pulse_props, 100.0) 

2293 td.append('flipped', '', '%d', pulse_props) 

2294 td.append('tstart', 'ms', '%.3f', pulse_props, 1000.0) 

2295 td.append('tend', 'ms', '%.3f', pulse_props, 1000.0) 

2296 td.append('width', 'ms', '%.3f', pulse_props, 1000.0) 

2297 td.append('P2-P1-dist', 'ms', '%.3f', pulse_props, 1000.0) 

2298 td.append('tau', 'ms', '%.3f', pulse_props, 1000.0) 

2299 td.append('firstpeak', '', '%d', pulse_props) 

2300 td.append('lastpeak', '', '%d', pulse_props) 

2301 td.append('n', '', '%d', pulse_props) 

2302 td.append_section('power spectrum') 

2303 td.append('peakfreq', 'Hz', '%.2f', pulse_props) 

2304 td.append('peakpower', 'dB', '%.2f', pulse_props) 

2305 td.append('poweratt5', 'dB', '%.2f', pulse_props) 

2306 td.append('poweratt50', 'dB', '%.2f', pulse_props) 

2307 td.append('lowcutoff', 'Hz', '%.2f', pulse_props) 

2308 fp = '-pulsefish' 

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

2310 

2311 

2312def load_pulse_fish(file_path): 

2313 """Load properties of pulse EODs from file. 

2314 

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

2316 percentages to fractions. 

2317 

2318 Parameters 

2319 ---------- 

2320 file_path: string 

2321 Path of the file to be loaded. 

2322 

2323 Returns 

2324 ------- 

2325 eod_props: list of dict 

2326 Properties of EODs. 

2327 

2328 Raises 

2329 ------ 

2330 FileNotFoundError: 

2331 If `file_path` does not exist. 

2332 

2333 See Also 

2334 -------- 

2335 save_pulse_fish() 

2336 

2337 """ 

2338 data = TableData(file_path) 

2339 eod_props = data.dicts() 

2340 for props in eod_props: 

2341 if 'winclipped' in props: 

2342 props['winclipped'] /= 100 

2343 if 'samplerate' in props: 

2344 props['samplerate'] *= 1000 

2345 if 'nfft' in props: 

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

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

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

2349 props['firstpeak'] = int(props['firstpeak']) 

2350 props['lastpeak'] = int(props['lastpeak']) 

2351 props['type'] = 'pulse' 

2352 if 'clipped' in props: 

2353 props['clipped'] /= 100 

2354 props['period'] /= 1000 

2355 props['noise'] /= 100 

2356 props['tstart'] /= 1000 

2357 props['tend'] /= 1000 

2358 props['width'] /= 1000 

2359 props['P2-P1-dist'] /= 1000 

2360 props['tau'] /= 1000 

2361 return eod_props 

2362 

2363 

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

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

2366 

2367 Parameters 

2368 ---------- 

2369 spec_data: 2D array of floats 

2370 Amplitude and phase spectrum of wave EOD as returned by 

2371 `analyze_wave()`. 

2372 unit: string 

2373 Unit of the waveform data. 

2374 idx: int or None 

2375 Index of fish. 

2376 basename: string or stream 

2377 If string, path and basename of file. 

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

2379 If stream, write wave spectrum into this stream. 

2380 kwargs: 

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

2382 

2383 Returns 

2384 ------- 

2385 filename: string 

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

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

2388 would have been appended to a basename. 

2389 

2390 See Also 

2391 -------- 

2392 load_wave_spectrum() 

2393 

2394 """ 

2395 td = TableData(spec_data[:,:6]*[1.0, 1.0, 1.0, 100.0, 1.0, 1.0], 

2396 ['harmonics', 'frequency', 'amplitude', 'relampl', 'relpower', 'phase'], 

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

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

2399 if spec_data.shape[1] > 6: 

2400 td.append('datapower', '%s^2/Hz' % unit, '%11.4e', spec_data[:,6]) 

2401 fp = '-wavespectrum' 

2402 if idx is not None: 

2403 fp += '-%d' % idx 

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

2405 

2406 

2407def load_wave_spectrum(file_path): 

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

2409 

2410 Parameters 

2411 ---------- 

2412 file_path: string 

2413 Path of the file to be loaded. 

2414 

2415 Returns 

2416 ------- 

2417 spec: 2D array of floats 

2418 Amplitude and phase spectrum of wave EOD: 

2419 harmonics, frequency, amplitude, relative amplitude in dB, 

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

2421 Can contain NaNs. 

2422 unit: string 

2423 Unit of amplitudes. 

2424 

2425 Raises 

2426 ------ 

2427 FileNotFoundError: 

2428 If `file_path` does not exist. 

2429 

2430 See Also 

2431 -------- 

2432 save_wave_spectrum() 

2433 """ 

2434 data = TableData(file_path) 

2435 spec = data.array() 

2436 spec[:,3] *= 0.01 

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

2438 

2439 

2440def save_pulse_spectrum(spec_data, unit, idx, basename, **kwargs): 

2441 """Save power spectrum of pulse EOD to file. 

2442 

2443 Parameters 

2444 ---------- 

2445 spec_data: 2D array of floats 

2446 Power spectrum of single pulse as returned by `analyze_pulse()`. 

2447 unit: string 

2448 Unit of the waveform data. 

2449 idx: int or None 

2450 Index of fish. 

2451 basename: string or stream 

2452 If string, path and basename of file. 

2453 '-pulsespectrum', the fish index, and a file extension are appended. 

2454 If stream, write pulse spectrum into this stream. 

2455 kwargs: 

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

2457 

2458 Returns 

2459 ------- 

2460 filename: string 

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

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

2463 would have been appended to a basename. 

2464 

2465 See Also 

2466 -------- 

2467 load_pulse_spectrum() 

2468 """ 

2469 td = TableData(spec_data[:,:2], ['frequency', 'power'], 

2470 ['Hz', '%s^2/Hz' % unit], ['%.2f', '%.4e']) 

2471 fp = '-pulsespectrum' 

2472 if idx is not None: 

2473 fp += '-%d' % idx 

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

2475 

2476 

2477def load_pulse_spectrum(file_path): 

2478 """Load power spectrum of pulse EOD from file. 

2479 

2480 Parameters 

2481 ---------- 

2482 file_path: string 

2483 Path of the file to be loaded. 

2484 

2485 Returns 

2486 ------- 

2487 spec: 2D array of floats 

2488 Power spectrum of single pulse: frequency, power 

2489 

2490 Raises 

2491 ------ 

2492 FileNotFoundError: 

2493 If `file_path` does not exist. 

2494 

2495 See Also 

2496 -------- 

2497 save_pulse_spectrum() 

2498 """ 

2499 data = TableData(file_path) 

2500 spec = data.array() 

2501 return spec 

2502 

2503 

2504def save_pulse_peaks(peak_data, unit, idx, basename, **kwargs): 

2505 """Save peak properties of pulse EOD to file. 

2506 

2507 Parameters 

2508 ---------- 

2509 peak_data: 2D array of floats 

2510 Properties of peaks and troughs of pulse EOD as returned by 

2511 `analyze_pulse()`. 

2512 unit: string 

2513 Unit of the waveform data. 

2514 idx: int or None 

2515 Index of fish. 

2516 basename: string or stream 

2517 If string, path and basename of file. 

2518 '-pulsepeaks', the fish index, and a file extension are appended. 

2519 If stream, write pulse peaks into this stream. 

2520 kwargs: 

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

2522 

2523 Returns 

2524 ------- 

2525 filename: string 

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

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

2528 would have been appended to a basename. 

2529 

2530 See Also 

2531 -------- 

2532 load_pulse_peaks() 

2533 """ 

2534 if len(peak_data) == 0: 

2535 return None 

2536 td = TableData(peak_data[:,:5]*[1.0, 1000.0, 1.0, 100.0, 1000.0], 

2537 ['P', 'time', 'amplitude', 'relampl', 'width'], 

2538 ['', 'ms', unit, '%', 'ms'], 

2539 ['%.0f', '%.3f', '%.5f', '%.2f', '%.3f']) 

2540 fp = '-pulsepeaks' 

2541 if idx is not None: 

2542 fp += '-%d' % idx 

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

2544 

2545 

2546def load_pulse_peaks(file_path): 

2547 """Load peak properties of pulse EOD from file. 

2548 

2549 Parameters 

2550 ---------- 

2551 file_path: string 

2552 Path of the file to be loaded. 

2553 

2554 Returns 

2555 ------- 

2556 peak_data: 2D array of floats 

2557 Properties of peaks and troughs of pulse EOD: 

2558 P, time, amplitude, relampl, width 

2559 unit: string 

2560 Unit of peak amplitudes. 

2561 

2562 Raises 

2563 ------ 

2564 FileNotFoundError: 

2565 If `file_path` does not exist. 

2566 

2567 See Also 

2568 -------- 

2569 save_pulse_peaks() 

2570 """ 

2571 data = TableData(file_path) 

2572 peaks = data.array() 

2573 peaks[:,1] *= 0.001 

2574 peaks[:,3] *= 0.01 

2575 peaks[:,4] *= 0.001 

2576 return peaks, data.unit('amplitude') 

2577 

2578 

2579def save_pulse_times(pulse_times, idx, basename, **kwargs): 

2580 """Save times of pulse EOD to file. 

2581 

2582 Parameters 

2583 ---------- 

2584 pulse_times: dict or array of floats 

2585 Times of EOD pulses. Either as array of times or 

2586 `props['peaktimes']` or `props['times']` as returned by 

2587 `analyze_pulse()`. 

2588 idx: int or None 

2589 Index of fish. 

2590 basename: string or stream 

2591 If string, path and basename of file. 

2592 '-pulsetimes', the fish index, and a file extension are appended. 

2593 If stream, write pulse times into this stream. 

2594 kwargs: 

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

2596 

2597 Returns 

2598 ------- 

2599 filename: string 

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

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

2602 would have been appended to a basename. 

2603 

2604 See Also 

2605 -------- 

2606 load_pulse_times() 

2607 """ 

2608 if isinstance(pulse_times, dict): 

2609 props = pulse_times 

2610 pulse_times = props.get('times', []) 

2611 pulse_times = props.get('peaktimes', pulse_times) 

2612 if len(pulse_times) == 0: 

2613 return None 

2614 td = TableData() 

2615 td.append('time', 's', '%.4f', pulse_times) 

2616 fp = '-pulsetimes' 

2617 if idx is not None: 

2618 fp += '-%d' % idx 

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

2620 

2621 

2622def load_pulse_times(file_path): 

2623 """Load times of pulse EOD from file. 

2624 

2625 Parameters 

2626 ---------- 

2627 file_path: string 

2628 Path of the file to be loaded. 

2629 

2630 Returns 

2631 ------- 

2632 pulse_times: array of floats 

2633 Times of pulse EODs in seconds. 

2634 

2635 Raises 

2636 ------ 

2637 FileNotFoundError: 

2638 If `file_path` does not exist. 

2639 

2640 See Also 

2641 -------- 

2642 save_pulse_times() 

2643 """ 

2644 data = TableData(file_path) 

2645 pulse_times = data.array()[:,0] 

2646 return pulse_times 

2647 

2648 

2649file_types = ['waveeodfs', 'wavefish', 'pulsefish', 'eodwaveform', 

2650 'wavespectrum', 'pulsepeaks', 'pulsespectrum', 'pulsetimes'] 

2651"""List of all file types generated and supported by the `save_*` and `load_*` functions.""" 

2652 

2653 

2654def parse_filename(file_path): 

2655 """Parse components of an EOD analysis file name. 

2656 

2657 Analysis files generated by the `eodanalysis` module are named 

2658 according to 

2659 ```plain 

2660 PATH/RECORDING-CHANNEL-TIME-FTYPE-N.EXT 

2661 ``` 

2662 

2663 Parameters 

2664 ---------- 

2665 file_path: string 

2666 Path of the file to be parsed. 

2667 

2668 Returns 

2669 ------- 

2670 recording: string 

2671 Path and basename of the recording, i.e. 'PATH/RECORDING'. 

2672 A leading './' is removed. 

2673 base_path: string 

2674 Path and basename of the analysis results, 

2675 i.e. 'PATH/RECORDING-CHANNEL-TIME'. A leading './' is removed. 

2676 channel: int 

2677 Channel of the recording 

2678 ('CHANNEL' component of the file name if present). 

2679 -1 if not present in `file_path`. 

2680 time: float 

2681 Start time of analysis window in seconds 

2682 ('TIME' component of the file name if present). 

2683 `None` if not present in `file_path`. 

2684 ftype: string 

2685 Type of analysis file (e.g. 'wavespectrum', 'pulsepeaks', etc.), 

2686 ('FTYPE' component of the file name if present). 

2687 See `file_types` for a list of all supported file types. 

2688 Empty string if not present in `file_path`. 

2689 index: int 

2690 Index of the EOD. 

2691 ('N' component of the file name if present). 

2692 -1 if not present in `file_path`. 

2693 ext: string 

2694 File extension *without* leading period 

2695 ('EXT' component of the file name). 

2696 

2697 """ 

2698 name, ext = os.path.splitext(file_path) 

2699 ext = ext[1:] 

2700 parts = name.split('-') 

2701 index = -1 

2702 if len(parts) > 0 and parts[-1].isdigit(): 

2703 index = int(parts[-1]) 

2704 parts = parts[:-1] 

2705 ftype = '' 

2706 if len(parts) > 0: 

2707 ftype = parts[-1] 

2708 parts = parts[:-1] 

2709 base_path = '-'.join(parts) 

2710 if base_path.startswith('./'): 

2711 base_path = base_path[2:] 

2712 time = None 

2713 if len(parts) > 0 and len(parts[-1]) > 0 and \ 

2714 parts[-1][0] == 't' and parts[-1][-1] == 's' and \ 

2715 parts[-1][1:-1].isdigit(): 

2716 time = float(parts[-1][1:-1]) 

2717 parts = parts[:-1] 

2718 channel = -1 

2719 if len(parts) > 0 and len(parts[-1]) > 0 and \ 

2720 parts[-1][0] == 'c' and parts[-1][1:].isdigit(): 

2721 channel = int(parts[-1][1:]) 

2722 parts = parts[:-1] 

2723 recording = '-'.join(parts) 

2724 if recording.startswith('./'): 

2725 recording = recording[2:] 

2726 return recording, base_path, channel, time, ftype, index, ext 

2727 

2728 

2729def save_analysis(output_basename, zip_file, eod_props, mean_eods, 

2730 spec_data, peak_data, wave_eodfs, wave_indices, unit, 

2731 verbose, **kwargs): 

2732 """Save EOD analysis results to files. 

2733 

2734 Parameters 

2735 ---------- 

2736 output_basename: string 

2737 Path and basename of files to be saved. 

2738 zip_file: bool 

2739 If `True`, write all analysis results into a zip archive. 

2740 eod_props: list of dict 

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

2742 `analyze_pulse()`. 

2743 mean_eods: list of 2D array of floats 

2744 Averaged EOD waveforms as returned by `eod_waveform()`, 

2745 `analyze_wave()`, and `analyze_pulse()`. 

2746 spec_data: list of 2D array of floats 

2747 Power spectra of single pulses as returned by 

2748 `analyze_pulse()`. 

2749 peak_data: list of 2D array of floats 

2750 Properties of peaks and troughs of pulse EODs as returned by 

2751 `analyze_pulse()`. 

2752 wave_eodfs: list of 2D array of float 

2753 Each item is a matrix with the frequencies and powers 

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

2755 by `harmonics.harmonic_groups()`. 

2756 wave_indices: array of int 

2757 Indices identifying each fish in `wave_eodfs` or NaN. unit: 

2758 string Unit of the waveform data. 

2759 verbose: int 

2760 Verbosity level. 

2761 kwargs: 

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

2763 """ 

2764 def write_file_zip(zf, save_func, output, *args, **kwargs): 

2765 if zf is None: 

2766 fp = save_func(*args, basename=output, **kwargs) 

2767 if verbose > 0 and fp is not None: 

2768 print('wrote file %s' % fp) 

2769 else: 

2770 with io.StringIO() as df: 

2771 fp = save_func(*args, basename=df, **kwargs) 

2772 if fp is not None: 

2773 fp = output_basename + fp 

2774 zf.writestr(os.path.basename(fp), df.getvalue()) 

2775 if verbose > 0: 

2776 print('zipped file %s' % fp) 

2777 

2778 

2779 if 'table_format' in kwargs and kwargs['table_format'] == 'py': 

2780 with open(output_basename+'.py', 'w') as f: 

2781 name = os.path.basename(output_basename) 

2782 for k, sdata in enumerate(spec_data): 

2783 # save wave fish only: 

2784 if len(sdata)>0 and sdata.shape[1] > 2: 

2785 fish = dict(amplitudes=sdata[:,3], phases=sdata[:,5]) 

2786 fish = normalize_wavefish(fish) 

2787 export_wavefish(fish, name+'-%d_harmonics' % k, f) 

2788 else: 

2789 zf = None 

2790 if zip_file: 

2791 zf = zipfile.ZipFile(output_basename + '.zip', 'w') 

2792 # all wave fish in wave_eodfs: 

2793 if len(wave_eodfs) > 0: 

2794 write_file_zip(zf, save_wave_eodfs, output_basename, 

2795 wave_eodfs, wave_indices, **kwargs) 

2796 # all wave and pulse fish: 

2797 for i, (mean_eod, sdata, pdata, props) in enumerate(zip(mean_eods, spec_data, peak_data, eod_props)): 

2798 write_file_zip(zf, save_eod_waveform, output_basename, 

2799 mean_eod, unit, i, **kwargs) 

2800 # power spectrum: 

2801 if len(sdata)>0: 

2802 if sdata.shape[1] == 2: 

2803 write_file_zip(zf, save_pulse_spectrum, output_basename, 

2804 sdata, unit, i, **kwargs) 

2805 else: 

2806 write_file_zip(zf, save_wave_spectrum, output_basename, 

2807 sdata, unit, i, **kwargs) 

2808 # peaks: 

2809 write_file_zip(zf, save_pulse_peaks, output_basename, 

2810 pdata, unit, i, **kwargs) 

2811 # times: 

2812 write_file_zip(zf, save_pulse_times, output_basename, 

2813 props, i, **kwargs) 

2814 # wave fish properties: 

2815 write_file_zip(zf, save_wave_fish, output_basename, 

2816 eod_props, unit, **kwargs) 

2817 # pulse fish properties: 

2818 write_file_zip(zf, save_pulse_fish, output_basename, 

2819 eod_props, unit, **kwargs) 

2820 

2821 

2822def load_analysis(file_pathes): 

2823 """Load all EOD analysis files. 

2824 

2825 Parameters 

2826 ---------- 

2827 file_pathes: list of string 

2828 Pathes of the analysis files of a single recording to be loaded. 

2829 

2830 Returns 

2831 ------- 

2832 mean_eods: list of 2D array of floats 

2833 Averaged EOD waveforms: time in seconds, mean, standard deviation, fit. 

2834 wave_eodfs: 2D array of floats 

2835 EODfs and power of wave type fish. 

2836 wave_indices: array of ints 

2837 Corresponding indices of fish, can contain negative numbers to 

2838 indicate frequencies without fish. 

2839 eod_props: list of dict 

2840 Properties of EODs. The 'index' property is an index into the 

2841 reurned lists. 

2842 spec_data: list of 2D array of floats 

2843 Amplitude and phase spectrum of wave-type EODs with columns 

2844 harmonics, frequency, amplitude, relative amplitude in dB, 

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

2846 Power spectrum of single pulse-type EODs with columns frequency, power 

2847 peak_data: list of 2D array of floats 

2848 Properties of peaks and troughs of pulse-type EODs with columns 

2849 P, time, amplitude, relampl, width 

2850 recording: string 

2851 Path and base name of the recording file. 

2852 channel: int 

2853 Analysed channel of the recording. 

2854 unit: string 

2855 Unit of EOD waveform. 

2856 """ 

2857 recording = None 

2858 channel = -1 

2859 eod_props = [] 

2860 zf = None 

2861 if len(file_pathes) == 1 and os.path.splitext(file_pathes[0])[1][1:] == 'zip': 

2862 zf = zipfile.ZipFile(file_pathes[0]) 

2863 file_pathes = sorted(zf.namelist()) 

2864 # first, read wave- and pulse-fish summaries: 

2865 pulse_fish = False 

2866 wave_fish = False 

2867 for f in file_pathes: 

2868 recording, _, channel, _, ftype, _, _ = parse_filename(f) 

2869 if zf is not None: 

2870 f = io.TextIOWrapper(zf.open(f, 'r')) 

2871 if ftype == 'wavefish': 

2872 eod_props.extend(load_wave_fish(f)) 

2873 wave_fish = True 

2874 elif ftype == 'pulsefish': 

2875 eod_props.extend(load_pulse_fish(f)) 

2876 pulse_fish = True 

2877 idx_offs = 0 

2878 if wave_fish and not pulse_fish: 

2879 idx_offs = sorted([ep['index'] for ep in eod_props])[0] 

2880 # then load all other files: 

2881 neods = len(eod_props) 

2882 if neods < 1: 

2883 neods = 1 

2884 eod_props = [None] 

2885 wave_eodfs = np.array([]) 

2886 wave_indices = np.array([]) 

2887 mean_eods = [None]*neods 

2888 spec_data = [None]*neods 

2889 peak_data = [None]*neods 

2890 unit = None 

2891 for f in file_pathes: 

2892 recording, _, channel, _, ftype, idx, _ = parse_filename(f) 

2893 if neods == 1 and idx > 0: 

2894 idx = 0 

2895 idx -= idx_offs 

2896 if zf is not None: 

2897 f = io.TextIOWrapper(zf.open(f, 'r')) 

2898 if ftype == 'waveeodfs': 

2899 wave_eodfs, wave_indices = load_wave_eodfs(f) 

2900 elif ftype == 'eodwaveform': 

2901 mean_eods[idx], unit = load_eod_waveform(f) 

2902 elif ftype == 'wavespectrum': 

2903 spec_data[idx], unit = load_wave_spectrum(f) 

2904 elif ftype == 'pulsepeaks': 

2905 peak_data[idx], unit = load_pulse_peaks(f) 

2906 elif ftype == 'pulsetimes': 

2907 pulse_times = load_pulse_times(f) 

2908 eod_props[idx]['times'] = pulse_times 

2909 eod_props[idx]['peaktimes'] = pulse_times 

2910 elif ftype == 'pulsespectrum': 

2911 spec_data[idx] = load_pulse_spectrum(f) 

2912 # fix wave spectra: 

2913 wave_eodfs = [fish.reshape(1, 2) if len(fish)>0 else fish 

2914 for fish in wave_eodfs] 

2915 if len(wave_eodfs) > 0 and len(spec_data) > 0: 

2916 eodfs = [] 

2917 for idx, fish in zip(wave_indices, wave_eodfs): 

2918 if idx >= 0: 

2919 spec = spec_data[idx] 

2920 specd = np.zeros((np.sum(np.isfinite(spec[:,-1])), 

2921 2)) 

2922 specd[:,0] = spec[np.isfinite(spec[:,-1]),1] 

2923 specd[:,1] = spec[np.isfinite(spec[:,-1]),-1] 

2924 eodfs.append(specd) 

2925 else: 

2926 specd = np.zeros((10, 2)) 

2927 specd[:,0] = np.arange(len(specd))*fish[0,0] 

2928 specd[:,1] = np.nan 

2929 eodfs.append(specd) 

2930 wave_eodfs = eodfs 

2931 return mean_eods, wave_eodfs, wave_indices, eod_props, spec_data, \ 

2932 peak_data, recording, channel, unit 

2933 

2934 

2935def load_recording(file_path, channel=0, load_kwargs={}, 

2936 eod_props=None, verbose=0): 

2937 """Load recording. 

2938 

2939 Parameters 

2940 ---------- 

2941 file_path: string 

2942 Full path of the file with the recorded data. 

2943 Extension is optional. If absent, look for the first file 

2944 with a reasonable extension. 

2945 channel: int 

2946 Channel of the recording to be returned. 

2947 load_kwargs: dict 

2948 Keyword arguments that are passed on to the  

2949 format specific loading functions. 

2950 eod_props: list of dict or None 

2951 List of EOD properties from which start and end times of 

2952 analysis window are extracted. 

2953 verbose: int 

2954 Verbosity level passed on to load function. 

2955 

2956 Returns 

2957 ------- 

2958 data: array of float 

2959 Data of the requested `channel`. 

2960 samplerate: float 

2961 Sampling rate in Hertz. 

2962 idx0: int 

2963 Start index of the analysis window. 

2964 idx1: int 

2965 End index of the analysis window. 

2966 data_file: str 

2967 Full path and name of the loaded file inclusively extension. 

2968 

2969 """ 

2970 data = None 

2971 samplerate = 0.0 

2972 idx0 = 0 

2973 idx1 = 0 

2974 data_file = '' 

2975 if len(os.path.splitext(file_path)[1]) > 1: 

2976 data_file = file_path 

2977 else: 

2978 data_files = glob.glob(file_path + os.extsep + '*') 

2979 for dfile in data_files: 

2980 if not os.path.splitext(dfile)[1][1:] in ['zip'] + list(TableData.ext_formats.values()): 

2981 data_file = dfile 

2982 break 

2983 if os.path.exists(data_file): 

2984 data, samplerate, unit, amax = load_data(data_file, 

2985 verbose=verbose, 

2986 **load_kwargs) 

2987 idx0 = 0 

2988 idx1 = len(data) 

2989 if eod_props is not None and len(eod_props) > 0 and 'twin' in eod_props[0]: 

2990 idx0 = int(eod_props[0]['twin']*samplerate) 

2991 if len(eod_props) > 0 and 'window' in eod_props[0]: 

2992 idx1 = idx0 + int(eod_props[0]['window']*samplerate) 

2993 return data[:,channel], samplerate, idx0, idx1, data_file 

2994 

2995 

2996def add_eod_analysis_config(cfg, thresh_fac=0.8, percentile=0.1, 

2997 win_fac=2.0, min_win=0.01, max_eods=None, 

2998 min_sem=False, unfilter_cutoff=0.0, 

2999 flip_wave='none', flip_pulse='none', 

3000 n_harm=10, min_pulse_win=0.001, 

3001 peak_thresh_fac=0.01, min_dist=50.0e-6, 

3002 width_frac = 0.5, fit_frac = 0.5, 

3003 ipi_cv_thresh=0.5, ipi_percentile=30.0): 

3004 """Add all parameters needed for the eod analysis functions as a new 

3005 section to a configuration. 

3006 

3007 Parameters 

3008 ---------- 

3009 cfg: ConfigFile 

3010 The configuration. 

3011  

3012 See `eod_waveform()`, `analyze_wave()`, and `analyze_pulse()` for 

3013 details on the remaining arguments. 

3014 """ 

3015 cfg.add_section('EOD analysis:') 

3016 cfg.add('eodSnippetFac', win_fac, '', 'The duration of EOD snippets is the EOD period times this factor.') 

3017 cfg.add('eodMinSnippet', min_win, 's', 'Minimum duration of cut out EOD snippets.') 

3018 cfg.add('eodMaxEODs', max_eods or 0, '', 'The maximum number of EODs used to compute the average EOD. If 0 use all EODs.') 

3019 cfg.add('eodMinSem', min_sem, '', 'Use minimum of s.e.m. to set maximum number of EODs used to compute the average EOD.') 

3020 cfg.add('unfilterCutoff', unfilter_cutoff, 'Hz', 'If non-zero remove effect of high-pass filter with this cut-off frequency.') 

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

3022 cfg.add('flipPulseEOD', flip_pulse, '', 'Flip EOD of pulse fish to make the first large peak positive (flip, none, or auto).') 

3023 cfg.add('eodHarmonics', n_harm, '', 'Number of harmonics fitted to the EOD waveform.') 

3024 cfg.add('eodMinPulseSnippet', min_pulse_win, 's', 'Minimum duration of cut out EOD snippets for a pulse fish.') 

3025 cfg.add('eodPeakThresholdFactor', peak_thresh_fac, '', 'Threshold for detection of peaks in pulse EODs as a fraction of the pulse amplitude.') 

3026 cfg.add('eodMinimumDistance', min_dist, 's', 'Minimum distance between peaks and troughs in a EOD pulse.') 

3027 cfg.add('eodPulseWidthFraction', width_frac, '', 'The width of a pulse is measured at this fraction of the pulse height.') 

3028 cfg.add('eodExponentialFitFraction', fit_frac, '', 'An exponential function is fitted on the tail of a pulse starting at this fraction of the height of the last peak.') 

3029 cfg.add('ipiCVThresh', ipi_cv_thresh, '', 'If coefficient of variation of interpulse intervals is smaller than this threshold, then use all intervals for computing EOD frequency.') 

3030 cfg.add('ipiPercentile', ipi_percentile, '%', 'Use only interpulse intervals shorter than this percentile to compute EOD frequency.') 

3031 

3032 

3033def eod_waveform_args(cfg): 

3034 """Translates a configuration to the respective parameter names of 

3035 the function `eod_waveform()`. 

3036  

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

3038 function. 

3039 

3040 Parameters 

3041 ---------- 

3042 cfg: ConfigFile 

3043 The configuration. 

3044 

3045 Returns 

3046 ------- 

3047 a: dict 

3048 Dictionary with names of arguments of the `eod_waveform()` function 

3049 and their values as supplied by `cfg`. 

3050 """ 

3051 a = cfg.map({'win_fac': 'eodSnippetFac', 

3052 'min_win': 'eodMinSnippet', 

3053 'max_eods': 'eodMaxEODs', 

3054 'min_sem': 'eodMinSem', 

3055 'unfilter_cutoff': 'unfilterCutoff'}) 

3056 return a 

3057 

3058 

3059def analyze_wave_args(cfg): 

3060 """Translates a configuration to the respective parameter names of 

3061 the function `analyze_wave()`. 

3062  

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

3064 function. 

3065 

3066 Parameters 

3067 ---------- 

3068 cfg: ConfigFile 

3069 The configuration. 

3070 

3071 Returns 

3072 ------- 

3073 a: dict 

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

3075 and their values as supplied by `cfg`. 

3076 """ 

3077 a = cfg.map({'n_harm': 'eodHarmonics', 

3078 'power_n_harmonics': 'powerNHarmonics', 

3079 'flip_wave': 'flipWaveEOD'}) 

3080 return a 

3081 

3082 

3083def analyze_pulse_args(cfg): 

3084 """Translates a configuration to the respective parameter names of 

3085 the function `analyze_pulse()`. 

3086  

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

3088 function. 

3089 

3090 Parameters 

3091 ---------- 

3092 cfg: ConfigFile 

3093 The configuration. 

3094 

3095 Returns 

3096 ------- 

3097 a: dict 

3098 Dictionary with names of arguments of the `analyze_pulse()` function 

3099 and their values as supplied by `cfg`. 

3100 """ 

3101 a = cfg.map({'min_pulse_win': 'eodMinPulseSnippet', 

3102 'peak_thresh_fac': 'eodPeakThresholdFactor', 

3103 'min_dist': 'eodMinimumDistance', 

3104 'width_frac': 'eodPulseWidthFraction', 

3105 'fit_frac': 'eodExponentialFitFraction', 

3106 'flip_pulse': 'flipPulseEOD', 

3107 'ipi_cv_thresh': 'ipiCVThresh', 

3108 'ipi_percentile': 'ipiPercentile'}) 

3109 return a 

3110 

3111 

3112def add_species_config(cfg, species_file='none', wave_max_rms=0.2, 

3113 pulse_max_rms=0.2): 

3114 """Add parameters needed for assigning EOD waveforms to species. 

3115 

3116 Parameters 

3117 ---------- 

3118 cfg: ConfigFile 

3119 The configuration. 

3120 species_file: string 

3121 File path to a file containing species names and corresponding 

3122 file names of EOD waveform templates. If 'none', no species 

3123 assignemnt is performed. 

3124 wave_max_rms: float 

3125 Maximum allowed rms difference (relative to standard deviation 

3126 of EOD waveform) to an EOD waveform template for assignment to 

3127 a wave fish species. 

3128 pulse_max_rms: float 

3129 Maximum allowed rms difference (relative to standard deviation 

3130 of EOD waveform) to an EOD waveform template for assignment to 

3131 a pulse fish species. 

3132 """ 

3133 cfg.add_section('Species assignment:') 

3134 cfg.add('speciesFile', species_file, '', 'File path to a file containing species names and corresponding file names of EOD waveform templates.') 

3135 cfg.add('maximumWaveSpeciesRMS', wave_max_rms, '', 'Maximum allowed rms difference (relative to standard deviation of EOD waveform) to an EOD waveform template for assignment to a wave fish species.') 

3136 cfg.add('maximumPulseSpeciesRMS', pulse_max_rms, '', 'Maximum allowed rms difference (relative to standard deviation of EOD waveform) to an EOD waveform template for assignment to a pulse fish species.') 

3137 

3138 

3139def add_eod_quality_config(cfg, max_clipped_frac=0.1, max_variance=0.0, 

3140 max_rms_error=0.05, min_power=-100.0, max_thd=0.0, 

3141 max_crossings=4, max_relampl_harm1=0.0, 

3142 max_relampl_harm2=0.0, max_relampl_harm3=0.0): 

3143 """Add parameters needed for assesing the quality of an EOD waveform. 

3144 

3145 Parameters 

3146 ---------- 

3147 cfg: ConfigFile 

3148 The configuration. 

3149  

3150 See `wave_quality()` and `pulse_quality()` for details on 

3151 the remaining arguments. 

3152 """ 

3153 cfg.add_section('Waveform selection:') 

3154 cfg.add('maximumClippedFraction', max_clipped_frac, '', 'Take waveform of the fish with the highest power only if the fraction of clipped signals is below this value.') 

3155 cfg.add('maximumVariance', max_variance, '', 'Skip waveform of fish if the standard error of the EOD waveform relative to the peak-to-peak amplitude is larger than this number. A value of zero allows any variance.') 

3156 cfg.add('maximumRMSError', max_rms_error, '', 'Skip waveform of wave fish if the root-mean-squared error of the fit relative to the peak-to-peak amplitude is larger than this number.') 

3157 cfg.add('minimumPower', min_power, 'dB', 'Skip waveform of wave fish if its power is smaller than this value.') 

3158 cfg.add('maximumTotalHarmonicDistortion', max_thd, '', 'Skip waveform of wave fish if its total harmonic distortion is larger than this value. If set to zero do not check.') 

3159 cfg.add('maximumCrossings', max_crossings, '', 'Maximum number of zero crossings per EOD period.') 

3160 cfg.add('maximumFirstHarmonicAmplitude', max_relampl_harm1, '', 'Skip waveform of wave fish if the amplitude of the first harmonic is higher than this factor times the amplitude of the fundamental. If set to zero do not check.') 

3161 cfg.add('maximumSecondHarmonicAmplitude', max_relampl_harm2, '', 'Skip waveform of wave fish if the ampltude of the second harmonic is higher than this factor times the amplitude of the fundamental. That is, the waveform appears to have twice the frequency than the fundamental. If set to zero do not check.') 

3162 cfg.add('maximumThirdHarmonicAmplitude', max_relampl_harm3, '', 'Skip waveform of wave fish if the ampltude of the third harmonic is higher than this factor times the amplitude of the fundamental. If set to zero do not check.') 

3163 

3164 

3165def wave_quality_args(cfg): 

3166 """Translates a configuration to the respective parameter names of 

3167 the function `wave_quality()`. 

3168  

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

3170 function. 

3171 

3172 Parameters 

3173 ---------- 

3174 cfg: ConfigFile 

3175 The configuration. 

3176 

3177 Returns 

3178 ------- 

3179 a: dict 

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

3181 and their values as supplied by `cfg`. 

3182 """ 

3183 a = cfg.map({'max_clipped_frac': 'maximumClippedFraction', 

3184 'max_rms_sem': 'maximumVariance', 

3185 'max_rms_error': 'maximumRMSError', 

3186 'min_power': 'minimumPower', 

3187 'max_crossings': 'maximumCrossings', 

3188 'min_freq': 'minimumFrequency', 

3189 'max_freq': 'maximumFrequency', 

3190 'max_thd': 'maximumTotalHarmonicDistortion', 

3191 'max_db_diff': 'maximumPowerDifference', 

3192 'max_harmonics_db': 'maximumHarmonicsPower', 

3193 'max_relampl_harm1': 'maximumFirstHarmonicAmplitude', 

3194 'max_relampl_harm2': 'maximumSecondHarmonicAmplitude', 

3195 'max_relampl_harm3': 'maximumThirdHarmonicAmplitude'}) 

3196 return a 

3197 

3198 

3199def pulse_quality_args(cfg): 

3200 """Translates a configuration to the respective parameter names of 

3201 the function `pulse_quality()`. 

3202  

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

3204 function. 

3205 

3206 Parameters 

3207 ---------- 

3208 cfg: ConfigFile 

3209 The configuration. 

3210 

3211 Returns 

3212 ------- 

3213 a: dict 

3214 Dictionary with names of arguments of the `pulse_quality()` function 

3215 and their values as supplied by `cfg`. 

3216 """ 

3217 a = cfg.map({'max_clipped_frac': 'maximumClippedFraction', 

3218 'max_rms_sem': 'maximumRMSNoise'}) 

3219 return a 

3220 

3221 

3222def main(): 

3223 import matplotlib.pyplot as plt 

3224 from .fakefish import pulsefish_eods 

3225 

3226 print('Analysis of EOD waveforms.') 

3227 

3228 # data: 

3229 samplerate = 44100.0 

3230 data = pulsefish_eods('Triphasic', 83.0, samplerate, 5.0, noise_std=0.02) 

3231 unit = 'mV' 

3232 eod_idx, _ = detect_peaks(data, 1.0) 

3233 eod_times = eod_idx/samplerate 

3234 

3235 # analyse EOD: 

3236 mean_eod, eod_times = eod_waveform(data, samplerate, eod_times) 

3237 mean_eod, props, peaks, power = analyze_pulse(mean_eod, eod_times) 

3238 

3239 # plot: 

3240 fig, axs = plt.subplots(1, 2) 

3241 plot_eod_waveform(axs[0], mean_eod, props, peaks, unit=unit) 

3242 axs[0].set_title('{type} fish: EODf = {EODf:.1f} Hz'.format(**props)) 

3243 plot_pulse_spectrum(axs[1], power, props) 

3244 plt.show() 

3245 

3246 

3247if __name__ == '__main__': 

3248 main()