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

496 statements  

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

1"""View and explore properties of EOD waveforms. 

2""" 

3 

4import os 

5import glob 

6import io 

7import sys 

8import argparse 

9 

10import numpy as np 

11import scipy.signal as sig 

12import matplotlib.pyplot as plt 

13 

14from pathlib import Path 

15from zipfile import ZipFile 

16from multiprocessing import Pool, freeze_support, cpu_count 

17 

18from thunderlab.configfile import ConfigFile 

19from thunderlab.tabledata import TableData, add_write_table_config, write_table_args 

20from thunderlab.dataloader import load_data 

21from thunderlab.multivariateexplorer import MultivariateExplorer 

22from thunderlab.multivariateexplorer import select_features, select_coloring 

23from thunderlab.multivariateexplorer import list_available_features 

24from thunderlab.multivariateexplorer import PrintHelp 

25from thunderlab.powerspectrum import decibel 

26 

27from .version import __version__, __year__ 

28from .harmonics import add_harmonic_groups_config 

29from .eodanalysis import detect_eods, add_eod_quality_config 

30from .eodanalysis import add_species_config 

31from .pulseanalysis import pulse_quality, pulse_quality_args 

32from .waveanalysis import wave_quality, wave_quality_args 

33from .bestwindow import analysis_window 

34from .thunderfish import configuration, plot_eods 

35 

36 

37basename = '' 

38 

39 

40class EODExplorer(MultivariateExplorer): 

41 """Simple GUI for viewing and exploring properties of EOD waveforms. 

42 

43 EODExplorer adapts a MultivariateExplorer to specific needs of EODs. 

44 

45 Static members 

46 -------------- 

47 - `groups`: names of groups of data columns that can be selected. 

48 - `select_EOD_properties()`: select data columns to be explored. 

49 """ 

50 

51 def __init__(self, data, data_cols, wave_fish, eod_data, 

52 add_waveforms, loaded_spec, rawdata_path): 

53 """ 

54 Parameters 

55 ---------- 

56 data: TableData 

57 Full table of EOD properties. Each row is a fish. 

58 data_cols: list of string or ints 

59 Names or indices of columns in `data` to be explored. 

60 You may use the static function `select_EOD_properties()` 

61 for assisting the selection of columns. 

62 wave_fish: boolean 

63 True if data are about wave-type weakly electric fish. 

64 False if data are about pulse-type weakly electric fish. 

65 eod_data: list of waveform data 

66 Either waveform data is only the EOD waveform, 

67 a ndarray of shape (time, ['time', 'voltage']), or 

68 it is a list with the first element being the EOD waveform, 

69 and the second element being a 2D ndarray of spectral properties 

70 of the EOD waveform with first column being the frequency or harmonics. 

71 add_waveforms: list of string 

72 List of what should be shown as waveform. Elements can be 

73 'first', 'second', 'ampl', 'power', or 'phase'. For 'first' and 'second' 

74 the first and second derivatives of the supplied EOD waveform a computed and shown. 

75 'ampl', 'power', and 'phase' select properties of the provided spectral properties. 

76 loaded_spec: boolean 

77 Indicates whether eod_data contains spectral properties. 

78 rawdata_path: string 

79 Base path to the raw recording, needed to show thunderfish 

80 when double clicking on a single EOD. 

81 """ 

82 self.wave_fish = wave_fish 

83 self.eoddata = data 

84 self.path = rawdata_path 

85 MultivariateExplorer.__init__(self, data[:,data_cols], 

86 None, 'EODExplorer') 

87 tunit = 'ms' 

88 dunit = '1/ms' 

89 if wave_fish: 

90 tunit = '1/EODf' 

91 dunit = 'EODf' 

92 wave_data = eod_data 

93 xlabels = ['Time [%s]' % tunit] 

94 ylabels = ['Voltage'] 

95 if 'first' in add_waveforms: 

96 # first derivative: 

97 if loaded_spec: 

98 if hasattr(sig, 'savgol_filter'): 

99 derivative = lambda x: (np.column_stack((x[0], \ 

100 sig.savgol_filter(x[0][:,1], 5, 2, 1, x[0][1,0]-x[0][0,0]))), x[1]) 

101 else: 

