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

1import os 

2import sys 

3import numpy as np 

4import matplotlib.pyplot as plt 

5 

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) 

17 

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 

29 

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 

41 

42 

43class ThunderfishDialog(QDialog): 

44 

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 

91 

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) 

99 

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') 

111 

112 # plots: 

113 plt.rcParams['axes.spines.top'] = False 

114 plt.rcParams['axes.spines.right'] = False 

115 

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') 

143 

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) 

173 

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) 

183 

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) 

227 

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) 

235 

236 def toggle_maximize(self): 

237 if self.isMaximized(): 

238 self.showNormal() 

239 else: 

240 self.showMaximized() 

241 

242 def toggle_fullscreen(self): 

243 if self.isFullScreen(): 

244 self.showNormal() 

245 else: 

246 self.showFullScreen() 

247 

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)) 

263 

264 def home(self): 

265 for n in self.navis: 

266 n.home() 

267 

268 def back(self): 

269 for n in self.navis: 

270 n.back() 

271 

272 def forward(self): 

273 for n in self.navis: 

274 n.forward() 

275 

276 def zoom(self): 

277 for n in self.navis: 

278 n.zoom() 

279 

280 def pan(self): 

281 for n in self.navis: 

282 n.pan() 

283 

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) 

292 

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) 

299 

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) 

306 

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) 

313 

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) 

320 

321 tools.addSeparator() 

322 

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) 

329 

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) 

335 

336 tools.addSeparator() 

337 

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) 

344 

345 return tools 

346 

347 

348class ThunderfishAnalyzer(Analyzer): 

349 

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) 

358 

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() 

366 

367 

368def audian_analyzer(browser): 

369 browser.remove_analyzer('plain') 

370 browser.remove_analyzer('statistics') 

371 ThunderfishAnalyzer(browser) 

372 

373 

374def main(): 

375 plugins = Plugins() 

376 plugins.add_analyzer_factory(audian_analyzer) 

377 audian_cli(sys.argv[1:], plugins) 

378 

379 

380if __name__ == '__main__': 

381 main()