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

1687 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-15 17:50 +0000

1""" 

2Analysis of EOD waveforms. 

3 

4## EOD waveform analysis 

5 

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

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

8 

9## Analysis of wave-type EODs 

10 

11- `waveeod_waveform()`: retrieve average EOD waveform via Fourier transform. 

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

13 

14## Analysis of pulse-type EODs 

15 

16- `pulse_spectrum()`: compute the spectrum of a single pulse-type EOD. 

17- `analyze_pulse_spectrum()`: analyze the spectrum of a pulse-type EOD. 

18- `analyze_pulse_phases()`: characterize all phases of a pulse-type EOD. 

19- `decompose_pulse()`: decompose single pulse waveform into sum of Gaussians. 

20- `analyze_pulse_tail()`: fit exponential to last peak/trough of pulse EOD. 

21- `analyze_pulse_intervals()`: basic statistics of interpulse intervals. 

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

23 

24## Similarity of EOD waveforms 

25 

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

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

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

29 

30## Quality assessment 

31 

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

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

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

35 

36## Visualization 

37 

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

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

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

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

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

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

44 

45## Storage 

46 

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

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

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

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

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

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

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

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

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

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

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

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

59- `save_pulse_phases()`: save phase properties of pulse EOD to file. 

60- `load_pulse_phases()`: load phase properties of pulse EOD from file. 

61- `save_pulse_gaussians()`: save Gaussian phase properties of pulse EOD to file. 

62- `load_pulse_gaussians()`: load Gaussian phase properties of pulse EOD from file. 

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

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

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

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

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

68- `load_recording()`: load recording. 

69 

70## Fit functions 

71 

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

73- `exp_decay()`: exponential decay. 

74- `gaussian_sum()`: sum of Gaussians. 

75- `gaussian_sum_spectrum()`: energy spectrum of sum of Gaussians. 

76- `gaussian_sum_costs()`: cost function for fitting sum of Gaussians. 

77 

78## Filter functions 

79 

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

81 

82## Configuration 

83 

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

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

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

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

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

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

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

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

92""" 

93 

94import io 

95import glob 

96import zipfile 

97import numpy as np 

98try: 

99 import matplotlib.pyplot as plt 

100except ImportError: 

101 pass 

102 

103from pathlib import Path 

104from scipy.optimize import curve_fit, minimize 

105from numba import jit 

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

107from thunderlab.eventdetection import threshold_crossings, threshold_crossing_times, merge_events 

108from thunderlab.powerspectrum import next_power_of_two, nfft, decibel 

109from thunderlab.tabledata import TableData 

110from thunderlab.dataloader import DataLoader 

111 

112from .harmonics import fundamental_freqs_and_power 

113from .fakefish import pulsefish_waveform 

114from .fakefish import normalize_pulsefish, export_pulsefish 

115from .fakefish import normalize_wavefish, export_wavefish 

116 

117 