102 derivative = lambda x: (np.column_stack((x[0][:-1,:], \ 

103 np.diff(x[0][:,1])/(x[0][1,0]-x[0][0,0]))), x[1]) 

104 else: 

105 if hasattr(sig, 'savgol_filter'): 

106 derivative = lambda x: np.column_stack((x, \ 

107 sig.savgol_filter(x[:,1], 5, 2, 1, x[1,0]-x[0,0]))) 

108 else: 

109 derivative = lambda x: np.column_stack((x[:-1,:], \ 

110 np.diff(x[:,1])/(x[1,0]-x[0,0]))) 

111 wave_data = list(map(derivative, wave_data)) 

112 ylabels.append('dV/dt [%s]' % dunit) 

113 if 'second' in add_waveforms: 

114 # second derivative: 

115 if loaded_spec: 

116 if hasattr(sig, 'savgol_filter'): 

117 derivative = lambda x: (np.column_stack((x[0], \ 

118 sig.savgol_filter(x[0][:,1], 5, 2, 2, x[0][1,0]-x[0][0,0]))), x[1]) 

119 else: 

120 derivative = lambda x: (np.column_stack((x[0][:-1,:], \ 

121 np.diff(x[0][:,2])/(x[0][1,0]-x[0][0,0]))), x[1]) 

122 else: 

123 if hasattr(sig, 'savgol_filter'): 

124 derivative = lambda x: np.column_stack((x, \ 

125 sig.savgol_filter(x[:,1], 5, 2, 2, x[1,0]-x[0,0]))) 

126 else: 

127 derivative = lambda x: np.column_stack((x[:-1,:], \ 

128 np.diff(x[:,2])/(x[1,0]-x[0,0]))) 

129 wave_data = list(map(derivative, wave_data)) 

130 ylabels.append('d^2V/dt^2 [%s^2]' % dunit) 

131 if loaded_spec: 

132 if wave_fish: 

133 indices = [0] 

134 phase = False 

135 xlabels.append('Harmonics') 

136 if 'ampl' in add_waveforms: 

137 indices.append(3) 

138 ylabels.append('Ampl [%]') 

139 if 'power' in add_waveforms: 

140 indices.append(4) 

141 ylabels.append('Power [dB]') 

142 if 'phase' in add_waveforms: 

143 indices.append(5) 

144 ylabels.append('Phase') 

145 phase = True 

146 def get_spectra(x): 

147 y = x[1][:,indices] 

148 if phase: 

149 y[y[:,-1]<0.0,-1] += 2.0*np.pi 

150 return (x[0], y) 

151 wave_data = list(map(get_spectra, wave_data)) 

152 else: 

153 xlabels.append('Frequency [Hz]') 

154 ylabels.append('Power [dB]') 

155 def get_spectra(x): 

156 y = x[1] 

157 y[:,1] = decibel(y[:,1], None) 

158 return (x[0], y) 

159 wave_data = list(map(get_spectra, wave_data)) 

160 self.set_wave_data(wave_data, xlabels, ylabels, True) 

161 

162 

163 def fix_scatter_plot(self, ax, data, label, axis): 

164 """Customize an axes of a scatter plot. 

165 

166 - Limits for amplitude and time like quantities start at zero. 

167 - Phases a labeled with multuples of pi. 

168 - Species labels are rotated. 

169 """ 

170 if any(l in label for l in ['ampl', 'power', 'width', 

171 'time', 'tau', 'P2-P1-dist', 

172 'var', 'peak', 'trough', 

173 'P2-P1-dist', 'rms', 'noise']): 

174 if np.all(data[np.isfinite(data)] >= 0.0): 

175 if axis == 'x': 

176 ax.set_xlim(0.0, None) 

177 elif axis == 'y': 

178 ax.set_ylim(0.0, None) 

179 elif axis == 'c': 

180 return 0.0, np.max(data), None 

181 else: 

182 if axis == 'x': 

183 ax.set_xlim(None, 0.0) 

184 elif axis == 'y': 

185 ax.set_ylim(None, 0.0) 

186 elif axis == 'c': 

187 return np.min(data), 0.0, None 

188 elif 'phase' in label: 

189 if axis == 'x': 

190 ax.set_xlim(-np.pi, np.pi) 

