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
« 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"""
4import os
5import glob
6import io
7import sys
8import argparse
10import numpy as np
11import scipy.signal as sig
12import matplotlib.pyplot as plt
14from pathlib import Path
15from zipfile import ZipFile
16from multiprocessing import Pool, freeze_support, cpu_count
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
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
37basename = ''
40class EODExplorer(MultivariateExplorer):
41 """Simple GUI for viewing and exploring properties of EOD waveforms.
43 EODExplorer adapts a MultivariateExplorer to specific needs of EODs.
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 """
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)
163 def fix_scatter_plot(self, ax, data, label, axis):
164 """Customize an axes of a scatter plot.
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
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)
284 def list_selection(self, indices):
285 """List file names and indices of selection.
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()))
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)
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']
377 @staticmethod
378 def select_EOD_properties(data, wave_fish, max_n, column_groups, add_columns):
379 """Select data columns to be explored.
381 First, groups of columns are selected, then individual
382 columns. Columns that are selected twice are removed from the
383 selection.
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.
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
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
523wave_fish = True
524load_spec = False
525data = None
526data_path = Path()
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)
567def main(cargs=None):
568 global data
569 global wave_fish
570 global load_spec
571 global data_path
572 global basename
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)
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
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)
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)
652 # check color map:
653 if not color_map in plt.colormaps():
654 parser.error(f'"{color_map}" is not a valid color map')
656 # load summary data:
657 wave_fish = 'wave' in file_name
658 data = TableData(file_name)
660 # basename:
661 basename = os.path.splitext(os.path.basename(file_name))[0]
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('')
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)
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)
711 # list columns:
712 if list_columns:
713 list_available_features(data, data_cols, color_idx)
714 parser.exit()
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())))
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()
740if __name__ == '__main__':
741 freeze_support() # needed by multiprocessing for some weired windows stuff
742 main()