Coverage for src / thunderfish / thunderfish.py: 0%

971 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-09 14:25 +0000

1"""# thunderfish 

2 

3Automatically detect and analyze all EOD waveforms in short recordings 

4and generated summary plots and data tables. 

5 

6Run it from the thunderfish development directory as: 

7```sh 

8> python3 -m thunderfish.thunderfish audiofile.wav 

9``` 

10Or install thunderfish 

11```sh 

12> pip install . 

13``` 

14Then you can run it directly from every directory: 

15```sh 

16> thunderfish audiofile.wav 

17``` 

18""" 

19 

20import sys 

21import os 

22import glob 

23import io 

24import zipfile 

25import argparse 

26import traceback 

27import numpy as np 

28import matplotlib.pyplot as plt 

29import matplotlib.gridspec as gridspec 

30import matplotlib.ticker as ticker 

31import matplotlib.lines as ml 

32 

33from pathlib import Path 

34from matplotlib.transforms import Bbox 

35from matplotlib.backends.backend_pdf import PdfPages 

36from multiprocessing import Pool, freeze_support, cpu_count 

37from audioio.audioconverter import parse_load_kwargs 

38from audioio import get_str, play, fade, load_audio 

39from thunderlab.configfile import ConfigFile 

40from thunderlab.dataloader import DataLoader 

41from thunderlab.powerspectrum import decibel, plot_decibel_psd, multi_psd 

42from thunderlab.powerspectrum import add_multi_psd_config, multi_psd_args 

43from thunderlab.tabledata import TableData, add_write_table_config, write_table_args 

44 

45from .version import __version__, __year__ 

46from .bestwindow import add_clip_config, add_best_window_config 

47from .bestwindow import clip_args, best_window_args 

48from .bestwindow import analysis_window, plot_data_window 

49from .checkpulse import check_pulse, add_check_pulse_config, check_pulse_args 

50from .pulses import extract_pulsefish 

51from .harmonics import add_psd_peak_detection_config, add_harmonic_groups_config 

52from .harmonics import harmonic_groups, harmonic_groups_args, psd_peak_detection_args 

53from .harmonics import colors_markers, plot_harmonic_groups 

54from .consistentfishes import consistent_fishes 

55from .fakefish import pulsefish_spectrum 

56from .pulseanalysis import analyze_pulse, plot_pulse_eods, plot_pulse_spectrum 

57from .waveanalysis import waveeod_waveform, analyze_wave, plot_wave_spectrum 

58from .eodanalysis import eod_waveform 

59from .eodanalysis import unfilter, clipped_fraction 

60from .eodanalysis import plot_eod_recording, plot_eod_waveform, plot_eod_snippets 

61from .eodanalysis import add_eod_analysis_config, eod_waveform_args 

62from .eodanalysis import analyze_wave_args, analyze_pulse_args 

63from .eodanalysis import add_species_config 

64from .eodanalysis import wave_quality, wave_quality_args, add_eod_quality_config 

65from .eodanalysis import pulse_quality, pulse_quality_args 

66from .eodanalysis import save_analysis, load_analysis, load_recording 

67from .eodanalysis import parse_filename, file_types 

68 

69 

70# Colors taken from https://github.com/bendalab/plottools/blob/master/src/plottools/colors.py (colors_vivid) 

71 

72trace_color = '#D71000' 

73""" Default color for traces.""" 

74 

75data_color = '#1040C0' 

76""" Default color for the full data trace.""" 

77 

78fit_color = '#8000C0' 

79""" Default color for fits.""" 

80 

81spectrum_color = '#1040C0' 

82""" Default color for spectra.""" 

83 

84analytic_spectrum_color = '#00D0B0' 

85""" Default color for analyically computed spectra.""" 

86 

87data_styles = dict(dstyle=dict(color=data_color, lw=1.4), 

88 wstyle=dict(color=trace_color, lw=1.4)) 

89 

90rec_style = dict(color=trace_color, lw=2) 

91 

92spectrum_style = dict(color=spectrum_color, lw=2) 

93 