191 ax.set_xticks(np.arange(-np.pi, 1.5*np.pi, 0.5*np.pi)) 

192 ax.set_xticklabels([u'-\u03c0', u'-\u03c0/2', '0', u'\u03c0/2', u'\u03c0']) 

193 elif axis == 'y': 

194 ax.set_ylim(-np.pi, np.pi) 

195 ax.set_yticks(np.arange(-np.pi, 1.5*np.pi, 0.5*np.pi)) 

196 ax.set_yticklabels([u'-\u03c0', u'-\u03c0/2', '0', u'\u03c0/2', u'\u03c0']) 

197 elif axis == 'c': 

198 if ax is not None: 

199 ax.set_yticklabels([u'-\u03c0', u'-\u03c0/2', '0', u'\u03c0/2', u'\u03c0']) 

200 return -np.pi, np.pi, np.arange(-np.pi, 1.5*np.pi, 0.5*np.pi) 

201 elif 'species' in label: 

202 if axis == 'x': 

203 for label in ax.get_xticklabels(): 

204 label.set_rotation(30) 

205 ax.set_xlabel('') 

206 ax.set_xlim(np.min(data)-0.5, np.max(data)+0.5) 

207 elif axis == 'y': 

208 ax.set_ylabel('') 

209 ax.set_ylim(np.min(data)-0.5, np.max(data)+0.5) 

210 elif axis == 'c': 

211 if ax is not None: 

212 ax.set_ylabel('') 

213 return np.min(data), np.max(data), None 

214 

215 

216 def fix_waveform_plot(self, axs, indices): 

217 """Adapt waveform plots to EOD waveforms, derivatives, and spectra. 

218 """ 

219 if len(indices) == 0: 

220 axs[0].text(0.5, 0.5, 'Click to plot EOD waveforms', 

221 transform = axs[0].transAxes, ha='center', va='center') 

222 axs[0].text(0.5, 0.3, 'n = %d' % len(self.raw_data), 

223 transform = axs[0].transAxes, ha='center', va='center') 

224 elif len(indices) == 1: 

225 file_name = self.eoddata[indices[0],'file'] if 'file' in self.eoddata else basename 

226 if 'index' in self.eoddata and np.isfinite(self.eoddata[indices[0],'index']) and \ 

227 np.any(self.eoddata[:,'index'] != self.eoddata[0,'index']): 

228 axs[0].set_title('%s: %d' % (file_name, 

229 self.eoddata[indices[0],'index'])) 

230 else: 

231 axs[0].set_title(file_name) 

232 if np.isfinite(self.eoddata[indices[0],'index']): 

233 axs[0].text(0.05, 0.85, '%.1fHz' % self.eoddata[indices[0],'EODf'], 

234 transform = axs[0].transAxes) 

235 else: 

236 axs[0].set_title('%d EOD waveforms selected' % len(indices)) 

237 for ax in axs: 

238 for l in ax.lines: 

239 l.set_linewidth(3.0) 

240 for ax, xl in zip(axs, self.wave_ylabels): 

241 if 'Voltage' in xl: 

242 ax.set_ylim(top=1.1) 

243 ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=4)) 

244 if 'dV/dt' in xl: 

245 ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=4)) 

246 if 'd^2V/dt^2' in xl: 

247 ax.yaxis.set_major_locator(plt.MaxNLocator(nbins=4)) 

248 if self.wave_fish: 

249 for ax, xl in zip(axs, self.wave_ylabels): 

250 if 'Voltage' in xl: 

251 ax.set_xlim(-0.7, 0.7) 

252 if 'Ampl' in xl or 'Power' in xl or 'Phase' in xl: 

253 ax.set_xlim(-0.5, 8.5) 

254 for l in ax.lines: 

255 l.set_marker('.') 

256 l.set_markersize(15.0) 

257 l.set_markeredgewidth(0.5) 

258 l.set_markeredgecolor('k') 

259 l.set_markerfacecolor(l.get_color()) 

260 if 'Ampl' in xl: 

261 ax.set_ylim(0.0, 100.0) 

262 ax.yaxis.set_major_locator(plt.MultipleLocator(25.0)) 

263 if 'Power' in xl: 