118def eod_waveform(data, rate, eod_times, win_fac=2.0, min_win=0.01, 

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

120 """Extract data snippets around each EOD, and compute a mean waveform with standard error. 

121 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

137 

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

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

140 be averaged. 

141 

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

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

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

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

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

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

148 observation). 

149 

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

151 

152 Parameters 

153 ---------- 

154 data: 1-D array of float 

155 The data to be analysed. 

156 rate: float 

157 Sampling rate of the data in Hertz. 

158 eod_times: 1-D array of float 

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

160 averaged. 

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

162 win_fac: float 

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

164 is determined as the minimum interval between EOD times. 

165 min_win: float 

166 The minimum size of the snippets in seconds. 

167 min_sem: bool 

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

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

170 max_eods: int or None 

171 Maximum number of EODs to be used for averaging. 

172 unfilter_cutoff: float 

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

174 applied to the mean EOD waveform. 

175  

176 Returns 

177 ------- 

178 mean_eod: 2-D array 

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

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

181 eod_times: 1-D array 

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

183 averaged EOD waveform. 

184 """ 

185 # indices of EOD times: 

186 eod_idx = np.round(eod_times*rate).astype(int) 

187 

188 # window size: 

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

190 win = 0.5*win_fac*period 

191 if 2*win < min_win: 

192 win = 0.5*min_win 

193 win_inx = int(win*rate) 

194 

195 # extract snippets: 

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

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

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

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

200 eod_times = eod_times[dn:dn+max_eods] 

201 eod_idx = eod_idx[dn:dn+max_eods] 

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

203 if len(eod_snippets) == 0: 

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

205 

206 # optimal number of snippets: 

207 step = 10 

208 if min_sem and len(eod_snippets) > step: 

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

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

211 idx = np.argmin(sems) 

212 # there is a local minimum: 

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

214 maxn = step*(idx+1) 

215 eod_snippets = eod_snippets[:maxn] 

216 eod_times = eod_times[:maxn] 

217 

218 # mean and std of snippets: 

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

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

221 if len(eod_snippets) > 1: 

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

223 

224 # apply inverse filter: 

225 if unfilter_cutoff and unfilter_cutoff > 0.0: 

226 unfilter(mean_eod[:, 1], rate, unfilter_cutoff) 

227 

228 # time axis: 

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

230 

231 return mean_eod, eod_times 

232 

233 

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

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

236 

237 Parameters 

238 ---------- 

239 eodf: float or ndarray 

240 EOD frequencies. 

241 temp: float 

242 Temperature in degree celsisus at which EOD frequencies in 

243 `eodf` were measured. 

244 temp_adjust: float 

245 Standard temperature in degree celsisus to which EOD 

246 frequencies are adjusted. 

247 q10: float 

248 Q10 value describing temperature dependence of EOD 

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

250 (2000) Brain Behav Evol, measured for Apteronotus 

251 lepthorhynchus in the lab. 

252 

253 Returns 

254 ------- 

255 eodf_corrected: float or array 

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

257 """ 

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

259 

260 

261def waveeod_waveform(data, rate, freq, win_fac=2.0, unfilter_cutoff=0.0): 

262 """Retrieve average EOD waveform via Fourier transform. 

263 

264 TODO: use power spectra to check minimum data segment needed and 

265 check for changes in frequency over several segments! 

266 

267 TODO: return waveform with higher samplign rate? (important for 

268 2kHz waves on 24kHz sampling). But this seems to render some EODs 

269 inacceptable in the further thunderfish processing pipeline. 

270 

271 Parameters 

272 ---------- 

273 data: 1-D array of float 

274 The data to be analysed. 

275 rate: float 

276 Sampling rate of the data in Hertz. 

277 freq: float 

278 EOD frequency. 

279 win_fac: float 

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

281 is determined as the minimum interval between EOD times. 

282 unfilter_cutoff: float 

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

284 applied to the mean EOD waveform. 

285  

286 Returns 

287 ------- 

288 mean_eod: 2-D array 

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

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

291 eod_times: 1-D array 

292 Times of EOD peaks in seconds that have been actually used to 

293 calculate the averaged EOD waveform. 

294 

295 """ 

296 

297 @jit(nopython=True) 

298 def fourier_wave(data, rate, freq): 

299 """ 

300 extracting wave via fourier coefficients 

301 """ 

302 twave = np.arange(0, (1+win_fac)/freq, 1/rate) 

303 wave = np.zeros(len(twave)) 

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

305 for k in range(0, 31): 

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

307 wave += np.real(Xk*np.exp(1j*2*np.pi*k*freq*twave)) 

308 return wave 

309 

310 @jit(nopython=True) 

311 def fourier_range(data, rate, f0, f1, df): 

312 wave = np.zeros(1) 

313 freq = f0 

314 for f in np.arange(f0, f1, df): 

315 w = fourier_wave(data, rate, f) 

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

317 wave = w 

318 freq = f 

319 return wave, freq 

320 

321 # TODO: parameterize! 

322 tsnippet = 2 

323 min_corr = 0.98 

324 min_ampl_frac = 0.5 

325 frange = 0.1 

326 fstep = 0.1 

327 waves = [] 

328 freqs = [] 

329 times = [] 

330 step = int(tsnippet*rate) 

331 for i in range(0, len(data) - step//2, step//2): 

332 w, f = fourier_range(data[i:i + step], rate, freq - frange, 

333 freq + frange + fstep/2, fstep) 

334 waves.append(w) 

335 freqs.append(f) 

336 """ 

337 waves.append(np.zeros(1)) 

338 freqs.append(freq) 

339 for f in np.arange(freq - frange, freq + frange + fstep/2, fstep): 

340 w = fourier_wave(data[i:i + step], rate, f) 

341 if np.max(w) - np.min(w) > np.max(waves[-1]) - np.min(waves[-1]): 

342 waves[-1] = w 

343 freqs[-1] = f 

344 """ 

345 times.append(np.arange(i/rate, (i + step)/rate, 1/freqs[-1])) 

346 eod_freq = np.mean(freqs) 

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

348 eod_times = np.zeros((0)) 

349 if len(waves) == 0: 

350 return mean_eod, eod_times 

351 for k in range(len(waves)): 

352 period = int(np.ceil(rate/freqs[k])) 

353 i = np.argmax(waves[k][:period]) 

354 waves[k] = waves[k][i:] 

355 n = np.min([len(w) for w in waves]) 

356 waves = np.array([w[:n] for w in waves]) 

357 # only snippets that are similar: 

358 if len(waves) > 1: 

359 corr = np.corrcoef(waves) 

360 nmax = np.argmax(np.sum(corr > min_corr, axis=1)) 

361 if nmax <= 1: 

362 nmax = 2 

363 select = np.sum(corr > min_corr, axis=1) >= nmax 

364 waves = waves[select] 

365 times = [times[k] for k in range(len(times)) if select[k]] 

366 if len(waves) == 0: 

367 return mean_eod, eod_times 

368 # only the largest snippets: 

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

370 select = ampls >= min_ampl_frac*np.max(ampls) 

371 waves = waves[select] 

372 times = [times[k] for k in range(len(times)) if select[k]] 

373 if len(waves) == 0: 

374 return mean_eod, eod_times 

375 """ 

376 #plt.plot(freqs) 

377 plt.plot(waves.T) 

378 plt.show() 

379 """ 

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

381 mean_eod[:, 0] = np.arange(len(mean_eod))/rate 

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

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

384 eod_times = np.concatenate(times) 

385 

386 # apply inverse filter: 

387 if unfilter_cutoff and unfilter_cutoff > 0.0: 

388 unfilter(mean_eod[:, 1], rate, unfilter_cutoff) 

389 

390 return mean_eod, eod_times 

391 

392 

393def unfilter(data, rate, cutoff): 

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

395 

396 Assumes high-pass filter 

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

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

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

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

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

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

403 

404 Parameters 

405 ---------- 

406 data: ndarray 

407 High-pass filtered original data. 

408 rate: float 

409 Sampling rate of `data` in Hertz. 

410 cutoff: float 

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

412 

413 Returns 

414 ------- 

415 data: ndarray 

416 Recovered original data. 

417 """ 

418 tau = 0.5/np.pi/cutoff 

419 fac = tau*rate 

420 data -= np.mean(data) 

421 d0 = data[0] 

422 x = d0 

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

424 d1 = data[k] 

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

426 data[k] = x 

427 d0 = d1 

428 return data 

429 

430 

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

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

433 

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

435  

436 Parameters 

437 ---------- 

438 t: float or array 

439 Time. 

440 freq: float 

441 Fundamental frequency. 

442 *ap: list of floats 

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

444  

445 Returns 

446 ------- 

447 x: float or array 

448 The Fourier series evaluated at times `t`. 

449 """ 

450 omega = 2.0*np.pi*freq 

451 x = 0.0 

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

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

454 return x 

455 

456 

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

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

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

460  

461 Parameters 

462 ---------- 

463 eod: 2-D array 

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

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

466 standard error of the EOD waveform, Further columns are 

467 optional but not used. 

468 freq: float or 2-D array 

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

470 frequency and peak height (columns) as returned from 

471 `harmonics.harmonic_groups()`. 

472 n_harm: int 

473 Maximum number of harmonics used for the Fourier decomposition. 

474 power_n_harmonics: int 

475 Sum over the first `power_n_harmonics` harmonics for computing 

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

477 n_harmonics: int 

478 The maximum power of higher harmonics is computed from 

479 harmonics higher than the maximum harmonics within the first 

480 three harmonics plus `n_harmonics`. 

481 flip_wave: 'auto', 'none', 'flip' 

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

483 - 'flip' flip waveform. 

484 - 'none' do not flip waveform. 

485  

486 Returns 

487 ------- 

488 meod: 2-D array of floats 

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

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

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

492 series to the waveform. 

493 props: dict 

494 A dictionary with properties of the analyzed EOD waveform. 

495 

496 - type: set to 'wave'. 

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

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

499 - flipped: True if the waveform was flipped. 

500 - amplitude: amplitude factor of the Fourier fit. 

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

502 EOD waveform relative to the p-p amplitude. 

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

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

505 about 0.05 the data are bad. 

506 - ncrossings: number of zero crossings per period 

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

508 to EOD period. 

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

510 relative to EOD period. 

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

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

513 to EOD period. 

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

515 EOD period. 

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

517 EOD period. 

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

519 EOD period. 

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

521 - min-p-p-distance: p-p-distance or EOD period minus p-p-distance, 

522 whichever is smaller, relative to EOD period. 

523 - relpeakampl: amplitude of peak or trough, whichever is larger, 

524 relative to p-p amplitude. 

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

526 in decibel relative to one. 

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

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

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

530 amplitudes squared of harmonics relative to amplitude 

531 of fundamental.  

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

533 differences in decibel power. 

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

535 in decibel. 

536 

537 spec_data: 2-D array of floats 

538 First six columns are from the spectrum of the extracted 

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

540 column its frequency, third column its amplitude, fourth 

541 column its amplitude relative to the fundamental, fifth column 

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

543 sixth column the phase shift relative to the fundamental. 

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

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

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

547 harmonics, first row is the fundamental frequency with index 

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

549 shift of zero. 

550 error_str: string 

551 If fitting of the fourier series failed, 

552 this is reported in this string. 

553 

554 Raises 

555 ------ 

556 IndexError: 

557 EOD data is less than one period long. 

558 """ 

559 error_str = '' 

560 

561 freq0 = freq 

562 if hasattr(freq, 'shape'): 

563 freq0 = freq[0][0] 

564 

565 # storage: 

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

567 meod[:, :-1] = eod 

568 

569 # subtract mean and flip: 

570 period = 1.0/freq0 

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

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

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

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

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

576 flipped = False 

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

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

579 flipped = True 

580 

581 # move peak of waveform to zero: 

582 offs = len(meod)//4 

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

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

585 

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

587 if len(meod) < pinx: 

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

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

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

591 i1 = i0 + 2*pinx 

592 if i1 > len(meod): 

593 i1 = len(meod) 

594 i0 = i1 - 2*pinx 

595 else: 

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

597 i1 = i0 + pinx 

598 

599 # subtract mean: 

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

601 

602 # zero crossings: 

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

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

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

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

607 if np.any(ut<0.0): 

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

609 else: 

610 up_time = 0.0 

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

612 if np.any(dt>0.0): 

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

614 else: 

615 down_time = 0.0 

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

617 peak_width = down_time - up_time 

618 trough_width = period - peak_width 

619 peak_time = 0.0 

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

621 phase1 = peak_time - up_time 

622 phase2 = down_time - peak_time 

623 phase3 = trough_time - down_time 

624 phase4 = up_time + period - trough_time 

625 distance = trough_time - peak_time 

626 min_distance = distance 

627 if distance > period/2: 

628 min_distance = period - distance 

629 

630 # fit fourier series: 

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

632 while n_harm > 1: 

633 params = [freq0] 

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

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

636 try: 

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

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

639 break 

640 except (RuntimeError, TypeError): 

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

642 n_harm //= 2 

643 # store fourier fit: 

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

645 # make all amplitudes positive: 

646 for i in range(n_harm): 

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

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

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

650 # phases relative to fundamental: 

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

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

653 phi0 = popt[2] 

654 for i in range(n_harm): 

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

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

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

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

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

660 # store fourier spectrum: 

661 if hasattr(freq, 'shape'): 

662 n = n_harm 

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

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

665 spec_data[:, :] = np.nan 

666 k = 0 

667 for i in range(n_harm): 

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

669 k += 1 

670 if k >= len(freq): 

671 break 

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

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

674 k += 1 

675 for i in range(n_harm, n): 

676 if k >= len(freq): 

677 break 

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

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

680 k += 1 

681 else: 

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

683 for i in range(n_harm): 

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

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

686 # smoothness of power spectrum: 

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

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

689 # maximum relative power of higher harmonics: 

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

691 db_powers -= db_powers[p_max] 

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

693 max_harmonics_power = -100.0 

694 else: 

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

696 # total harmonic distortion: 

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

698 

699 # peak-to-peak and trough amplitudes: 

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

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

702 

703 # variance and fit error: 

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

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

706 

707 # store results: 

708 props = {} 

709 props['type'] = 'wave' 

710 props['EODf'] = freq0 

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

712 props['flipped'] = flipped 

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

714 props['rmserror'] = rmserror 

715 if rmssem: 

716 props['noise'] = rmssem 

717 props['ncrossings'] = ncrossings 

718 props['peakwidth'] = peak_width/period 

719 props['troughwidth'] = trough_width/period 

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

721 props['leftpeak'] = phase1/period 

722 props['rightpeak'] = phase2/period 

723 props['lefttrough'] = phase3/period 

724 props['righttrough'] = phase4/period 

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

726 props['min-p-p-distance'] = min_distance/period 

727 props['relpeakampl'] = relpeakampl 

728 pnh = power_n_harmonics if power_n_harmonics > 0 else n_harm 

729 pnh = min(n_harm, pnh) 

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

731 if hasattr(freq, 'shape'): 

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

733 props['thd'] = thd 

734 props['dbdiff'] = db_diff 

735 props['maxdb'] = max_harmonics_power 

736 

737 return meod, props, spec_data, error_str 

738 

739 

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

741 """Exponential decay function. 

742 

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

744 

745 Parameters 

746 ---------- 

747 t: float or array 

748 Time. 

749 tau: float 

750 Time constant of exponential decay. 

751 ampl: float 

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

753 steady-state value. 

754 offs: float 

755 Steady-state value. 

756  

757 Returns 

758 ------- 

759 x: float or array 

760 The exponential decay evaluated at times `t`. 

761 

762 """ 

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

764 

765 

766def pulse_spectrum(eod, ratetime=None, freq_resolution=1.0, fade_frac=0.0): 

767 """Compute the spectrum of a single pulse-type EOD. 

768  

769 Parameters 

770 ---------- 

771 eod: 1-D or 2-D array 

772 The eod waveform of which the spectrum is computed. 

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

774 need to also pass a sampling rate in `rate`. 

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

776 column is the eod waveform. Further columns are ignored. 

777 ratetime: None or float or array of float 

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

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

780 freq_resolution: float 

781 The frequency resolution of the spectrum. 

782 fade_frac: float 

783 Fraction of time of the EOD waveform that is used to fade in 

784 and out to zero baseline. 

785  

786 Returns 

787 ------- 

788 freqs: 1-D array of float 

789 The frequency components of the energy spectrum. 

790 energy: 1-D array of float 

791 The energy spectrum of the single pulse EOD 

792 with unit (x s)^2 = x^2 s/Hz. 

793 The integral over the energy spectrum `np.sum(energy)*freqs[1]` 

794 equals the integral over the squared eod, `np.sum(eod**2)/rate`. 

795 That is, by making the energy spectrum a power sepctrum 

796 (dividing the energy by the FFT window duration), the integral 

797 over the power spectrum equals the mean-squared signal 

798 (variance). But the single-pulse spectrum is not a power-spectrum. 

799 because in the limit to infinitely long window, the power vanishes! 

800 

801 See Also 

802 -------- 

803 thunderfish.fakefish.pulsefish_spectrum(): analytically computed spectra for simulated pulse EODs. 

804 

805 """ 

806 if eod.ndim == 2: 

807 rate = 1.0/(eod[1, 0] - eod[0, 0]) 

808 eod = eod[:, 1] 

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

810 rate = 1.0/(ratetime[1] - ratetime[0]) 

811 else: 

812 rate = ratetime 

813 n_fft = nfft(rate, freq_resolution) 

814 # subtract mean computed from the ends of the EOD snippet: 

815 n = len(eod)//20 if len(eod) >= 20 else 1 

816 eod = eod - 0.5*(np.mean(eod[:n]) + np.mean(eod[-n:])) 

817 # zero padding: 

818 max_idx = np.argmax(eod) 

819 n0 = max_idx 

820 n1 = len(eod) - max_idx 

821 n = 2*max(n0, n1) 

822 if n_fft < n: 

823 n_fft = next_power_of_two(n) 

824 data = np.zeros(n_fft) 

825 data[n_fft//2 - n0:n_fft//2 + n1] = eod 

826 # fade in and out: 

827 if fade_frac > 0: 

828 fn = int(fade_frac*len(eod)) 

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

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

831 # spectrum: 

832 dt = 1/rate 

833 freqs = np.fft.rfftfreq(n_fft, dt) 

834 fourier = np.fft.rfft(data)*dt 

835 energy = 2*np.abs(fourier)**2 # one-sided spectrum! 

836 return freqs, energy 

837 

838 

839def analyze_pulse_spectrum(freqs, energy): 

840 """Analyze the spectrum of a pulse-type EOD. 

841  

842 Parameters 

843 ---------- 

844 freqs: 1-D array of float 

845 The frequency components of the energy spectrum. 

846 energy: 1-D array of float 

847 The energy spectrum of the single pulse-type EOD. 

848  

849 Returns 

850 ------- 

851 peak_freq: float 

852 Frequency at peak energy of the spectrum in Hertz. 

853 peak_energy: float 

854 Peak energy of the pulse spectrum in x^2 s/Hz. 

855 trough_freq: float 

856 Frequency at trough before peak in Hertz. 

857 trough_energy: float 

858 Energy of trough before peak in x^2 s/Hz. 

859 att5: float 

860 Attenuation of average energy below 5 Hz relative to 

861 peak energy in decibel. 

862 att50: float 

863 Attenuation of average energy below 50 Hz relative to 

864 peak energy in decibel. 

865 low_cutoff: float 

866 Frequency at which the energy reached half of the 

867 peak energy relative to the DC energy in Hertz. 

868 high_cutoff: float 

869 3dB roll-off frequency in Hertz. 

870 """ 

871 ip = np.argmax(energy) 

872 peak_freq = freqs[ip] 

873 peak_energy = energy[ip] 

874 it = np.argmin(energy[:ip]) if ip > 0 else 0 

875 trough_freq = freqs[it] 

876 trough_energy = energy[it] 

877 att5 = decibel(np.mean(energy[freqs<5.0]), peak_energy) 

878 att50 = decibel(np.mean(energy[freqs<50.0]), peak_energy) 

879 low_cutoff = freqs[decibel(energy, peak_energy) > 0.5*att5][0] 

880 high_cutoff = freqs[decibel(energy, peak_energy) > -3.0][-1] 

881 return peak_freq, peak_energy, trough_freq, trough_energy, \ 

882 att5, att50, low_cutoff, high_cutoff 

883 

884 

885def analyze_pulse_phases(threshold, eod, ratetime=None, 

886 min_dist=50.0e-6, width_frac=0.5): 

887 """ Characterize all phases of a pulse-type EOD. 

888  

889 Parameters 

890 ---------- 

891 threshold: float 

892 Threshold for detecting peaks and troughs. 

893 eod: 1-D or 2-D array 

894 The eod waveform to be analyzed. 

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

896 need to also pass a sampling rate in `rate`. 

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

898 column is the eod waveform. Further columns are ignored. 

899 ratetime: None or float or array of float 

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

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

902 min_dist: float 

903 Minimum distance between peak and troughs of the pulse. 

904 width_frac: float 

905 The width of an EOD phase is measured at this fraction of a peak's 

906 height (0-1). 

907  

908 Returns 

909 ------- 

910 phases: 2-D array 

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

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

913 time relative to largest positive peak, amplitude, 

914 amplitude normalized to P1 phase, 

915 width of peak/trough at `width_frac` height, 

916 area under the peak, and area under the peak relative to total area. 

917 Empty if waveform is not a pulse EOD. 

918 """ 

919 if eod.ndim == 2: 

920 time = eod[:, 0] 

921 eod = eod[:, 1] 

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

923 time = ratetime 

924 else: 

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

926 dt = time[1] - time[0] 

927 # find peaks and troughs: 

928 peak_idx, trough_idx = detect_peaks(eod, threshold) 

929 if len(peak_idx) == 0: 

930 return np.zeros((0, 7)) 

931 # and their width: 

932 peak_widths = peak_width(time, eod, peak_idx, trough_idx, 

933 peak_frac=width_frac, base='max') 

934 trough_widths = peak_width(time, -eod, trough_idx, peak_idx, 

935 peak_frac=width_frac, base='max') 

936 # combine peaks and troughs: 

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

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

939 pts_idx = np.argsort(pt_idx) 

940 phase_list = pt_idx[pts_idx] 

941 width_list = pt_widths[pts_idx] 

942 # remove multiple peaks that are too close: 

943 # TODO: XXX replace by Dexters function that keeps the maximum peak 

944 rmidx = [(k, k+1) for k in np.where(np.diff(time[phase_list]) < min_dist)[0]] 

945 # flatten and keep maximum and minimum phase: 

946 max_idx = np.argmax(eod) 

947 min_idx = np.argmin(eod) 

948 rmidx = np.unique([k for kk in rmidx for k in kk 

949 if phase_list[k] != max_idx and phase_list[k] != min_idx]) 

950 # delete: 

951 if len(rmidx) > 0: 

952 phase_list = np.delete(phase_list, rmidx) 

953 width_list = np.delete(width_list, rmidx) 

954 if len(phase_list) == 0: 

955 return np.zeros((0, 7)) 

956 # find P1: 

957 p1i = np.argmax(phase_list == max_idx) 

958 ## truncate peaks to the left: # TODO: REALLY? WHY? 

959 #offs = 0 if p1i <= 2 else p1i - 2 

960 #phase_list = phase_list[offs:] 

961 #width_list = width_list[offs:] 

962 #p1i -= offs 

963 # amplitudes: 

964 amplitudes = eod[phase_list] 

965 max_ampl = np.abs(amplitudes[p1i]) 

966 # phase areas: 

967 phase_areas = np.zeros(len(phase_list)) 

968 for i in range(len(phase_list)): 

969 sign_fac = np.sign(eod[phase_list[i]]) 

970 i0 = phase_list[i - 1] if i > 0 else 0 

971 i1 = phase_list[i + 1] if i + 1 < len(phase_list) else len(eod) 

972 if i0 > 0 and sign_fac*eod[i0] > 0 and \ 

973 i1 < len(eod) and sign_fac*eod[i1] > 0: 

974 phase_areas[i] = 0 

975 else: 

976 snippet = sign_fac*eod[i0:i1] 

977 phase_areas[i] = np.sum(snippet[snippet > 0])*dt 

978 phase_areas[i] *= sign_fac 

979 total_area = np.sum(np.abs(phase_areas)) 

980 # store phase properties: 

981 phases = np.zeros((len(phase_list), 7)) 

982 for i, pi in enumerate(phase_list): 

983 phases[i, :] = [i + 1 - p1i, time[pi], amplitudes[i], 

984 amplitudes[i]/max_ampl, width_list[i], 

985 phase_areas[i], phase_areas[i]/total_area] 

986 return phases 

987 

988 

989def gaussian_sum(x, *pas): 

990 """Sum of Gaussians. 

991  

992 Parameters 

993 ---------- 

994 x: array of float 

995 The x array over which the sum of Gaussians is evaluated. 

996 *pas: list of floats 

997 The parameters of the Gaussians in a flat list. 

998 Position, amplitude, and standard deviation of first Gaussian, 

999 position, amplitude, and standard deviation of second Gaussian, 

1000 and so on. 

1001  

1002 Returns 

1003 ------- 

1004 sg: array of float 

1005 The sum of Gaussians for the times given in `t`. 

1006 """ 

1007 sg = np.zeros(len(x)) 

1008 for pos, ampl, std in zip(pas[0:-2:3], pas[1:-1:3], pas[2::3]): 

1009 sg += ampl*np.exp(-0.5*((x - pos)/std)**2) 

1010 return sg 

1011 

1012 

1013def gaussian_sum_spectrum(f, *pas): 

1014 """Energy spectrum of sum of Gaussians. 

1015 

1016 Parameters 

1017 ---------- 

1018 f: 1-D array of float 

1019 The frequencies at which to evaluate the spectrum. 

1020 *pas: list of floats 

1021 The parameters of the Gaussians in a flat list. 

1022 Position, amplitude, and standard deviation of first Gaussian, 

1023 position, amplitude, and standard deviation of second Gaussian, 

1024 and so on. 

1025  

1026 Returns 

1027 ------- 

1028 energy: 1-D array of float 

1029 The one-sided energy spectrum of the sum of Gaussians. 

1030 """ 

1031 spec = np.zeros(len(f), dtype=complex) 

1032 for dt, a, s in zip(pas[0:-2:3], pas[1:-1:3], pas[2::3]): 

1033 gauss = a*np.sqrt(2*np.pi)*s*np.exp(-0.5*(2*np.pi*s*f)**2) 

1034 shift = np.exp(-2j*np.pi*f*dt) 

1035 spec += gauss*shift 

1036 spec *= np.sqrt(2) # because of one-sided spectrum 

1037 return np.abs(spec)**2 

1038 

1039 

1040def gaussian_sum_costs(pas, time, eod, freqs, energy): 

1041 """ Cost function for fitting sum of Gaussian to pulse EOD. 

1042  

1043 Parameters 

1044 ---------- 

1045 pas: list of floats 

1046 The pulse parameters in a flat list. 

1047 Position, amplitude, and standard deviation of first phase, 

1048 position, amplitude, and standard deviation of second phase, 

1049 and so on. 

1050 time: 1-D array of float 

1051 Time points of the EOD waveform. 

1052 eod: 1-D array of float 

1053 The real EOD waveform. 

1054 freqs: 1-D array of float 

1055 The frequency components of the spectrum. 

1056 energy: 1-D array of float 

1057 The energy spectrum of the real pulse. 

1058  

1059 Returns 

1060 ------- 

1061 costs: float 

1062 Weighted sum of rms waveform difference and rms spectral difference. 

1063 """ 

1064 eod_fit = gaussian_sum(time, *pas) 

1065 eod_rms = np.sqrt(np.mean((eod_fit - eod)**2))/np.max(np.abs(eod)) 

1066 level = decibel(energy) 

1067 level_range = 30 

1068 n = np.argmax(level) 

1069 energy_fit = gaussian_sum_spectrum(freqs, *pas) 

1070 level_fit = decibel(energy_fit) 

1071 weight = np.ones(n) 

1072 weight[freqs[:n] < 10] = 100 

1073 weight /= np.sum(weight) 

1074 spec_rms = np.sqrt(np.mean(weight*(level_fit[:n] - level[:n])**2))/level_range 

1075 costs = eod_rms + 5*spec_rms 

1076 #print(f'{costs:.4f}, {eod_rms:.4f}, {spec_rms:.4f}') 

1077 return costs 

1078 

1079 

1080def decompose_pulse(eod, freqs, energy, phases, width_frac=0.5): 

1081 """Decompose single pulse waveform into sum of Gaussians. 

1082 

1083 Use the output to simulate pulse-type EODs using the functions 

1084 provided in the thunderfish.fakefish module. 

1085  

1086 Parameters 

1087 ---------- 

1088 eod: 2-D array of float 

1089 The eod waveform. First column is time in seconds and second 

1090 column is the eod waveform. Further columns are ignored. 

1091 freqs: 1-D array of float 

1092 The frequency components of the spectrum. 

1093 energy: 1-D array of float 

1094 The energy spectrum of the real pulse. 

1095 phases: 2-D array of float 

1096 Properties of the EOD phases as returned by analyze_pulse_phases().  

1097 width_frac: float 

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

1099 height (0-1). 

1100 

1101 Returns 

1102 ------- 

1103 pulse: dict 

1104 Dictionary with phase times, amplitudes and standard 

1105 deviations of Gaussians fitted to the pulse waveform. Use the 

1106 functions provided in thunderfish.fakefish to simulate pulse 

1107 fish EODs from this data. 

1108 

1109 """ 

1110 pulse = dict(times=np.zeros(0), amplitudes=np.zeros(0), 

1111 stdevs=np.zeros(0)) 

1112 if len(phases) < 1: 

1113 return pulse 

1114 if eod.ndim == 2: 

1115 time = eod[:, 0] 

1116 eod = eod[:, 1] 

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

1118 time = ratetime 

1119 else: 

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

1121 # convert half width to standard deviation: 

1122 fac = 0.5/np.sqrt(-2*np.log(width_frac)) 

1123 # fit parameter as single list: 

1124 tas = [] 

1125 for t, a, s in zip(phases[:, 1], phases[:, 2], phases[:, 4]*fac): 

1126 tas.extend((t, a, s)) 

1127 tas = np.asarray(tas) 

1128 # fit EOD waveform: 

1129 try: 

1130 tas, _ = curve_fit(gaussian_sum, time, eod, tas) 

1131 except RuntimeError as e: 

1132 print('Fit gaussian_sum failed in decompose_pulse():', e) 

1133 return pulse 

1134 # fit EOD waveform and spectrum: 

1135 bnds = [(1e-5, None) if k%3 == 2 else (None, None) 

1136 for k in range(len(tas))] 

1137 res = minimize(gaussian_sum_costs, tas, 

1138 args=(time, eod, freqs, energy), bounds=bnds) 

1139 if not res.success: 

1140 print('Optimization gaussian_sum_costs failed in decompose_pulse():', 

1141 res.message) 

1142 else: 

1143 tas = res.x 

1144 # add another Gaussian: 

1145 rms_norm = np.max(np.abs(eod)) 

1146 rms_old = np.sqrt(np.mean((gaussian_sum(time, *tas) - eod)**2))/rms_norm 

1147 eod_diff = np.abs(gaussian_sum(time, *tas) - eod)/rms_norm 

1148 if np.max(eod_diff) > 0.1: 

1149 print('add Gaussian', np.max(eod_diff)) 

1150 ntas = np.concatenate((tas, (time[np.argmax(eod_diff)], np.max(eod_diff), 

1151 np.mean(tas[2::3])))) 

1152 bnds = [(1e-5, None) if k%3 == 2 else (None, None) 

1153 for k in range(len(ntas))] 

1154 res = minimize(gaussian_sum_costs, ntas, 

1155 args=(time, eod, freqs, energy), bounds=bnds) 

1156 if res.success: 

1157 rms_new = np.sqrt(np.mean((gaussian_sum(time, *res.x) - eod)**2))/rms_norm 

1158 if rms_new < 0.8*rms_old: 

1159 tas = res.x 

1160 else: 

1161 print('removed new Gaussian') 

1162 else: 

1163 print('Optimization gaussian_sum_costs for additional Gaussian failed in decompose_pulse():', 

1164 res.message) 

1165 times = np.asarray(tas[0::3]) 

1166 ampls = np.asarray(tas[1::3]) 

1167 stdevs = np.asarray(tas[2::3]) 

1168 pulse = dict(times=times, amplitudes=ampls, stdevs=stdevs) 

1169 return pulse 

1170 

1171 

1172def analyze_pulse_tail(peak_index, eod, ratetime=None, 

1173 threshold=0.0, fit_frac=0.5): 

1174 """ Fit exponential to last peak/trough of pulse EOD. 

1175  

1176 Parameters 

1177 ---------- 

1178 peak_index: int 

1179 Index of last peak in `eod`. 

1180 eod: 1-D or 2-D array 

1181 The eod waveform to be analyzed. 

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

1183 need to also pass a sampling rate in `rate`. 

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

1185 column is the eod waveform. Further columns are ignored. 

1186 ratetime: None or float or array of float 

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

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

1189 threshold: float 

1190 Minimum size of peaks of the pulse waveform. 

1191 fit_frac: float or None 

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

1193 starting where the waveform falls below this fraction of the 

1194 peak's height (0-1). 

1195 If None, do not attempt to fit. 

1196  

1197 Returns 

1198 ------- 

1199 tau: float 

1200 Time constant of pulse tail in seconds. 

1201 fit: 1-D array of float 

1202 Time trace of the fit corresponding to `eod`. 

1203 """ 

1204 if fit_frac is None: 

1205 return None, None 

1206 if eod.ndim == 2: 

1207 time = eod[:, 0] 

1208 eod = eod[:, 1] 

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

1210 time = ratetime 

1211 else: 

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

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

1214 pi = peak_index 

1215 # positive or negative decay: 

1216 sign = 1 

1217 eodpp = eod[pi:] - 0.5*threshold 

1218 eodpn = -eod[pi:] - 0.5*threshold 

1219 if np.sum(eodpn[eodpn > 0]) > np.sum(eodpp[eodpp > 0]): 

1220 sign = -1 

1221 if sign*eod[pi] < 0: 

1222 pi += np.argmax(sign*eod[pi:]) 

1223 pi_ampl = np.abs(eod[pi]) 

1224 n = len(eod[pi:]) 

1225 # no sufficiently large initial value: 

1226 if pi_ampl*fit_frac <= 0.5*threshold: 

1227 fit_frac = False 

1228 # no sufficiently long decay: 

1229 if n < 10: 

1230 fit_frac = False 

1231 # not decaying towards zero: 

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

1233 if np.sum(np.abs(eod[pi + 2:]) > max_line[2:])/n > 0.05: 

1234 fit_frac = False 

1235 if not fit_frac: 

1236 return None, None 

1237 thresh = eod[pi]*fit_frac 

1238 inx = pi + np.argmax(sign*eod[pi:] < sign*thresh) 

1239 thresh = eod[inx]*np.exp(-1.0) 

1240 tau_inx = np.argmax(sign*eod[inx:] < sign*thresh) 

1241 if tau_inx < 2: 

1242 tau_inx = 2 

1243 rridx = inx + 6*tau_inx 

1244 if rridx > len(eod) - 1: 

1245 rridx = len(eod) - 1 

1246 if rridx - inx < 3*tau_inx: 

1247 return None, None 

1248 tau = time[inx + tau_inx] - time[inx] 

1249 params = [tau, eod[inx] - eod[rridx], eod[rridx]] 

1250 try: 

1251 popt, pcov = curve_fit(exp_decay, time[inx:rridx] - time[inx], 

1252 eod[inx:rridx], params, 

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

1254 except TypeError: 

1255 popt, pcov = curve_fit(exp_decay, time[inx:rridx] - time[inx], 

1256 eod[inx:rridx], params) 

1257 if popt[0] > 1.2*tau: 

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

1259 rridx = inx + 6*tau_inx 

1260 if rridx > len(eod) - 1: 

1261 rridx = len(eod) - 1 

1262 try: 

1263 popt, pcov = curve_fit(exp_decay, time[inx:rridx] - time[inx], 

1264 eod[inx:rridx], popt, 

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

1266 except TypeError: 

1267 popt, pcov = curve_fit(exp_decay, time[inx:rridx] - time[inx], 

1268 eod[inx:rridx], popt) 

1269 tau = popt[0] 

1270 fit = np.zeros(len(eod)) 

1271 fit[:] = np.nan 

1272 fit[inx:rridx] = exp_decay(time[inx:rridx] - time[inx], *popt) 

1273 return tau, fit 

1274 

1275 

1276def analyze_pulse_intervals(eod_times, ipi_cv_thresh=0.5, 

1277 ipi_percentile=30.0): 

1278 """ Basic statistics of interpulse intervals. 

1279  

1280 Parameters 

1281 ---------- 

1282 eod_times: 1-D array or None 

1283 List of times of detected EODs. 

1284 ipi_cv_thresh: float 

1285 If the coefficient of variation of the interpulse intervals 

1286 (IPIs) is smaller than this threshold, then the statistics of 

1287 IPIs is estimated from all IPIs. Otherwise only intervals 

1288 smaller than a certain percentile are used. 

1289 ipi_percentile: float 

1290 When computing the statistics of IPIs from a subset of the 

1291 IPIs, only intervals smaller than this percentile (between 0 

1292 and 100) are used. 

1293 

1294 Returns 

1295 ------- 

1296 ipi_median: float 

1297 Median inter-pulse interval. 

1298 ipi_mean: float 

1299 Mean inter-pulse interval. 

1300 ipi_std: float 

1301 Standard deviation of inter-pulse intervals. 

1302 

1303 """ 

1304 if eod_times is None: 

1305 return None, None, None 

1306 inter_pulse_intervals = np.diff(eod_times) 

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

1308 if ipi_cv < ipi_cv_thresh: 

1309 ipi_median = np.median(inter_pulse_intervals) 

1310 ipi_mean = np.mean(inter_pulse_intervals) 

1311 ipi_std = np.std(inter_pulse_intervals) 

1312 else: 

1313 intervals = inter_pulse_intervals[inter_pulse_intervals < 

1314 np.percentile(inter_pulse_intervals, ipi_percentile)] 

1315 ipi_median = np.median(intervals) 

1316 ipi_mean = np.mean(intervals) 

1317 ipi_std = np.std(intervals) 

1318 return ipi_median, ipi_mean, ipi_std 

1319 

1320 

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

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

1323 width_frac=0.5, fit_frac=0.5, 

1324 freq_resolution=1.0, fade_frac=0.0, 

1325 flip_pulse='none', ipi_cv_thresh=0.5, 

1326 ipi_percentile=30.0): 

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

1328  

1329 Parameters 

1330 ---------- 

1331 eod: 2-D array 

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

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

1334 standard error of the EOD waveform, Further columns are 

1335 optional but not used. 

1336 eod_times: 1-D array or None 

1337 List of times of detected EOD peaks. 

1338 min_pulse_win: float 

1339 The minimum size of cut-out EOD waveform. 

1340 peak_thresh_fac: float 

1341 Set the threshold for peak detection to the maximum pulse 

1342 amplitude times this factor. 

1343 min_dist: float 

1344 Minimum distance between peak and troughs of the pulse. 

1345 width_frac: float 

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

1347 height (0-1). 

1348 fit_frac: float or None 

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

1350 starting where the waveform falls below this fraction of the 

1351 peak's height (0-1). 

1352 freq_resolution: float 

1353 The frequency resolution of the spectrum of the single pulse. 

1354 fade_frac: float 

1355 Fraction of time of the EOD waveform that is fade in and out 

1356 to zero baseline. 

1357 flip_pulse: 'auto', 'none', 'flip' 

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

1359 - 'flip' flip waveform. 

1360 - 'none' do not flip waveform. 

1361 ipi_cv_thresh: float 

1362 If the coefficient of variation of the interpulse intervals 

1363 (IPIs) is smaller than this threshold, then the statistics of 

1364 IPIs is estimated from all IPIs. Otherwise only intervals 

1365 smaller than a certain percentile are used. 

1366 ipi_percentile: float 

1367 When computing the statistics of IPIs from a subset of the 

1368 IPIs, only intervals smaller than this percentile (between 0 

1369 and 100) are used. 

1370  

1371 Returns 

1372 ------- 

1373 meod: 2-D array of floats 

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

1375 second column the eod waveform. 

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

1377 As the two last columns the waveform resulting from the 

1378 decomposition into Gaussians and the fit to the tail of the 

1379 last peak is appended. 

1380 props: dict 

1381 A dictionary with properties of the analyzed EOD waveform. 

1382 

1383 - type: set to 'pulse'. 

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

1385 if provided. 

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

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

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

1389 `eod_times`, if provided. 

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

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

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

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

1394 EOD waveform relative to the p-p amplitude. 

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

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

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

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

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

1400 - peak-dist: distance between minimum and maximum phase in seconds. 

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

1402 - firstphase: index of the first phase in the pulse (i.e. -1 for P-1) 

1403 - lastphase: index of the last phase in the pulse (i.e. 3 for P3) 

1404 - totalarea: sum of areas under positive and negative peaks. 

1405 - positivearea: area under positive phases relative to total area. 

1406 - negativearea: area under negative phases relative to total area. 

1407 - polaritybalance: contrast between areas under positive and 

1408 negative phases. 

1409 - peakfreq: frequency at peak energy of the single pulse spectrum 

1410 in Hertz. 

1411 - peakenergy: peak energy of the single pulse spectrum. 

1412 - troughfreq: frequency at trough before peak in Hertz. 

1413 - troughenergy: energy of trough before peak in x^2 s/Hz. 

1414 - energyatt5: attenuation of average energy below 5 Hz relative to 

1415 peak energy in decibel. 

1416 - energyatt50: attenuation of average energy below 50 Hz relative to 

1417 peak energy in decibel. 

1418 - lowcutoff: frequency at which the energy reached half of the 

1419 peak energy relative to the DC energy in Hertz. 

1420 - highcutoff: 3dB roll-off frequency of spectrum in Hertz. 

1421 - flipped: True if the waveform was flipped. 

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

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

1424 if provided. 

1425 

1426 Empty if waveform is not a pulse EOD. 

1427 phases: 2-D array 

1428 For each phase (rows) of the EOD waveform 

1429 7 columns: the phase index (1 is P1, i.e. the largest positive phase), 

1430 time relative to largest positive phase, amplitude, 

1431 amplitude normalized to P1 phase, 

1432 width of phase at half height, 

1433 area under the phase, and area under the phase relative to total area. 

1434 Empty if waveform is not a pulse EOD. 

1435 pulse: dict 

1436 Dictionary with phase times, amplitudes and standard 

1437 deviations of Gaussians fitted to the pulse waveform. Use the 

1438 functions provided in thunderfish.fakefish to simulate pulse 

1439 fish EODs from this data. 

1440 energy: 2-D array 

1441 The energy spectrum of a single pulse. First column are the 

1442 frequencies, second column the energy in x^2 s/Hz. 

1443 Empty if waveform is not a pulse EOD. 

1444 

1445 """ 

1446 # storage: 

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

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

1449 meod[:, -2] = np.nan 

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

1451 toffs = 0 

1452 

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

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

1455 n = len(meod) 

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

1457 toffs += meod[idx0,0] 

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

1459 # minimum in standard deviation: 

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

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

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

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

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

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

1466 lidx = 0 

1467 ridx = len(meod) 

1468 if l_std > max_std and lstd_idx > lidx: 

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

1470 if r_std > max_std and rstd_idx < ridx: 

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

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

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

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

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

1476 #plt.show() 

1477 meod = meod[lidx:ridx,:] 

1478 

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

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

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

1482 

1483 # largest positive and negative peak and flip: # TODO move after cutting! 

1484 flipped = False 

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

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

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

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

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

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

1491 # two major peaks: 

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

1493 # flip: 

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

1495 peak_idx = min_idx 

1496 min_idx = max_idx 

1497 max_idx = peak_idx 

1498 flipped = True 

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

1500 # flip: 

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

1502 peak_idx = min_idx 

1503 min_idx = max_idx 

1504 max_idx = peak_idx 

1505 flipped = True 

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

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

1508 

1509 # minimum threshold for peak detection: # return from cutting function 

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

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

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

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

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

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

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

1517 min_thresh = 0.5*(max_ampl + min_ampl) 

1518 fit_frac = None 

1519 # threshold for peak detection: 

1520 threshold = max_ampl*peak_thresh_fac 

1521 if threshold < min_thresh: 

1522 threshold = min_thresh 

1523 if threshold <= 0.0: 

1524 return meod, {}, [], [] 

1525 

1526 # cut out relevant signal: 

1527 # TODO: this needs to be improved! 

1528 # more to the right in case of a shallow decay to baseline! 

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

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

1531 t0 = meod[lidx,0] 

1532 t1 = meod[ridx,0] 

1533 width = t1 - t0 

1534 if width < min_pulse_win: 

1535 width = min_pulse_win 

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

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

1538 # expand width: 

1539 leidx = lidx - width_idx//2 

1540 if leidx < 0: 

1541 leidx = 0 

1542 reidx = ridx + width_idx//2 

1543 if reidx >= len(meod): 

1544 reidx = len(meod) 

1545 meod = meod[leidx:reidx,:] 

1546 max_idx -= leidx 

1547 min_idx -= leidx 

1548 

1549 # move peak of waveform to zero: 

1550 toffs += meod[max_idx, 0] 

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

1552 

1553 tau = None 

1554 dist = 0.0 

1555 peaks = [] 

1556 

1557 # amplitude and variance: 

1558 ppampl = max_ampl + min_ampl 

1559 dist = meod[min_idx, 0] - meod[max_idx, 0] # TODO: move to analyze_pulse_phases() and handle non existent minimum 

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

1561 

1562 # integrals and polarity balance: 

1563 pos_area = np.sum(meod[meod[:, 1] >= 0, 1])*dt 

1564 neg_area = np.sum(meod[meod[:, 1] < 0, 1])*dt 

1565 total_area = np.sum(np.abs(meod[:, 1]))*dt 

1566 integral_area = np.sum(meod[:, 1])*dt # = pos_area - neg_area 

1567 polarity_balance = integral_area/total_area 

1568 

1569 # characterize EOD phases: 

1570 phases = analyze_pulse_phases(threshold, meod, None, 

1571 min_dist=min_dist, width_frac=width_frac) 

1572 

1573 # fit exponential to last peak/trough: 

1574 if len(phases) > 1: 

1575 pi = np.argmin(np.abs(meod[:, 0] - phases[-1, 1])) 

1576 tau, fit = analyze_pulse_tail(pi, meod, None, 

1577 threshold, fit_frac) 

1578 if fit is not None: 

1579 meod[:, -1] = fit 

1580 

1581 # energy spectrum of single pulse: 

1582 freqs, energy = pulse_spectrum(meod, None, freq_resolution, fade_frac) 

1583 # store spectrum: 

1584 eenergy = np.zeros((len(energy), 2)) 

1585 eenergy[:, 0] = freqs 

1586 eenergy[:, 1] = energy 

1587 # analyse spectrum: 

1588 peakfreq, peakenergy, troughfreq, troughenergy, \ 

1589 att5, att50, lowcutoff, highcutoff = \ 

1590 analyze_pulse_spectrum(freqs, energy) 

1591 

1592 # decompose EOD waveform: 

1593 pulse = decompose_pulse(meod, freqs, energy, phases, width_frac) 

1594 if len(pulse['amplitudes']) > 0: 

1595 eod_fit = pulsefish_waveform(pulse, meod[:, 0]) 

1596 meod[:, -2] = eod_fit 

1597 

1598 # analyze pulse intervals: 

1599 ipi_median, ipi_mean, ipi_std = analyze_pulse_intervals(eod_times, 

1600 ipi_cv_thresh, 

1601 ipi_percentile) 

1602 

1603 # store properties: 

1604 props = {} 

1605 props['type'] = 'pulse' 

1606 if eod_times is not None: 

1607 props['EODf'] = 1.0/ipi_median 

1608 props['period'] = ipi_median 

1609 props['IPI-mean'] = ipi_mean 

1610 props['IPI-std'] = ipi_std 

1611 props['max-ampl'] = max_ampl 

1612 props['min-ampl'] = min_ampl 

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

1614 if rmssem: 

1615 props['noise'] = rmssem 

1616 props['tstart'] = t0 

1617 props['tend'] = t1 

1618 props['width'] = t1 - t0 

1619 props['peak-dist'] = dist 

1620 if tau: 

1621 props['tau'] = tau 

1622 props['firstphase'] = phases[0, 0] if len(phases) > 0 else 1 

1623 props['lastphase'] = phases[-1, 0] if len(phases) > 0 else 1 

1624 props['totalarea'] = total_area 

1625 props['positivearea'] = pos_area/total_area 

1626 props['negativearea'] = neg_area/total_area 

1627 props['polaritybalance'] = polarity_balance 

1628 props['peakfreq'] = peakfreq 

1629 props['peakenergy'] = peakenergy 

1630 props['troughfreq'] = troughfreq 

1631 props['troughenergy'] = troughenergy 

1632 props['energyatt5'] = att5 

1633 props['energyatt50'] = att50 

1634 props['lowcutoff'] = lowcutoff 

1635 props['highcutoff'] = highcutoff 

1636 props['flipped'] = flipped 

1637 if eod_times is not None: 

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

1639 props['times'] = eod_times + toffs 

1640 

1641 return meod, props, phases, pulse, eenergy 

1642 

1643 

1644def load_species_waveforms(species_file='none'): 

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

1646  

1647 Parameters 

1648 ---------- 

1649 species_file: string 

1650 Name of file containing species definitions. The content of 

1651 this file is as follows: 

1652  

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

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

1655 table for wave fish. 

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

1657 table for pulse fish. 

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

1659 separated by colons (':'): 

1660  

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

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

1663 EOD waveform. 

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

1665 

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

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

1668 the third field, it is taken from the corresponding 

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

1670 excluded and a warning is issued. 

1671 

1672 Example file content: 

1673 ``` plain 

1674 Wavefish 

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

1676 Eigen : C_91008L01-eodwaveform-0.csv 

1677 

1678 Pulsefish 

1679 Gymnotus : pulsefish/gymnotus.csv 

1680 Brachy : H_91009L11-eodwaveform-0.csv 

1681 ``` 

1682  

1683 Returns 

1684 ------- 

1685 wave_names: list of strings 

1686 List of species names of wave-type fish. 

1687 wave_eods: list of 2-D arrays 

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

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

1690 second column is the EOD waveform. The waveforms contain 

1691 exactly one EOD period. 

1692 pulse_names: list of strings 

1693 List of species names of pulse-type fish. 

1694 pulse_eods: list of 2-D arrays 

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

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

1697 second column is the EOD waveform. 

1698 """ 

1699 if not Path(species_file).is_file(): 

1700 return [], [], [], [] 

1701 wave_names = [] 

1702 wave_eods = [] 

1703 pulse_names = [] 

1704 pulse_eods = [] 

1705 fish_type = 'wave' 

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

1707 for line in sf: 

1708 line = line.strip() 

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

1710 continue 

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

1712 fish_type = 'wave' 

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

1714 fish_type = 'pulse' 

1715 else: 

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

1717 if len(ls) < 2: 

1718 continue 

1719 name = ls[0] 

1720 waveform_file = ls[1] 

1721 eod = TableData(waveform_file).array() 

1722 eod[:, 0] *= 0.001 

1723 if fish_type == 'wave': 

1724 eodf = None 

1725 if len(ls) > 2: 

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

1727 else: 

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

1729 try: 

1730 wave_spec = TableData(spectrum_file) 

1731 eodf = wave_spec[0, 1] 

1732 except FileNotFoundError: 

1733 pass 

1734 if eodf is None: 

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

1736 continue 

1737 eod[:, 0] *= eodf 

1738 wave_names.append(name) 

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

1740 elif fish_type == 'pulse': 

1741 pulse_names.append(name) 

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

1743 return wave_names, wave_eods, pulse_names, pulse_eods 

1744 

1745 

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

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

1748 

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

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

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

1752 amplitude before computing rms difference. Also compute the rms 

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

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

1755 

1756 Parameters 

1757 ---------- 

1758 eod1: 2-D array 

1759 Time and amplitude of reference EOD. 

1760 eod2: 2-D array 

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

1762 eod1f: float 

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

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

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

1766 set the EOD frequency to one (default). 

1767 eod2f: float 

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

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

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

1771 set the EOD frequency to one (default). 

1772 

1773 Returns 

1774 ------- 

1775 rmse: float 

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

1777 their standard deviation over one period. 

1778 """ 

1779 # copy: 

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

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

1782 # scale to multiples of EOD period: 

1783 eod1[:, 0] *= eod1f 

1784 eod2[:, 0] *= eod2f 

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

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

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

1788 if n1 > n2: 

1789 eod1, eod2 = eod2, eod1 

1790 n1, n2 = n2, n1 

1791 # one period around time zero: 

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

1793 k0 = i0-n1//2 

1794 if k0 < 0: 

1795 k0 = 0 

1796 k1 = k0 + n1 + 1 

1797 if k1 >= len(eod1): 

1798 k1 = len(eod1) 

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

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

1801 k0 = i-n1//2 

1802 if k0 < 0: 

1803 k0 = 0 

1804 k1 = k0 + n1 + 1 

1805 if k1 >= len(eod1): 

1806 k1 = len(eod1) 

1807 eod1 = eod1[k0:k1,:] 

1808 # normalize amplitudes of first EOD: 

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

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

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

1812 # set time zero to maximum of second EOD: 

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

1814 k0 = i0-n2//2 

1815 if k0 < 0: 

1816 k0 = 0 

1817 k1 = k0 + n2 + 1 

1818 if k1 >= len(eod2): 

1819 k1 = len(eod2) 

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

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

1822 # interpolate eod2 to the time base of eod1: 

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

1824 # normalize amplitudes of second EOD: 

1825 eod2w -= np.min(eod2w) 

1826 eod2w /= np.max(eod2w) 

1827 # root-mean-square difference: 

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

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

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

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

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

1833 eod2w -= np.min(eod2w) 

1834 eod2w /= np.max(eod2w) 

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

1836 # take the smaller value: 

1837 rmse = min(rmse1, rmse2) 

1838 return rmse 

1839 

1840 

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

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

1843 

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

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

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

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

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

1849 smaller of the two rms values is returned. 

1850 

1851 Parameters 

1852 ---------- 

1853 eod1: 2-D array 

1854 Time and amplitude of reference EOD. 

1855 eod2: 2-D array 

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

1857 wfac: float 

1858 Multiply the distance between minimum and maximum by this factor 

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

1860 

1861 Returns 

1862 ------- 

1863 rmse: float 

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

1865 relative to their standard deviation over the analysis window. 

1866 """ 

1867 # copy: 

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

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

1870 # width of the pulses: 

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

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

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

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

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

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

1877 w = wfac*max(w1, w2) 

1878 # cut out signal from the reference EOD: 

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

1880 i0 = imax1-n//2 

1881 if i0 < 0: 

1882 i0 = 0 

1883 i1 = imax1+n//2+1 

1884 if i1 >= len(eod1): 

1885 i1 = len(eod1) 

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

1887 eod1 = eod1[i0:i1,:] 

1888 # normalize amplitude of first EOD: 

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

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

1891 # interpolate eod2 to the time base of eod1: 

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

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

1894 # normalize amplitude of second EOD: 

1895 eod2w /= np.max(eod2w) 

1896 # root-mean-square difference: 

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

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

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

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

1901 eod2w /= np.max(eod2w) 

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

1903 # take the smaller value: 

1904 rmse = min(rmse1, rmse2) 

1905 return rmse 

1906 

1907 

1908def clipped_fraction(data, rate, eod_times, mean_eod, 

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

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

1911 

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

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

1914 amplitude `min_clip` and `max_clip`. 

1915 

1916 Parameters 

1917 ---------- 

1918 data: 1-D array of float 

1919 The data to be analysed. 

1920 rate: float 

1921 Sampling rate of the data in Hertz. 

1922 eod_times: 1-D array of float 

1923 Array of EOD times in seconds. 

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

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

1926 to set width of snippets. 

1927 min_clip: float 

1928 Minimum amplitude that is not clipped. 

1929 max_clip: float 

1930 Maximum amplitude that is not clipped. 

1931  

1932 Returns 

1933 ------- 

1934 clipped_frac: float 

1935 Fraction of snippets that are clipped. 

1936 """ 

1937 # snippets: 

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

1939 w0 = -idx0 

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

1941 eod_idx = np.round(eod_times*rate).astype(int) 

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

1943 # fraction of clipped snippets: 

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

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

1946 / len(eod_snippets) 

1947 return clipped_frac 

1948 

1949 

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

1951 max_freq=2000.0, max_clipped_frac=0.1, 

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

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

1954 max_harmonics_db=-5.0, max_relampl_harm1=0.0, 

1955 max_relampl_harm2=0.0, max_relampl_harm3=0.0): 

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

1957  

1958 Parameters 

1959 ---------- 

1960 props: dict 

1961 A dictionary with properties of the analyzed EOD waveform 

1962 as returned by `analyze_wave()`. 

1963 harm_relampl: 1-D array of floats or None 

1964 Relative amplitude of at least the first 3 harmonics without 

1965 the fundamental. 

1966 min_freq: float 

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

1968 max_freq: float 

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

1970 max_clipped_frac: float 

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

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

1973 max_crossings: int 

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

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

1976 max_rms_sem: float 

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

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

1979 max_rms_error: float 

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

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

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

1983 min_power: float 

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

1985 max_thd: float 

1986 If larger than zero, then maximum total harmonic distortion 

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

1988 max_db_diff: float 

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

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

1991 Low values enforce smoother power spectra. 

1992 max_harmonics_db: 

1993 Maximum power of higher harmonics relative to peak power in 

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

1995 max_relampl_harm1: float 

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

1997 relative to fundamental. 

1998 max_relampl_harm2: float 

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

2000 relative to fundamental. 

2001 max_relampl_harm3: float 

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

2003 relative to fundamental. 

2004  

2005 Returns 

2006 ------- 

2007 remove: bool 

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

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

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

2011 frequencies, but remove it from the waveform properties if 

2012 `skip_reason` is not empty. 

2013 skip_reason: string 

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

2015 indicating the failure. 

2016 msg: string 

2017 A textual representation of the values tested. 

2018 """ 

2019 remove = False 

2020 msg = [] 

2021 skip_reason = [] 

2022 # EOD frequency: 

2023 if 'EODf' in props: 

2024 eodf = props['EODf'] 

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

2026 if eodf < min_freq or eodf > max_freq: 

2027 remove = True 

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

2029 (eodf, min_freq, max_freq)] 

2030 # clipped fraction: 

2031 if 'clipped' in props: 

2032 clipped_frac = props['clipped'] 

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

2034 if max_clipped_frac > 0 and clipped_frac >= max_clipped_frac: 

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

2036 (100.0*clipped_frac, 100.0*max_clipped_frac)] 

2037 # too many zero crossings: 

2038 if 'ncrossings' in props: 

2039 ncrossings = props['ncrossings'] 

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

2041 if max_crossings > 0 and ncrossings > max_crossings: 

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

2043 (ncrossings, max_crossings)] 

2044 # noise: 

2045 rms_sem = None 

2046 if 'rmssem' in props: 

2047 rms_sem = props['rmssem'] 

2048 if 'noise' in props: 

2049 rms_sem = props['noise'] 

2050 if rms_sem is not None: 

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

2052 if max_rms_sem > 0.0 and rms_sem >= max_rms_sem: 

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

2054 (100.0*rms_sem, 100.0*max_rms_sem)] 