94eod_styles = dict(wave_style=dict(color=trace_color, lw=1.3), 

95 magnified_style=dict(color=trace_color, lw=0.8), 

96 positive_style=dict(facecolor='#00A050', alpha=0.2, 

97 edgecolor='none'), 

98 negative_style=dict(facecolor='#1040C0', alpha=0.2, 

99 edgecolor='none'), 

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

101 fit_style=dict(color=fit_color, lw=1), 

102 phase_style=dict(zorder=50, ls='', marker='o', color=trace_color, 

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

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

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

106 zero_style=dict(color='0.3', lw=0.5), 

107 fontsize='small') 

108 

109snippet_style = dict(scaley=False, lw=0.5, color='0.6') 

110 

111wave_spec_styles = dict(ampl_style=dict(marker='o', color=spectrum_color, 

112 markersize=6), 

113 ampl_stem_style=dict(color=spectrum_color, alpha=0.5, 

114 lw=1), 

115 phase_style=dict(marker='p', color=spectrum_color, 

116 markersize=6), 

117 phase_stem_style=dict(color=spectrum_color, alpha=0.5, 

118 lw=1)) 

119 

120pulse_spec_styles = dict(max_freq=40_000, 

121 spec_style=dict(color=spectrum_color, lw=2), 

122 analytic_style=dict(color=analytic_spectrum_color, 

123 lw=2), 

124 peak_style=dict(ls='', marker='o', markersize=5, 

125 color=spectrum_color, mec='white', 

126 mew=1), 

127 cutoff_style=dict(ls='-', color='0.5', lw=1), 

128 att5_color='0.8', 

129 att50_color='0.9', 

130 fontsize='small') 

131 

132def configuration(): 

133 """Assemble configuration parameter for thunderfish. 

134 

135 Returns 

136 ------- 

137 cfg: ConfigFile 

138 Configuration parameters. 

139 """ 

140 cfg = ConfigFile() 

141 add_multi_psd_config(cfg) 

142 cfg.add('frequencyThreshold', 1.0, 'Hz', 

143 'The fundamental frequency of each fish needs to be detected in each power spectrum within this threshold.') 

144 # TODO: make this threshold dependent on frequency resolution! 

145 cfg.add('minPSDAverages', 3, '', 'Minimum number of fft averages for estimating the power spectrum.') # needed by fishfinder 

146 add_psd_peak_detection_config(cfg) 

147 add_harmonic_groups_config(cfg) 

148 add_clip_config(cfg) 

149 cfg.add('unwrapData', False, '', 'Unwrap clipped voltage traces.') 

150 add_best_window_config(cfg, win_size=8.0, w_cv_ampl=10.0) 

151 add_check_pulse_config(cfg) 

152 add_eod_analysis_config(cfg, min_pulse_win=0.004, fade_frac=0.05) 

153 del cfg['eodSnippetFac'] 

154 del cfg['eodMinSnippet'] 

155 del cfg['eodMinSem'] 

156 add_eod_quality_config(cfg) 

157 add_species_config(cfg) 

158 add_write_table_config(cfg, table_format='csv', unit_style='row', 

159 align_columns=True, shrink_width=False) 

160 return cfg 

161 

162 

163def save_configuration(cfg, config_file): 

164 """Save configuration parameter for thunderfish to a file. 

165 

166 Parameters 

167 ---------- 

168 cfg: ConfigFile 

169 Configuration parameters and their values. 

170 config_file: Path 

171 Path and name of the configuration file to be loaded. 

172 """ 

173 if config_file.suffix != '.cfg': 

174 print('configuration file name must have .cfg as extension!') 

175 else: 

176 print(f'write configuration to {config_file} ...') 

177 del cfg['fileColumnNumbers'] 

178 del cfg['fileShrinkColumnWidth'] 

179 del cfg['fileMissing'] 

180 del cfg['fileLaTeXLabelCommand'] 

181 del cfg['fileLaTeXMergeStd'] 

182 cfg.write(config_file) 

183 

184 

185def detect_eods(data, rate, min_clip, max_clip, name, mode, 

186 verbose, plot_level, cfg): 

187 """Detect EODs of all fish present in the data. 

188 

189 Parameters 

190 ---------- 

191 data: array of floats 

192 The recording in which to detect EODs. 

193 rate: float 

194 Sampling rate of the dataset. 

195 min_clip: float 

196 Minimum amplitude that is not clipped. 

197 max_clip: float 

198 Maximum amplitude that is not clipped. 

199 name: string 

200 Name of the recording (e.g. its filename). 

201 mode: string 

202 Characters in the string indicate what and how to analyze: 

203 - 'w': analyze wavefish 

204 - 'p': analyze pulsefish 

205 - 'P': analyze only the pulsefish with the largest amplitude (not implemented yet)  

206 verbose: int 

207 Print out information about EOD detection if greater than zero. 

208 plot_level : int 

209 Similar to verbosity levels, but with plots.  

210 cfg: ConfigFile 

211 Configuration parameters. 

212 

213 Returns 

214 ------- 

215 psd_data: list of 2D arrays 

216 List of power spectra (frequencies and power) of the analysed data 

217 for different frequency resolutions. 

218 wave_eodfs: list of 2D arrays 

219 Frequency and power of fundamental frequency/harmonics of all wave fish. 

220 wave_indices: array of int 

221 Indices of wave fish mapping from wave_eodfs to eod_props. 

222 If negative, then that EOD frequency has no waveform described in eod_props. 

223 eod_props: list of dict 

224 Lists of EOD properties as returned by analyze_pulse() and analyze_wave() 

225 for each waveform in mean_eods. 

226 mean_eods: list of 2-D arrays with time, mean, sem, and fit. 

227 Averaged EOD waveforms of pulse and wave fish. 

228 spec_data: list of 2_D arrays 

229 For each pulsefish a power spectrum of the single pulse and for 

230 each wavefish the relative amplitudes and phases of the harmonics. 

231 phase_data: list of dict 

232 For each pulse fish a dictionary with phase properties 

233 (indices, times, amplitudes, relamplitudes, widths, areas, relareas, zeros), 

234 empty dict for wave fish. 

235 pulse_data: list of dict 

236 For each pulse fish a dictionary with phase times, amplitudes and standard 

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

238 functions provided in thunderfish.fakefish to simulate pulse 

239 fish EODs from this data. 

240 power_thresh: 2 D array or None 

241 Frequency (first column) and power (second column) of threshold 

242 derived from single pulse spectra to discard false wave fish. 

243 None if no pulse fish was detected. 

244 skip_reason: list of string 

245 Reasons, why an EOD was discarded. 

246 """ 

247 dfreq = np.nan 

248 nfft = 0 

249 psd_data = [[]] 

250 wave_eodfs = [] 

251 wave_indices = [] 

252 if 'w' in mode: 

253 # detect wave fish: 

254 psd_data = multi_psd(data, rate, **multi_psd_args(cfg)) 

255 dfreq = np.mean(np.diff(psd_data[0][:,0])) 

256 nfft = int(rate/dfreq) 

257 h_kwargs = psd_peak_detection_args(cfg) 

258 h_kwargs.update(harmonic_groups_args(cfg)) 

259 wave_eodfs_list = [] 

260 for i, psd in enumerate(psd_data): 

261 wave_eodfs = harmonic_groups(psd[:,0], psd[:,1], verbose-1, **h_kwargs)[0] 

262 if verbose > 0 and len(psd_data) > 1: 

263 numpsdresolutions = cfg.value('numberPSDResolutions') 

264 print('fundamental frequencies detected in power spectrum of window %d at resolution %d:' 

265 % (i//numpsdresolutions, i%numpsdresolutions)) 

266 if len(wave_eodfs) > 0: 

267 print(' ' + ' '.join(['%.1f' % freq[0, 0] for freq in wave_eodfs])) 

268 else: 

269 print(' none') 

270 wave_eodfs_list.append(wave_eodfs) 

271 wave_eodfs = consistent_fishes(wave_eodfs_list, 

272 df_th=cfg.value('frequencyThreshold')) 

273 if verbose > 0: 

274 if len(wave_eodfs) > 0: 

275 print('found %2d EOD frequencies consistent in all power spectra:' % len(wave_eodfs)) 

276 print(' ' + ' '.join(['%.1f' % freq[0, 0] for freq in wave_eodfs])) 

277 else: 

278 print('no fundamental frequencies are consistent in all power spectra') 

279 

280 # analysis results: 

281 eod_props = [] 

282 mean_eods = [] 

283 spec_data = [] 

284 phase_data = [] 

285 pulseeod_data = [] 

286 power_thresh = None 

287 skip_reason = [] 

288 max_pulse_amplitude = 0.0 

289 zoom_window = [] 

290 

291 if 'p' in mode: 

292 # detect pulse fish: 

293 _, eod_times, eod_peaktimes, zoom_window, _ = \ 

294 extract_pulsefish(data, rate, max_clip, verbose=verbose-1, 

295 plot_level=plot_level, 

296 save_path=os.path.splitext(os.path.basename(name))[0]) 

297 

298 #eod_times = [] 

299 #eod_peaktimes = [] 

300 if verbose > 0: 

301 if len(eod_times) > 0: 

302 print('found %2d pulsefish EODs' % len(eod_times)) 

303 else: 

304 print('no pulsefish EODs found') 

305 

306 # analyse eod waveform of pulse-fish: 

307 for k, (eod_ts, eod_pts) in enumerate(zip(eod_times, eod_peaktimes)): 

308 mean_eod, eod_times0 = \ 

309 eod_waveform(data, rate, eod_ts, win_fac=0.8, 

310 min_win=cfg.value('eodMinPulseSnippet'), 

311 min_sem=False, **eod_waveform_args(cfg)) 

312 unfilter_cutoff = cfg.value('unfilterCutoff') 

313 if unfilter_cutoff and unfilter_cutoff > 0: 

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

315 mean_eod, props, phases, pulse, power = \ 

316 analyze_pulse(mean_eod, None, eod_times0, verbose=verbose-1, 

317 **analyze_pulse_args(cfg)) 

318 if len(phases) == 0: 

319 if verbose > 0: 

320 print('no phases in pulse EOD detected') 

321 continue 

322 clipped_frac = clipped_fraction(data, rate, eod_times0, 

323 mean_eod, min_clip, max_clip) 

324 props['peaktimes'] = eod_pts # XXX that should go into analyze pulse 

325 props['index'] = len(eod_props) 

326 props['clipped'] = clipped_frac 

327 props['samplerate'] = rate 

328 props['nfft'] = nfft 

329 props['dfreq'] = dfreq 

330 

331 # add good waveforms only: 

332 skips, msg, skipped_clipped = pulse_quality(props, **pulse_quality_args(cfg)) 

333 

334 if len(skips) == 0: 

335 eod_props.append(props) 

336 mean_eods.append(mean_eod) 

337 spec_data.append(power) 

338 phase_data.append(phases) 

339 pulseeod_data.append(pulse) 

340 if verbose > 0: 

341 print('take %6.1fHz pulse fish: %s' % (props['EODf'], msg)) 

342 else: 

343 skip_reason += ['%.1fHz pulse fish %s' % (props['EODf'], skips)] 

344 if verbose > 0: 

345 print('skip %6.1fHz pulse fish: %s (%s)' % 

346 (props['EODf'], skips, msg)) 

347 

348 # threshold for wave fish peaks based on single pulse spectra: 

349 if len(skips) == 0 or skipped_clipped: 

350 if max_pulse_amplitude < props['p-p-amplitude']: 

351 max_pulse_amplitude = props['p-p-amplitude'] 

352 i0 = np.argmin(np.abs(mean_eod[:,0])) 

353 i1 = len(mean_eod) - i0 

354 pulse_data = np.zeros(len(data)) 

355 for t in props['peaktimes']: 

356 idx = int(t*rate) 

357 ii0 = i0 if idx-i0 >= 0 else idx 

358 ii1 = i1 if idx+i1 < len(pulse_data) else len(pulse_data)-1-idx 

359 pulse_data[idx-ii0:idx+ii1] = mean_eod[i0-ii0:i0+ii1,1] 

360 pulse_psd = multi_psd(pulse_data, rate, **multi_psd_args(cfg)) 

361 pulse_power = pulse_psd[0][:,1] 

362 pulse_power *= len(data)/rate/props['period']/len(props['peaktimes']) 

363 pulse_power *= 5.0 

364 if power_thresh is None: 

365 power_thresh = pulse_psd[0] 

366 power_thresh[:,1] = pulse_power 

367 else: 

368 power_thresh[:,1] += pulse_power 

369 

370 # remove wavefish below pulse fish power: 

371 if 'w' in mode and power_thresh is not None: 

372 n = len(wave_eodfs) 

373 maxh = 3 # XXX make parameter 

374 df = power_thresh[1,0] - power_thresh[0,0] 

375 for k, fish in enumerate(reversed(wave_eodfs)): 

376 idx = np.array(fish[:maxh,0]//df, dtype=int) 

377 for offs in range(-2, 3): 

378 nbelow = np.sum(fish[:maxh,1] < power_thresh[idx+offs,1]) 

379 if nbelow > 0: 

380 wave_eodfs.pop(n-1-k) 

381 if verbose > 0: 

382 print('skip %6.1fHz wave fish: %2d harmonics are below pulsefish threshold' % (fish[0,0], nbelow)) 

383 break 

384 

385 if 'w' in mode: 

386 # analyse EOD waveform of all wavefish: 

387 powers = np.array([np.sum(fish[:,1]) for fish in wave_eodfs]) 

388 power_indices = np.argsort(-powers) 

389 wave_indices = np.zeros(len(wave_eodfs), dtype=int) - 3 

390 for k, idx in enumerate(power_indices): 

391 fish = wave_eodfs[idx] 

392 """ 

393 eod_times = np.arange(0.0, len(data)/rate, 1.0/fish[0,0]) 

394 mean_eod, eod_times = \ 

395 eod_waveform(data, rate, eod_times, win_fac=3.0, min_win=0.0, 

396 min_sem=(k==0), **eod_waveform_args(cfg)) 

397 """ 

398 mean_eod, eod_times = \ 

399 waveeod_waveform(data, rate, fish[0, 0], win_fac=3.0) 

400 if len(mean_eod) == 0: 

401 continue 

402 unfilter_cutoff = cfg.value('unfilterCutoff') 

403 if unfilter_cutoff and unfilter_cutoff > 0: 

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

405 mean_eod, props, sdata, error_str = \ 

406 analyze_wave(mean_eod, None, fish, **analyze_wave_args(cfg)) 

407 if error_str: 

408 print(f'{name}: {error_str}') 

409 clipped_frac = clipped_fraction(data, rate, eod_times, 

410 mean_eod, min_clip, max_clip) 

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

412 props['index'] = len(eod_props) 

413 props['clipped'] = clipped_frac 

414 props['samplerate'] = rate 

415 props['nfft'] = nfft 

416 props['dfreq'] = dfreq 

417 # remove wave fish that are smaller than the largest pulse fish: 

418 if props['p-p-amplitude'] < 0.01*max_pulse_amplitude: 

419 rm_indices = power_indices[k:] 

420 if verbose > 0: 

421 print('skip %6.1fHz wave fish: power=%5.1fdB, p-p amplitude=%5.1fdB smaller than pulse fish=%5.1dB - 20dB' % 

422 (props['EODf'], decibel(powers[idx]), 

423 decibel(props['p-p-amplitude']), decibel(max_pulse_amplitude))) 

424 for idx in rm_indices[1:]: 

425 print('skip %6.1fHz wave fish: power=%5.1fdB even smaller' % 

426 (wave_eodfs[idx][0,0], decibel(powers[idx]))) 

427 wave_eodfs = [eodfs for idx, eodfs in enumerate(wave_eodfs) 

428 if idx not in rm_indices] 

429 wave_indices = np.array([idcs for idx, idcs in enumerate(wave_indices) 

430 if idx not in rm_indices], dtype=int) 

431 break 

432 # add good waveforms only: 

433 remove, skips, msg = wave_quality(props, sdata[1:,3], **wave_quality_args(cfg)) 

434 if len(skips) == 0: 

435 wave_indices[idx] = props['index'] 

436 eod_props.append(props) 

437 mean_eods.append(mean_eod) 

438 spec_data.append(sdata) 

439 phase_data.append(dict()) 

440 pulseeod_data.append(dict()) 

441 if verbose > 0: 

442 print('take %6.1fHz wave fish: %s' % (props['EODf'], msg)) 

443 else: 

444 wave_indices[idx] = -2 if remove else -1 

445 skip_reason += ['%.1fHz wave fish %s' % (props['EODf'], skips)] 

446 if verbose > 0: 

447 print('%-6s %6.1fHz wave fish: %s (%s)' % 

448 ('remove' if remove else 'skip', props['EODf'], skips, msg)) 

449 wave_eodfs = [eodfs for idx, eodfs in zip(wave_indices, wave_eodfs) if idx > -2] 

450 wave_indices = np.array([idx for idx in wave_indices if idx > -2], dtype=int) 

451 return (psd_data, wave_eodfs, wave_indices, eod_props, mean_eods, 

452 spec_data, phase_data, pulseeod_data, power_thresh, skip_reason, zoom_window) 

453 

454 

455def remove_eod_files(output_basename, verbose, cfg): 

456 """Remove all files from previous runs of thunderfish 

457 """ 

458 ff = cfg.value('fileFormat') 

459 if ff == 'py': 

460 fext = 'py' 

461 else: 

462 fext = TableData.extensions[ff] 

463 # remove all files from previous runs of thunderfish: 

464 for fn in glob.glob('%s*.%s' % (output_basename, fext)): 

465 os.remove(fn) 

466 if verbose > 0: 

467 print('removed file %s' % fn) 

468 

469 

470def plot_style(): 

471 """Set style of plots. 

472 """ 

473 plt.rcParams['figure.facecolor'] = 'white' 

474 plt.rcParams['axes.facecolor'] = 'none' 

475 plt.rcParams['xtick.direction'] = 'out' 

476 plt.rcParams['ytick.direction'] = 'out' 

477 

478 

479def axes_style(ax): 

480 """Fix style of axes. 

481 

482 Parameters 

483 ---------- 

484 ax: matplotlib axes 

485 """ 

486 ax.spines['top'].set_visible(False) 

487 ax.spines['right'].set_visible(False) 

488 ax.get_xaxis().tick_bottom() 

489 ax.get_yaxis().tick_left() 

490 

491 

492def plot_eods(title, message_filename, 

493 raw_data, rate, channel, idx0, idx1, clipped, 

494 psd_data, wave_eodfs, wave_indices, mean_eods, eod_props, 

495 phase_data, pulse_data, spec_data, indices, unit, zoom_window, 

496 n_snippets=10, power_thresh=None, label_power=True, 

497 all_eods=False, spec_plots='auto', skip_bad=True, 

498 log_freq=False, min_freq=0.0, max_freq=3000.0, 

499 interactive=True, verbose=0): 

500 """Creates an output plot for the thunderfish program. 

501 

502 This output contains the raw trace where the analysis window is 

503 marked, the power-spectrum of this analysis window where the 

504 detected fish are marked, plots of averaged EOD plots, and 

505 spectra of the EOD waveforms. 

506 

507 Parameters 

508 ---------- 

509 title: string 

510 Title string for the plot 

511 message_filename: string or None 

512 Path to meta-data message. 

513 raw_data: array 

514 Dataset. 

515 rate: float 

516 Sampling rate of the dataset. 

517 channel: int or None 

518 Channel of the recording to be put into the plot title. 

519 If None, do not write the channel into the title. 

520 idx0: float 

521 Index of the beginning of the analysis window in the dataset. 

522 idx1: float 

523 Index of the end of the analysis window in the dataset. 

524 clipped: float 

525 Fraction of clipped amplitudes. 

526 psd_data: 2D array 

527 Power spectrum (frequencies and power) of the analysed data. 

528 wave_eodfs: array 

529 Frequency and power of fundamental frequency/harmonics of several fish. 

530 wave_indices: array of int 

531 Indices of wave fish mapping from wave_eodfs to eod_props. 

532 If negative, then that EOD frequency has no waveform described in eod_props. 

533 mean_eods: list of 2-D arrays with time, mean and std. 

534 Mean trace for the mean EOD plot. 

535 eod_props: list of dict 

536 Properties for each waveform in mean_eods. 

537 phase_data: list of dict 

538 For each pulsefish a list of phase properties 

539 (indices, times, amplitudes, relamplitudes, widths, areas, relareas, zeros), 

540 empty dict for wave fish. 

541 pulse_data: list of dict 

542 For each pulse fish a dictionary with phase times, amplitudes and standard 

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

544 functions provided in thunderfish.fakefish to simulate pulse 

545 fish EODs from this data. 

546 spec_data: list of 2_D arrays 

547 For each pulsefish a power spectrum of the single pulse and for 

548 each wavefish the relative amplitudes and phases of the harmonics. 

549 indices: list of int or None 

550 Indices of the fish in eod_props to be plotted. 

551 If None try to plot all. 

552 unit: string 

553 Unit of the trace and the mean EOD. 

554 zoom_window: tuple of floats 

555 Start and endtime of suggested window for plotting pulse EOD timepoints. 

556 n_snippets: int 

557 Number of EOD waveform snippets to be plotted. If zero do not plot any. 

558 power_thresh: 2 D array or None 

559 Frequency (first column) and power (second column) of threshold 

560 derived from single pulse spectra to discard false wave fish. 

561 label_power: boolean 

562 If `True` put the power in decibel in addition to the frequency 

563 into the legend. 

564 all_eods: bool 

565 Plot all EOD waveforms. 

566 spec_plots: bool or 'auto' 

567 Plot amplitude spectra of EOD waveforms. 

568 If 'auto', plot them if there is a singel waveform only. 

569 skip_bad: bool 

570 Skip harmonic groups without index (entry in indices is negative). 

571 log_freq: boolean 

572 Logarithmic (True) or linear (False) frequency axis of power spectrum of recording. 

573 min_freq: float 

574 Limits of frequency axis of power spectrum of recording 

575 are set to `(min_freq, max_freq)` if `max_freq` is greater than zero 

576 max_freq: float 

577 Limits of frequency axis of power spectrum of recording 

578 are set to `(min_freq, max_freq)` and limits of power axis are computed 

579 from powers below max_freq if `max_freq` is greater than zero 

580 interactive: bool 

581 If True install some keyboard interaction. 

582 verbose: int 

583 Print out information about data to be plotted if greater than zero. 

584 

585 Returns 

586 ------- 

587 fig: plt.figure 

588 Figure with the plots. 

589 """ 

590 

591 def keypress(event): 

592 if event.key in 'pP': 

593 if idx1 > idx0: 

594 playdata = 1.0 * raw_data[idx0:idx1] 

595 else: 

596 playdata = 1.0 * raw_data[:] 

597 fade(playdata, rate, 0.1) 

598 play(playdata, rate, blocking=False) 

599 if event.key in 'mM' and message_filename: 

600 # play voice message: 

601 msg, msg_rate = load_audio(message_filename) 

602 play(msg, msg_rate, blocking=False) 

603 

604 def recording_format_coord(x, y): 

605 return 'full recording: x=%.3f s, y=%.3f' % (x, y) 

606 

607 def recordingzoom_format_coord(x, y): 

608 return 'recording zoom-in: x=%.3f s, y=%.3f' % (x, y) 

609 

610 def psd_format_coord(x, y): 

611 return 'power spectrum: x=%.1f Hz, y=%.1f dB' % (x, y) 

612 

613 def meaneod_format_coord(x, y): 

614 return 'mean EOD waveform: x=%.2f ms, y=%.3f' % (x, y) 

615 

616 def ampl_format_coord(x, y): 

617 return u'amplitude spectrum: x=%.0f, y=%.2f' % (x, y) 

618 

619 def phase_format_coord(x, y): 

620 return u'phase spectrum: x=%.0f, y=%.2f \u03c0' % (x, y/np.pi) 

621 

622 def pulsepsd_format_coord(x, y): 

623 return 'single pulse power spectrum: x=%.1f Hz, y=%.1f dB' % (x, y) 

624 

625 # count number of fish types to be plotted: 

626 if indices is None: 

627 indices = np.arange(len(eod_props)) 

628 else: 

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

630 nwave = 0 

631 npulse = 0 

632 for idx in indices: 

633 if eod_props[idx]['type'] == 'pulse': 

634 npulse += 1 

635 elif eod_props[idx]['type'] == 'wave': 

636 nwave += 1 

637 neods = nwave + npulse 

638 

639 if verbose > 0: 

640 print('plot: %2d waveforms: %2d wave fish, %2d pulse fish and %2d EOD frequencies.' 

641 % (len(indices), nwave, npulse, len(wave_eodfs))) 

642 

643 # size and positions: 

644 if spec_plots == 'auto': 

645 spec_plots = len(indices) == 1 

646 large_plots = spec_plots or len(indices) <= 2 

647 width = 14.0 

648 height = 10.0 

649 if all_eods and len(indices) > 0: 

650 nrows = len(indices) if spec_plots else (len(indices)+1)//2 

651 if large_plots: 

652 height = 6.0 + 4.0*nrows 

653 else: 

654 height = 6.4 + 1.9*nrows 

655 leftx = 1.0/width 

656 midx = 0.5 + leftx 

657 fullwidth = 1.0-1.4/width 

658 halfwidth = 0.5-1.4/width 

659 pheight = 3.0/height 

660 

661 # figure: 

662 plot_style() 

663 fig = plt.figure(figsize=(width, height)) 

664 if interactive: 

665 fig.canvas.mpl_connect('key_press_event', keypress) 

666 

667 # plot title: 

668 if channel is not None: 

669 title += ' c%d' % channel 

670 ax = fig.add_axes([0.2/width, 1.0-0.6/height, 1.0-0.4/width, 0.55/height]) 

671 ax.text(0.0, 1.0, title, fontsize=22, va='top') 

672 ax.text(1.0, 1.0, f'thunderfish version {__version__}', fontsize=16, ha='right', va='top') 

673 ax.set_frame_on(False) 

674 ax.set_axis_off() 

675 ax.set_navigate(False) 

676 

677 # layout of recording and psd plots: 

678 #force_both = True # set to True for debugging pulse and wave detection 

679 force_both = False 

680 posy = 1.0 - 4.0/height 

681 axr = None 

682 axp = None 

683 legend_inside = True 

684 legendwidth = 2.2/width if label_power else 1.7/width 

685 if neods == 0: 

686 axr = fig.add_axes([leftx, posy, fullwidth, pheight]) # top, wide 

687 if len(psd_data) > 0: 

688 axp = fig.add_axes([leftx, 2.0/height, fullwidth, pheight]) # bottom, wide 

689 else: 

690 if npulse == 0 and nwave > 2 and psd_data is not None and \ 

691 len(psd_data) > 0 and not force_both: 

692 axp = fig.add_axes([leftx, posy, fullwidth-legendwidth, pheight]) # top, wide 

693 legend_inside = False 

694 elif (npulse > 0 or psd_data is None or len(psd_data) == 0) \ 

695 and len(wave_eodfs) == 0 and not force_both: 

696 axr = fig.add_axes([leftx, posy, fullwidth, pheight]) # top, wide 

697 else: 

698 axr = fig.add_axes([leftx, posy, halfwidth, pheight]) # top left 

699 label_power = False 

700 legendwidth = 2.2/width 

701 axp = fig.add_axes([midx, posy, halfwidth, pheight]) # top, right 

702 

703 # best window data: 

704 data = raw_data[idx0:idx1] if idx1 > idx0 else raw_data 

705 

706 # plot recording 

707 pulse_colors, pulse_markers = colors_markers() 

708 pulse_colors = pulse_colors[3:] 

709 pulse_markers = pulse_markers[3:] 

710 if axr is not None: 

711 axes_style(axr) 

712 twidth = 0.1 

713 if len(indices) > 0: 

714 if eod_props[indices[0]]['type'] == 'wave': 

715 twidth = 5.0/eod_props[indices[0]]['EODf'] 

716 else: 

717 if len(wave_eodfs) > 0: 

718 twidth = 3.0/eod_props[indices[0]]['EODf'] 

719 else: 

720 twidth = 10.0/eod_props[indices[0]]['EODf'] 

721 twidth = (1+twidth//0.005)*0.005 

722 if data is not None and len(data) > 0: 

723 plot_eod_recording(axr, data, rate, unit, twidth, 

724 idx0/rate, rec_style) 

725 plot_pulse_eods(axr, data, rate, 

726 zoom_window, twidth, eod_props, 

727 idx0/rate, colors=pulse_colors, 

728 markers=pulse_markers, frameon=True, 

729 loc='upper right') 

730 if axr.get_legend() is not None: 

731 axr.get_legend().get_frame().set_color('white') 

732 axr.set_title('Recording', fontsize=14, y=1.05) 

733 axr.format_coord = recordingzoom_format_coord 

734 

735 # plot psd 

736 wave_colors, wave_markers = colors_markers() 

737 if axp is not None: 

738 axes_style(axp) 

739 if power_thresh is not None: 

740 axp.plot(power_thresh[:,0], decibel(power_thresh[:,1]), '#CCCCCC', lw=1) 

741 if len(wave_eodfs) > 0: 

742 kwargs = {} 

743 if len(wave_eodfs) > 1: 

744 title = '%d EOD frequencies' % len(wave_eodfs) 

745 kwargs = {'title': title if len(wave_eodfs) > 2 else None } 

746 if legend_inside: 

747 kwargs.update({'bbox_to_anchor': (1.05, 1.1), 

748 'loc': 'upper right', 'legend_rows': 10, 

749 'frameon': True}) 

750 else: 

751 kwargs.update({'bbox_to_anchor': (1.02, 1.1), 

752 'loc': 'upper left', 'legend_rows': 14, 

753 'labelspacing': 0.6, 'frameon': False}) 

754 plot_harmonic_groups(axp, wave_eodfs, wave_indices, max_groups=0, 

755 skip_bad=skip_bad, 

756 sort_by_freq=True, label_power=label_power, 

757 colors=wave_colors, markers=wave_markers, 

758 **kwargs) 

759 if legend_inside: 

760 axp.get_legend().get_frame().set_color('white') 

761 if psd_data is not None and len(psd_data) > 0: 

762 plot_decibel_psd(axp, psd_data[:,0], psd_data[:,1], 

763 log_freq=log_freq, min_freq=min_freq, 

764 max_freq=max_freq, ymarg=5.0, 

765 sstyle=spectrum_style) 

766 axp.yaxis.set_major_locator(ticker.MaxNLocator(6)) 

767 if len(wave_eodfs) == 1: 

768 axp.get_legend().set_visible(False) 

769 label = '%6.1f Hz' % wave_eodfs[0][0, 0] 

770 axp.set_title('Powerspectrum: %s' % label, y=1.05, fontsize=14) 

771 else: 

772 axp.set_title('Powerspectrum', y=1.05, fontsize=14) 

773 axp.format_coord = psd_format_coord 

774 

775 # get fish labels from legends: 

776 if axp is not None: 

777 w, _ = axp.get_legend_handles_labels() 

778 eodf_labels = [wi.get_label().split()[0] for wi in w] 

779 legend_wave_eodfs = np.array([float(f) if f[0] != '(' else np.nan for f in eodf_labels]) 

780 if axr is not None: 

781 p, _ = axr.get_legend_handles_labels() 

782 eodf_labels = [pi.get_label().split()[0] for pi in p] 

783 legend_pulse_eodfs = np.array([float(f) if f[0] != '(' else np.nan for f in eodf_labels]) 

784 

785 # layout: 

786 sheight = 1.4/height 

787 sstep = 1.6/height 

788 max_plots = len(indices) 

789 if not all_eods: 

790 if large_plots: 

791 max_plots = 1 if spec_plots else 2 

792 else: 

793 max_plots = 4 

794 if large_plots: 

795 pstep = pheight + 1.0/height 

796 ty = 1.08 

797 my = 1.10 

798 ny = 6 

799 else: 

800 posy -= 0.2/height 

801 pheight = 1.3/height 

802 pstep = 1.9/height 

803 ty = 1.10 

804 my = 1.16 

805 ny = 4 

806 posy -= pstep 

807 

808 # sort indices by p-p amplitude: 

809 pp_ampls = [eod_props[idx]['p-p-amplitude'] for idx in indices] 

810 pp_indices = np.argsort(pp_ampls)[::-1] 

811 

812 # plot EOD waveform and spectra: 

813 for k, idx in enumerate(indices[pp_indices]): 

814 if k >= max_plots: 

815 break 

816 # plot EOD waveform: 

817 mean_eod = mean_eods[idx] 

818 props = eod_props[idx] 

819 phases = phase_data[idx] 

820 lx = leftx if spec_plots or k%2 == 0 else midx 

821 ax = fig.add_axes([lx, posy, halfwidth, pheight]) 

822 axes_style(ax) 

823 ax.yaxis.set_major_locator(ticker.MaxNLocator(ny)) 

824 if len(indices) > 1: 

825 ax.text(0.3, ty, '{EODf:.1f} Hz {type} fish'.format(**props), 

826 transform=ax.transAxes, fontsize=14, zorder=20) 

827 mx = 0.25 

828 else: 

829 ax.text(-0.1, ty, '{EODf:.1f} Hz {type} fish'.format(**props), 

830 transform=ax.transAxes, fontsize=14, zorder=20) 

831 ax.text(0.5, ty, 'Averaged EOD', ha='center', 

832 transform=ax.transAxes, fontsize=14, zorder=20) 

833 mx = -0.14 

834 eodf = props['EODf'] 

835 if props['type'] == 'wave': 

836 if axp is not None: 

837 wk = np.nanargmin(np.abs(legend_wave_eodfs - eodf)) 

838 ma = ml.Line2D([mx], [my], color=w[wk].get_color(), marker=w[wk].get_marker(), 

839 markersize=w[wk].get_markersize(), mec='none', clip_on=False, 

840 label=w[wk].get_label(), transform=ax.transAxes) 

841 ax.add_line(ma) 

842 else: 

843 if axr is not None and len(legend_pulse_eodfs) > 0: 

844 pk = np.argmin(np.abs(legend_pulse_eodfs - eodf)) 

845 ma = ml.Line2D([mx], [my], color=p[pk].get_color(), marker=p[pk].get_marker(), 

846 markersize=p[pk].get_markersize(), mec='none', clip_on=False, 

847 label=p[pk].get_label(), transform=ax.transAxes) 

848 ax.add_line(ma) 

849 plot_eod_waveform(ax, mean_eod, props, phases, unit, **eod_styles) 

850 if props['type'] == 'pulse' and 'times' in props: 

851 plot_eod_snippets(ax, data, rate, mean_eod[0,0], mean_eod[-1,0], 

852 props['times'], n_snippets, props['flipped'], 

853 props['aoffs'], snippet_style) 

854 if not large_plots and k < max_plots-2: 

855 ax.set_xlabel('') 

856 ax.format_coord = meaneod_format_coord 

857 

858 # plot spectra: 

859 if spec_plots: 

860 spec = spec_data[idx] 

861 if props['type'] == 'pulse': 

862 ax = fig.add_axes([midx, posy, halfwidth, pheight]) 

863 axes_style(ax) 

864 plot_pulse_spectrum(ax, spec, props, **pulse_spec_styles) 

865 ax.set_title('Single pulse spectrum', fontsize=14, y=1.05) 

866 ax.format_coord = pulsepsd_format_coord 

867 else: 

868 axa = fig.add_axes([midx, posy+sstep, halfwidth, sheight]) 

869 axes_style(axa) 

870 axp = fig.add_axes([midx, posy, halfwidth, sheight]) 

871 axes_style(axp) 

872 plot_wave_spectrum(axa, axp, spec, props, unit, 

873 **wave_spec_styles) 

874 axa.set_title('Amplitude and phase spectrum', fontsize=14, y=1.05) 

875 axa.set_xticklabels([]) 

876 axa.yaxis.set_major_locator(ticker.MaxNLocator(4)) 

877 axa.format_coord = ampl_format_coord 

878 axp.format_coord = phase_format_coord 

879 

880 if spec_plots or k%2 == 1: 

881 posy -= pstep 

882 

883 # whole trace: 

884 ax = fig.add_axes([leftx, 0.6/height, fullwidth, 0.9/height]) 

885 axes_style(ax) 

886 if raw_data is not None and len(raw_data) > 0: 

887 plot_data_window(ax, raw_data, rate, unit, idx0, idx1, clipped, 

888 **data_styles) 

889 ax.format_coord = recording_format_coord 

890 

891 return fig 

892 

893 

894def plot_eod_subplots(base_name, multi_pdf, subplots, title, raw_data, rate, idx0, idx1, 

895 clipped, psd_data, wave_eodfs, wave_indices, mean_eods, 

896 eod_props, phase_data, pulse_data, spec_data, 

897 unit, zoom_window, 

898 n_snippets=10, power_thresh=None, label_power=True, 

899 skip_bad=True, log_freq=False, 

900 min_freq=0.0, max_freq=3000.0, save=True): 

901 """Plot time traces and spectra into separate windows or files. 

902 

903 Parameters 

904 ---------- 

905 base_name: string 

906 Basename of audio_file. 

907 multi_pdf: matplotlib.PdfPages or None 

908 PdfPages instance in which to save plots. 

909 subplots: string 

910 Specifies which subplots to plot: 

911 r) recording with best window, t) data trace with detected pulse fish, 

912 p) power spectrum with detected wave fish, w/W) mean EOD waveform, 

913 s/S) EOD spectrum, e/E) EOD waveform and spectra. With capital letters 

914 all fish are saved into a single pdf file, with small letters each fish 

915 is saved into a separate file. 

916 title: str 

917 Plot title for multi_pdf plots. 

918 raw_data: array 

919 Dataset. 

920 rate: float 

921 Sampling rate of the dataset. 

922 idx0: float 

923 Index of the beginning of the analysis window in the dataset. 

924 idx1: float 

925 Index of the end of the analysis window in the dataset. 

926 clipped: float 

927 Fraction of clipped amplitudes. 

928 psd_data: 2D array 

929 Power spectrum (frequencies and power) of the analysed data. 

930 wave_eodfs: array 

931 Frequency and power of fundamental frequency/harmonics of several fish. 

932 wave_indices: array of int 

933 Indices of wave fish mapping from wave_eodfs to eod_props. 

934 If negative, then that EOD frequency has no waveform described in eod_props. 

935 mean_eods: list of 2-D arrays with time, mean and std. 

936 Mean trace for the mean EOD plot. 

937 eod_props: list of dict 

938 Properties for each waveform in mean_eods. 

939 phase_data: list of dict 

940 For each pulsefish a list of phase properties 

941 (indices, times, amplitudes, relamplitudes, widths, areas, relareas, zeros), 

942 empty dict for wave fish. 

943 pulse_data: list of dict 

944 For each pulse fish a dictionary with phase times, amplitudes and standard 

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

946 functions provided in thunderfish.fakefish to simulate pulse 

947 fish EODs from this data. 

948 spec_data: list of 2_D arrays 

949 For each pulsefish a power spectrum of the single pulse and for 

950 each wavefish the relative amplitudes and phases of the harmonics. 

951 unit: string 

952 Unit of the trace and the mean EOD. 

953 zoom_window: tuple of floats 

954 Start and endtime of suggested window for plotting pulse EOD timepoints. 

955 n_snippets: int 

956 Number of EOD waveform snippets to be plotted. If zero do not plot any. 

957 power_thresh: 2 D array or None 

958 Frequency (first column) and power (second column) of threshold 

959 derived from single pulse spectra to discard false wave fish. 

960 label_power: boolean 

961 If `True` put the power in decibel in addition to the frequency 

962 into the legend. 

963 skip_bad: bool 

964 Skip harmonic groups without index (entry in indices is negative). 

965 log_freq: boolean 

966 Logarithmic (True) or linear (False) frequency axis of power spectrum of recording. 

967 min_freq: float 

968 Limits of frequency axis of power spectrum of recording 

969 are set to `(min_freq, max_freq)` if `max_freq` is greater than zero 

970 max_freq: float 

971 Limits of frequency axis of power spectrum of recording 

972 are set to `(min_freq, max_freq)` and limits of power axis are computed 

973 from powers below max_freq if `max_freq` is greater than zero 

974 save: bool 

975 If True save plots to files instead of showing them. 

976 """ 

977 plot_style() 

978 if 'r' in subplots: 

979 top = 0.1 if multi_pdf is not None else 0 

980 fig, ax = plt.subplots(figsize=(10, 2)) 

981 fig.subplots_adjust(left=0.07, right=0.99, bottom=0.22, top=0.95-top) 

982 plot_data_window(ax, raw_data, rate, unit, idx0, idx1, clipped, 

983 **data_styles) 

984 ax.yaxis.set_major_locator(ticker.MaxNLocator(5)) 

985 axes_style(ax) 

986 if multi_pdf is not None: 

987 fig.suptitle(title) 

988 multi_pdf.savefig(fig) 

989 else: 

990 fig.savefig(base_name + '-recording.pdf') 

991 if 't' in subplots: 

992 top = 0.1 if multi_pdf is not None else 0 

993 fig, ax = plt.subplots(figsize=(10, 6)) 

994 twidth = 0.1 

995 if len(eod_props) > 0: 

996 if eod_props[0]['type'] == 'wave': 

997 twidth = 5.0/eod_props[0]['EODf'] 

998 else: 

999 if len(wave_eodfs) > 0: 

1000 twidth = 3.0/eod_props[0]['EODf'] 

1001 else: 

1002 twidth = 10.0/eod_props[0]['EODf'] 

1003 twidth = (1+twidth//0.005)*0.005 

1004 pulse_colors, pulse_markers = colors_markers() 

1005 pulse_colors = pulse_colors[3:] 

1006 pulse_markers = pulse_markers[3:] 

1007 plot_eod_recording(ax, raw_data[idx0:idx1], rate, unit, 

1008 twidth, idx0/rate, rec_style) 

1009 plot_pulse_eods(ax, raw_data[idx0:idx1], rate, zoom_window, 

1010 twidth, eod_props, idx0/rate, 

1011 colors=pulse_colors, markers=pulse_markers, 

1012 frameon=True, loc='upper right') 

1013 if ax.get_legend() is not None: 

1014 ax.get_legend().get_frame().set_color('white') 

1015 axes_style(ax) 

1016 if multi_pdf is not None: 

1017 fig.suptitle(title) 

1018 multi_pdf.savefig(fig) 

1019 else: 

1020 fig.savefig(base_name + '-trace.pdf') 

1021 if 'p' in subplots: 

1022 top = 0.1 if multi_pdf is not None else 0 

1023 fig, ax = plt.subplots(figsize=(10, 5)) 

1024 fig.subplots_adjust(left=0.08, right=0.975, bottom=0.11, top=0.9-top) 

1025 axes_style(ax) 

1026 if power_thresh is not None: 

1027 ax.plot(power_thresh[:,0], decibel(power_thresh[:,1]), '#CCCCCC', lw=1) 

1028 if len(wave_eodfs) > 0: 

1029 kwargs = {} 

1030 if len(wave_eodfs) > 1: 

1031 title = '%d EOD frequencies' % len(wave_eodfs) 

1032 kwargs = {'title': title if len(wave_eodfs) > 2 else None } 

1033 if len(wave_eodfs) > 2: 

1034 fig.subplots_adjust(left=0.08, right=0.78, bottom=0.11, top=0.9-top) 

1035 kwargs.update({'bbox_to_anchor': (1.01, 1.1), 

1036 'loc': 'upper left', 'legend_rows': 14, 

1037 'labelspacing': 0.6}) 

1038 else: 

1039 kwargs.update({'bbox_to_anchor': (1.05, 1.1), 

1040 'loc': 'upper right', 'legend_rows': 10}) 

1041 wave_colors, wave_markers = colors_markers() 

1042 plot_harmonic_groups(ax, wave_eodfs, wave_indices, max_groups=0, 

1043 skip_bad=skip_bad, 

1044 sort_by_freq=True, label_power=label_power, 

1045 colors=wave_colors, markers=wave_markers, 

1046 frameon=False, **kwargs) 

1047 plot_decibel_psd(ax, psd_data[:,0], psd_data[:,1], log_freq=log_freq, 

1048 min_freq=min_freq, max_freq=max_freq, ymarg=5.0, 

1049 sstyle=spectrum_style) 

1050 ax.yaxis.set_major_locator(ticker.MaxNLocator(6)) 

1051 if len(wave_eodfs) == 1: 

1052 ax.get_legend().set_visible(False) 

1053 label = '%6.1f Hz' % wave_eodfs[0][0, 0] 

1054 ax.set_title('Powerspectrum: %s' % label, y=1.05) 

1055 else: 

1056 ax.set_title('Powerspectrum', y=1.05) 

1057 if multi_pdf is not None: 

1058 fig.suptitle(title) 

1059 multi_pdf.savefig(fig) 

1060 else: 

1061 fig.savefig(base_name + '-psd.pdf') 

1062 if 'w' in subplots or 'W' in subplots: 

1063 mpdf = None 

1064 top = 0.1 if multi_pdf is not None else 0 

1065 if 'W' in subplots and save and multi_pdf is None: 

1066 mpdf = PdfPages(base_name + '-waveforms.pdf') 

1067 for meod, props, phases in zip(mean_eods, eod_props, phase_data): 

1068 if meod is None: 

1069 continue 

1070 fig, ax = plt.subplots(figsize=(5, 3)) 

1071 fig.subplots_adjust(left=0.18, right=0.98, bottom=0.14, top=0.9-top) 

1072 if not props is None: 

1073 ax.set_title('{index:d}: {EODf:.1f} Hz {type} fish'.format(**props)) 

1074 plot_eod_waveform(ax, meod, props, phases, unit, **eod_styles) 

1075 data = raw_data[idx0:idx1] if idx1 > idx0 else raw_data 

1076 if not props is None and props['type'] == 'pulse' and \ 

1077 'times' in props: 

1078 plot_eod_snippets(ax, data, rate, meod[0,0], 

1079 meod[-1,0], props['times'], 

1080 n_snippets, props['flipped'], 

1081 props['aoffs'], snippet_style) 

1082 ax.yaxis.set_major_locator(ticker.MaxNLocator(6)) 

1083 axes_style(ax) 

1084 if multi_pdf is not None: 

1085 fig.suptitle(title) 

1086 multi_pdf.savefig(fig) 

1087 elif mpdf is not None: 

1088 mpdf.savefig(fig) 

1089 elif save: 

1090 fig.savefig(base_name + '-waveform-%d.pdf' % props['index']) 

1091 if mpdf is not None: 

1092 mpdf.close() 

1093 if 's' in subplots or 'S' in subplots: 

1094 mpdf = None 

1095 top = 0.1 if multi_pdf is not None else 0 

1096 if 'S' in subplots and save and multi_pdf is None: 

1097 mpdf = PdfPages(base_name + '-spectrum.pdf') 

1098 for props, pulse, spec in zip(eod_props, pulse_data, spec_data): 

1099 if spec is None: 

1100 continue 

1101 if props is not None and props['type'] == 'pulse': 

1102 fig, ax = plt.subplots(figsize=(5, 3.5)) 

1103 fig.subplots_adjust(left=0.15, right=0.967, bottom=0.14, top=0.88-top) 

1104 axes_style(ax) 

1105 ax.set_title('{index:d}: {EODf:.1f} Hz {type} fish'.format(**props), y=1.07) 

1106 plot_pulse_spectrum(ax, spec, props, **pulse_spec_styles) 

1107 else: 

1108 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(5, 3.5)) 

1109 fig.subplots_adjust(left=0.15, right=0.97, bottom=0.14, top=0.88-top, hspace=0.4) 

1110 axes_style(ax1) 

1111 axes_style(ax2) 

1112 if not props is None: 

1113 ax1.set_title('{index:d}: {EODf:.1f} Hz {type} fish'.format(**props), y=1.15) 

1114 plot_wave_spectrum(ax1, ax2, spec, props, unit, 

1115 **wave_spec_styles) 

1116 ax1.set_xticklabels([]) 

1117 ax1.yaxis.set_major_locator(ticker.MaxNLocator(4)) 

1118 if multi_pdf is not None: 

1119 fig.suptitle(title) 

1120 multi_pdf.savefig(fig) 

1121 elif mpdf is not None: 

1122 mpdf.savefig(fig) 

1123 elif save: 

1124 fig.savefig(base_name + '-spectrum-%d.pdf' % props['index']) 

1125 if mpdf is not None: 

1126 mpdf.close() 

1127 if 'e' in subplots or 'E' in subplots: 

1128 mpdf = None 

1129 top = 0.1 if multi_pdf is not None else 0 

1130 if 'E' in subplots and save and multi_pdf is None: 

1131 mpdf = PdfPages(base_name + '-eods.pdf') 

1132 for meod, props, phases, pulse, spec in zip(mean_eods, eod_props, phase_data, pulse_data, spec_data): 

1133 if meod is None or spec is None: 

1134 continue 

1135 fig = plt.figure(figsize=(10, 3.5)) 

1136 gs = gridspec.GridSpec(nrows=2, ncols=2, left=0.09, right=0.98, 

1137 bottom=0.14, top=0.88-top, wspace=0.4, hspace=0.4) 

1138 ax1 = fig.add_subplot(gs[:,0]) 

1139 if not props is None: 

1140 ax1.set_title('{index:d}: {EODf:.1f} Hz {type} fish'.format(**props), y=1.07) 

1141 plot_eod_waveform(ax1, meod, props, phases, unit, **eod_styles) 

1142 data = raw_data[idx0:idx1] if idx1 > idx0 else raw_data 

1143 if not props is None and props['type'] == 'pulse' and 'times' in props: 

1144 plot_eod_snippets(ax1, data, rate, meod[0,0], 

1145 meod[-1,0], props['times'], 

1146 n_snippets, props['flipped'], 

1147 props['aoffs'], snippet_style) 

1148 ax1.yaxis.set_major_locator(ticker.MaxNLocator(6)) 

1149 axes_style(ax1) 

1150 if not props is None and props['type'] == 'pulse': 

1151 ax2 = fig.add_subplot(gs[:,1]) 

1152 axes_style(ax2) 

1153 plot_pulse_spectrum(ax2, spec, props, **pulse_spec_styles) 

1154 ax2.set_title('Single pulse spectrum', y=1.07) 

1155 else: 

1156 ax2 = fig.add_subplot(gs[0,1]) 

1157 ax3 = fig.add_subplot(gs[1,1]) 

1158 axes_style(ax2) 

1159 axes_style(ax3) 

1160 plot_wave_spectrum(ax2, ax3, spec, props, unit, 

1161 **wave_spec_styles) 

1162 ax2.set_title('Amplitude and phase spectrum', y=1.15) 

1163 ax2.set_xticklabels([]) 

1164 ax2.yaxis.set_major_locator(ticker.MaxNLocator(4)) 

1165 if multi_pdf is not None: 

1166 fig.suptitle(title) 

1167 multi_pdf.savefig(fig) 

1168 elif mpdf is not None: 

1169 mpdf.savefig(fig) 

1170 elif save: 

1171 fig.savefig(base_name + '-eod-%d.pdf' % props['index']) 

1172 if mpdf is not None: 

1173 mpdf.close() 

1174 if not save: 

1175 plt.show() 

1176 plt.close('all') 

1177 

1178 

1179def thunderfish_plot(files, data_path=None, load_kwargs={}, 

1180 all_eods=False, spec_plots='auto', skip_bad=True, 

1181 title_str='{name}', save_plot=False, multi_pdf=None, 

1182 save_subplots='', log_freq=False, 

1183 min_freq=0.0, max_freq=3000.0, output_folder='.', 

1184 keep_path=False, verbose=0): 

1185 """Generate plots from saved analysis results. 

1186 

1187 Parameters 

1188 ---------- 

1189 files: list of str 

1190 Analysis files from a single recording. 

1191 data_path: str 

1192 Path where to find the raw data. 

1193 load_kwargs: dict 

1194 Key-word arguments for the `load_data()` function. 

1195 all_eods: bool 

1196 If True, plot all EOD waveforms. 

1197 spec_plots: bool or 'auto' 

1198 Plot amplitude spectra of EOD waveforms. 

1199 If 'auto', plot them if there is a singel waveform only. 

1200 skip_bad: bool 

1201 Skip harmonic groups without index in the spectrum plot. 

1202 save_plot: bool 

1203 If True, save plots as pdf file. 

1204 multi_pdf: matplotlib.PdfPages or None 

1205 PdfPages instance in which to save plots. 

1206 save_subplots: string 

1207 If not empty, specifies subplots to be saved as separate pdf 

1208 files: r) recording with best window, t) data trace with 

1209 detected pulse fish, p) power spectrum with detected wave 

1210 fish, w/W) mean EOD waveform, s/S) EOD spectrum, e/E) EOD 

1211 waveform and spectra. Capital letters produce a single 

1212 multipage pdf containing plots of all detected fish. 

1213 log_freq: boolean 

1214 Logarithmic (True) or linear (False) frequency axis of 

1215 power spectrum of recording. 

1216 min_freq: float 

1217 Limits of frequency axis of power spectrum of recording 

1218 are set to `(min_freq, max_freq)`, if `max_freq` is greater than zero 

1219 max_freq: float 

1220 Limits of frequency axis of power spectrum of recording 

1221 are set to `(min_freq, max_freq)` and limits of power axis are computed 

1222 from powers below max_freq, if `max_freq` is greater than zero 

1223 output_folder: string 

1224 Folder where to save results. 

1225 keep_path: bool 

1226 Add relative path of data files to output path. 

1227 verbose: int 

1228 Verbosity level (for debugging). 

1229 """ 

1230 #if len(save_subplots) == 0: 

1231 # save_subplots = 'rtpwsed' # plot everything 

1232 # load analysis results: 

1233 mean_eods, wave_eodfs, wave_indices, eod_props, spec_data, \ 

1234 phase_data, pulse_data, base_name, channel, unit = load_analysis(files) 

1235 if len(mean_eods) == 0 or all(me is None for me in mean_eods): 

1236 save_subplots = save_subplots.replace('w', '') 

1237 save_subplots = save_subplots.replace('W', '') 

1238 save_subplots = save_subplots.replace('e', '') 

1239 save_subplots = save_subplots.replace('E', '') 

1240 save_subplots = save_subplots.replace('d', '') 

1241 if len(spec_data) == 0 or all(sd is None for sd in spec_data): 

1242 save_subplots = save_subplots.replace('s', '') 

1243 save_subplots = save_subplots.replace('S', '') 

1244 save_subplots = save_subplots.replace('e', '') 

1245 save_subplots = save_subplots.replace('E', '') 

1246 save_subplots = save_subplots.replace('d', '') 

1247 clipped = 0.0 

1248 if len(eod_props) > 0 and not eod_props[0] is None and \ 

1249 'winclipped' in eod_props[0]: 

1250 clipped = eod_props[0]['winclipped'] 

1251 zoom_window = [1.2, 1.3] 

1252 # load recording: 

1253 psd_data = None 

1254 if base_name: 

1255 name = os.path.basename(base_name) if data_path and data_path != '.' else base_name 

1256 data_path = os.path.join(data_path, name) 

1257 data, rate, idx0, idx1, info_dict = \ 

1258 load_recording(data_path, channel, load_kwargs, 

1259 eod_props, verbose-1) 

1260 if data is None: 

1261 save_subplots = save_subplots.replace('r', '') 

1262 save_subplots = save_subplots.replace('t', '') 

1263 save_subplots = save_subplots.replace('d', '') 

1264 if verbose > 0: 

1265 print('loaded', data_path) 

1266 if len(eod_props) > 0 and not eod_props[0] is None and \ 

1267 'dfreq' in eod_props[0] and np.isfinite(eod_props[0]['dfreq']) and \ 

1268 data is not None and len(data) > 0: 

1269 psd_data = multi_psd(data[idx0:idx1], rate, 

1270 1.1*eod_props[0]['dfreq'])[0] 

1271 if psd_data is not None and len(psd_data) > 0: 

1272 for idx, fish in zip(wave_indices, wave_eodfs): 

1273 if idx < 0: 

1274 for k in range(len(fish)): 

1275 fish[k,1] = psd_data[np.argmin(np.abs(psd_data[:,0] - fish[k,0])),1] 

1276 if psd_data is None: 

1277 save_subplots = save_subplots.replace('p', '') 

1278 save_subplots = save_subplots.replace('d', '') 

1279 # file name for output files: 

1280 fn = base_name if keep_path else os.path.basename(base_name) 

1281 output_basename = os.path.join(output_folder, fn) 

1282 if channel >= 0: 

1283 output_basename += f'-c{channel}' 

1284 # make directory if necessary: 

1285 if keep_path: 

1286 outpath = os.path.dirname(output_basename) 

1287 if not os.path.exists(outpath): 

1288 if verbose > 0: 

1289 print('mkdir %s' % outpath) 

1290 os.makedirs(outpath) 

1291 # plot: 

1292 title = title_str.format(**info_dict) 

1293 if len(save_subplots) == 0 or 'd' in save_subplots: 

1294 fig = plot_eods(title, None, data, rate, 

1295 channel, idx0, idx1, clipped, psd_data, 

1296 wave_eodfs, wave_indices, mean_eods, 

1297 eod_props, phase_data, pulse_data, spec_data, 

1298 None, unit, 

1299 zoom_window, 10, None, True, all_eods, 

1300 spec_plots, skip_bad, log_freq, min_freq, 

1301 max_freq, interactive=not save_plot, 

1302 verbose=verbose-1) 

1303 if save_plot: 

1304 if multi_pdf is not None: 

1305 multi_pdf.savefig(fig) 

1306 else: 

1307 fig.savefig(output_basename + '.pdf') 

1308 else: 

1309 fig.canvas.manager.set_window_title('thunderfish') 

1310 plt.show() 

1311 plt.close() 

1312 save_subplots = save_subplots.replace('d', '') 

1313 if len(save_subplots) > 0: 

1314 plot_eod_subplots(output_basename, multi_pdf, save_subplots, title, 

1315 data, rate, idx0, idx1, clipped, psd_data, wave_eodfs, 

1316 wave_indices, mean_eods, eod_props, 

1317 phase_data, pulse_data, spec_data, 

1318 unit, zoom_window, 10, 

1319 None, True, skip_bad, log_freq, min_freq, 

1320 max_freq, save_plot) 

1321 return None 

1322 

1323 

1324def thunderfish(filename, load_kwargs, cfg, channel=0, 

1325 time=None, time_file=False, 

1326 mode='wp', log_freq=False, min_freq=0.0, max_freq=3000, 

1327 save_data=False, zip_file=False, 

1328 all_eods=False, spec_plots='auto', skip_bad=True, 

1329 title_str='{name}', save_plot=False, multi_pdf=None, save_subplots='', 

1330 output_folder='.', keep_path=False, 

1331 verbose=0, plot_level=0): 

1332 """Automatically detect and analyze all EOD waveforms in a short recording. 

1333 

1334 Parameters 

1335 ---------- 

1336 filename: string 

1337 Path of the data file to be analyzed. 

1338 load_kwargs: dict 

1339 Key-word arguments for the `load_data()` function. 

1340 cfg: dict 

1341 channel: int 

1342 Channel to be analyzed. 

1343 time: string, float, or None 

1344 Start time of analysis window: "beginning", "center", "end", 

1345 "best", or time in seconds (as float or string). If not None 

1346 overwrites "windowPosition" in cofiguration file. 

1347 time_file: bool 

1348 If `True` add time of analysis window to output file names. 

1349 mode: 'w', 'p', 'P', 'wp', or 'wP' 

1350 Analyze wavefish ('w'), all pulse fish ('p'), or largest pulse 

1351 fish only ('P'). 

1352 log_freq: boolean 

1353 Logarithmic (True) or linear (False) frequency axis of 

1354 power spectrum of recording. 

1355 min_freq: float 

1356 Limits of frequency axis of power spectrum of recording 

1357 are set to `(min_freq, max_freq)`, if `max_freq` is greater than zero 

1358 max_freq: float 

1359 Limits of frequency axis of power spectrum of recording 

1360 are set to `(min_freq, max_freq)` and limits of power axis are computed 

1361 from powers below max_freq, if `max_freq` is greater than zero 

1362 save_data: bool 

1363 If True save analysis results in files. If False, just plot the data. 

1364 zip_data: bool 

1365 If True, store all analysis results in a single zip file. 

1366 all_eods: bool 

1367 If True, plot all EOD waveforms. 

1368 spec_plots: bool or 'auto' 

1369 Plot amplitude spectra of EOD waveforms. 

1370 If 'auto', plot them if there is a singel waveform only. 

1371 skip_bad: bool 

1372 Skip harmonic groups without index in the spectrum plot. 

1373 save_plot: bool 

1374 If True, save plots as pdf file. 

1375 multi_pdf: matplotlib.PdfPages or None 

1376 PdfPages instance in which to save plots. 

1377 save_subplots: string 

1378 If not empty, specifies subplots to be saved as separate pdf 

1379 files: r) recording with best window, t) data trace with 

1380 detected pulse fish, p) power spectrum with detected wave 

1381 fish, w/W) mean EOD waveform, s/S) EOD spectrum, e/E) EOD 

1382 waveform and spectra. Capital letters produce a single 

1383 multipage pdf containing plots of all detected fish. 

1384 output_folder: string 

1385 Folder where to save results. 

1386 keep_path: bool 

1387 Add relative path of data files to output path. 

1388 verbose: int 

1389 Verbosity level (for debugging). 

1390 plot_level: int 

1391 Plot intermediate results. 

1392 

1393 Returns 

1394 ------- 

1395 msg: string or None 

1396 In case of errors, an error message. 

1397 """ 

1398 # check data file: 

1399 if len(filename) == 0: 

1400 return 'you need to specify a file containing some data' 

1401 

1402 # file names: 

1403 fn = filename if keep_path else os.path.basename(filename) 

1404 outfilename = os.path.splitext(fn)[0] 

1405 messagefilename = os.path.splitext(fn)[0] + '-message.wav' 

1406 if not os.path.isfile(messagefilename): 

1407 messagefilename = None 

1408 

1409 # load data: 

1410 try: 

1411 all_data = DataLoader(filename, verbose=verbose, **load_kwargs) 

1412 rate = all_data.rate 

1413 unit = all_data.unit 

1414 ampl_max = all_data.ampl_max 

1415 species = get_str(all_data.metadata(), ['species'], default='') 

1416 if len(species) > 0: 

1417 species += ' ' 

1418 info_dict = dict(path=os.fsdecode(all_data.filepath), 

1419 name=all_data.basename(), 

1420 species=species) 

1421 for k in range(1, 10): 

1422 info_dict[f'part{k}'] = '' 

1423 offs = 1 

1424 for k, part in enumerate(all_data.filepath.parts): 

1425 if k == 0 and part == all_data.filepath.anchor: 

1426 offs = 0 

1427 continue 

1428 if part == all_data.filepath.name: 

1429 break 

1430 info_dict[f'part{k + offs}'] = part 

1431 except IOError as e: 

1432 return f'{filename}: failed to open file:' + str(e) 

1433 # select channel: 

1434 channels = all_data.shape[1] 

1435 chan_list = [channel] 

1436 if channel < 0: 

1437 chan_list = range(channels) 

1438 elif channel >= channels: 

1439 return f'{filename}: invalid channel {channel} ({channels} channels)' 

1440 # process all channels: 

1441 for chan in chan_list: 

1442 raw_data = all_data[:,chan] 

1443 if len(raw_data) <= 1: 

1444 return '{filename}: empty data file' 

1445 if verbose >= 0 and len(chan_list) > 1: 

1446 print(' channel {chan}') 

1447 # analysis window: 

1448 win_pos = cfg.value('windowPosition') 

1449 if time is not None: 

1450 win_pos = time 

1451 data, idx0, idx1, clipped, min_clip, max_clip = \ 

1452 analysis_window(raw_data, rate, ampl_max, win_pos, cfg, 

1453 plot_level>0) 

1454 found_bestwindow = idx1 > 0 

1455 if not found_bestwindow: 

1456 return f'{filename}: not enough data for requested window length. You may want to adjust the windowSize parameter in the configuration file.' 

1457 

1458 # detect EODs in the data: 

1459 psd_data, wave_eodfs, wave_indices, eod_props, \ 

1460 mean_eods, spec_data, phase_data, pulse_data, power_thresh, skip_reason, zoom_window = \ 

1461 detect_eods(data, rate, min_clip, max_clip, filename, mode, 

1462 verbose, plot_level, cfg) 

1463 if not found_bestwindow: 

1464 wave_eodfs = [] 

1465 wave_indices = [] 

1466 eod_props = [] 

1467 mean_eods = [] 

1468 

1469 # add analysis window to EOD properties: 

1470 for props in eod_props: 

1471 props['twin'] = idx0/rate 

1472 props['window'] = (idx1 - idx0)/rate 

1473 props['winclipped'] = clipped 

1474 

1475 # warning message in case no fish has been found: 

1476 if found_bestwindow and not eod_props : 

1477 msg = ', '.join(skip_reason) 

1478 if msg: 

1479 print(filename + ': no fish found:', msg) 

1480 else: 

1481 print(filename + ': no fish found.') 

1482 

1483 # format plot title: 

1484 info_dict['channel'] = chan 

1485 if channels > 1: 

1486 if channels > 100: 

1487 info_dict['chanstr'] = f'-c{chan:03d}' 

1488 elif channels > 10: 

1489 info_dict['chanstr'] = f'-c{chan:02d}' 

1490 else: 

1491 info_dict['chanstr'] = f'-c{chan:d}' 

1492 else: 

1493 info_dict['chanstr'] = '' 

1494 if time_file: 

1495 info_dict['time'] = f'-t{idx0/rate:.0f}s' 

1496 else: 

1497 info_dict['time'] = '' 

1498 title = title_str.format(**info_dict) 

1499 # file name for output files: 

1500 output_basename = os.path.join(output_folder, 

1501 outfilename + info_dict['chanstr'] + info_dict['time']) 

1502 # make directory if necessary: 

1503 if keep_path and found_bestwindow: 

1504 outpath = os.path.dirname(output_basename) 

1505 if not os.path.exists(outpath): 

1506 if verbose > 0: 

1507 print('mkdir %s' % outpath) 

1508 os.makedirs(outpath) 

1509 # save results to files: 

1510 if save_data: 

1511 remove_eod_files(output_basename, verbose, cfg) 

1512 if found_bestwindow: 

1513 save_analysis(output_basename, zip_file, eod_props, 

1514 mean_eods, spec_data, phase_data, pulse_data, 

1515 wave_eodfs, wave_indices, unit, verbose, 

1516 **write_table_args(cfg)) 

1517 # summary plots: 

1518 if save_plot or not save_data: 

1519 n_snippets = 10 

1520 if len(save_subplots) == 0 or 'd' in save_subplots: 

1521 chl = chan if channels > 1 else None 

1522 fig = plot_eods(title, messagefilename, 

1523 raw_data, rate, chl, idx0, idx1, 

1524 clipped, psd_data[0], wave_eodfs, 

1525 wave_indices, mean_eods, eod_props, 

1526 phase_data, pulse_data, spec_data, None, unit, 

1527 zoom_window, n_snippets, power_thresh, 

1528 True, all_eods, spec_plots, skip_bad, 

1529 log_freq, min_freq, max_freq, 

1530 interactive=not save_plot, 

1531 verbose=verbose) 

1532 if save_plot: 

1533 if multi_pdf is not None: 

1534 multi_pdf.savefig(fig) 

1535 else: 

1536 fig.savefig(output_basename + '.pdf') 

1537 else: 

1538 fig.canvas.manager.set_window_title('thunderfish') 

1539 plt.show() 

1540 plt.close() 

1541 save_subplots = save_subplots.replace('d', '') 

1542 if len(save_subplots) > 0: 

1543 plot_eod_subplots(output_basename, multi_pdf, save_subplots, 

1544 title, raw_data, rate, idx0, idx1, clipped, 

1545 psd_data[0], wave_eodfs, 

1546 wave_indices, mean_eods, eod_props, 

1547 phase_data, pulse_data, spec_data, unit, 

1548 zoom_window, n_snippets, 

1549 power_thresh, True, skip_bad, 

1550 log_freq, min_freq, max_freq, 

1551 save_plot) 

1552 all_data.close() 

1553 return None 

1554 

1555 

1556def run_thunderfish(file_args): 

1557 """Helper function for mutlithreading Pool().map(). 

1558 """ 

1559 results = file_args[1][0] 

1560 verbose = file_args[1][-1] if results else file_args[1][-2]+1 

1561 if verbose > 1: 

1562 print('='*70) 

1563 try: 

1564 if results: 

1565 thunderfish_plot(file_args[0], *file_args[1][1:]) 

1566 else: 

1567 if verbose > 0: 

1568 print('analyze recording %s ...' % file_args[0]) 

1569 msg = thunderfish(file_args[0], *file_args[1][1:]) 

1570 if msg: 

1571 print(msg) 

1572 except (KeyboardInterrupt, SystemExit): 

1573 print('\nthunderfish interrupted by user... exit now.') 

1574 sys.exit(0) 

1575 except: 

1576 print(traceback.format_exc()) 

1577 

1578 

1579def main(cargs=None): 

1580 # config file name: 

1581 cfgfile = Path(__package__ + '.cfg') 

1582 

1583 # command line arguments: 

1584 if cargs is None: 

1585 cargs = sys.argv[1:] 

1586 parser = argparse.ArgumentParser(add_help=False, 

1587 description='Extract and analyze EOD waveforms of electric fish.', 

1588 epilog='version %s by Benda-Lab (2015-%s)' % (__version__, __year__)) 

1589 parser.add_argument('-h', '--help', action='store_true', 

1590 help='show this help message and exit') 

1591 parser.add_argument('--version', action='version', version=__version__) 

1592 parser.add_argument('-v', action='count', dest='verbose', default=0, 

1593 help='verbosity level. Increase by specifying -v multiple times, or like -vvv') 

1594 parser.add_argument('-V', action='count', dest='plot_level', default=0, 

1595 help='level for debugging plots. Increase by specifying -V multiple times, or like -VVV') 

1596 parser.add_argument('-C', dest='config_params', default=[], 

1597 action='append', metavar='KEY:VAL', 

1598 help='set configuration values from key:value pairs') 

1599 parser.add_argument('-L', dest='list_config', action='count', default=0, 

1600 help='list configuration settings. If specified twice also print explanations.') 

1601 parser.add_argument('-c', dest='save_config', action='store_true', 

1602 help=f'save configuration to file {cfgfile} after reading all configuration files') 

1603 parser.add_argument('--channel', default=0, type=int, 

1604 help='channel to be analyzed (defaults to first channel, negative channel selects all channels)') 

1605 parser.add_argument('-t', dest='time', default=None, type=str, metavar='TIME', 

1606 help='start time of analysis window in recording: "beginning", "center", "end", "best", or time in seconds (overwrites "windowPosition" in cofiguration file)') 

1607 parser.add_argument('-u', dest='unwrap', action='store_true', 

1608 help='unwrap clipped files, toggles unwrap setting of config file.') 

1609 parser.add_argument('-T', dest='time_file', action='store_true', 

1610 help='add start time of analysis file to output file names') 

1611 parser.add_argument('-m', dest='mode', default='wp', type=str, 

1612 choices=['w', 'p', 'wp'], 

1613 help='extract wave "w" and/or pulse "p" fish EODs') 

1614 parser.add_argument('-a', dest='all_eods', action='store_true', 

1615 help='show all EOD waveforms in the summary plot') 

1616 parser.add_argument('-S', dest='spec_plots', action='store_true', 

1617 help='plot spectra for all EOD waveforms in the summary plot') 

1618 parser.add_argument('-b', dest='skip_bad', action='store_false', 

1619 help='indicate bad EODs in legend of power spectrum') 

1620 parser.add_argument('-l', dest='log_freq', type=float, metavar='MINFREQ', 

1621 nargs='?', const=100.0, default=0.0, 

1622 help='logarithmic frequency axis in power spectrum with optional minimum frequency (defaults to 100 Hz)') 

1623 parser.add_argument('-p', dest='save_plot', action='store_true', 

1624 help='save output plots as pdf files') 

1625 parser.add_argument('-M', dest='multi_pdf', default='', type=str, metavar='PDFFILE', 

1626 help='save all summary plots of all recordings in a multi page pdf file. Disables parallel jobs.') 

1627 parser.add_argument('-P', dest='save_subplots', default='', type=str, metavar='rtpwsed', 

1628 help='show or save subplots separately: r) recording with analysis window, t) data trace with detected pulse fish, p) power spectrum with detected wave fish, w/W) mean EOD waveform, s/S) EOD spectrum, e/E) EOD waveform and spectra, d) the default summary plot. Capital letters produce a single multipage pdf containing plots of all detected fish') 

1629 parser.add_argument('--title', default='{species}{name}{chanstr}{time}', type=str, 

1630 help='title string for plots with fields {path}, {basename}, {species}, {channel}, {chanstr}, and {time}') 

1631 parser.add_argument('-d', dest='rawdata_path', default='.', type=str, metavar='PATH', 

1632 help='path to raw EOD recordings needed for plotting based on analysis results') 

1633 parser.add_argument('-j', dest='jobs', nargs='?', type=int, default=None, const=0, 

1634 help='number of jobs run in parallel. Without argument use all CPU cores.') 

1635 parser.add_argument('-s', dest='save_data', action='store_true', 

1636 help='save analysis results to files') 

1637 parser.add_argument('-z', dest='zip_file', action='store_true', 

1638 help='save analysis results in a single zip file') 

1639 parser.add_argument('-f', dest='format', default='auto', type=str, 

1640 choices=TableData.formats + ['py'], 

1641 help='file format used for saving analysis results, defaults to the format specified in the configuration file or "csv"') 

1642 parser.add_argument('-o', dest='outpath', default='.', type=str, 

1643 help='path where to store results and figures (defaults to current working directory)') 

1644 parser.add_argument('-k', dest='keep_path', action='store_true', 

1645 help='keep path of input file when saving analysis files, i.e. append path of input file to OUTPATH') 

1646 parser.add_argument('-i', dest='load_kwargs', default=[], 

1647 action='append', metavar='KWARGS', 

1648 help='key-word arguments for the data loader function') 

1649 parser.add_argument('file', nargs='*', default='', type=str, 

1650 help='name of a file with time series data of an EOD recording, may include wildcards') 

1651 args = parser.parse_args(cargs) 

1652 

1653 # help: 

1654 if args.help: 

1655 parser.print_help() 

1656 print() 

1657 print('examples:') 

1658 print('- analyze the single file data.wav interactively:') 

1659 print(' > thunderfish data.wav') 

1660 print('- extract wavefish only:') 

1661 print(' > thunderfish -m w data.wav') 

1662 print('- analyze all wav files in the current working directory and save analysis results and plot to files:') 

1663 print(' > thunderfish -s -p *.wav') 

1664 print('- analyze all wav files in the river1/ directory, use all CPUs, and write files directly to "results/":') 

1665 print(' > thunderfish -j -s -p -o results/ river1/*.wav') 

1666 print('- analyze all wav files in the river1/ directory and write results combined in zip files to "results/river1/":') 

1667 print(' > thunderfish -s -z -p -o results/ -k river1/*.wav') 

1668 print() 

1669 print('- write configuration file:') 

1670 print(' > thunderfish -c') 

1671 parser.exit() 

1672 

1673 # set verbosity level from command line: 

1674 verbose = args.verbose 

1675 plot_level = args.plot_level 

1676 if verbose < plot_level: 

1677 verbose = plot_level 

1678 

1679 # interactive plot: 

1680 plt.rcParams['keymap.quit'] = 'ctrl+w, alt+q, q' 

1681 plt.ioff() 

1682 

1683 # expand wildcard patterns: 

1684 files = [] 

1685 if os.name == 'nt': 

1686 for fn in args.file: 

1687 files.extend(glob.glob(fn)) 

1688 else: 

1689 files = [f for f in args.file if '-message' not in f] 

1690 

1691 # configure: 

1692 file_name = files[0] if len(files) > 0 else '' 

1693 cfg = configuration() 

1694 cfg.load_files(cfgfile, file_name, 4, verbose) 

1695 if args.format != 'auto': 

1696 cfg.set('fileFormat', args.format) 

1697 if args.unwrap: 

1698 cfg.set('unwrapData', not cfg.value('unwrapData')) 

1699 cfg.set_values(args.config_params) 

1700 

1701 # list or save configuration: 

1702 if args.list_config > 0: 

1703 cfg.write(comments=args.list_config > 1) 

1704 exit() 

1705 elif args.save_config: 

1706 save_configuration(cfg, cfgfile) 

1707 exit() 

1708 

1709 # no files: 

1710 if len(files) == 0: 

1711 parser.error('you need to specify at least one file for the analysis') 

1712 

1713 # plot parameter: 

1714 spec_plots = 'auto' 

1715 if args.spec_plots: 

1716 spec_plots = True 

1717 

1718 # multi-page pdfs: 

1719 multi_pdf = None 

1720 if len(args.multi_pdf) > 0: 

1721 args.save_plot = True 

1722 args.jobs = None # PdfPages does not work yet with mutliprocessing 

1723 ext = os.path.splitext(args.multi_pdf)[1] 

1724 if ext != os.extsep + 'pdf': 

1725 args.multi_pdf += os.extsep + 'pdf' 

1726 multi_pdf = PdfPages(args.multi_pdf) 

1727 

1728 # create output folder: 

1729 if args.save_data or args.save_plot: 

1730 if not os.path.exists(args.outpath): 

1731 if verbose > 1: 

1732 print('mkdir %s' % args.outpath) 

1733 os.makedirs(args.outpath) 

1734 

1735 # kwargs for data loader: 

1736 load_kwargs = parse_load_kwargs(args.load_kwargs) 

1737 

1738 # frequency limits for power spectrum: 

1739 min_freq = 0.0 

1740 max_freq = 3000.0 

1741 log_freq = args.log_freq 

1742 if log_freq > 0.0: 

1743 min_freq = log_freq 

1744 max_freq = min_freq*20 

1745 if max_freq < 2000: 

1746 max_freq = 2000 

1747 log_freq = True 

1748 else: 

1749 log_freq = False 

1750 

1751 # check if all input files are results: 

1752 exts = TableData.ext_formats.values() 

1753 results = True 

1754 # check and group by recording: 

1755 result_files = [] 

1756 for f in sorted(files): 

1757 _, base_name, _, _, ftype, _, ext = parse_filename(f) 

1758 if ext == 'zip' or (ext in exts and ftype in file_types): 

1759 if len(result_files) == 0 or \ 

1760 not result_files[-1][-1].startswith(str(base_name)): 

1761 result_files.append([f]) 

1762 else: 

1763 result_files[-1].append(f) 

1764 else: 

1765 results = False 

1766 break 

1767 if results: 

1768 files = result_files 

1769 

1770 # adjust verbosity: 

1771 v = verbose 

1772 if len(files) > 1: 

1773 v += 1 

1774 

1775 # run on pool: 

1776 pool_args = (results, load_kwargs, cfg, args.channel, args.time, 

1777 args.time_file, args.mode, log_freq, min_freq, 

1778 max_freq, args.save_data, args.zip_file, 

1779 args.all_eods, spec_plots, args.skip_bad, args.title, 

1780 args.save_plot, multi_pdf, args.save_subplots, 

1781 args.outpath, args.keep_path, v-1, plot_level) 

1782 if results: 

1783 pool_args = (results, args.rawdata_path, load_kwargs, 

1784 args.all_eods, spec_plots, args.skip_bad, args.title, 

1785 args.save_plot, multi_pdf, args.save_subplots, 

1786 log_freq, min_freq, max_freq, args.outpath, 

1787 args.keep_path, v) 

1788 if args.jobs is not None and (args.save_data or args.save_plot) and len(files) > 1: 

1789 cpus = cpu_count() if args.jobs == 0 else args.jobs 

1790 if verbose > 1: 

1791 print('run on %d cpus' % cpus) 

1792 p = Pool(cpus) 

1793 p.map(run_thunderfish, zip(files, [pool_args]*len(files))) 

1794 else: 

1795 list(map(run_thunderfish, zip(files, [pool_args]*len(files)))) 

1796 if multi_pdf is not None: 

1797 multi_pdf.close() 

1798 

1799 

1800if __name__ == '__main__': 

1801 freeze_support() # needed by multiprocessing for some weired windows stuff 

1802 main()