264 ax.set_ylim(-60.0, 2.0) 

265 ax.yaxis.set_major_locator(plt.MultipleLocator(20.0)) 

266 if 'Phase' in xl: 

267 ax.set_ylim(0.0, 2.0*np.pi) 

268 ax.set_yticks(np.arange(0.0, 2.5*np.pi, 0.5*np.pi)) 

269 ax.set_yticklabels(['0', u'\u03c0/2', u'\u03c0', u'3\u03c0/2', u'2\u03c0']) 

270 else: 

271 for ax, xl in zip(axs, self.wave_ylabels): 

272 if 'Voltage' in xl: 

273 ax.set_xlim(-1.0, 1.5) 

274 if 'Power' in xl: 

275 ax.set_xlim(1.0, 2000.0) 

276 ax.set_xscale('log') 

277 ax.set_ylim(-60.0, 2.0) 

278 ax.yaxis.set_major_locator(plt.MultipleLocator(20.0)) 

279 if len(indices) > 0: 

280 for ax in axs: 

281 ax.axhline(c='k', lw=1) 

282 

283 

284 def list_selection(self, indices): 

285 """List file names and indices of selection. 

286 

287 If only a single EOD is selected, list all of its properties. 

288 """ 

289 print('') 

290 print('selected EODs:') 

291 if 'index' in self.eoddata and \ 

292 np.any(self.eoddata[:,'index'] != self.eoddata[0,'index']): 

293 for i in indices: 

294 file_name = self.eoddata[i,'file'] if 'file' in self.eoddata else basename 

295 if np.isfinite(self.eoddata[i,'index']): 

296 print('%s : %d' % (file_name, self.eoddata[i,'index'])) 

297 else: 

298 print(file_name) 

299 elif 'file' in self.eoddata: 

300 for i in indices: 

301 print(self.eoddata[i,'file']) 

302 if len(indices) == 1: 

303 # write eoddata line on terminal: 

304 keylen = 0 

305 keys = [] 

306 values = [] 

307 for c in range(self.eoddata.columns()): 

308 k, v = self.eoddata.key_value(indices[0], c) 

309 keys.append(k) 

310 values.append(v) 

311 if keylen < len(k): 

312 keylen = len(k) 

313 for k, v in zip(keys, values): 

314 fs = '%%-%ds: %%s' % keylen 

315 print(fs % (k, v.strip())) 

316 

317 

318 def analyze_selection(self, index): 

319 """Launch thunderfish on the selected EOD. 

320 """ 

321 # load data: 

322 file_base = self.eoddata[index,'file'] if 'file' in self.eoddata else basename 

323 bp = os.path.join(self.path, file_base) 

324 fn = glob.glob(bp + '.*') 

325 if len(fn) == 0: 

326 print('no recording found for %s' % bp) 

327 return 

328 recording = fn[0] 

329 channel = 0 

330 try: 

331 raw_data, rate, unit, ampl_max = load_data(recording) 

332 raw_data = raw_data[:,channel] 

333 except IOError as e: 

334 print('%s: failed to open file: did you provide a path to the raw data (-P option)?' % (recording)) 

335 return 

336 if len(raw_data) <= 1: 

337 print('%s: empty data file' % recording) 

338 return 

339 # load configuration: 

340 cfgfile = __package__ + '.cfg' 

341 cfg = configuration(cfgfile, False, recording) 

342 cfg.load_files(cfgfile, recording, 4) 

343 if 'flipped' in self.eoddata: 

344 fs = 'flip' if self.eoddata[index,'flipped'] else 'none' 

345 cfg.set('flipWaveEOD', fs) 

346 cfg.set('flipPulseEOD', fs) 

347 # best_window: 

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

349 analysis_window(raw_data, rate, ampl_max, 

350 cfg.value('windowPosition'), cfg) 

351 # detect EODs in the data: 

352 psd_data, fishlist, _, eod_props, mean_eods, \ 

353 spec_data, peak_data, pulse_data, power_thresh, skip_reason, zoom_window = \ 

354 detect_eods(data, rate, None, None, None, min_clip, max_clip, 

355 recording, 0, 0, cfg) 

356 # plot EOD: 

357 idx = int(self.eoddata[index,'index']) if 'index' in self.eoddata else 0 