2055 # fit error: 

2056 if 'rmserror' in props: 

2057 rms_error = props['rmserror'] 

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

2059 if max_rms_error > 0.0 and rms_error >= max_rms_error: 

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

2061 (100.0*rms_error, 100.0*max_rms_error)] 

2062 # wave power: 

2063 if 'power' in props: 

2064 power = props['power'] 

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

2066 if power < min_power: 

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

2068 (power, min_power)] 

2069 # total harmonic distortion: 

2070 if 'thd' in props: 

2071 thd = props['thd'] 

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

2073 if max_thd > 0.0 and thd > max_thd: 

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

2075 (100.0*thd, 100.0*max_thd)] 

2076 # smoothness of spectrum: 

2077 if 'dbdiff' in props: 

2078 db_diff = props['dbdiff'] 

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

2080 if max_db_diff > 0.0 and db_diff > max_db_diff: 

2081 remove = True 

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

2083 (db_diff, max_db_diff)] 

2084 # maximum power of higher harmonics: 

2085 if 'maxdb' in props: 

2086 max_harmonics = props['maxdb'] 

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

2088 if max_harmonics > max_harmonics_db: 

2089 remove = True 

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

2091 (max_harmonics, max_harmonics_db)] 

2092 # relative amplitude of harmonics: 

