Coverage for src / thunderfish / audianthunder.py: 0%
271 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-09 14:25 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-09 14:25 +0000
1import os
2import sys
3import numpy as np
4import matplotlib.pyplot as plt
6try:
7 from audian.audian import audian_cli
8 from audian.plugins import Plugins
9 from audian.analyzer import Analyzer
10except ImportError:
11 print()
12 print('ERROR: You need to install audian (https://github.com/bendalab/audian):')
13 print()
14 print('pip install audian')
15 print()
16 sys.exit(1)
18from io import StringIO
19from pathlib import Path
20from matplotlib.figure import Figure
21from matplotlib.backends.backend_qtagg import FigureCanvas
22from matplotlib.backends.backend_qtagg import \
23 NavigationToolbar2QT as NavigationToolbar
24from PyQt5.QtCore import Qt
25from PyQt5.QtGui import QFont
26from PyQt5.QtWidgets import QDialog, QShortcut, QVBoxLayout, QHBoxLayout
27from PyQt5.QtWidgets import QWidget, QTabWidget, QToolBar, QAction, QStyle
28from PyQt5.QtWidgets import QPushButton, QLabel, QScrollArea, QFileDialog
30from thunderlab.powerspectrum import decibel, plot_decibel_psd, multi_psd
31from thunderlab.tabledata import write_table_args
32from .thunderfish import configuration, detect_eods
33from .thunderfish import rec_style, spectrum_style, eod_styles, snippet_style
34from .thunderfish import wave_spec_styles, pulse_spec_styles
35from .bestwindow import clip_args, clip_amplitudes
36from .harmonics import colors_markers, plot_harmonic_groups
37from .eodanalysis import plot_eod_waveform, plot_eod_snippets
38from .eodanalysis import plot_eod_recording, save_analysis
39from .pulseanalysis import plot_pulse_eods, plot_pulse_spectrum
40from .waveanalysis import plot_wave_spectrum
43class ThunderfishDialog(QDialog):
45 def __init__(self, time, data, unit, ampl_max, channel,
46 file_path, cfg, parent, *args, **kwargs):
47 super().__init__(parent, *args, **kwargs)
48 self.time = time
49 self.rate = 1/np.mean(np.diff(self.time))
50 self.data = data
51 self.unit = unit
52 self.ampl_max = ampl_max
53 self.channel = channel
54 self.cfg = cfg
55 self.file_path = file_path
56 self.navis = []
57 self.pulse_colors, self.pulse_markers = colors_markers()
58 self.pulse_colors = self.pulse_colors[3:]
59 self.pulse_markers = self.pulse_markers[3:]
60 self.wave_colors, self.wave_markers = colors_markers()
61 # collect stdout:
62 orig_stdout = sys.stdout
63 sys.stdout = StringIO()
64 # clipping amplitudes:
65 self.min_clip, self.max_clip = \
66 clip_amplitudes(self.data, max_ampl=self.ampl_max,
67 **clip_args(self.cfg, self.rate))
68 # detect EODs in the data:
69 self.psd_data, self.wave_eodfs, self.wave_indices, self.eod_props, \
70 self.mean_eods, self.spec_data, self.phase_data, self.pulse_data, \
71 self.power_thresh, self.skip_reason, self.zoom_window = \
72 detect_eods(self.data, self.rate,
73 min_clip=self.min_clip, max_clip=self.max_clip,
74 name=self.file_path, mode='wp',
75 verbose=2, plot_level=0, cfg=self.cfg)
76 # add analysis window to EOD properties:
77 for props in self.eod_props:
78 props['twin'] = time[0]
79 props['window'] = time[-1] - time[0]
80 self.nwave = 0
81 self.npulse = 0
82 for i in range(len(self.eod_props)):
83 if self.eod_props[i]['type'] == 'pulse':
84 self.npulse += 1
85 elif self.eod_props[i]['type'] == 'wave':
86 self.nwave += 1
87 self.neods = self.nwave + self.npulse
88 # read out stdout:
89 log = sys.stdout.getvalue()
90 sys.stdout = orig_stdout
92 # dialog:
93 vbox = QVBoxLayout(self)
94 self.tabs = QTabWidget(self)
95 self.tabs.setDocumentMode(True)
96 self.tabs.setMovable(True)
97 self.tabs.setTabsClosable(False)
98 vbox.addWidget(self.tabs)
100 # log messages:
101 self.log = QLabel(self)
102 self.log.setTextInteractionFlags(Qt.TextSelectableByMouse)
103 self.log.setText(log)
104 self.log.setFont(QFont('monospace'))
105 self.log.setMinimumSize(self.log.sizeHint())
106 self.scroll = QScrollArea(self)
107 self.scroll.setWidget(self.log)
108 #vsb = self.scroll.verticalScrollBar()
109 #vsb.setValue(vsb.maximum())
110 self.tabs.addTab(self.scroll, 'Log')
112 # plots:
113 plt.rcParams['axes.spines.top'] = False
114 plt.rcParams['axes.spines.right'] = False
116 # tab with recording trace:
117 canvas = FigureCanvas(Figure(figsize=(10, 5), layout='constrained'))
118 navi = NavigationToolbar(canvas, self)
119 navi.hide()
120 self.navis.append(navi)
121 trace_idx = self.tabs.addTab(canvas, 'Trace')
122 ax = canvas.figure.subplots()
123 twidth = 0.1
124 if len(self.eod_props) > 0:
125 if self.eod_props[0]['type'] == 'wave':
126 twidth = 5.0/self.eod_props[0]['EODf']
127 else:
128 if len(self.wave_eodfs) > 0:
129 twidth = 3.0/self.eod_props[0]['EODf']
130 else:
131 twidth = 10.0/self.eod_props[0]['EODf']
132 twidth = (1+twidth//0.005)*0.005
133 plot_eod_recording(ax, self.data, self.rate, self.unit,
134 twidth, time[0], rec_style)
135 self.zoom_window = [1.2, 1.3]
136 plot_pulse_eods(ax, self.data, self.rate, self.zoom_window,
137 twidth, self.eod_props, time[0],
138 colors=self.pulse_colors,
139 markers=self.pulse_markers,
140 frameon=True, loc='upper right')
141 if ax.get_legend() is not None:
142 ax.get_legend().get_frame().set_color('white')
144 # tab with power spectrum:
145 canvas = FigureCanvas(Figure(figsize=(10, 5), layout='constrained'))
146 navi = NavigationToolbar(canvas, self)
147 navi.hide()
148 self.navis.append(navi)
149 spec_idx = self.tabs.addTab(canvas, 'Spectrum')
150 ax = canvas.figure.subplots()
151 if self.power_thresh is not None:
152 ax.plot(self.power_thresh[:, 0], decibel(self.power_thresh[:, 1]),
153 '#CCCCCC', lw=1)
154 if len(self.wave_eodfs) > 0:
155 plot_harmonic_groups(ax, self.wave_eodfs, self.wave_indices,
156 max_groups=0, skip_bad=False,
157 sort_by_freq=True, label_power=False,
158 colors=self.wave_colors,
159 markers=self.wave_markers,
160 frameon=False, loc='upper right')
161 deltaf = cfg.value('frequencyResolution')
162 if len(self.eod_props) > 0:
163 deltaf = 1.1*self.eod_props[0]['dfreq']
164 self.psd_data = multi_psd(self.data, self.rate, deltaf)[0]
165 plot_decibel_psd(ax, self.psd_data[:, 0], self.psd_data[:, 1],
166 log_freq=False, min_freq=0, max_freq=3000,
167 ymarg=5.0, sstyle=spectrum_style)
168 ax.yaxis.set_major_locator(plt.MaxNLocator(6))
169 if self.nwave > self.npulse:
170 self.tabs.setCurrentIndex(spec_idx)
171 else:
172 self.tabs.setCurrentIndex(trace_idx)
174 self.eod_tabs = None
175 if len(self.eod_props) > 0:
176 # tabs of EODs:
177 self.eod_tabs = QTabWidget(self)
178 self.eod_tabs.setDocumentMode(True)
179 self.eod_tabs.setMovable(True)
180 self.eod_tabs.setTabBarAutoHide(False)
181 self.eod_tabs.setTabsClosable(False)
182 vbox.addWidget(self.eod_tabs)
184 # plot EODs:
185 for k in range(len(self.eod_props)):
186 props = self.eod_props[k]
187 n_snippets = 10
188 canvas = FigureCanvas(Figure(figsize=(10, 5),
189 layout='constrained'))
190 navi = NavigationToolbar(canvas, self)
191 navi.hide()
192 self.navis.append(navi)
193 self.eod_tabs.addTab(canvas,
194 f'{k}: {self.eod_props[k]['EODf']:.0f}Hz')
195 gs = canvas.figure.add_gridspec(2, 2)
196 axe = canvas.figure.add_subplot(gs[:, 0])
197 plot_eod_waveform(axe, self.mean_eods[k], props,
198 self.phase_data[k],
199 unit=self.unit, **eod_styles)
200 if props['type'] == 'pulse' and 'times' in props:
201 plot_eod_snippets(axe, self.data, self.rate,
202 self.mean_eods[k][0, 0],
203 self.mean_eods[k][-1, 0],
204 props['times'], n_snippets,
205 props['flipped'],
206 props['aoffs'], snippet_style)
207 if props['type'] == 'wave':
208 axa = canvas.figure.add_subplot(gs[0, 1])
209 axp = canvas.figure.add_subplot(gs[1, 1], sharex=axa)
210 plot_wave_spectrum(axa, axp, self.spec_data[k], props,
211 unit=self.unit, **wave_spec_styles)
212 else:
213 axs = canvas.figure.add_subplot(gs[:, 1])
214 plot_pulse_spectrum(axs, self.spec_data[k], props,
215 **pulse_spec_styles)
216 self.tools = self.setup_toolbar()
217 close = QPushButton('&Close', self)
218 close.pressed.connect(self.accept)
219 QShortcut('q', self).activated.connect(close.animateClick)
220 QShortcut('Ctrl+Q', self).activated.connect(close.animateClick)
221 hbox = QHBoxLayout()
222 hbox.setContentsMargins(0, 0, 0, 0)
223 hbox.addWidget(self.tools)
224 hbox.addWidget(QLabel())
225 hbox.addWidget(close)
226 vbox.addLayout(hbox)
228 def resizeEvent(self, event):
229 if self.eod_tabs is None:
230 super().resizeEvent(event)
231 else:
232 h = (event.size().height() - self.tools.height())//2 - 10
233 self.tabs.setMaximumHeight(h)
234 self.eod_tabs.setMaximumHeight(h)
236 def toggle_maximize(self):
237 if self.isMaximized():
238 self.showNormal()
239 else:
240 self.showMaximized()
242 def toggle_fullscreen(self):
243 if self.isFullScreen():
244 self.showNormal()
245 else:
246 self.showFullScreen()
248 def save(self):
249 base_name = self.file_path.with_suffix('.zip')
250 cstr = f'-c{self.channel}'
251 tstr = f'-t{self.time[0]:.0f}s'
252 base_name = base_name.with_stem(base_name.stem + cstr + tstr)
253 filters = ['All files (*)', 'ZIP files (*.zip)']
254 base_name = QFileDialog.getSaveFileName(self, 'Save analysis as',
255 os.fspath(base_name),
256 ';;'.join(filters))[0]
257 if base_name:
258 save_analysis(base_name, True, self.eod_props,
259 self.mean_eods, self.spec_data,
260 self.phase_data, self.pulse_data,
261 self.wave_eodfs, self.wave_indices, self.unit, 0,
262 **write_table_args(self.cfg))
264 def home(self):
265 for n in self.navis:
266 n.home()
268 def back(self):
269 for n in self.navis:
270 n.back()
272 def forward(self):
273 for n in self.navis:
274 n.forward()
276 def zoom(self):
277 for n in self.navis:
278 n.zoom()
280 def pan(self):
281 for n in self.navis:
282 n.pan()
284 def setup_toolbar(self):
285 tools = QToolBar(self)
286 act = QAction('&Home', self)
287 act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon))
288 act.setToolTip('Reset zoom (h, Home)')
289 act.setShortcuts(['h', 'r', Qt.Key_Home])
290 act.triggered.connect(self.home)
291 tools.addAction(act)
293 act = QAction('&Back', self)
294 act.setIcon(self.style().standardIcon(QStyle.SP_ArrowBack))
295 act.setToolTip('Zoom backward (c)')
296 act.setShortcuts(['c', Qt.Key_Backspace])
297 act.triggered.connect(self.back)
298 tools.addAction(act)
300 act = QAction('&Forward', self)
301 act.setIcon(self.style().standardIcon(QStyle.SP_ArrowForward))
302 act.setToolTip('Zoom forward (v)')
303 act.setShortcuts(['v'])
304 act.triggered.connect(self.forward)
305 tools.addAction(act)
307 act = QAction('&Zoom', self)
308 #act.setIcon(self.style().standardIcon(QStyle.SP_TitleBarMaxButton))
309 act.setToolTip('Rectangular zoom (o)')
310 act.setShortcuts(['o'])
311 act.triggered.connect(self.zoom)
312 tools.addAction(act)
314 act = QAction('&Pan', self)
315 #act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon))
316 act.setToolTip('Pan and zoom (p)')
317 act.setShortcuts(['p'])
318 act.triggered.connect(self.pan)
319 tools.addAction(act)
321 tools.addSeparator()
323 act = QAction('&Maximize', self)
324 act.setIcon(self.style().standardIcon(QStyle.SP_TitleBarMaxButton))
325 act.setToolTip('Maximize window (m)')
326 act.setShortcuts(['m', 'Ctrl+M', 'Ctrl+Shift+M'])
327 act.triggered.connect(self.toggle_maximize)
328 tools.addAction(act)
330 act = QAction('&Fullscreen', self)
331 act.setToolTip('Fullscreen window (f)')
332 act.setShortcuts(['f'])
333 act.triggered.connect(self.toggle_fullscreen)
334 tools.addAction(act)
336 tools.addSeparator()
338 act = QAction('&Save', self)
339 act.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
340 act.setToolTip('Save analysis results to zip file (s)')
341 act.setShortcuts(['s', 'CTRL+S'])
342 act.triggered.connect(self.save)
343 tools.addAction(act)
345 return tools
348class ThunderfishAnalyzer(Analyzer):
350 def __init__(self, browser):
351 super().__init__(browser, 'thunderfish', 'filtered')
352 self.dialog = None
353 # configure:
354 cfgfile = Path(__package__ + '.cfg')
355 self.cfg = configuration()
356 self.cfg.load_files(cfgfile, browser.data.file_path, 4)
357 self.cfg.set('unwrapData', browser.data.data.unwrap)
359 def analyze(self, t0, t1, channel, traces):
360 time, data = traces[self.source_name]
361 dialog = ThunderfishDialog(time, data, self.source.unit,
362 self.source.ampl_max, channel,
363 self.browser.data.file_path,
364 self.cfg, self.browser)
365 dialog.show()
368def audian_analyzer(browser):
369 browser.remove_analyzer('plain')
370 browser.remove_analyzer('statistics')
371 ThunderfishAnalyzer(browser)
374def main():
375 plugins = Plugins()
376 plugins.add_analyzer_factory(audian_analyzer)
377 audian_cli(sys.argv[1:], plugins)
380if __name__ == '__main__':
381 main()