358 for k in ['toolbar', 'keymap.back', 'keymap.forward', 

359 'keymap.zoom', 'keymap.pan']: 

360 plt.rcParams[k] = self.plt_params[k] 

361 fig = plot_eods(file_base, None, raw_data, rate, None, idx0, idx1, 

362 clipped, psd_data[0], fishlist, None, 

363 mean_eods, eod_props, peak_data, pulse_data, spec_data, 

364 [idx], unit, zoom_window, 10, None, True, False, 

365 'auto', False, 0.0, 3000.0, 

366 interactive=True, verbose=0) 

367 fig.canvas.manager.set_window_title('thunderfish: %s' % file_base) 

368 plt.show(block=False) 

369 

370 

371 """Names of groups of data columns that can be selected by the select_EOD_properties() function. 

372 """ 

373 groups = ['all', 'allpower', 'noise', 'timing', 'wave', 

374 'ampl', 'relampl', 'power', 'relpower', 'phase', 

375 'time', 'width', 'peaks', 'none'] 

376 

377 @staticmethod 

378 def select_EOD_properties(data, wave_fish, max_n, column_groups, add_columns): 

379 """Select data columns to be explored. 

380 

381 First, groups of columns are selected, then individual 

382 columns. Columns that are selected twice are removed from the 

383 selection. 

384 

385 Parameters 

386 ---------- 

387 data: TableData 

388 Table with EOD properties from which columns are selected. 

389 wave_fish: boolean. 

390 Indicates if data contains properties of wave- or pulse-type electric fish. 

391 max_n: int 

392 Maximum number of harmonics (wave-type fish) or peaks (pulse-type fish) 

393 to be selected. 

394 column_groups: list of string 

395 List of name denoting groups of columns to be selected. Supported groups are 

396 listed in `EODExplor.groups`. 

397 add_columns: list of string or int 

398 List of further individual columns to be selected. 

399 

400 Returns 

401 ------- 

402 data_cols: list of int 

403 Indices of data columns to be shown by EODExplorer. 

404 error: string 

405 In case of an invalid column group, an error string. 

406 """ 

407 if wave_fish: 

408 # maximum number of harmonics: 

409 if max_n == 0: 

410 max_n = 100 

411 else: 

412 max_n += 1 

413 for k in range(1, max_n): 

414 if not f'phase{k}' in data: 

415 max_n = k 

416 break 

417 else: 

418 # minimum number of peaks: 

419 min_peaks = -10 

420 for k in range(1, min_peaks, -1): 

421 if not f'P{k}ampl' in data or not np.all(np.isfinite(data[:, f'P{k}ampl'])): 

422 min_peaks = k + 1 

423 break 

424 # maximum number of peaks: 

425 if max_n == 0: 

426 max_peaks = 20 

427 else: 

428 max_peaks = max_n + 1 

429 for k in range(1, max_peaks): 

430 if not f'P{k}ampl' in data or not np.all(np.isfinite(data[:, f'P{k}ampl'])): 

431 max_peaks = k 

432 break 

433 

434 # default columns: 

435 group_cols = ['EODf'] 

436 if 'EODf_adjust' in data: 

437 group_cols.append('EODf_adjust') 

438 if len(column_groups) == 0: 

439 column_groups = ['all'] 

440 for group in column_groups: 

441 if group == 'none': 

442 group_cols = [] 

443 elif wave_fish: 

444 if group == 'noise': 

445 group_cols.extend(['noise', 'rmserror', 'power', 'thd', 

446 'dbdiff', 'ppampl', 'relampl1', 'relampl2', 'relampl3']) 

447 elif group == 'timing' or group == 'time': 

448 group_cols.extend(['peakwidth', 'troughwidth', 'ppdist', 'phaseslope']) 

449 elif group == 'wave': 

450 group_cols.extend(['thd', 'minwidth', 'minppdist', 'relmaxampl', 'power2', 'phaseslope']) 

451 elif group == 'ampl': 

452 for k in range(0, max_n): 

453 group_cols.append(f'ampl{k}') 

454 elif group == 'relampl': 

455 for k in range(1, max_n): 

456 group_cols.append(f'relampl{k}') 

457 elif group == 'relpower' or group == 'power': 