2093 if harm_relampl is not None: 

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

2095 if k >= len(harm_relampl): 

2096 break 

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

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

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

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

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

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

2103 

2104 

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

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

2107  

2108 Parameters 

2109 ---------- 

2110 props: dict 

2111 A dictionary with properties of the analyzed EOD waveform 

2112 as returned by `analyze_pulse()`. 

2113 max_clipped_frac: float 

2114 Maximum allowed fraction of clipped data. 

2115 max_rms_sem: float 

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

2117 relative to p-p amplitude. 

2118 

2119 Returns 

2120 ------- 

2121 skip_reason: string 

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

2123 indicating the failure. 

2124 msg: string 

2125 A textual representation of the values tested. 

2126 skipped_clipped: bool 

2127 True if waveform was skipped because of clipping. 

2128 """ 

2129 msg = [] 

2130 skip_reason = [] 

2131 skipped_clipped = False 

2132 # clipped fraction: 

2133 if 'clipped' in props: 

2134 clipped_frac = props['clipped'] 

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

2136 if clipped_frac >= max_clipped_frac: 

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

2138 (100.0*clipped_frac, 100.0*max_clipped_frac)] 

2139 skipped_clipped = True 

2140 # noise: 

2141 rms_sem = None 

2142 if 'rmssem' in props: 

2143 rms_sem = props['rmssem'] 

2144 if 'noise' in props: 

2145 rms_sem = props['noise'] 

2146 if rms_sem is not None: 

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

2148 if max_rms_sem > 0.0 and rms_sem >= max_rms_sem: 

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

2150 (100.0*rms_sem, 100.0*max_rms_sem)] 

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

2152 

2153 

2154def plot_eod_recording(ax, data, rate, unit=None, width=0.1, 

2155 toffs=0.0, pstyle=dict(lw=2, color='tab:red')): 

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

2157 

2158 Parameters 

2159 ---------- 

2160 ax: matplotlib axes 

2161 Axes used for plotting. 

2162 data: 1D ndarray 

2163 Recorded data to be plotted. 

2164 rate: float 

2165 Sampling rate of the data in Hertz. 

2166 unit: string 

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

2168 width: float 

2169 Width of data segment to be plotted in seconds. 

2170 toffs: float 

2171 Time of first data value in seconds. 

2172 pstyle: dict 

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

2174 """ 

2175 widx2 = int(width*rate)//2 

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