458 for k in range(1, max_n): 

459 group_cols.append(f'relpower{k}') 

460 elif group == 'phase': 

461 for k in range(0, max_n): 

462 group_cols.append(f'phase{k}') 

463 elif group == 'all': 

464 group_cols.extend(['thd', 'minwidth', 'minppdist', 'relmaxampl', 'power2', 'phaseslope']) 

465 for k in range(1, max_n): 

466 group_cols.append(f'relampl{k}') 

467 group_cols.append(f'phase{k}') 

468 elif group == 'allpower': 

469 group_cols.extend(['power', 'thd', 'power2']) 

470 for k in range(1, max_n): 

471 group_cols.append(f'relpower{k}') 

472 group_cols.append(f'phase{k}') 

473 else: 

474 return None, f'"{group}" is not a valid data group for wave-type fish' 

475 else: # pulse fish 

476 if group == 'noise': 

477 group_cols.extend(['noise', 'rmserror', 'ppampl']) 

478 elif group == 'timing': 

479 group_cols.extend(['tstart', 'tend', 'width', 'tau', 'ppdist', 

480 'nphases', 'firstphase', 'lastphase', 'iqrange']) 

481 elif group == 'power': 

482 group_cols.extend(['peakfreq', 'peakenergy', 'troughfreq', 'troughenergy', 

483 'energyatt5', 'energyatt50', 'lowcutoff', 'highcutoff']) 

484 elif group == 'time': 

485 for k in range(min_peaks, max_peaks): 

486 if k != 1: 

487 group_cols.append(f'P{k}time') 

488 elif group == 'ampl': 

489 for k in range(min_peaks, max_peaks): 

490 group_cols.append(f'P{k}ampl') 

491 elif group == 'relampl': 

492 for k in range(min_peaks, max_peaks): 

493 if k != 1: 

494 group_cols.append(f'P{k}relampl') 

495 elif group == 'width': 

496 for k in range(min_peaks, max_peaks): 

497 group_cols.append(f'P{k}width') 

498 elif group == 'area': 

499 group_cols.appen('polaritybalance') 

500 for k in range(min_peaks, max_peaks): 

501 group_cols.append(f'P{k}area') 

502 elif group == 'phases': 

503 group_cols.extend(['nphases', 'firstphase', 'lastpeak']) 

504 elif group == 'all': 

505 group_cols.extend(['nphases', 'firstphase', 'lastpeak']) 

506 for k in range(min_peaks, max_peaks): 

507 if k != 1: 

508 group_cols.append(f'P{k}relampl') 

509 group_cols.append(f'P{k}time') 

510 group_cols.append(f'P{k}width') 

511 group_cols.extend(['tau', 'ppdist']) 

512 group_cols.extend(['peakfreq', 'peakenergy', 'troughfreq', 'troughenergy', 

513 'energyatt5', 'energyatt50', 'lowcutoff', 'highcutoff']) 

514 else: 

515 return None, f'"{group}" is not a valid data group for pulse-type fish' 

516 # additional data columns: 

517 group_cols.extend(add_columns) 

518 # translate to indices: 

519 data_cols = select_features(data, group_cols) 

520 return data_cols, None 

521 

522 

523wave_fish = True 

524load_spec = False 

525data = None 

526data_path = Path() 

527 

528 

529def load_waveform(idx): 

530 eodf = data[idx,'EODf'] 

531 if not np.isfinite(eodf): 

532 if load_spec: 

533 return None, None 

534 else: 

535 return None 

536 file_name = data[idx, 'file'] if 'file' in data else '-'.join(basename.split('-')[:-1]) 

537 file_index = int(data[idx, 'index']) if 'index' in data else 0 

538 file_channel = int(data[idx, 'channel']) if 'channel' in data else 0 

539 eod_filename = data_path / f'{file_name}-c{file_channel}-eodwaveform-{file_index}.csv' 

540 if eod_filename.exists(): 

541 eod_table = TableData(eod_filename) 

542 else: 

543 zip_filename = data_path / f'{file_name}-c{file_channel}.zip' 

544 if zip_filename.exists(): 

545 with ZipFile(zip_filename, 'r') as zf: 

546 if eod_filename.name in zf.namelist(): 

547 file_buffer = io.TextIOWrapper(zf.open(eod_filename.name, 'r')) 

548 eod_table = TableData(file_buffer) 

549 eod = eod_table[:, 'mean'] 

550 norm = np.max(eod) 

551 if wave_fish: 

552 eod = np.column_stack((eod_table[:, 'time']*0.001*eodf, eod/norm)) 

553 else: 

554 eod = np.column_stack((eod_table[:, 'time'], eod/norm)) 

555 if not load_spec: 

556 return eod 

557 fish_type = 'wave' if wave_fish else 'pulse' 

558 spec_filename = data_path / f'{file_name}-c{file_channel}-{fish_type}spectrum-{file_index}.csv' 

559 spec_table = TableData(spec_filename) 

560 spec_data = spec_table.array() 

561 if not wave_fish: 

562 spec_data = spec_data[spec_data[:, 0] < 2000.0, :] 

563 spec_data = spec_data[::5, :] 

564 return (eod, spec_data) 

565 

566 

567def main(cargs=None): 

568 global data 

569 global wave_fish 

570 global load_spec 

571 global data_path 

572 global basename 

573 

574 # command line arguments: 

575 if cargs is None: 

576 cargs = sys.argv[1:] 

577 parser = argparse.ArgumentParser(add_help=False, 

578 description='View and explore properties of EOD waveforms.', 

579 epilog='version %s by Benda-Lab (2019-%s)' % (__version__, __year__)) 

580 parser.add_argument('-h', '--help', nargs=0, action=PrintHelp, 

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

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

583 parser.add_argument('-v', dest='verbose', action='store_true', default=False, 

584 help='verbose output') 

585 parser.add_argument('-l', dest='list_columns', action='store_true', 

586 help='list all available data columns and exit') 

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

588 help='number of jobs run in parallel for loading waveform data. Without argument use all CPU cores.') 

589 parser.add_argument('-D', dest='column_groups', default=[], type=str, action='append', 

590 choices=EODExplorer.groups, 

591 help='default selection of data columns, check them with the -l option') 

592 parser.add_argument('-d', dest='add_data_cols', action='append', default=[], metavar='COLUMN', 

593 help='data columns to be appended or removed (if already listed) for analysis') 

594 parser.add_argument('-n', dest='max_n', default=0, type=int, metavar='MAX', 

595 help='maximum number of harmonics or peaks to be used') 

596 parser.add_argument('-w', dest='add_waveforms', default=[], type=str, action='append', 

597 choices=['first', 'second', 'ampl', 'power', 'phase'], 

598 help='add first or second derivative of EOD waveform, or relative amplitude, power, or phase to the plot of selected EODs.') 

599 parser.add_argument('-s', dest='save_pca', action='store_true', 

600 help='save PCA components and exit') 

601 parser.add_argument('-c', dest='color_col', default='EODf', type=str, metavar='COLUMN', 

602 help='data column to be used for color code or "row"') 

603 parser.add_argument('-m', dest='color_map', default='jet', type=str, metavar='CMAP', 

604 help='name of color map') 

605 parser.add_argument('-p', dest='data_path', default='.', type=str, metavar='PATH', 

606 help='path to the analyzed EOD waveform data') 

607 parser.add_argument('-P', dest='rawdata_path', default='.', type=str, metavar='PATH', 

608 help='path to the raw EOD recordings') 

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

610 choices=TableData.formats + ['same'], 

611 help='file format used for saving PCA data ("same" uses same format as input file)') 

612 parser.add_argument('file', default='', type=str, 

613 help='a wavefish.* or pulsefish.* summary file as generated by collectfish') 

614 args = parser.parse_args(cargs) 

615 

616 # read in command line arguments:  

617 verbose = args.verbose 

618 list_columns = args.list_columns 

619 jobs = args.jobs 

620 file_name = args.file 

621 column_groups = args.column_groups 

622 add_data_cols = args.add_data_cols 

623 max_n = args.max_n 

624 add_waveforms = args.add_waveforms 

625 save_pca = args.save_pca 

626 color_col = args.color_col 

627 color_map = args.color_map 

628 data_path = Path(args.data_path) 

629 rawdata_path = args.rawdata_path 

630 data_format = args.format 