2177 i0 = (i0//widx2)*widx2 

2178 i1 = i0 + 2*widx2 

2179 if i0 < 0: 

2180 i0 = 0 

2181 if i1 >= len(data): 

2182 i1 = len(data) 

2183 time = np.arange(len(data))/rate + toffs 

2184 tunit = 'sec' 

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

2186 time *= 1000.0 

2187 tunit = 'ms' 

2188 ax.plot(time, data, **pstyle) 

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

2190 

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

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

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

2194 dy = ymax - ymin 

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

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

2197 ax.set_ylabel('Amplitude') 

2198 else: 

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

2200 

2201 

2202def plot_pulse_eods(ax, data, rate, zoom_window, width, eod_props, 

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

2204 legend_rows=8, **kwargs): 

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

2206 

2207 Parameters 

2208 ---------- 

2209 ax: matplotlib axes 

2210 Axes used for plotting. 

2211 data: 1D ndarray 

2212 Recorded data (these are not plotted!). 

2213 rate: float 

2214 Sampling rate of the data in Hertz. 

2215 zoom_window: tuple of floats 

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

2217 width: float 

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

2219 eod_props: list of dictionaries 

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

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

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

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

2224 detected EOD pulse times. 

2225 toffs: float 

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

2227 to the pulse times in `eod_props`. 

2228 colors: list of colors or None 

2229 If not None list of colors for plotting each cluster 

2230 markers: list of markers or None 

2231 If not None list of markers for plotting each cluster 

2232 marker_size: float 

2233 Size of markers used to mark the pulses. 

2234 legend_rows: int 

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

2236 kwargs:  

2237 Key word arguments for the legend of the plot. 

2238 """ 

2239 k = 0 

2240 for eod in eod_props: 

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

2242 continue 

2243 if 'times' not in eod: 

2244 continue 

2245 

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

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

2248 width *= 2 

2249 if zoom_window[1] - width < 0: 

2250 width = width/2 

2251 break 

2252 

2253 x = eod['peaktimes'] + toffs 

2254 pidx = np.round(eod['peaktimes']*rate).astype(int) 

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

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

2257 color_kwargs = {} 

2258 #if colors is not None: 

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

2260 if marker_size is not None: 

2261 color_kwargs['ms'] = marker_size 

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

2263 if legend_rows > 5 and k >= legend_rows: 

2264 label = None 

2265 if markers is None: 

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

2267 else: 

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

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

2270 zorder=-1, **color_kwargs) 

2271 k += 1 

2272 

2273 # legend: 

2274 if k > 1: 

2275 if legend_rows > 0: 

2276 if legend_rows > 5: 

2277 ncol = 1 

2278 else: 

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

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

2281 else: 

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

2283 

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

2285 if len(zoom_window)>0: 

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

2287 

2288 i0 = max(0,int((zoom_window[1]-width)*rate)) 

2289 i1 = int(zoom_window[1]*rate) 

2290 

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

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

2293 dy = ymax - ymin 

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

2295 

2296 

2297def plot_eod_snippets(ax, data, rate, tmin, tmax, eod_times, 

2298 n_snippets=10, flip=False, 

2299 sstyle=dict(scaley=False, 

2300 lw=0.5, color='0.6')): 

2301 """Plot a few EOD waveform snippets. 

2302 

2303 Parameters 

2304 ---------- 

2305 ax: matplotlib axes 

2306 Axes used for plotting. 

2307 data: 1D ndarray 

2308 Recorded data from which the snippets are taken. 

2309 rate: float 

2310 Sampling rate of the data in Hertz. 

2311 tmin: float 

2312 Start time of each snippet. 

2313 tmax: float 

2314 End time of each snippet. 

2315 eod_times: 1-D array 

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

2317 n_snippets: int 

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

2319 flip: bool 

2320 If True flip the snippets upside down. 

2321 sstyle: dict 

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

2323 """ 

2324 if data is None or n_snippets <= 0: 

2325 return 

2326 i0 = int(tmin*rate) 

2327 i1 = int(tmax*rate) 

2328 time = 1000.0*np.arange(i0, i1)/rate 

2329 step = len(eod_times)//n_snippets 

2330 if step < 1: 

2331 step = 1 

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

2333 idx = int(np.round(t*rate)) 

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

2335 continue 

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

2337 if flip: 

2338 snippet *= -1 

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

2340 zorder=-5, **sstyle) 

2341 

2342 

2343def plot_eod_waveform(ax, eod_waveform, props, phases=None, 

2344 unit=None, tfac=1, 

2345 mstyle=dict(lw=2, color='tab:red'), 

2346 pstyle=dict(facecolor='tab:green', alpha=0.2, 

2347 edgecolor='none'), 

2348 nstyle=dict(facecolor='tab:blue', alpha=0.2, 

2349 edgecolor='none'), 

2350 sstyle=dict(color='0.8'), 

2351 fstyle=dict(lw=3, color='tab:blue'), 

2352 zstyle=dict(lw=1, color='0.7')): 

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

2354 

2355 Parameters 

2356 ---------- 

2357 ax: matplotlib axes 

2358 Axes used for plotting. 

2359 eod_waveform: 2-D array 

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

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

2362 standard error, the optional fourth column is a fit of the 

2363 whole waveform, and the optional fourth column is a fit of  

2364 the tails of a pulse waveform. 

2365 props: dict 

2366 A dictionary with properties of the analyzed EOD waveform as 

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

2368 phases: 2_D arrays or None 

2369 List of phase properties (index, time, amplitude, 

2370 relative amplitude, width, area) of a EOD pulse 

2371 as returned by `analyze_pulse()`. 

2372 unit: string 

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

2374 tfac: float 

2375 Factor scaling the time axis limits. 

2376 mstyle: dict 

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

2378 pstyle: dict 

2379 Arguments passed on to the fill_between command for coloring 

2380 positive phases. 

2381 nstyle: dict 

2382 Arguments passed on to the fill_between command for coloring 

2383 negative phases. 

2384 sstyle: dict 

2385 Arguments passed on to the fill_between command for the 

2386 standard error of the EOD. 

2387 fstyle: dict 

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

2389 zstyle: dict 

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

2391 """ 

2392 ax.autoscale(True) 

2393 time = 1000 * eod_waveform[:, 0] 

2394 mean_eod = eod_waveform[:, 1] 

2395 # plot zero line: 

2396 ax.axhline(0.0, zorder=-5, **zstyle) 

2397 # plot areas: 

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

2399 if pstyle is not None and len(pstyle) > 0: 

2400 ax.fill_between(time, mean_eod, 0, mean_eod >= 0, zorder=4, 

2401 **pstyle) 

2402 if nstyle is not None and len(nstyle) > 0: 

2403 ax.fill_between(time, mean_eod, 0, mean_eod <= 0, zorder=4, 

2404 **nstyle) 

2405 # plot fits: 

2406 if eod_waveform.shape[1] > 3 and np.all(np.isfinite(eod_waveform[:, 3])): 

2407 ax.plot(time, eod_waveform[:, 3], zorder=4, **fstyle) 

2408 if eod_waveform.shape[1] > 4: 

2409 fs = dict(**fstyle) 

2410 if 'lw' in fs: 

2411 fs['lw'] *= 2 

2412 ax.plot(time, eod_waveform[:, 4], zorder=5, **fs) 

2413 # plot waveform: 

2414 ax.plot(time, mean_eod, zorder=10, **mstyle) 

2415 # plot standard error: 

2416 if eod_waveform.shape[1] > 2: 

2417 std_eod = eod_waveform[:, 2] 

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

2419 ax.autoscale_view(False) 

2420 ax.autoscale(False) 

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

2422 zorder=-10, **sstyle) 

2423 # ax height dimensions: 

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

2425 dxu = (time[-1] - time[0])/pixelx 

2426 xfs = plt.rcParams['font.size']*dxu 

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

2428 ymin, ymax = ax.get_ylim() 

2429 unity = ymax - ymin 

2430 dyu = np.abs(unity)/pixely 

2431 yfs = plt.rcParams['font.size']*dyu 

2432 # annotate fit: 

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

2434 ty = 0.0 

2435 if tau is not None and eod_waveform.shape[1] > 4: 

2436 if tau < 0.001: 

2437 label = f'\u03c4={1.e6*tau:.0f}\u00b5s' 

2438 else: 

2439 label = f'\u03c4={1.e3*tau:.2f}ms' 

2440 inx = np.argmin(np.isnan(eod_waveform[:, 4])) 

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

2442 ty = 0.7*eod_waveform[inx, 4] 

2443 if np.abs(ty) < 0.5*yfs: 

2444 ty = 0.5*yfs*np.sign(ty) 

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

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

2447 # annotate phases: 

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

2449 for i, p in enumerate(phases): 

2450 ax.plot(1000*p[1], p[2], 'o', clip_on=False, zorder=0, 

2451 alpha=0.4, color=mstyle['color'], ms=12, 

2452 mec='none', mew=0) 

2453 label = f'P{p[0]:.0f}' 

2454 if p[0] != 1: 

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

2456 ts = f'{1.0e6*p[1]:.0f}\u00b5s' 

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

2458 ts = f'{1.0e3*p[1]:.2f}ms' 

2459 else: 

2460 ts = f'{1.0e3*p[1]:.3g}ms' 

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

2462 ps = f'{100*p[3]:.1f}%' 

2463 else: 

2464 ps = f'{100*p[3]:.0f}%' 

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

2466 left_text = (p[2] > 0 and p[1] >= 0.0) or \ 

2467 (p[2] < 0 and p[1] >= phases[np.argmin(phases[:, 2]), 1]) 

2468 va = 'baseline' 

2469 dy = 0.6*yfs 

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

2471 if i > 0 and i < len(phases) - 1: 

2472 if phases[i - 1][2] > p[2] and phases[i + 1][2] > p[2]: 

2473 va = 'top' 

2474 dy = -dy 

2475 elif sign < 0: 

2476 va = 'top' 

2477 dy = -dy 

2478 if p[0] == 1: 

2479 dy = 0.0 

2480 if p[2] + dy < ymin + 1.3*yfs: 

2481 dy = ymin + 1.3*yfs - p[2] 

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

2483 sign*p[2] + dy < sign*ty + 1.2*yfs: 

2484 dy = ty + sign*1.2*yfs - p[2] 

2485 dx = 0.5*xfs 

2486 if left_text: 

2487 ax.text(1000*p[1] + dx, p[2] + dy, label, 

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

2489 else: 

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

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

2492 # area: 

2493 if len(p) > 6: 

2494 if np.abs(p[6]) < 1e-8: 

2495 continue 

2496 elif np.abs(p[6]) < 0.05: 

2497 label = f'{100*p[6]:.1f}%' 

2498 else: 

2499 label = f'{100*p[6]:.0f}%' 

2500 x = 1000*p[1] 

2501 if i > 0 and i < len(phases) - 1: 

2502 xl = 1000*phases[i - 1][1] 

2503 xr = 1000*phases[i + 1][1] 

2504 tsnippet = time[(time > xl) & (time < xr)] 

2505 snippet = mean_eod[(time > xl) & (time < xr)] 

2506 tsnippet = tsnippet[np.sign(p[2])*snippet > 0] 

2507 snippet = snippet[np.sign(p[2])*snippet > 0] 

2508 xc = np.sum(tsnippet*snippet)/np.sum(snippet) 

2509 x = xc 

2510 if abs(p[3]) > 0.5: 

2511 ax.text(x, sign*0.6*yfs, label, 

2512 rotation='vertical', 

2513 va='top' if sign < 0 else 'bottom', 

2514 ha='center', zorder=20) 

2515 elif abs(p[3]) > 0.25: 

2516 ax.text(x, sign*0.6*yfs, label, 

2517 va='top' if sign < 0 else 'baseline', 

2518 ha='center', zorder=20) 

2519 else: 

2520 ax.text(x, -sign*0.4*yfs, label, 

2521 va='baseline' if sign < 0 else 'top', 

2522 ha='center', zorder=20) 

2523 # annotate plot: 

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

2525 unit = '' 

2526 if props is not None: 

2527 label = f'p-p amplitude = {props["p-p-amplitude"]:.3g} {unit}\n' 

2528 if 'n' in props: 

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

2530 label += f'n = {props["n"]} {eods}\n' 

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

2532 label += 'flipped\n' 

2533 if 'polaritybalance' in props: 

2534 label += f'PB={100*props["polaritybalance"]:.0f} %\n' 

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

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

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

2538 else: 

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

2540 va='top', zorder=20) 

2541 # axis:  

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

2543 lim = tfac*1000.0/props['EODf'] 

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

2545 else: 

2546 ax.set_xlim(tfac*time[0], tfac*time[-1]) 

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

2548 if unit: 

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

2550 else: 

2551 ax.set_ylabel('Amplitude') 

2552 

2553 

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

2555 mstyle=dict(color='tab:blue', markersize=10), 

2556 sstyle=dict(color='tab:blue', alpha=0.5, lw=2)): 

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

2558 

2559 Parameters 

2560 ---------- 

2561 axa: matplotlib axes 

2562 Axes for amplitude plot. 

2563 axp: matplotlib axes 

2564 Axes for phase plot. 

2565 spec: 2-D array 

2566 The amplitude spectrum of a single pulse as returned by 

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

2568 second column its frequency, third column its amplitude, 

2569 fourth column its amplitude relative to the fundamental, fifth 

2570 column is power of harmonics relative to fundamental in 

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

2572 fundamental. 

2573 props: dict 

2574 A dictionary with properties of the analyzed EOD waveform as 

2575 returned by `analyze_wave()`. 

2576 unit: string 

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

2578 mstyle: dict 

2579 Arguments passed on to the stem plot command for the markers. 

2580 sstyle: dict 

2581 Arguments passed on to the stem plot command for the stem lines. 

2582 """ 

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

2584 # amplitudes: 

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

2586 plt.setp(markers, clip_on=False, **mstyle) 

2587 plt.setp(stemlines, **sstyle) 

2588 axa.set_xlim(0.5, n+0.5) 

2589 axa.set_ylim(bottom=0) 

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

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

2592 if unit: 

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

2594 else: 

2595 axa.set_ylabel('Amplitude') 

2596 # phases: 

2597 phases = spec[:n,5] 

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

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

2600 plt.setp(markers, clip_on=False, **mstyle) 

2601 plt.setp(stemlines, **sstyle) 

2602 axp.set_xlim(0.5, n+0.5) 

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

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

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

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

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

2608 axp.set_xlabel('Harmonics') 

2609 axp.set_ylabel('Phase') 

2610 

2611 

2612def plot_pulse_spectrum(ax, energy, props, min_freq=1.0, max_freq=10000.0, 

2613 sstyle=dict(lw=3, color='tab:blue'), 

2614 astyle=dict(lw=4, color='tab:cyan'), 

2615 pstyle=dict(ls='', marker='o', markersize=10, 

2616 color='tab:blue', mec='none', mew=0, 

2617 alpha=0.4), 

2618 cstyle=dict(lw=1, ls='-', color='0.5'), 

2619 att5_color='0.8', att50_color='0.9'): 

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

2621 

2622 Parameters 

2623 ---------- 

2624 ax: matplotlib axes 

2625 Axes used for plotting. 

2626 energy: 2-D array 

2627 The energy spectrum of a single pulse as returned by `analyze_pulse()`. 

2628 First column are the frequencies, second column the energy. 

2629 An optional third column is an analytically computed spectrum. 

2630 props: dict 

2631 A dictionary with properties of the analyzed EOD waveform as 

2632 returned by `analyze_pulse()`. 

2633 min_freq: float 

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

2635 max_freq: float 

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

2637 sstyle: dict 

2638 Arguments passed on to the plot command for the energy spectrum 

2639 computed from the data. 

2640 astyle: dict 

2641 Arguments passed on to the plot command for the energy spectrum 

2642 that was analytically computed from the Gaussian fits 

2643 (optional third column in `energy`). 

2644 pstyle: dict 

2645 Arguments passed on to the plot command for marking the peak frequency. 

2646 cstyle: dict 

2647 Arguments passed on to the plot command for the line marking 

2648 the lower cutoff frequency. 

2649 att5_color: matplotlib color specification 

2650 Color for the rectangular patch marking the first 5 Hz. 

2651 att50_color: matplotlib color specification 

2652 Color for the rectangular patch marking the first 50 Hz. 

2653 """ 

2654 ax.axvspan(1, 50, color=att50_color, zorder=10) 

2655 att = props['energyatt50'] 

2656 if att < -10: 

2657 ax.text(10, att + 1, f'{att:.0f}dB', 

2658 ha='left', va='bottom', zorder=100) 

2659 else: 