631 

632 # read configuration: 

633 cfgfile = __package__ + '.cfg' 

634 cfg = ConfigFile() 

635 add_eod_quality_config(cfg) 

636 add_harmonic_groups_config(cfg) 

637 add_species_config(cfg) 

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

639 align_columns=True, shrink_width=False) 

640 cfg.load_files(cfgfile, file_name, 4) 

641 

642 # output format: 

643 if data_format == 'same': 

644 ext = os.path.splitext(file_name)[1][1:] 

645 if ext in TableData.ext_formats: 

646 data_format = TableData.ext_formats[ext] 

647 else: 

648 data_format = 'dat' 

649 if data_format != 'auto': 

650 cfg.set('fileFormat', data_format) 

651 

652 # check color map: 

653 if not color_map in plt.colormaps(): 

654 parser.error(f'"{color_map}" is not a valid color map') 

655 

656 # load summary data: 

657 wave_fish = 'wave' in file_name 

658 data = TableData(file_name) 

659 

660 # basename: 

661 basename = os.path.splitext(os.path.basename(file_name))[0] 

662 

663 # check quality: 

664 skipped = 0 

665 for r in reversed(range(data.rows())): 

666 idx = 0 

667 if 'index' in data: 

668 idx = data[r, 'index'] 

669 skips = '' 

670 if wave_fish: 

671 harm_rampl = np.array([0.01*data[r, f'relampl{k + 1}'] for k in range(3) 

672 if f'relampl{k + 1}' in data]) 

673 props = data.row_dict(r) 

674 if 'clipped' in props: 

675 props['clipped'] *= 0.01 

676 if 'noise' in props: 

677 props['noise'] *= 0.01 

678 if 'rmserror' in props: 

679 props['rmserror'] *= 0.01 

680 if 'thd' in props: 

681 props['thd'] *= 0.01 

682 _, skips, msg = wave_quality(props, harm_rampl, **wave_quality_args(cfg)) 

683 else: 

684 props = data.row_dict(r) 

685 if 'clipped' in props: 

686 props['clipped'] *= 0.01 

687 if 'noise' in props: 

688 props['noise'] *= 0.01 

689 skips, msg, _ = pulse_quality(props, **pulse_quality_args(cfg)) 

690 if len(skips) > 0: 

691 if verbose: 

692 print(f'skip fish {idx:2d} from {data[r,"file"] if "file" in data else basename}:', skips) 

693 del data[r, :] 

694 skipped += 1 

695 if verbose and skipped > 0: 

696 print('') 

697 

698 # select columns (EOD properties) to be shown: 

699 data_cols, error = \ 

700 EODExplorer.select_EOD_properties(data, wave_fish, max_n, 

701 column_groups, add_data_cols) 

702 if error: 

703 parser.error(error) 

704 

705 # select column used for coloring the data: 

706 colors, color_label, color_idx, error = \ 

707 select_coloring(data, data_cols, color_col) 

708 if error: 

709 parser.error(error) 

710 

711 # list columns: 

712 if list_columns: 

713 list_available_features(data, data_cols, color_idx) 

714 parser.exit() 

715 

716 # load waveforms: 

717 load_spec = 'ampl' in add_waveforms or 'power' in add_waveforms or 'phase' in add_waveforms 

718 if jobs is not None: 

719 cpus = cpu_count() if jobs == 0 else jobs 

720 p = Pool(cpus) 

721 eod_data = p.map(load_waveform, range(data.rows())) 

722 del p 

723 else: 

724 eod_data = list(map(load_waveform, range(data.rows()))) 

725 

726 # explore: 

727 eod_expl = EODExplorer(data, data_cols, wave_fish, eod_data, 

728 add_waveforms, load_spec, rawdata_path) 

729 # write pca: 

730 if save_pca: 

731 eod_expl.compute_pca(False) 

732 eod_expl.save_pca(basename, False, **write_table_args(cfg)) 

733 eod_expl.compute_pca(True) 

734 eod_expl.save_pca(basename, True, **write_table_args(cfg)) 

735 else: 

736 eod_expl.set_colors(colors, color_label, color_map) 

737 eod_expl.show() 

738 

739 

740if __name__ == '__main__': 

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

742 main()