2660 ax.text(10, att - 1, f'{att:.0f}dB', 

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

2662 ax.axvspan(1, 5, color=att5_color, zorder=20) 

2663 att = props['energyatt5'] 

2664 if att < -10: 

2665 ax.text(4, att + 1, f'{att:.0f}dB', 

2666 ha='right', va='bottom', zorder=100) 

2667 else: 

2668 ax.text(4, att - 1, f'{att:.0f}dB', 

2669 ha='right', va='top', zorder=100) 

2670 lowcutoff = props['lowcutoff'] 

2671 if lowcutoff >= min_freq: 

2672 ax.plot([lowcutoff, lowcutoff, 1], [-60, 0.5*att, 0.5*att], 

2673 zorder=30, **cstyle) 

2674 ax.text(1.2*lowcutoff, 0.5*att - 1, f'{lowcutoff:.0f}Hz', 

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

2676 highcutoff = props['highcutoff'] 

2677 ax.plot([highcutoff, highcutoff], [-60, -3], zorder=30, **cstyle) 

2678 ax.text(1.2*highcutoff, -3, f'{highcutoff:.0f}Hz', 

2679 ha='left', va='center', zorder=100) 

2680 ref_energy = np.max(energy[:, 1]) 

2681 if energy.shape[1] > 2 and np.all(np.isfinite(energy[:, 2])) and len(astyle) > 0: 

2682 db = decibel(energy[:, 2], ref_energy) 

2683 ax.plot(energy[:, 0], db, zorder=45, **astyle) 

2684 db = decibel(energy[:, 1], ref_energy) 

2685 ax.plot(energy[:, 0], db, zorder=50, **sstyle) 

2686 peakfreq = props['peakfreq'] 

2687 if peakfreq >= min_freq: 

2688 ax.plot(peakfreq, 0, zorder=60, clip_on=False, **pstyle) 

2689 ax.text(peakfreq*1.2, 1, f'{peakfreq:.0f}Hz', 

2690 va='bottom', zorder=100) 

2691 troughfreq = props['troughfreq'] 

2692 if troughfreq >= min_freq: 

2693 troughenergy = decibel(props['troughenergy'], ref_energy) 

2694 ax.plot(troughfreq, troughenergy, zorder=60, **pstyle) 

2695 ax.text(troughfreq, troughenergy - 3, 

2696 f'{troughenergy:.0f}dB @ {troughfreq:.0f}Hz', 

2697 ha='center', va='top', zorder=100) 

2698 ax.set_xlim(min_freq, max_freq) 

2699 ax.set_xscale('log') 

2700 ax.set_ylim(-60, 2) 

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

2702 ax.set_ylabel('Energy [dB]') 

2703 

2704 

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

2706 """Save mean EOD waveform to file. 

2707 

2708 Parameters 

2709 ---------- 

2710 mean_eod: 2D array of floats 

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

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

2713 unit: string 

2714 Unit of the waveform data. 

2715 idx: int or None 

2716 Index of fish. 

2717 basename: string or stream 

2718 If string, path and basename of file. 

2719 If `basename` does not have an extension, 

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

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

2722 kwargs: 

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

2724 

2725 Returns 

2726 ------- 

2727 filename: Path 

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

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

2730 would have been appended to a basename. 

2731 

2732 See Also 

2733 -------- 

2734 load_eod_waveform() 

2735 """ 

2736 td = TableData(mean_eod[:, :3]*[1000.0, 1.0, 1.0], 

2737 ['time', 'mean', 'sem'], 

2738 ['ms', unit, unit], 

2739 ['%.3f', '%.6g', '%.6g']) 

2740 if mean_eod.shape[1] > 3: 

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

2742 if mean_eod.shape[1] > 4: 

2743 td.append('tailfit', unit, '%.5f', value=mean_eod[:, 4]) 

2744 fp = '' 

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

2746 if not ext: 

2747 fp = '-eodwaveform' 

2748 if idx is not None: 

2749 fp += f'-{idx}' 

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

2751 

2752 

2753def load_eod_waveform(file_path): 

2754 """Load EOD waveform from file. 

2755 

2756 Parameters 

2757 ---------- 

2758 file_path: string 

2759 Path of the file to be loaded. 

2760 

2761 Returns 

2762 ------- 

2763 mean_eod: 2D array of floats 

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

2765 unit: string 

2766 Unit of EOD waveform. 

2767 

2768 Raises 

2769 ------ 

2770 FileNotFoundError: 

2771 If `file_path` does not exist. 

2772 

2773 See Also 

2774 -------- 

2775 save_eod_waveform() 

2776 """ 

2777 data = TableData(file_path) 

2778 mean_eod = data.array() 

2779 mean_eod[:, 0] *= 0.001 

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

2781 

2782 

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

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

2785 

2786 Parameters 

2787 ---------- 

2788 wave_eodfs: list of 2D arrays 

2789 Each item is a matrix with the frequencies and powers 

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

2791 by `harmonics.harmonic_groups()`. 

2792 wave_indices: array 

2793 Indices identifying each fish or NaN. 

2794 If None no index column is inserted. 

2795 basename: string or stream 

2796 If string, path and basename of file. 

2797 If `basename` does not have an extension, 

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

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

2800 kwargs: 

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

2802 

2803 Returns 

2804 ------- 

2805 filename: Path 

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

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

2808 would have been appended to a basename. 

2809 

2810 See Also 

2811 -------- 

2812 load_wave_eodfs() 

2813 

2814 """ 

2815 eodfs = fundamental_freqs_and_power(wave_eodfs) 

2816 td = TableData() 

2817 if wave_indices is not None: 

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

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

2820 for wi in wave_indices]) 

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

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

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

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

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

2826 

2827 

2828def load_wave_eodfs(file_path): 

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

2830 

2831 Parameters 

2832 ---------- 

2833 file_path: string 

2834 Path of the file to be loaded. 

2835 

2836 Returns 

2837 ------- 

2838 eodfs: 2D array of floats 

2839 EODfs and power of wave type fish. 

2840 indices: array of ints 

2841 Corresponding indices of fish, can contain negative numbers to 

2842 indicate frequencies without fish. 

2843 

2844 Raises 

2845 ------ 

2846 FileNotFoundError: 

2847 If `file_path` does not exist. 

2848 

2849 See Also 

2850 -------- 

2851 save_wave_eodfs() 

2852 """ 

2853 data = TableData(file_path) 

2854 eodfs = data.array() 

2855 if 'index' in data: 

2856 indices = data[:, 'index'] 

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

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

2859 eodfs = eodfs[:, 1:] 

2860 else: 

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

2862 return eodfs, indices 

2863 

2864 

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

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

2867 

2868 Parameters 

2869 ---------- 

2870 eod_props: list of dict 

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

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

2873 unit: string 

2874 Unit of the waveform data. 

2875 basename: string or stream 

2876 If string, path and basename of file. 

2877 If `basename` does not have an extension, 

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

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

2880 kwargs: 

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

2882 

2883 Returns 

2884 ------- 

2885 filename: Path or None 

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

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

2888 would have been appended to a basename. 

2889 None if no wave fish are contained in eod_props and 

2890 consequently no file was written. 

2891 

2892 See Also 

2893 -------- 

2894 load_wave_fish() 

2895 """ 

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

2897 if len(wave_props) == 0: 

2898 return None 

2899 td = TableData() 

2900 if 'twin' in wave_props[0] or 'rate' in wave_props[0] or \ 

2901 'nfft' in wave_props[0]: 

2902 td.append_section('recording') 

2903 if 'twin' in wave_props[0]: 

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

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

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

2907 value=wave_props, fac=100.0) 

2908 if 'samplerate' in wave_props[0]: 

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

2910 value=wave_props, fac=0.001) 

2911 if 'nfft' in wave_props[0]: 

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

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

2914 td.append_section('waveform') 

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

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

2917 td.append('p-p-amplitude', unit, '%.5f', value=wave_props) 

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

2919 if 'datapower' in wave_props[0]: 

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

2921 td.append('thd', '%', '%.2f', value=wave_props, fac=100.0) 

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

2923 td.append('maxdb', 'dB', '%7.2f', value=wave_props) 

2924 if 'noise' in wave_props[0]: 

2925 td.append('noise', '%', '%.1f', value=wave_props, fac=100.0) 

2926 td.append('rmserror', '%', '%.2f', value=wave_props, fac=100.0) 

2927 if 'clipped' in wave_props[0]: 

2928 td.append('clipped', '%', '%.1f', value=wave_props, fac=100.0) 

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

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

2931 td.append_section('timing') 

2932 td.append('ncrossings', '', '%d', value=wave_props) 

2933 td.append('peakwidth', '%', '%.2f', value=wave_props, fac=100.0) 

2934 td.append('troughwidth', '%', '%.2f', value=wave_props, fac=100.0) 

2935 td.append('minwidth', '%', '%.2f', value=wave_props, fac=100.0) 

2936 td.append('leftpeak', '%', '%.2f', value=wave_props, fac=100.0) 

2937 td.append('rightpeak', '%', '%.2f', value=wave_props, fac=100.0) 

2938 td.append('lefttrough', '%', '%.2f', value=wave_props, fac=100.0) 

2939 td.append('righttrough', '%', '%.2f', value=wave_props, fac=100.0) 

2940 td.append('p-p-distance', '%', '%.2f', value=wave_props, fac=100.0) 

2941 td.append('min-p-p-distance', '%', '%.2f', 

2942 value=wave_props, fac=100.0) 

2943 td.append('relpeakampl', '%', '%.2f', value=wave_props, fac=100.0) 

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

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

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

2947 

2948 

2949def load_wave_fish(file_path): 

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

2951 

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

2953 percentages to fractions. 

2954 

2955 Parameters 

2956 ---------- 

2957 file_path: string 

2958 Path of the file to be loaded. 

2959 

2960 Returns 

2961 ------- 

2962 eod_props: list of dict 

2963 Properties of EODs. 

2964 

2965 Raises 

2966 ------ 

2967 FileNotFoundError: 

2968 If `file_path` does not exist. 

2969 

2970 See Also 

2971 -------- 

2972 save_wave_fish() 

2973 

2974 """ 

2975 data = TableData(file_path) 

2976 eod_props = data.dicts() 

2977 for props in eod_props: 

2978 if 'winclipped' in props: 

2979 props['winclipped'] /= 100 

2980 if 'samplerate' in props: 

2981 props['samplerate'] *= 1000 

2982 if 'nfft' in props: 

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

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

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

2986 props['type'] = 'wave' 

2987 props['thd'] /= 100 

2988 props['noise'] /= 100 

2989 props['rmserror'] /= 100 

2990 if 'clipped' in props: 

2991 props['clipped'] /= 100 

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

2993 props['peakwidth'] /= 100 

2994 props['troughwidth'] /= 100 

2995 props['minwidth'] /= 100 

2996 props['leftpeak'] /= 100 

2997 props['rightpeak'] /= 100 

2998 props['lefttrough'] /= 100 

2999 props['righttrough'] /= 100 

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

3001 props['min-p-p-distance'] /= 100 

3002 props['relpeakampl'] /= 100 

3003 return eod_props 

3004 

3005 

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

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

3008 

3009 Parameters 

3010 ---------- 

3011 eod_props: list of dict 

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

3013 `analyze_pulse()`. Only properties of pulse fish are saved. 

3014 unit: string 

3015 Unit of the waveform data. 

3016 basename: string or stream 

3017 If string, path and basename of file. 

3018 If `basename` does not have an extension, 

3019 '-pulsefish' and a file extension are appended. 

3020 If stream, write pulse fish properties into this stream. 

3021 kwargs: 

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

3023 

3024 Returns 

3025 ------- 

3026 filename: Path or None 

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

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

3029 would have been appended to a basename. 

3030 None if no pulse fish are contained in eod_props and 

3031 consequently no file was written. 

3032 

3033 See Also 

3034 -------- 

3035 load_pulse_fish() 

3036 """ 

3037 pulse_props = [p for p in eod_props if p['type'] == 'pulse'] 

3038 if len(pulse_props) == 0: 

3039 return None 

3040 td = TableData() 

3041 if 'twin' in pulse_props[0] or 'samplerate' in pulse_props[0] or \ 

3042 'nfft' in pulse_props[0]: 

3043 td.append_section('recording') 

3044 if 'twin' in pulse_props[0]: 

3045 td.append('twin', 's', '%7.2f', value=pulse_props) 

3046 td.append('window', 's', '%7.2f', value=pulse_props) 

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

3048 value=pulse_props, fac=100.0) 

3049 if 'samplerate' in pulse_props[0]: 

3050 td.append('samplerate', 'kHz', '%.3f', value=pulse_props, 

3051 fac=0.001) 

3052 if 'nfft' in pulse_props[0]: 

3053 td.append('nfft', '', '%d', value=pulse_props) 

3054 td.append('dfreq', 'Hz', '%.2f', value=pulse_props) 

3055 td.append_section('waveform') 

3056 td.append('index', '', '%d', value=pulse_props) 

3057 td.append('EODf', 'Hz', '%7.2f', value=pulse_props) 

3058 td.append('period', 'ms', '%7.2f', value=pulse_props, fac=1000.0) 

3059 td.append('max-ampl', unit, '%.5f', value=pulse_props) 

3060 td.append('min-ampl', unit, '%.5f', value=pulse_props) 

3061 td.append('p-p-amplitude', unit, '%.5f', value=pulse_props) 

3062 if 'noise' in pulse_props[0]: 

3063 td.append('noise', '%', '%.2f', value=pulse_props, fac=100.0) 

3064 if 'clipped' in pulse_props[0]: 

3065 td.append('clipped', '%', '%.2f', value=pulse_props, fac=100.0) 

3066 td.append('flipped', '', '%d', value=pulse_props) 

3067 td.append('tstart', 'ms', '%.3f', value=pulse_props, fac=1000.0) 

3068 td.append('tend', 'ms', '%.3f', value=pulse_props, fac=1000.0) 

3069 td.append('width', 'ms', '%.3f', value=pulse_props, fac=1000.0) 

3070 td.append('peak-dist', 'ms', '%.3f', value=pulse_props, fac=1000.0) 

3071 td.append('tau', 'ms', '%.3f', value=pulse_props, fac=1000.0) 

3072 td.append('firstphase', '', '%d', value=pulse_props) 

3073 td.append('lastphase', '', '%d', value=pulse_props) 

3074 td.append('totalarea', f'{unit}*ms', '%.4f', 

3075 value=pulse_props, fac=1000.0) 

3076 td.append('positivearea', '%', '%.2f', 

3077 value=pulse_props, fac=100.0) 

3078 td.append('negativearea', '%', '%.2f', 

3079 value=pulse_props, fac=100.0) 

3080 td.append('polaritybalance', '%', '%.2f', 

3081 value=pulse_props, fac=100.0) 

3082 td.append('n', '', '%d', value=pulse_props) 

3083 td.append_section('spectrum') 

3084 td.append('peakfreq', 'Hz', '%.2f', value=pulse_props) 

3085 td.append('peakenergy', f'{unit}^2s/Hz', '%.3g', value=pulse_props) 

3086 td.append('troughfreq', 'Hz', '%.2f', value=pulse_props) 

3087 td.append('troughenergy', f'{unit}^2s/Hz', '%.3g', value=pulse_props) 

3088 td.append('energyatt5', 'dB', '%.2f', value=pulse_props) 

3089 td.append('energyatt50', 'dB', '%.2f', value=pulse_props) 

3090 td.append('lowcutoff', 'Hz', '%.2f', value=pulse_props) 

3091 td.append('highcutoff', 'Hz', '%.2f', value=pulse_props) 

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

3093 fp = '-pulsefish' if not ext else '' 

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

3095 

3096 

3097def load_pulse_fish(file_path): 

3098 """Load properties of pulse EODs from file. 

3099 

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

3101 percentages to fractions. 

3102 

3103 Parameters 

3104 ---------- 

3105 file_path: string 

3106 Path of the file to be loaded. 

3107 

3108 Returns 

3109 ------- 

3110 eod_props: list of dict 

3111 Properties of EODs. 

3112 

3113 Raises 

3114 ------ 

3115 FileNotFoundError: 

3116 If `file_path` does not exist. 

3117 

3118 See Also 

3119 -------- 

3120 save_pulse_fish() 

3121 

3122 """ 

3123 data = TableData(file_path) 

3124 eod_props = data.dicts() 

3125 for props in eod_props: 

3126 if 'winclipped' in props: 

3127 props['winclipped'] /= 100 

3128 if 'samplerate' in props: 

3129 props['samplerate'] *= 1000 

3130 if 'nfft' in props: 

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

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

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

3134 props['firstphase'] = int(props['firstphase']) 

3135 props['lastphase'] = int(props['lastphase']) 

3136 if 'totalarea' in props: 

3137 props['totalarea'] /= 1000 

3138 if 'positivearea' in props: 

3139 props['positivearea'] /= 100 

3140 if 'negativearea' in props: 

3141 props['negativearea'] /= 100 

3142 if 'polaritybalance' in props: 

3143 props['polaritybalance'] /= 100 

3144 props['type'] = 'pulse' 

3145 if 'clipped' in props: 

3146 props['clipped'] /= 100 

3147 props['period'] /= 1000 

3148 props['noise'] /= 100 

3149 props['tstart'] /= 1000 

3150 props['tend'] /= 1000 

3151 props['width'] /= 1000 

3152 props['peak-dist'] /= 1000 

3153 props['tau'] /= 1000 

3154 return eod_props 

3155 

3156 

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

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

3159 

3160 Parameters 

3161 ---------- 

3162 spec_data: 2D array of floats 

3163 Amplitude and phase spectrum of wave EOD as returned by 

3164 `analyze_wave()`. 

3165 unit: string 

3166 Unit of the waveform data. 

3167 idx: int or None 

3168 Index of fish. 

3169 basename: string or stream 

3170 If string, path and basename of file. 

3171 If `basename` does not have an extension, 

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

3173 If stream, write wave spectrum into this stream. 

3174 kwargs: 

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

3176 

3177 Returns 

3178 ------- 

3179 filename: Path 

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

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

3182 would have been appended to a basename. 

3183 

3184 See Also 

3185 -------- 

3186 load_wave_spectrum() 

3187 

3188 """ 

3189 td = TableData(spec_data[:, :6]*[1.0, 1.0, 1.0, 100.0, 1.0, 1.0], 

3190 ['harmonics', 'frequency', 'amplitude', 'relampl', 'relpower', 'phase'], 

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

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

3193 if spec_data.shape[1] > 6: 

3194 td.append('datapower', '%s^2/Hz' % unit, '%11.4e', 

3195 value=spec_data[:, 6]) 

3196 fp = '' 

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

3198 if not ext: 

3199 fp = '-wavespectrum' 

3200 if idx is not None: 

3201 fp += f'-{idx}' 

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

3203 

3204 

3205def load_wave_spectrum(file_path): 

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

3207 

3208 Parameters 

3209 ---------- 

3210 file_path: string 

3211 Path of the file to be loaded. 

3212 

3213 Returns 

3214 ------- 

3215 spec: 2D array of floats 

3216 Amplitude and phase spectrum of wave EOD: 

3217 harmonics, frequency, amplitude, relative amplitude in dB, 

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

3219 Can contain NaNs. 

3220 unit: string 

3221 Unit of amplitudes. 

3222 

3223 Raises 

3224 ------ 

3225 FileNotFoundError: 

3226 If `file_path` does not exist. 

3227 

3228 See Also 

3229 -------- 

3230 save_wave_spectrum() 

3231 """ 

3232 data = TableData(file_path) 

3233 spec = data.array() 

3234 spec[:, 3] *= 0.01 

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

3236 

3237 

3238def save_pulse_spectrum(spec_data, unit, idx, basename, **kwargs): 

3239 """Save energy spectrum of pulse EOD to file. 

3240 

3241 Parameters 

3242 ---------- 

3243 spec_data: 2D array of floats 

3244 Energy spectrum of single pulse as returned by `analyze_pulse()`. 

3245 unit: string 

3246 Unit of the waveform data. 

3247 idx: int or None 

3248 Index of fish. 

3249 basename: string or stream 

3250 If string, path and basename of file. 

3251 If `basename` does not have an extension, 

3252 '-pulsespectrum', the fish index, and a file extension are appended. 

3253 If stream, write pulse spectrum into this stream. 

3254 kwargs: 

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

3256 

3257 Returns 

3258 ------- 

3259 filename: Path 

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

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

3262 would have been appended to a basename. 

3263 

3264 See Also 

3265 -------- 

3266 load_pulse_spectrum() 

3267 """ 

3268 td = TableData(spec_data[:, :2], ['frequency', 'energy'], 

3269 ['Hz', '%s^2s/Hz' % unit], ['%.2f', '%.4e']) 

3270 fp = '' 

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

3272 if not ext: 

3273 fp = '-pulsespectrum' 

3274 if idx is not None: 

3275 fp += f'-{idx}' 

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

3277 

3278 

3279def load_pulse_spectrum(file_path): 

3280 """Load energy spectrum of pulse EOD from file. 

3281 

3282 Parameters 

3283 ---------- 

3284 file_path: string 

3285 Path of the file to be loaded. 

3286 

3287 Returns 

3288 ------- 

3289 spec: 2D array of floats 

3290 Energy spectrum of single pulse: frequency, energy 

3291 

3292 Raises 

3293 ------ 

3294 FileNotFoundError: 

3295 If `file_path` does not exist. 

3296 

3297 See Also 

3298 -------- 

3299 save_pulse_spectrum() 

3300 """ 

3301 data = TableData(file_path) 

3302 spec = data.array() 

3303 return spec 

3304 

3305 

3306def save_pulse_phases(phase_data, unit, idx, basename, **kwargs): 

3307 """Save phase properties of pulse EOD to file. 

3308 

3309 Parameters 

3310 ---------- 

3311 phase_data: 2D array of floats 

3312 Properties of phases of pulse EOD as returned by 

3313 `analyze_pulse()`. 

3314 unit: string 

3315 Unit of the waveform data. 

3316 idx: int or None 

3317 Index of fish. 

3318 basename: string or stream 

3319 If string, path and basename of file. 

3320 If `basename` does not have an extension, 

3321 '-pulsephases', the fish index, and a file extension are appended. 

3322 If stream, write pulse phases into this stream. 

3323 kwargs: 

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

3325 

3326 Returns 

3327 ------- 

3328 filename: Path 

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

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

3331 would have been appended to a basename. 

3332 

3333 See Also 

3334 -------- 

3335 load_pulse_phases() 

3336 """ 

3337 if len(phase_data) == 0: 

3338 return None 

3339 td = TableData(phase_data[:, :7]*[1, 1000, 1, 100, 1000, 1000, 100], 

3340 ['P', 'time', 'amplitude', 'relampl', 'width', 'area', 'relarea'], 

3341 ['', 'ms', unit, '%', 'ms', f'{unit}*ms', '%'], 

3342 ['%.0f', '%.3f', '%.5f', '%.2f', '%.3f', '%.4f', '%.2f']) 

3343 fp = '' 

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

3345 if not ext: 

3346 fp = '-pulsephases' 

3347 if idx is not None: 

3348 fp += f'-{idx}' 

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

3350 

3351 

3352def load_pulse_phases(file_path): 

3353 """Load phase properties of pulse EOD from file. 

3354 

3355 Parameters 

3356 ---------- 

3357 file_path: string 

3358 Path of the file to be loaded. 

3359 

3360 Returns 

3361 ------- 

3362 phase_data: 2D array of floats 

3363 Properties of phases of pulse EOD: 

3364 P, time, amplitude, relampl, width 

3365 unit: string 

3366 Unit of phase amplitudes. 

3367 

3368 Raises 

3369 ------ 

3370 FileNotFoundError: 

3371 If `file_path` does not exist. 

3372 

3373 See Also 

3374 -------- 

3375 save_pulse_phases() 

3376 """ 

3377 data = TableData(file_path) 

3378 phases = data.array() 

3379 phases[:, 1] *= 0.001 

3380 phases[:, 3] *= 0.01 

3381 phases[:, 4] *= 0.001 

3382 phases[:, 5] *= 0.001 

3383 phases[:, 6] *= 0.01 

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

3385 

3386 

3387def save_pulse_gaussians(pulse_data, unit, idx, basename, **kwargs): 

3388 """Save Gaussian phase properties of pulse EOD to file. 

3389 

3390 Parameters 

3391 ---------- 

3392 pulse: dict 

3393 Dictionary with phase times, amplitudes and standard 

3394 deviations of Gaussians fitted to the pulse waveform 

3395 as returned by `decompose_pulse()` and `analyze_pulse()`. 

3396 unit: string 

3397 Unit of the waveform data. 

3398 idx: int or None 

3399 Index of fish. 

3400 basename: string or stream 

3401 If string, path and basename of file. 

3402 If `basename` does not have an extension, 

3403 '-pulsegaussians', the fish index, and a file extension are appended. 

3404 If stream, write pulse phases into this stream. 

3405 kwargs: 

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

3407 

3408 Returns 

3409 ------- 

3410 filename: Path 

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

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

3413 would have been appended to a basename. 

3414 

3415 See Also 

3416 -------- 

3417 load_pulse_gaussians() 

3418 """ 

3419 if len(pulse_data) == 0: 

3420 return None 

3421 td = TableData(pulse_data, 

3422 units=['ms', unit, 'ms'], 

3423 formats=['%.3f', '%.5f', '%.3f']) 

3424 td[:, 'times'] *= 1000 

3425 td[:, 'stdevs'] *= 1000 

3426 fp = '' 

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

3428 if not ext: 

3429 fp = '-pulsegaussians' 

3430 if idx is not None: 

3431 fp += f'-{idx}' 

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

3433 

3434 

3435def load_pulse_gaussians(file_path): 

3436 """Load Gaussian phase properties of pulse EOD from file. 

3437 

3438 Parameters 

3439 ---------- 

3440 file_path: string 

3441 Path of the file to be loaded. 

3442 

3443 Returns 

3444 ------- 

3445 pulse: dict 

3446 Dictionary with phase times, amplitudes and standard 

3447 deviations of Gaussians fitted to the pulse waveform. 

3448 Use the functions provided in thunderfish.fakefish to simulate 

3449 pulse fish EODs from this data. 

3450 unit: string 

3451 Unit of Gaussian amplitudes. 

3452 

3453 Raises 

3454 ------ 

3455 FileNotFoundError: 

3456 If `file_path` does not exist. 

3457 

3458 See Also 

3459 -------- 

3460 save_pulse_gaussians() 

3461 

3462 """ 

3463 data = TableData(file_path) 

3464 pulses = data.dict() 

3465 pulses['times'] = np.asarray(pulses['times']) 

3466 pulses['amplitudes'] = np.asarray(pulses['amplitudes']) 

3467 pulses['stdevs'] = np.asarray(pulses['stdevs']) 

3468 pulses['times'] *= 0.001 

3469 pulses['stdevs'] *= 0.001 

3470 return pulses, data.unit('amplitudes') 

3471 

3472 

3473def save_pulse_times(pulse_times, idx, basename, **kwargs): 

3474 """Save times of pulse EOD to file. 

3475 

3476 Parameters 

3477 ---------- 

3478 pulse_times: dict or array of floats 

3479 Times of EOD pulses. Either as array of times or 

3480 `props['peaktimes']` or `props['times']` as returned by 

3481 `analyze_pulse()`. 

3482 idx: int or None 

3483 Index of fish. 

3484 basename: string or stream 

3485 If string, path and basename of file. 

3486 If `basename` does not have an extension, 

3487 '-pulsetimes', the fish index, and a file extension are appended. 

3488 If stream, write pulse times into this stream. 

3489 kwargs: 

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

3491 

3492 Returns 

3493 ------- 

3494 filename: Path 

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

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

3497 would have been appended to a basename. 

3498 

3499 See Also 

3500 -------- 

3501 load_pulse_times() 

3502 """ 

3503 if isinstance(pulse_times, dict): 

3504 props = pulse_times 

3505 pulse_times = props.get('times', []) 

3506 pulse_times = props.get('peaktimes', pulse_times) 

3507 if len(pulse_times) == 0: 

3508 return None 

3509 td = TableData() 

3510 td.append('time', 's', '%.4f', value=pulse_times) 

3511 fp = '' 

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

3513 if not ext: 

3514 fp = '-pulsetimes' 

3515 if idx is not None: 

3516 fp += f'-{idx}' 

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

3518 

3519 

3520def load_pulse_times(file_path): 

3521 """Load times of pulse EOD from file. 

3522 

3523 Parameters 

3524 ---------- 

3525 file_path: string 

3526 Path of the file to be loaded. 

3527 

3528 Returns 

3529 ------- 

3530 pulse_times: array of floats 

3531 Times of pulse EODs in seconds. 

3532 

3533 Raises 

3534 ------ 

3535 FileNotFoundError: 

3536 If `file_path` does not exist. 

3537 

3538 See Also 

3539 -------- 

3540 save_pulse_times() 

3541 """ 

3542 data = TableData(file_path) 

3543 pulse_times = data.array()[:, 0] 

3544 return pulse_times 

3545 

3546 

3547file_types = ['waveeodfs', 'wavefish', 'pulsefish', 'eodwaveform', 

3548 'wavespectrum', 'pulsephases', 'pulsegaussians', 'pulsespectrum', 'pulsetimes'] 

3549"""List of all file types generated and supported by the `save_*` and `load_*` functions.""" 

3550 

3551 

3552def parse_filename(file_path): 

3553 """Parse components of an EOD analysis file name. 

3554 

3555 Analysis files generated by the `eodanalysis` module are named 

3556 according to 

3557 ```plain 

3558 PATH/RECORDING-CHANNEL-TIME-FTYPE-N.EXT 

3559 ``` 

3560 

3561 Parameters 

3562 ---------- 

3563 file_path: string 

3564 Path of the file to be parsed. 

3565 

3566 Returns 

3567 ------- 

3568 recording: string 

3569 Path and basename of the recording, i.e. 'PATH/RECORDING'. 

3570 A leading './' is removed. 

3571 base_path: string 

3572 Path and basename of the analysis results, 

3573 i.e. 'PATH/RECORDING-CHANNEL-TIME'. A leading './' is removed. 

3574 channel: int 

3575 Channel of the recording 

3576 ('CHANNEL' component of the file name if present). 

3577 -1 if not present in `file_path`. 

3578 time: float 

3579 Start time of analysis window in seconds 

3580 ('TIME' component of the file name if present). 

3581 `None` if not present in `file_path`. 

3582 ftype: string 

3583 Type of analysis file (e.g. 'wavespectrum', 'pulsephases', etc.), 

3584 ('FTYPE' component of the file name if present). 

3585 See `file_types` for a list of all supported file types. 

3586 Empty string if not present in `file_path`. 

3587 index: int 

3588 Index of the EOD. 

3589 ('N' component of the file name if present). 

3590 -1 if not present in `file_path`. 

3591 ext: string 

3592 File extension *without* leading period 

3593 ('EXT' component of the file name). 

3594 

3595 """ 

3596 file_path = Path(file_path) 

3597 ext = file_path.suffix 

3598 ext = ext[1:] 

3599 parts = file_path.stem.split('-') 

3600 index = -1 

3601 if len(parts) > 0 and parts[-1].isdigit(): 

3602 index = int(parts[-1]) 

3603 parts = parts[:-1] 

3604 ftype = '' 

3605 if len(parts) > 0: 

3606 ftype = parts[-1] 

3607 parts = parts[:-1] 

3608 base_path = file_path.parent / '-'.join(parts) 

3609 time = None 

3610 if len(parts) > 0 and len(parts[-1]) > 0 and \ 

3611 parts[-1][0] == 't' and parts[-1][-1] == 's' and \ 

3612 parts[-1][1:-1].isdigit(): 

3613 time = float(parts[-1][1:-1]) 

3614 parts = parts[:-1] 

3615 channel = -1 

3616 if len(parts) > 0 and len(parts[-1]) > 0 and \ 

3617 parts[-1][0] == 'c' and parts[-1][1:].isdigit(): 

3618 channel = int(parts[-1][1:]) 

3619 parts = parts[:-1] 

3620 recording = '-'.join(parts) 

3621 return recording, base_path, channel, time, ftype, index, ext 

3622 

3623 

3624def save_analysis(output_basename, zip_file, eod_props, mean_eods, spec_data, 

3625 phase_data, pulse_data, wave_eodfs, wave_indices, unit, 

3626 verbose, **kwargs): 

3627 """Save EOD analysis results to files. 

3628 

3629 Parameters 

3630 ---------- 

3631 output_basename: string 

3632 Path and basename of files to be saved. 

3633 zip_file: bool 

3634 If `True`, write all analysis results into a zip archive. 

3635 eod_props: list of dict 

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

3637 `analyze_pulse()`. 

3638 mean_eods: list of 2D array of floats 

3639 Averaged EOD waveforms as returned by `eod_waveform()`, 

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

3641 spec_data: list of 2D array of floats 

3642 Energy spectra of single pulses as returned by 

3643 `analyze_pulse()`. 

3644 phase_data: list of 2D array of floats 

3645 Properties of phases of pulse EODs as returned by 

3646 `analyze_pulse()`. 

3647 pulse_data: list of dict 

3648 For each pulse fish a dictionary with phase times, amplitudes and standard 

3649 deviations of Gaussians fitted to the pulse waveform. 

3650 wave_eodfs: list of 2D array of float 

3651 Each item is a matrix with the frequencies and powers 

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

3653 by `harmonics.harmonic_groups()`. 

3654 wave_indices: array of int 

3655 Indices identifying each fish in `wave_eodfs` or NaN. 

3656 unit: string 

3657 Unit of the waveform data. 

3658 verbose: int 

3659 Verbosity level. 

3660 kwargs: 

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

3662 """ 

3663 def write_file_zip(zf, save_func, output, *args, **kwargs): 

3664 if zf is None: 

3665 fp = save_func(*args, basename=output, **kwargs) 

3666 if verbose > 0 and fp is not None: 

3667 print('wrote file', fp) 

3668 else: 

3669 with io.StringIO() as df: 

3670 fp = save_func(*args, basename=df, **kwargs) 

3671 if fp is not None: 

3672 fp = Path(output + str(fp)) 

3673 zf.writestr(fp.name, df.getvalue()) 

3674 if verbose > 0: 

3675 print('zipped file', fp.name) 

3676 

3677 

3678 if 'table_format' in kwargs and kwargs['table_format'] == 'py': 

3679 with open(output_basename + '.py', 'w') as f: 

3680 name = Path(output_basename).stem 

3681 for k in range(len((spec_data))): 

3682 if len(pulse_data[k]) > 0: 

3683 fish = normalize_pulsefish(pulse_data[k]) 

3684 export_pulsefish(fish, f'{name}-{k}_phases', f) 

3685 else: 

3686 sdata = spec_data[k] 

3687 if len(sdata) > 0 and sdata.shape[1] > 2: 

3688 fish = dict(amplitudes=sdata[:, 3], phases=sdata[:, 5]) 

3689 fish = normalize_wavefish(fish) 

3690 export_wavefish(fish, f'{name}-{k}_harmonics', f) 

3691 else: 

3692 zf = None 

3693 if zip_file: 

3694 zf = zipfile.ZipFile(output_basename + '.zip', 'w') 

3695 # all wave fish in wave_eodfs: 

3696 if len(wave_eodfs) > 0: 

3697 write_file_zip(zf, save_wave_eodfs, output_basename, 

3698 wave_eodfs, wave_indices, **kwargs) 

3699 # all wave and pulse fish: 

3700 for i, (mean_eod, sdata, pdata, pulse, props) in enumerate(zip(mean_eods, spec_data, phase_data, 

3701 pulse_data, eod_props)): 

3702 write_file_zip(zf, save_eod_waveform, output_basename, 

3703 mean_eod, unit, i, **kwargs) 

3704 # spectrum: 

3705 if len(sdata)>0: 

3706 if sdata.shape[1] == 2: 

3707 write_file_zip(zf, save_pulse_spectrum, output_basename, 

3708 sdata, unit, i, **kwargs) 

3709 else: 

3710 write_file_zip(zf, save_wave_spectrum, output_basename, 

3711 sdata, unit, i, **kwargs) 

3712 # phases: 

3713 write_file_zip(zf, save_pulse_phases, output_basename, 

3714 pdata, unit, i, **kwargs) 

3715 # pulses: 

3716 write_file_zip(zf, save_pulse_gaussians, output_basename, 

3717 pulse, unit, i, **kwargs) 

3718 # times: 

3719 write_file_zip(zf, save_pulse_times, output_basename, 

3720 props, i, **kwargs) 

3721 # wave fish properties: 

3722 write_file_zip(zf, save_wave_fish, output_basename, 

3723 eod_props, unit, **kwargs) 

3724 # pulse fish properties: 

3725 write_file_zip(zf, save_pulse_fish, output_basename, 

3726 eod_props, unit, **kwargs) 

3727 

3728 

3729def load_analysis(file_pathes): 

3730 """Load all EOD analysis files. 

3731 

3732 Parameters 

3733 ---------- 

3734 file_pathes: list of string 

3735 Pathes of the analysis files of a single recording to be loaded. 

3736 

3737 Returns 

3738 ------- 

3739 mean_eods: list of 2D array of floats 

3740 Averaged EOD waveforms: time in seconds, mean, standard deviation, fit. 

3741 wave_eodfs: 2D array of floats 

3742 EODfs and power of wave type fish. 

3743 wave_indices: array of ints 

3744 Corresponding indices of fish, can contain negative numbers to 

3745 indicate frequencies without fish. 

3746 eod_props: list of dict 

3747 Properties of EODs. The 'index' property is an index into the 

3748 reurned lists. 

3749 spec_data: list of 2D array of floats 

3750 Amplitude and phase spectrum of wave-type EODs with columns 

3751 harmonics, frequency, amplitude, relative amplitude in dB, 

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

3753 Energy spectrum of single pulse-type EODs with columns 

3754 frequency and energy. 

3755 phase_data: list of 2D array of floats 

3756 Properties of phases of pulse-type EODs with columns 

3757 P, time, amplitude, relampl, width, area, relarea 

3758 pulse_data: list of dict 

3759 For each pulse fish a dictionary with phase times, amplitudes and standard 

3760 deviations of Gaussians fitted to the pulse waveform. Use the 

3761 functions provided in thunderfish.fakefish to simulate pulse 

3762 fish EODs from this data. 

3763 recording: string 

3764 Path and base name of the recording file. 

3765 channel: int 

3766 Analysed channel of the recording. 

3767 unit: string 

3768 Unit of EOD waveform. 

3769 """ 

3770 recording = None 

3771 channel = -1 

3772 eod_props = [] 

3773 zf = None 

3774 if len(file_pathes) == 1 and Path(file_pathes[0]).suffix[1:] == 'zip': 

3775 zf = zipfile.ZipFile(file_pathes[0]) 

3776 file_pathes = sorted(zf.namelist()) 

3777 # read wave- and pulse-fish summaries: 

3778 pulse_fish = False 

3779 wave_fish = False 

3780 for f in file_pathes: 

3781 recording, _, channel, _, ftype, _, _ = parse_filename(f) 

3782 if zf is not None: 

3783 f = io.TextIOWrapper(zf.open(f, 'r')) 

3784 if ftype == 'wavefish': 

3785 eod_props.extend(load_wave_fish(f)) 

3786 wave_fish = True 

3787 elif ftype == 'pulsefish': 

3788 eod_props.extend(load_pulse_fish(f)) 

3789 pulse_fish = True 

3790 idx_offs = 0 

3791 if wave_fish and not pulse_fish: 

3792 idx_offs = sorted([ep['index'] for ep in eod_props])[0] 

3793 # load all other files: 

3794 neods = len(eod_props) 

3795 if neods < 1: 

3796 neods = 1 

3797 eod_props = [None] 

3798 wave_eodfs = np.array([]) 

3799 wave_indices = np.array([]) 

3800 mean_eods = [None]*neods 

3801 spec_data = [None]*neods 

3802 phase_data = [None]*neods 

3803 pulse_data = [None]*neods 

3804 unit = None 

3805 for f in file_pathes: 

3806 recording, _, channel, _, ftype, idx, _ = parse_filename(f) 

3807 if neods == 1 and idx > 0: 

3808 idx = 0 

3809 idx -= idx_offs 

3810 if zf is not None: 

3811 f = io.TextIOWrapper(zf.open(f, 'r')) 

3812 if ftype == 'waveeodfs': 

3813 wave_eodfs, wave_indices = load_wave_eodfs(f) 

3814 elif ftype == 'eodwaveform': 

3815 mean_eods[idx], unit = load_eod_waveform(f) 

3816 elif ftype == 'wavespectrum': 

3817 spec_data[idx], unit = load_wave_spectrum(f) 

3818 elif ftype == 'pulsephases': 

3819 phase_data[idx], unit = load_pulse_phases(f) 

3820 elif ftype == 'pulsegaussians': 

3821 pulse_data[idx], unit = load_pulse_gaussians(f) 

3822 elif ftype == 'pulsetimes': 

3823 pulse_times = load_pulse_times(f) 

3824 eod_props[idx]['times'] = pulse_times 

3825 eod_props[idx]['peaktimes'] = pulse_times 

3826 elif ftype == 'pulsespectrum': 

3827 spec_data[idx] = load_pulse_spectrum(f) 

3828 # fix wave spectra: 

3829 wave_eodfs = [fish.reshape(1, 2) if len(fish)>0 else fish 

3830 for fish in wave_eodfs] 

3831 if len(wave_eodfs) > 0 and len(spec_data) > 0: 

3832 eodfs = [] 

3833 for idx, fish in zip(wave_indices, wave_eodfs): 

3834 if idx >= 0: 

3835 spec = spec_data[idx] 

3836 specd = np.zeros((np.sum(np.isfinite(spec[:, -1])), 

3837 2)) 

3838 specd[:, 0] = spec[np.isfinite(spec[:, -1]),1] 

3839 specd[:, 1] = spec[np.isfinite(spec[:, -1]),-1] 

3840 eodfs.append(specd) 

3841 else: 

3842 specd = np.zeros((10, 2)) 

3843 specd[:, 0] = np.arange(len(specd))*fish[0,0] 

3844 specd[:, 1] = np.nan 

3845 eodfs.append(specd) 

3846 wave_eodfs = eodfs 

3847 return mean_eods, wave_eodfs, wave_indices, eod_props, spec_data, \ 

3848 phase_data, pulse_data, recording, channel, unit 

3849 

3850 

3851def load_recording(file_path, channel=0, load_kwargs={}, 

3852 eod_props=None, verbose=0): 

3853 """Load recording. 

3854 

3855 Parameters 

3856 ---------- 

3857 file_path: string or Path 

3858 Full path of the file with the recorded data. 

3859 Extension is optional. If absent, look for the first file 

3860 with a reasonable extension. 

3861 channel: int 

3862 Channel of the recording to be returned. 

3863 load_kwargs: dict 

3864 Keyword arguments that are passed on to the  

3865 format specific loading functions. 

3866 eod_props: list of dict or None 

3867 List of EOD properties from which start and end times of 

3868 analysis window are extracted. 

3869 verbose: int 

3870 Verbosity level passed on to load function. 

3871 

3872 Returns 

3873 ------- 

3874 data: array of float 

3875 Data of the requested `channel`. 

3876 rate: float 

3877 Sampling rate in Hertz. 

3878 idx0: int 

3879 Start index of the analysis window. 

3880 idx1: int 

3881 End index of the analysis window. 

3882 info_dict: dict 

3883 Dictionary with path, basename, species, channel, chanstr, time. 

3884 """ 

3885 data = None 

3886 rate = 0.0 

3887 idx0 = 0 

3888 idx1 = 0 

3889 data_file = '' 

3890 info_dict = {} 

3891 file_path = Path(file_path) 

3892 if len(file_path.suffix) > 1: 

3893 data_file = file_path 

3894 else: 

3895 data_files = file_path.parent.glob(file_path.stem + '*') 

3896 for dfile in data_files: 

3897 if not dfile.suffix[1:] in ['zip'] + list(TableData.ext_formats.values()): 

3898 data_file = dfile 

3899 break 

3900 if data_file.is_file(): 

3901 all_data = DataLoader(data_file, verbose=verbose, **load_kwargs) 

3902 rate = all_data.rate 

3903 unit = all_data.unit 

3904 ampl_max = all_data.ampl_max 

3905 data = all_data[:, channel] 

3906 species = get_str(all_data.metadata(), ['species'], default='') 

3907 if len(species) > 0: 

3908 species += ' ' 

3909 info_dict = dict(path=all_data.filepath, 

3910 basename=all_data.basename(), 

3911 species=species, 

3912 channel=channel) 

3913 if all_data.channels > 1: 

3914 if all_data.channels > 100: 

3915 info_dict['chanstr'] = f'-c{channel:03d}' 

3916 elif all_data.channels > 10: 

3917 info_dict['chanstr'] = f'-c{channel:02d}' 

3918 else: 

3919 info_dict['chanstr'] = f'-c{channel:d}' 

3920 else: 

3921 info_dict['chanstr'] = '' 

3922 idx0 = 0 

3923 idx1 = len(data) 

3924 if eod_props is not None and len(eod_props) > 0 and 'twin' in eod_props[0]: 

3925 idx0 = int(eod_props[0]['twin']*rate) 

3926 if len(eod_props) > 0 and 'window' in eod_props[0]: 

3927 idx1 = idx0 + int(eod_props[0]['window']*rate) 

3928 info_dict['time'] = '-t{idx0/rate:.0f}s' 

3929 all_data.close() 

3930 

3931 return data, rate, idx0, idx1, info_dict 

3932 

3933 

3934def add_eod_analysis_config(cfg, thresh_fac=0.8, percentile=0.1, 

3935 win_fac=2.0, min_win=0.01, max_eods=None, 

3936 min_sem=False, unfilter_cutoff=0.0, 

3937 flip_wave='none', flip_pulse='none', 

3938 n_harm=10, min_pulse_win=0.001, 

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

3940 width_frac = 0.5, fit_frac = 0.5, 

3941 freq_resolution=1.0, fade_frac=0.0, 

3942 ipi_cv_thresh=0.5, ipi_percentile=30.0): 

3943 """Add all parameters needed for the eod analysis functions as a new 

3944 section to a configuration. 

3945 

3946 Parameters 

3947 ---------- 

3948 cfg: ConfigFile 

3949 The configuration. 

3950  

3951 See `eod_waveform()`, `analyze_wave()`, and `analyze_pulse()` for 

3952 details on the remaining arguments. 

3953 """ 

3954 cfg.add_section('EOD analysis:') 

3955 cfg.add('eodSnippetFac', win_fac, '', 'The duration of EOD snippets is the EOD period times this factor.') 

3956 cfg.add('eodMinSnippet', min_win, 's', 'Minimum duration of cut out EOD snippets.') 

3957 cfg.add('eodMaxEODs', max_eods or 0, '', 'The maximum number of EODs used to compute the average EOD. If 0 use all EODs.') 

3958 cfg.add('eodMinSem', min_sem, '', 'Use minimum of s.e.m. to set maximum number of EODs used to compute the average EOD.') 

3959 cfg.add('unfilterCutoff', unfilter_cutoff, 'Hz', 'If non-zero remove effect of high-pass filter with this cut-off frequency.') 

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

3961 cfg.add('flipPulseEOD', flip_pulse, '', 'Flip EOD of pulse fish to make the first large peak positive (flip, none, or auto).') 

3962 cfg.add('eodHarmonics', n_harm, '', 'Number of harmonics fitted to the EOD waveform.') 

3963 cfg.add('eodMinPulseSnippet', min_pulse_win, 's', 'Minimum duration of cut out EOD snippets for a pulse fish.') 

3964 cfg.add('eodPeakThresholdFactor', peak_thresh_fac, '', 'Threshold for detection of peaks and troughs in pulse EODs as a fraction of the pulse amplitude.') 

3965 cfg.add('eodMinimumDistance', min_dist, 's', 'Minimum distance between peaks and troughs in a EOD pulse.') 

3966 cfg.add('eodPulseWidthFraction', width_frac, '', 'The width of a pulse is measured at this fraction of the pulse height.') 

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

3968 cfg.add('eodPulseFrequencyResolution', freq_resolution, 'Hz', 'Frequency resolution of single pulse spectrum.') 

3969 cfg.add('eodPulseFadeFraction', 100*fade_frac, '%', 'Fraction of time of the EOD waveform snippet that is used to fade in and out to zero baseline.') 

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

3971 cfg.add('ipiPercentile', ipi_percentile, '%', 'Use only interpulse intervals shorter than this percentile to compute EOD frequency.') 

3972 

3973 

3974def eod_waveform_args(cfg): 

3975 """Translates a configuration to the respective parameter names of 

3976 the function `eod_waveform()`. 

3977  

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

3979 function. 

3980 

3981 Parameters 

3982 ---------- 

3983 cfg: ConfigFile 

3984 The configuration. 

3985 

3986 Returns 

3987 ------- 

3988 a: dict 

3989 Dictionary with names of arguments of the `eod_waveform()` function 

3990 and their values as supplied by `cfg`. 

3991 """ 

3992 a = cfg.map({'win_fac': 'eodSnippetFac', 

3993 'min_win': 'eodMinSnippet', 

3994 'max_eods': 'eodMaxEODs', 

3995 'min_sem': 'eodMinSem', 

3996 'unfilter_cutoff': 'unfilterCutoff'}) 

3997 return a 

3998 

3999 

4000def analyze_wave_args(cfg): 

4001 """Translates a configuration to the respective parameter names of 

4002 the function `analyze_wave()`. 

4003  

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

4005 function. 

4006 

4007 Parameters 

4008 ---------- 

4009 cfg: ConfigFile 

4010 The configuration. 

4011 

4012 Returns 

4013 ------- 

4014 a: dict 

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

4016 and their values as supplied by `cfg`. 

4017 """ 

4018 a = cfg.map({'n_harm': 'eodHarmonics', 

4019 'power_n_harmonics': 'powerNHarmonics', 

4020 'flip_wave': 'flipWaveEOD'}) 

4021 return a 

4022 

4023 

4024def analyze_pulse_args(cfg): 

4025 """Translates a configuration to the respective parameter names of 

4026 the function `analyze_pulse()`. 

4027  

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

4029 function. 

4030 

4031 Parameters 

4032 ---------- 

4033 cfg: ConfigFile 

4034 The configuration. 

4035 

4036 Returns 

4037 ------- 

4038 a: dict 

4039 Dictionary with names of arguments of the `analyze_pulse()` function 

4040 and their values as supplied by `cfg`. 

4041 """ 

4042 a = cfg.map({'min_pulse_win': 'eodMinPulseSnippet', 

4043 'peak_thresh_fac': 'eodPeakThresholdFactor', 

4044 'min_dist': 'eodMinimumDistance', 

4045 'width_frac': 'eodPulseWidthFraction', 

4046 'fit_frac': 'eodExponentialFitFraction', 

4047 'flip_pulse': 'flipPulseEOD', 

4048 'freq_resolution': 'eodPulseFrequencyResolution', 

4049 'fade_frac': 'eodPulseFadeFraction', 

4050 'ipi_cv_thresh': 'ipiCVThresh', 

4051 'ipi_percentile': 'ipiPercentile'}) 

4052 a['fade_frac'] *= 0.01 

4053 return a 

4054 

4055 

4056def add_species_config(cfg, species_file='none', wave_max_rms=0.2, 

4057 pulse_max_rms=0.2): 

4058 """Add parameters needed for assigning EOD waveforms to species. 

4059 

4060 Parameters 

4061 ---------- 

4062 cfg: ConfigFile 

4063 The configuration. 

4064 species_file: string 

4065 File path to a file containing species names and corresponding 

4066 file names of EOD waveform templates. If 'none', no species 

4067 assignemnt is performed. 

4068 wave_max_rms: float 

4069 Maximum allowed rms difference (relative to standard deviation 

4070 of EOD waveform) to an EOD waveform template for assignment to 

4071 a wave fish species. 

4072 pulse_max_rms: float 

4073 Maximum allowed rms difference (relative to standard deviation 

4074 of EOD waveform) to an EOD waveform template for assignment to 

4075 a pulse fish species. 

4076 """ 

4077 cfg.add_section('Species assignment:') 

4078 cfg.add('speciesFile', species_file, '', 'File path to a file containing species names and corresponding file names of EOD waveform templates.') 

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

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

4081 

4082 

4083def add_eod_quality_config(cfg, max_clipped_frac=0.1, max_variance=0.0, 

4084 max_rms_error=0.05, min_power=-100.0, max_thd=0.0, 

4085 max_crossings=4, max_relampl_harm1=0.0, 

4086 max_relampl_harm2=0.0, max_relampl_harm3=0.0): 

4087 """Add parameters needed for assesing the quality of an EOD waveform. 

4088 

4089 Parameters 

4090 ---------- 

4091 cfg: ConfigFile 

4092 The configuration. 

4093  

4094 See `wave_quality()` and `pulse_quality()` for details on 

4095 the remaining arguments. 

4096 """ 

4097 cfg.add_section('Waveform selection:') 

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

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

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

4101 cfg.add('minimumPower', min_power, 'dB', 'Skip waveform of wave fish if its power is smaller than this value.') 

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

4103 cfg.add('maximumCrossings', max_crossings, '', 'Maximum number of zero crossings per EOD period.') 

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

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

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

4107 

4108 

4109def wave_quality_args(cfg): 

4110 """Translates a configuration to the respective parameter names of 

4111 the function `wave_quality()`. 

4112  

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

4114 function. 

4115 

4116 Parameters 

4117 ---------- 

4118 cfg: ConfigFile 

4119 The configuration. 

4120 

4121 Returns 

4122 ------- 

4123 a: dict 

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

4125 and their values as supplied by `cfg`. 

4126 """ 

4127 a = cfg.map({'max_clipped_frac': 'maximumClippedFraction', 

4128 'max_rms_sem': 'maximumVariance', 

4129 'max_rms_error': 'maximumRMSError', 

4130 'min_power': 'minimumPower', 

4131 'max_crossings': 'maximumCrossings', 

4132 'min_freq': 'minimumFrequency', 

4133 'max_freq': 'maximumFrequency', 

4134 'max_thd': 'maximumTotalHarmonicDistortion', 

4135 'max_db_diff': 'maximumPowerDifference', 

4136 'max_harmonics_db': 'maximumHarmonicsPower', 

4137 'max_relampl_harm1': 'maximumFirstHarmonicAmplitude', 

4138 'max_relampl_harm2': 'maximumSecondHarmonicAmplitude', 

4139 'max_relampl_harm3': 'maximumThirdHarmonicAmplitude'}) 

4140 return a 

4141 

4142 

4143def pulse_quality_args(cfg): 

4144 """Translates a configuration to the respective parameter names of 

4145 the function `pulse_quality()`. 

4146  

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

4148 function. 

4149 

4150 Parameters 

4151 ---------- 

4152 cfg: ConfigFile 

4153 The configuration. 

4154 

4155 Returns 

4156 ------- 

4157 a: dict 

4158 Dictionary with names of arguments of the `pulse_quality()` function 

4159 and their values as supplied by `cfg`. 

4160 """ 

4161 a = cfg.map({'max_clipped_frac': 'maximumClippedFraction', 

4162 'max_rms_sem': 'maximumRMSNoise'}) 

4163 return a 

4164 

4165 

4166def main(): 

4167 import matplotlib.pyplot as plt 

4168 from .fakefish import pulsefish_eods 

4169 

4170 print('Analysis of EOD waveforms.') 

4171 

4172 # data: 

4173 rate = 96_000 

4174 data = pulsefish_eods('Triphasic', 83.0, rate, 5.0, noise_std=0.02) 

4175 unit = 'mV' 

4176 eod_idx, _ = detect_peaks(data, 1.0) 

4177 eod_times = eod_idx/rate 

4178 

4179 # analyse EOD: 

4180 mean_eod, eod_times = eod_waveform(data, rate, eod_times) 

4181 mean_eod, props, peaks, pulses, energy = analyze_pulse(mean_eod, eod_times) 

4182 

4183 # plot: 

4184 fig, axs = plt.subplots(1, 2) 

4185 plot_eod_waveform(axs[0], mean_eod, props, peaks, unit=unit) 

4186 axs[0].set_title(f'{props["type"]} fish: EODf = {props["EODf"]:.1f} Hz') 

4187 plot_pulse_spectrum(axs[1], energy, props) 

4188 plt.show() 

4189 

4190 

4191if __name__ == '__main__': 

4192 main()