Coverage for src/thunderfish/eodanalysis.py: 11%
727 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"""
2Analyse EOD waveforms.
4## EOD detection
6- `detect_eods()`: detect EODs of all fish present in the data.
8## EOD waveform analysis
10- `eod_waveform()`: compute an averaged EOD waveform.
11- `adjust_eodf()`: adjust EOD frequencies to a standard temperature.
13## Undo high-pass filter
15- `unfilter()`: apply inverse high-pass filter on time series.
16- `unfilter_coeff()`: apply inverse high-pass filter on Fourier coefficients.
18## Similarity of EOD waveforms
20- `wave_similarity()`: root-mean squared difference between two wave fish EODs.
21- `pulse_similarity()`: root-mean squared difference between two pulse fish EODs.
22- `load_species_waveforms()`: load template EOD waveforms for species matching.
24## Quality assessment
26- `clipped_fraction()`: compute fraction of clipped EOD waveform snippets.
28## Visualization
30- `plot_eod_recording()`: plot a zoomed in range of the recorded trace.
31- `zoom_eod_recording()`: set optimal time limits for EOD recording.
32- `plot_eod_snippets()`: plot a few EOD waveform snippets.
34## Storage
36- `save_eod_waveform()`: save mean EOD waveform to file.
37- `load_eod_waveform()`: load EOD waveform from file.
38- `save_analysis(): save EOD analysis results to files.
39- `load_analysis()`: load EOD analysis files.
40- `load_recording()`: load recording.
42## Configuration
44- `add_eod_analysis_config()`: add parameters for EOD analysis functions to configuration.
45- `eod_waveform_args()`: retrieve parameters for `eod_waveform()` from configuration.
46- `add_species_config()`: add parameters needed for assigning EOD waveforms to species.
47- `add_eod_quality_config()`: add parameters for `wave_quality()` and `pulse_quality()` to configuration.
48"""
50import os
51import io
52import numpy as np
53import matplotlib.pyplot as plt
55from pathlib import Path
56from zipfile import ZipFile
57from audioio import get_str
58from thunderlab.eventdetection import detect_peaks, snippets
59from thunderlab.fourier import normalize_fourier_coeffs
60from thunderlab.powerspectrum import decibel, spectrogram, spectrum_args
61from thunderlab.tabledata import TableData
62from thunderlab.dataloader import DataLoader
64from .files import parse_filename, open_from_zip, close_zip
65from .fakefish import pulsefish_spectrum
66from .fakefish import normalize_pulsefish, export_pulsefish
67from .fakefish import normalize_wavefish, export_wavefish
68from .harmonics import harmonic_groups_args, psd_peak_detection_args
69from .harmonics import harmonic_groups, closest, consistent
70from .harmonics import plot_selected_groups
71from .pulses import extract_pulsefish, extract_pulsefish_args
72from .pulseanalysis import analyze_pulse, analyze_pulse_args
73from .pulseanalysis import pulsetrain_spectrum
74from .pulseanalysis import pulse_quality, pulse_quality_args
75from .pulseanalysis import save_pulse_fish, load_pulse_fish
76from .pulseanalysis import save_pulse_spectrum, load_pulse_spectrum
77from .pulseanalysis import save_pulse_phases, load_pulse_phases
78from .pulseanalysis import save_pulse_gaussians, load_pulse_gaussians
79from .pulseanalysis import save_pulse_times, load_pulse_times
80from .waveanalysis import extract_wave, analyze_wave, wave_quality
81from .waveanalysis import extract_wave_args, analyze_wave_args
82from .waveanalysis import wave_quality_args
83from .waveanalysis import save_wave_eodfs, load_wave_eodfs
84from .waveanalysis import save_wave_fish, load_wave_fish
85from .waveanalysis import save_wave_phases, load_wave_phases
86from .waveanalysis import save_wave_spectrum, load_wave_spectrum
89def detect_eods(data, rate, power_freqs, power_times, powers,
90 min_clip, max_clip, name, mode, verbose, plot_level, cfg):
91 """Detect EODs of all fish present in the data.
93 This is meant to detect EODs in short (many seconds) recordings,
94 like for example with the fishfinder or dedicated head-tail
95 measurements. The assumption is that the EOD of every fish is
96 stable in amplitude and discharge rate within the data.
98 Parameters
99 ----------
100 data: array of floats
101 The recording in which to detect EODs.
102 rate: float
103 Sampling rate of the dataset.
104 power_freqs: 1D array of float or None
105 Frequencies for `powers`. Can be empty.
106 power_times: 1D array of float
107 Times for `powers`. Can be empty.
108 powers: 2D array of float or None
109 Spectrogram of the data, if available. First column are
110 frequencies, second column times.
111 min_clip: float
112 Minimum amplitude that is not clipped.
113 max_clip: float
114 Maximum amplitude that is not clipped.
115 name: string
116 Name of the recording (e.g. its filename).
117 mode: string
118 Characters in the string indicate what and how to analyze:
119 - 'w': analyze wavefish
120 - 'p': analyze pulsefish
121 - 'P': analyze only the pulsefish with the largest amplitude (not implemented yet)
122 verbose: int
123 Print out information about EOD detection if greater than zero.
124 plot_level : int
125 Similar to verbosity levels, but with plots.
126 cfg: ConfigFile
127 Configuration parameters.
129 Returns
130 -------
131 power_freqs: 1D array of float
132 Frequencies for `powers`. Can be empty.
133 powers: 1D array of float
134 Power spectrum of the data.
135 wave_eodfs: list of 2D arrays
136 Frequency and power of fundamental frequency/harmonics of all wave fish.
137 wave_indices: array of int
138 Indices of wave fish mapping from wave_eodfs to eod_props.
139 If negative, then that EOD frequency has no waveform described in eod_props.
140 eod_props: list of dict
141 Lists of EOD properties as returned by analyze_pulse() and analyze_wave()
142 for each waveform in mean_eods.
143 mean_eods: list of 2-D arrays with time, mean, sem, and fit.
144 Averaged EOD waveforms of pulse and wave fish.
145 spec_data: list of 2_D arrays
146 For each pulsefish a power spectrum of the single pulse and for
147 each wavefish the relative amplitudes and phases of the harmonics.
148 phase_data: list of dict
149 For each pulse fish a dictionary with phase properties
150 (indices, times, amplitudes, relamplitudes, widths, areas, relareas, zeros),
151 empty dict for wave fish.
152 pulse_data: list of dict
153 For each pulse fish a dictionary with phase times, amplitudes and standard
154 deviations of Gaussians fitted to the pulse waveform. Use the
155 functions provided in thunderfish.fakefish to simulate pulse
156 fish EODs from this data.
157 power_thresh: 2 D array or None
158 Frequency (first column) and power (second column) of threshold
159 derived from single pulse spectra to discard false wave fish.
160 None if no pulse fish was detected.
161 skip_reason: list of string
162 Reasons, why an EOD was discarded.
164 """
165 dfreq = np.nan
166 nfft = 0
167 wave_eodfs = []
168 wave_indices = []
169 if 'w' in mode:
170 # detect wave fish:
171 if power_freqs is None or power_times is None or powers is None or \
172 power_freqs[1] > cfg.value('frequencyResolution'):
173 power_freqs, power_times, powers = \
174 spectrogram(data, rate, **spectrum_args(cfg))
175 dfreq = np.mean(np.diff(power_freqs))
176 nfft = int(rate/dfreq)
177 h_kwargs = psd_peak_detection_args(cfg)
178 h_kwargs.update(harmonic_groups_args(cfg))
179 wave_eodfs_list = []
180 for i, psd in enumerate(powers.T):
181 wave_eodfs = harmonic_groups(power_freqs, psd, verbose - 1,
182 **h_kwargs)[0]
183 if verbose > 0 and powers.shape[1] > 1:
184 print(f'{len(wave_eodfs)} fundamental frequencies detected in spectrum of window {i}:')
185 if len(wave_eodfs) > 0:
186 print(' ' + ' '.join([f'{freq[0, 0]:.1f}' for freq in wave_eodfs]))
187 else:
188 print(' none')
189 wave_eodfs_list.append(wave_eodfs)
190 min_closest = max(1, (len(wave_eodfs_list) + 1) // 3)
191 freq_thresh = cfg.value('frequencyThreshold')
192 wave_eodfs, wave_windows = \
193 closest(wave_eodfs_list,
194 df_thresh=freq_thresh, close_thresh=1*freq_thresh,
195 min_closest=min_closest)
196 if len(wave_windows) > 0:
197 p0 = np.min(wave_windows[:, 0])
198 p1 = np.max(wave_windows[:, 1])
199 powers = np.mean(powers[:, p0:p1 + 1], 1)
200 else:
201 powers = np.mean(powers, 1)
202 if verbose > 0:
203 if verbose > 1:
204 print()
205 if len(wave_eodfs) > 0:
206 fstr = 'ies' if len(wave_eodfs) > 1 else 'y'
207 print(f'found {len(wave_eodfs):2d} EOD frequenc{fstr} sufficiently close in all spectra:')
208 if verbose > 1:
209 for freq, win in zip(wave_eodfs, wave_windows):
210 print(f' {freq[0, 0]:6.1f}Hz in spectra {win[0]} - {win[1]}')
211 else:
212 print(' ' + ' '.join([f'{freq[0, 0]:.1f}' for freq in wave_eodfs]))
213 else:
214 print('no fundamental frequencies are sufficiently close in all spectra')
215 if plot_level > 0:
216 fig, ax = plt.subplots(layout='constrained')
217 ax.set_title(f'{len(wave_eodfs)} closest EOD frequencies')
218 plot_selected_groups(ax, wave_eodfs_list, wave_eodfs, wave_windows)
219 ax.set_xlabel('index of spectrum segment')
220 plt.show()
222 # analysis results:
223 eod_props = []
224 mean_eods = []
225 spec_data = []
226 phase_data = []
227 pulseeod_data = []
228 power_thresh = None
229 skip_reason = []
230 max_pulse_amplitude = 0.0
232 if 'p' in mode:
233 # detect pulse fish:
234 eods, eod_times, eod_peaktimes, _ = \
235 extract_pulsefish(data, rate,
236 verbose=verbose - 1,
237 plot_level=plot_level,
238 **extract_pulsefish_args(cfg))
239 if verbose > 0:
240 if len(eod_times) > 0:
241 print(f'found {len(eod_times):2d} pulsefish EODs')
242 else:
243 print('no pulsefish EODs found')
244 if plot_level > 0 and len(eod_times) > 0:
245 fig, ax = plt.subplots()
246 for eod, times in zip(eods, eod_times):
247 ax.plot(eod[:, 0], eod[:, 1], label=f'{1/np.median(np.diff(times)):.1f}Hz')
248 ax.legend()
249 plt.show()
251 # analyse eod waveform of pulse-fish:
252 for mean_eod, eod_ts, eod_pts in zip(eods, eod_times, eod_peaktimes):
253 """
254 mean_eod, eod_times0 = \
255 eod_waveform(data, rate, eod_ts, win_fac=0.8,
256 min_win=cfg.value('eodMinPulseSnippet'),
257 min_sem=False, **eod_waveform_args(cfg))
258 """
259 unfilter_cutoff = cfg.value('unfilterCutoff')
260 if unfilter_cutoff and unfilter_cutoff > 0:
261 unfilter(mean_eod[:, 1], frate, unfilter_cutoff)
262 mean_eod, props, phases, pulse, power = \
263 analyze_pulse(mean_eod, None, eod_ts, verbose=verbose-1,
264 **analyze_pulse_args(cfg))
265 if len(phases) == 0:
266 if verbose > 0:
267 print('no phases in pulse EOD detected')
268 continue
269 clipped_frac = clipped_fraction(data, rate, eod_ts,
270 mean_eod, min_clip, max_clip)
271 props['peaktimes'] = eod_ts # XXX that should go into analyze pulse
272 props['index'] = len(eod_props)
273 props['clipped'] = clipped_frac
274 props['samplerate'] = rate
275 props['nfft'] = nfft
276 props['dfreq'] = dfreq
278 # add good waveforms only:
279 skips, msg, skipped_clipped = \
280 pulse_quality(props, **pulse_quality_args(cfg))
282 if len(skips) == 0:
283 eod_props.append(props)
284 mean_eods.append(mean_eod)
285 spec_data.append(power)
286 phase_data.append(phases)
287 pulseeod_data.append(pulse)
288 if verbose > 0:
289 print(f'take {props['EODf']:7.2f}Hz pulse fish: {msg}')
290 else:
291 skip_reason += [f'{props['EODf']:.2f}Hz pulse fish {skips}']
292 if verbose > 0:
293 print(f'skip {props['EODf']:7.2f}Hz pulse fish: {skips} ({msg})')
295 # threshold for wave fish peaks based on single pulse spectra:
296 if len(skips) == 0 or skipped_clipped:
297 if max_pulse_amplitude < props['ppampl']:
298 max_pulse_amplitude = props['ppampl']
300 pulse_freqs, pulse_power = \
301 pulsetrain_spectrum(eod_pts, mean_eod, None, len(data)/rate, rate,
302 fade_frac=0.05, **spectrum_args(cfg))
303 pulse_power *= len(data)/rate/props['period']/len(props['peaktimes'])
304 pulse_power *= 5
305 if power_thresh is None:
306 power_thresh = np.zeros((len(pulse_freqs), 2))
307 power_thresh[:, 0] = pulse_freqs
308 power_thresh[:, 1] = pulse_power
309 else:
310 power_thresh[:, 1] += pulse_power
312 # remove wavefish below pulse fish power:
313 if 'w' in mode and power_thresh is not None:
314 n = len(wave_eodfs)
315 maxh = 3 # XXX TODO make parameter
316 df = power_thresh[1,0] - power_thresh[0,0]
317 for k, fish in enumerate(reversed(wave_eodfs)):
318 idx = np.array(fish[:maxh,0]//df, dtype=int)
319 for offs in range(-2, 3):
320 nbelow = np.sum(fish[:maxh,1] < power_thresh[idx+offs,1])
321 if nbelow > 0:
322 wave_eodfs.pop(n-1-k)
323 if verbose > 0:
324 print(f'skip {fish[0,0]:7.2f}Hz wave fish: {nbelow:2d} harmonics are below pulsefish threshold')
325 break
327 if 'w' in mode:
328 # analyse EOD waveform of all wavefish:
329 if verbose > 1:
330 print()
331 fish_powers = np.array([np.sum(fish[:,1]) for fish in wave_eodfs])
332 power_indices = np.argsort(-fish_powers)
333 wave_indices = np.zeros(len(wave_eodfs), dtype=int) - 3
334 for k, idx in enumerate(power_indices):
335 fish = wave_eodfs[idx]
336 window = wave_windows[idx]
337 iw = int(rate/power_freqs[1])//2
338 i0 = int(power_times[window[0]]*rate) - iw
339 i1 = int(power_times[window[1]]*rate) + iw
340 coeffs, mean_eod, eod_freq, times, n_eods, eskips = \
341 extract_wave(data[i0:i1], rate, fish[0, 0],
342 power_freqs[1], verbose=verbose - 1,
343 plot_level=plot_level,
344 **extract_wave_args(cfg))
345 if len(mean_eod) == 0 or len(eskips) > 0:
346 # waveform extraction failed
347 continue
348 for h in range(len(fish)):
349 fish[h, 0] = (h + 1)*eod_freq
350 unfilter_cutoff = cfg.value('unfilterCutoff')
351 if unfilter_cutoff:
352 coeffs = unfilter_coeff(eod_freq, coeffs, unfilter_cutoff)
353 if plot_level > 0:
354 w = fourier_synthesis(eod_freq, coeffs,
355 1/mean_eod[1, 0], len(mean_eod))
356 fig, ax = plt.subplots(layout='constrained')
357 ax.set_title(f'EODf={eod_freq:.1f}Hz, unfilter high-pass filter $f_{{cutoff}}={unfilter_cutoff:.0f}$Hz')
358 ax.plot(1000*mean_eod[:, 0], mean_eod[:, 1],
359 label='original')
360 ax.plot(1000*mean_eod[:, 0], w, label='unfiltered')
361 ax.set_xlabel('time [ms]')
362 ax.legend()
363 plt.show()
364 mean_eod, props, phases, sdata = \
365 analyze_wave(mean_eod, None, fish, coeffs,
366 **analyze_wave_args(cfg))
367 eod_times = np.arange(i0/rate, i1/rate, 1/fish[0, 0])
368 clipped_frac = clipped_fraction(data[i0:i1], rate, eod_times,
369 mean_eod, min_clip, max_clip)
370 props['n'] = n_eods
371 props['nsegments'] = len(times)
372 props['index'] = len(eod_props)
373 props['clipped'] = clipped_frac
374 props['samplerate'] = rate
375 props['nfft'] = nfft
376 props['dfreq'] = dfreq
377 # remove wave fish that are smaller than the largest pulse fish:
378 if props['ppampl'] < 0.01*max_pulse_amplitude:
379 rm_indices = power_indices[k:]
380 if verbose > 0:
381 print(f'skip {props['EODf']:7.2f}Hz wave fish: power={decibel(fish_powers[idx]):5.1f}dB, p-p amplitude={decibel(props['ppampl']):5.1f}dB smaller than pulse fish={decibel(max_pulse_amplitude):5.1f}dB - 20dB')
382 for idx in rm_indices[1:]:
383 print(f'skip {wave_eodfs[idx][0,0]:7.2f}Hz wave fish: power={decibel(fish_powers[idx]):5.1f}dB even smaller')
384 if verbose > 1:
385 print()
386 wave_eodfs = [eodfs for idx, eodfs in enumerate(wave_eodfs)
387 if idx not in rm_indices]
388 wave_indices = np.array([idcs for idx, idcs in enumerate(wave_indices)
389 if idx not in rm_indices], dtype=int)
390 break
391 # add good waveforms only:
392 remove, skips, msg = wave_quality(props, sdata[1:, 3],
393 **wave_quality_args(cfg))
394 if len(eskips) + len(skips) == 0:
395 wave_indices[idx] = props['index']
396 eod_props.append(props)
397 mean_eods.append(mean_eod)
398 spec_data.append(sdata)
399 phase_data.append(phases)
400 pulseeod_data.append(dict())
401 if verbose > 0:
402 print(f'take {props['EODf']:7.2f}Hz wave fish:', msg)
403 else:
404 if len(skips) == 0:
405 skips = eskips
406 elif len(eskips) > 0:
407 skips = eskips + ', ' + skips
408 wave_indices[idx] = -2 if remove else -1
409 skip_reason += [f'{props["EODf"]:.1f}Hz wave fish {skips}']
410 if verbose > 0:
411 rstr = 'remove' if remove else 'skip'
412 print(f'{rstr:<7s} {props["EODf"]:7.2f}Hz wave fish: {skips} ({msg})')
413 if verbose > 1:
414 print()
415 wave_eodfs = [eodfs for idx, eodfs in zip(wave_indices, wave_eodfs) if idx > -2]
416 wave_indices = np.array([idx for idx in wave_indices if idx > -2], dtype=int)
417 return (power_freqs, powers, wave_eodfs, wave_indices, eod_props,
418 mean_eods, spec_data, phase_data, pulseeod_data,
419 power_thresh, skip_reason)
422def eod_waveform(data, rate, eod_times, win_fac=2.0, min_win=0.01,
423 min_sem=False, max_eods=None):
424 """Extract data snippets around each EOD, and compute a mean waveform with standard error.
426 Retrieving the EOD waveform of a wave fish works under the following
427 conditions: (i) at a signal-to-noise ratio \\(SNR = P_s/P_n\\),
428 i.e. the power \\(P_s\\) of the EOD of interest relative to the
429 largest other EOD \\(P_n\\), we need to average over at least \\(n >
430 (SNR \\cdot c_s^2)^{-1}\\) snippets to bring the standard error of the
431 averaged EOD waveform down to \\(c_s\\) relative to its
432 amplitude. For a s.e.m. less than 5% ( \\(c_s=0.05\\) ) and an SNR of
433 -10dB (the signal is 10 times smaller than the noise, \\(SNR=0.1\\) ) we
434 get \\(n > 0.00025^{-1} = 4000\\) data snippets - a recording a
435 couple of seconds long. (ii) Very important for wave fish is that
436 they keep their frequency constant. Slight changes in the EOD
437 frequency will corrupt the average waveform. If the period of the
438 waveform changes by \\(c_f=\\Delta T/T\\), then after \\(n =
439 1/c_f\\) periods moved the modified waveform through a whole period.
440 This is in the range of hundreds or thousands waveforms.
442 NOTE: we need to take into account a possible error in the estimate
443 of the EOD period. This will limit the maximum number of snippets to
444 be averaged.
446 If `min_sem` is set, the algorithm checks for a global minimum of
447 the s.e.m. as a function of snippet number. If there is one then
448 the average is computed for this number of snippets, otherwise all
449 snippets are taken from the provided data segment. Note that this
450 check only works for the strongest EOD in a recording. For weaker
451 EOD the s.e.m. always decays with snippet number (empirical
452 observation).
454 TODO: use power spectra to check for changes in EOD frequency!
456 Parameters
457 ----------
458 data: 1-D array of float
459 The data to be analysed.
460 rate: float
461 Sampling rate of the data in Hertz.
462 eod_times: 1-D array of float
463 Array of EOD times in seconds over which the waveform should be
464 averaged.
465 WARNING: The first data point must be at time zero!
466 win_fac: float
467 The snippet size is the EOD period times `win_fac`. The EOD period
468 is determined as the minimum interval between EOD times.
469 min_win: float
470 The minimum size of the snippets in seconds.
471 min_sem: bool
472 If set, check for minimum in s.e.m. to set the maximum numbers
473 of EODs to be used for computing the average waveform.
474 max_eods: int or None
475 Maximum number of EODs to be used for averaging.
476 unfilter_cutoff: float
477 If not zero, the cutoff frequency for an inverse high-pass filter
478 applied to the mean EOD waveform.
480 Returns
481 -------
482 mean_eod: 2-D array
483 Average of the EOD snippets. First column is time in seconds,
484 second column the mean eod, third column the standard error.
485 eod_times: 1-D array
486 Times of EOD peaks in seconds that have been actually used to calculate the
487 averaged EOD waveform.
488 """
489 # indices of EOD times:
490 eod_idx = np.round(eod_times*rate).astype(int)
492 # window size:
493 period = np.min(np.diff(eod_times))
494 win = 0.5*win_fac*period
495 if 2*win < min_win:
496 win = 0.5*min_win
497 win_inx = int(win*rate)
499 # extract snippets:
500 eod_times = eod_times[(eod_idx >= win_inx) & (eod_idx < len(data)-win_inx)]
501 eod_idx = eod_idx[(eod_idx >= win_inx) & (eod_idx < len(data)-win_inx)]
502 if max_eods and max_eods > 0 and len(eod_idx) > max_eods:
503 dn = (len(eod_idx) - max_eods)//2
504 eod_times = eod_times[dn:dn+max_eods]
505 eod_idx = eod_idx[dn:dn+max_eods]
506 eod_snippets = snippets(data, eod_idx, -win_inx, win_inx)
507 if len(eod_snippets) == 0:
508 return np.zeros((0, 3)), eod_times
510 # optimal number of snippets:
511 step = 10
512 if min_sem and len(eod_snippets) > step:
513 sems = [np.mean(np.std(eod_snippets[:k], axis=0, ddof=1)/np.sqrt(k))
514 for k in range(step, len(eod_snippets), step)]
515 idx = np.argmin(sems)
516 # there is a local minimum:
517 if idx > 0 and idx < len(sems)-1:
518 maxn = step*(idx+1)
519 eod_snippets = eod_snippets[:maxn]
520 eod_times = eod_times[:maxn]
522 # mean and std of snippets:
523 mean_eod = np.zeros((len(eod_snippets[0]), 3))
524 mean_eod[:, 1] = np.mean(eod_snippets, axis=0)
525 if len(eod_snippets) > 1:
526 mean_eod[:, 2] = np.std(eod_snippets, axis=0, ddof=1)/np.sqrt(len(eod_snippets))
528 # time axis:
529 mean_eod[:, 0] = (np.arange(len(mean_eod)) - win_inx) / rate
531 return mean_eod, eod_times
534def adjust_eodf(eodf, temp, temp_adjust=25.0, q10=1.62):
535 """Adjust EOD frequencies to a standard temperature using Q10.
537 Parameters
538 ----------
539 eodf: float or ndarray
540 EOD frequencies.
541 temp: float
542 Temperature in degree celsisus at which EOD frequencies in
543 `eodf` were measured.
544 temp_adjust: float
545 Standard temperature in degree celsisus to which EOD
546 frequencies are adjusted.
547 q10: float
548 Q10 value describing temperature dependence of EOD
549 frequencies. The default of 1.62 is from Dunlap, Smith, Yetka
550 (2000) Brain Behav Evol, measured for Apteronotus
551 lepthorhynchus in the lab.
553 Returns
554 -------
555 eodf_corrected: float or array
556 EOD frequencies adjusted to `temp_adjust` using `q10`.
557 """
558 return eodf*q10**((temp_adjust - temp) / 10.0)
561def unfilter(data, rate, fcutoff):
562 """Apply inverse high-pass filter on time series.
564 Assumes high-pass filter
565 \\[ \\tau \\dot y = -y + \\tau \\dot x \\]
566 has been applied on the original data \\(x\\), where
567 \\(\\tau=(2\\pi f_{cutoff})^{-1}\\) is the time constant of the
568 filter. To recover \\(x\\) the ODE
569 \\[ \\tau \\dot x = y + \\tau \\dot y \\]
570 is applied on the filtered data \\(y\\).
572 Parameters
573 ----------
574 data: 1D ndarray of float
575 High-pass filtered original data.
576 rate: float
577 Sampling rate of `data` in Hertz.
578 fcutoff: float
579 Cutoff frequency \\(f_{cutoff}\\) of the high-pass filter in Hertz.
581 Returns
582 -------
583 data: ndarray
584 Recovered original data.
585 """
586 tau = 0.5/np.pi/fcutoff
587 fac = tau*rate
588 data -= np.mean(data)
589 d0 = data[0]
590 x = d0
591 for k in range(len(data)):
592 d1 = data[k]
593 x += (d1 - d0) + d0/fac
594 data[k] = x
595 d0 = d1
596 return data
599def unfilter_coeff(freq, coeffs, fcutoff):
600 """Apply inverse high-pass filter on Fourier coefficients.
602 Assumes first-order high-pass filter
603 \\[ \\tau \\dot y = -y + \\tau \\dot x \\]
604 has been applied on the original data \\(x\\), where
605 \\(\\tau=(2\\pi f_{cutoff})^{-1}\\) is the time constant of the
606 filter. The transfer function of this high-pass filter is
607 \\[ H(\\omega) = \\frac{i\\omega\\tau}{1 + i\\omega\\tau} \\]
609 To undo the effect of the high-pass filter, the complex-valued
610 Fourier coefficients \\(c_k\\) of the waveform for frequencies \\(k f_0\\)
611 are multiplied with the inverse transfer function
612 \\[ IH(\\omega) = \\frac{1}{H(\\omega)} = 1 - i\\frac{1}{\\omega\\tau} \\]
613 which simplifies for the harmonics to
614 \\[ IH(kf_0) = 1 - i\\frac{f_{cutoff}}{k f_0} \\]
617 Parameters
618 ----------
619 freq: float
620 Fundamental frequency \\(f_0\\).
621 coeffs: 1D array of complex
622 For each harmonics the complex valued Fourier coefficient
623 as, for example, returned by `fourier_coeffs()`.
624 The first one is the offset.
625 fcutoff: float
626 Cutoff frequency \\(f_{cutoff}\\) of the high-pass filter in Hertz.
628 Returns
629 -------
630 coeffs: 1D array of complex
631 Unfiltered Fourier coefficients. Normalized to zero phase shift
632 of the fundamental and no offset.
633 """
634 ihp_coeffs = np.zeros(len(coeffs), dtype=complex)
635 ihp_coeffs[1:] = 1 - 1j/(np.arange(1, len(coeffs))*freq/fcutoff)
636 coeffs *= ihp_coeffs
637 coeffs = normalize_fourier_coeffs(coeffs)
638 return coeffs
641def load_species_waveforms(species_file='none'):
642 """Load template EOD waveforms for species matching.
644 Parameters
645 ----------
646 species_file: str
647 Name of file containing species definitions. The content of
648 this file is as follows:
650 - Empty lines and line starting with a hash ('#') are skipped.
651 - A line with the key-word 'wavefish' marks the beginning of the
652 table for wave fish.
653 - A line with the key-word 'pulsefish' marks the beginning of the
654 table for pulse fish.
655 - Each line in a species table has three fields,
656 separated by colons (':'):
658 1. First field is an abbreviation of the species name.
659 2. Second field is the filename of the recording containing the
660 EOD waveform.
661 3. The optional third field is the EOD frequency of the EOD waveform.
663 The EOD frequency is used to normalize the time axis of a
664 wave fish EOD to one EOD period. If it is not specified in
665 the third field, it is taken from the corresponding
666 *-wavespectrum-* file, if present. Otherwise the species is
667 excluded and a warning is issued.
669 Example file content:
670 ``` plain
671 Wavefish
672 Aptero : F_91009L20-eodwaveform-0.csv : 823Hz
673 Eigen : C_91008L01-eodwaveform-0.csv
675 Pulsefish
676 Gymnotus : pulsefish/gymnotus.csv
677 Brachy : H_91009L11-eodwaveform-0.csv
678 ```
680 Returns
681 -------
682 wave_names: list of str
683 List of species names of wave-type fish.
684 wave_eods: list of 2-D arrays
685 List of EOD waveforms of wave-type fish corresponding to
686 `wave_names`. First column of a waveform is time in seconds,
687 second column is the EOD waveform. The waveforms contain
688 exactly one EOD period.
689 pulse_names: list of str
690 List of species names of pulse-type fish.
691 pulse_eods: list of 2-D arrays
692 List of EOD waveforms of pulse-type fish corresponding to
693 `pulse_names`. First column of a waveform is time in seconds,
694 second column is the EOD waveform.
695 """
696 if not Path(species_file).is_file():
697 return [], [], [], []
698 wave_names = []
699 wave_eods = []
700 pulse_names = []
701 pulse_eods = []
702 fish_type = 'wave'
703 with open(species_file, 'r') as sf:
704 for line in sf:
705 line = line.strip()
706 if len(line) == 0 or line[0] == '#':
707 continue
708 if line.lower() == 'wavefish':
709 fish_type = 'wave'
710 elif line.lower() == 'pulsefish':
711 fish_type = 'pulse'
712 else:
713 ls = [s.strip() for s in line.split(':')]
714 if len(ls) < 2:
715 continue
716 name = ls[0]
717 waveform_file = ls[1]
718 eod = TableData(waveform_file).array()
719 eod[:, 0] *= 0.001
720 if fish_type == 'wave':
721 eodf = None
722 if len(ls) > 2:
723 eodf = float(ls[2].replace('Hz', '').strip())
724 else:
725 spectrum_file = waveform_file.replace('eodwaveform', 'wavespectrum')
726 try:
727 wave_spec = TableData(spectrum_file)
728 eodf = wave_spec[0, 1]
729 except FileNotFoundError:
730 pass
731 if eodf is None:
732 print('warning: unknown EOD frequency of %s. Skip.' % name)
733 continue
734 eod[:, 0] *= eodf
735 wave_names.append(name)
736 wave_eods.append(eod[:, :2])
737 elif fish_type == 'pulse':
738 pulse_names.append(name)
739 pulse_eods.append(eod[:, :2])
740 return wave_names, wave_eods, pulse_names, pulse_eods
743def wave_similarity(eod1, eod2, eod1f=1.0, eod2f=1.0):
744 """Root-mean squared difference between two wave fish EODs.
746 Compute the root-mean squared difference between two wave fish
747 EODs over one period. The better sampled signal is down-sampled to
748 the worse sampled one. Amplitude are normalized to peak-to-peak
749 amplitude before computing rms difference. Also compute the rms
750 difference between the one EOD and the other one inverted in
751 amplitude. The smaller of the two rms values is returned.
753 Parameters
754 ----------
755 eod1: 2-D array
756 Time and amplitude of reference EOD.
757 eod2: 2-D array
758 Time and amplitude of EOD that is to be compared to `eod1`.
759 eod1f: float
760 EOD frequency of `eod1` used to transform the time axis of `eod1`
761 to multiples of the EOD period. If already normalized to EOD period,
762 as for example by the `load_species_waveforms()` function, then
763 set the EOD frequency to one (default).
764 eod2f: float
765 EOD frequency of `eod2` used to transform the time axis of `eod2`
766 to multiples of the EOD period. If already normalized to EOD period,
767 as for example by the `load_species_waveforms()` function, then
768 set the EOD frequency to one (default).
770 Returns
771 -------
772 rmse: float
773 Root-mean-squared difference between the two EOD waveforms relative to
774 their standard deviation over one period.
775 """
776 # copy:
777 eod1 = np.array(eod1[:, :2])
778 eod2 = np.array(eod2[:, :2])
779 # scale to multiples of EOD period:
780 eod1[:, 0] *= eod1f
781 eod2[:, 0] *= eod2f
782 # make eod1 the waveform with less samples per period:
783 n1 = int(1.0/(eod1[1,0]-eod1[0,0]))
784 n2 = int(1.0/(eod2[1,0]-eod2[0,0]))
785 if n1 > n2:
786 eod1, eod2 = eod2, eod1
787 n1, n2 = n2, n1
788 # one period around time zero:
789 i0 = np.argmin(np.abs(eod1[:, 0]))
790 k0 = i0-n1//2
791 if k0 < 0:
792 k0 = 0
793 k1 = k0 + n1 + 1
794 if k1 >= len(eod1):
795 k1 = len(eod1)
796 # cut out one period from the reference EOD around maximum:
797 i = k0 + np.argmax(eod1[k0:k1,1])
798 k0 = i-n1//2
799 if k0 < 0:
800 k0 = 0
801 k1 = k0 + n1 + 1
802 if k1 >= len(eod1):
803 k1 = len(eod1)
804 eod1 = eod1[k0:k1,:]
805 # normalize amplitudes of first EOD:
806 eod1[:, 1] -= np.min(eod1[:, 1])
807 eod1[:, 1] /= np.max(eod1[:, 1])
808 sigma = np.std(eod1[:, 1])
809 # set time zero to maximum of second EOD:
810 i0 = np.argmin(np.abs(eod2[:, 0]))
811 k0 = i0-n2//2
812 if k0 < 0:
813 k0 = 0
814 k1 = k0 + n2 + 1
815 if k1 >= len(eod2):
816 k1 = len(eod2)
817 i = k0 + np.argmax(eod2[k0:k1,1])
818 eod2[:, 0] -= eod2[i,0]
819 # interpolate eod2 to the time base of eod1:
820 eod2w = np.interp(eod1[:, 0], eod2[:, 0], eod2[:, 1])
821 # normalize amplitudes of second EOD:
822 eod2w -= np.min(eod2w)
823 eod2w /= np.max(eod2w)
824 # root-mean-square difference:
825 rmse1 = np.sqrt(np.mean((eod1[:, 1] - eod2w)**2))/sigma
826 # root-mean-square difference of the flipped signal:
827 i = k0 + np.argmin(eod2[k0:k1,1])
828 eod2[:, 0] -= eod2[i,0]
829 eod2w = np.interp(eod1[:, 0], eod2[:, 0], -eod2[:, 1])
830 eod2w -= np.min(eod2w)
831 eod2w /= np.max(eod2w)
832 rmse2 = np.sqrt(np.mean((eod1[:, 1] - eod2w)**2))/sigma
833 # take the smaller value:
834 rmse = min(rmse1, rmse2)
835 return rmse
838def pulse_similarity(eod1, eod2, wfac=10.0):
839 """Root-mean squared difference between two pulse fish EODs.
841 Compute the root-mean squared difference between two pulse fish
842 EODs over `wfac` times the distance between minimum and maximum of
843 the wider EOD. The waveforms are normalized to their maxima prior
844 to computing the rms difference. Also compute the rms difference
845 between the one EOD and the other one inverted in amplitude. The
846 smaller of the two rms values is returned.
848 Parameters
849 ----------
850 eod1: 2-D array
851 Time and amplitude of reference EOD.
852 eod2: 2-D array
853 Time and amplitude of EOD that is to be compared to `eod1`.
854 wfac: float
855 Multiply the distance between minimum and maximum by this factor
856 to get the window size over which to compute the rms difference.
858 Returns
859 -------
860 rmse: float
861 Root-mean-squared difference between the two EOD waveforms
862 relative to their standard deviation over the analysis window.
863 """
864 # copy:
865 eod1 = np.array(eod1[:, :2])
866 eod2 = np.array(eod2[:, :2])
867 # width of the pulses:
868 imin1 = np.argmin(eod1[:, 1])
869 imax1 = np.argmax(eod1[:, 1])
870 w1 = np.abs(eod1[imax1,0]-eod1[imin1,0])
871 imin2 = np.argmin(eod2[:, 1])
872 imax2 = np.argmax(eod2[:, 1])
873 w2 = np.abs(eod2[imax2,0]-eod2[imin2,0])
874 w = wfac*max(w1, w2)
875 # cut out signal from the reference EOD:
876 n = int(w/(eod1[1,0]-eod1[0,0]))
877 i0 = imax1-n//2
878 if i0 < 0:
879 i0 = 0
880 i1 = imax1+n//2+1
881 if i1 >= len(eod1):
882 i1 = len(eod1)
883 eod1[:, 0] -= eod1[imax1,0]
884 eod1 = eod1[i0:i1,:]
885 # normalize amplitude of first EOD:
886 eod1[:, 1] /= np.max(eod1[:, 1])
887 sigma = np.std(eod1[:, 1])
888 # interpolate eod2 to the time base of eod1:
889 eod2[:, 0] -= eod2[imax2,0]
890 eod2w = np.interp(eod1[:, 0], eod2[:, 0], eod2[:, 1])
891 # normalize amplitude of second EOD:
892 eod2w /= np.max(eod2w)
893 # root-mean-square difference:
894 rmse1 = np.sqrt(np.mean((eod1[:, 1] - eod2w)**2))/sigma
895 # root-mean-square difference of the flipped signal:
896 eod2[:, 0] -= eod2[imin2,0]
897 eod2w = np.interp(eod1[:, 0], eod2[:, 0], -eod2[:, 1])
898 eod2w /= np.max(eod2w)
899 rmse2 = np.sqrt(np.mean((eod1[:, 1] - eod2w)**2))/sigma
900 # take the smaller value:
901 rmse = min(rmse1, rmse2)
902 return rmse
905def clipped_fraction(data, rate, eod_times, mean_eod,
906 min_clip=-np.inf, max_clip=np.inf):
907 """Compute fraction of clipped EOD waveform snippets.
909 Cut out snippets at each `eod_times` based on time axis of
910 `mean_eod`. Check which fraction of snippets exceeds clipping
911 amplitude `min_clip` and `max_clip`.
913 Parameters
914 ----------
915 data: 1-D array of float
916 The data to be analysed.
917 rate: float
918 Sampling rate of the data in Hertz.
919 eod_times: 1-D array of float
920 Array of EOD times in seconds.
921 mean_eod: 2-D array with time, mean, sem, and fit.
922 Averaged EOD waveform of wave fish. Only the time axis is used
923 to set width of snippets.
924 min_clip: float
925 Minimum amplitude that is not clipped.
926 max_clip: float
927 Maximum amplitude that is not clipped.
929 Returns
930 -------
931 clipped_frac: float
932 Fraction of snippets that are clipped.
933 """
934 # snippets:
935 idx0 = np.argmin(np.abs(mean_eod[:, 0])) # index of time zero
936 w0 = -idx0
937 w1 = len(mean_eod[:, 0]) - idx0
938 eod_idx = np.round(eod_times*rate).astype(int)
939 eod_snippets = snippets(data, eod_idx, w0, w1)
940 # fraction of clipped snippets:
941 if len(eod_snippets) == 0:
942 return 0
943 else:
944 clipped_frac = np.sum(np.any((eod_snippets > max_clip) |
945 (eod_snippets < min_clip), axis=1)) \
946 / len(eod_snippets)
947 return clipped_frac
950def plot_eod_recording(ax, data, rate, unit=None, width=None,
951 toffs=0.0, rec_style=dict(lw=2, color='tab:red')):
952 """Plot the recorded trace.
954 Parameters
955 ----------
956 ax: matplotlib axes
957 Axes used for plotting.
958 data: 1D ndarray
959 Recorded data to be plotted.
960 rate: float
961 Sampling rate of the data in Hertz.
962 unit: str
963 Optional unit of the data used for y-label.
964 width: float or None
965 Width of central data segment to be plotted in seconds.
966 If None plot all.
967 toffs: float
968 Time of first data value in seconds.
969 rec_style: dict
970 Arguments passed on to the plot command for the recorded trace.
972 Returns
973 -------
974 tfac: float
975 Factor that has been used to multiply time.
976 """
977 if width is None or width > len(data)/rate:
978 width = len(data)/rate
979 time = np.arange(len(data))/rate + toffs
980 t_center = 0.5*(time[-1] + time[0])
981 t0 = t_center - width/2
982 t1 = t_center + width/2
983 tfac = 1
984 tunit = 's'
985 if np.abs(t0) < 1.0 and np.abs(t1) < 1.0:
986 tfac = 1000
987 tunit = 'ms'
988 # plot:
989 ax.plot(tfac*time, data, **rec_style)
990 ax.set_xlim(tfac*t0, tfac*t1)
991 # annotate:
992 ax.set_xlabel(f'Time [{tunit}]')
993 ymin = np.min(data)
994 ymax = np.max(data)
995 dy = ymax - ymin
996 ax.set_ylim(ymin - 0.05*dy, ymax + 0.05*dy)
997 if len(unit) == 0 or unit == 'a.u.':
998 ax.set_ylabel('Amplitude')
999 else:
1000 ax.set_ylabel(f'Amplitude [{unit}]')
1001 return tfac
1004def zoom_eod_recording(ax, eod_props, data, rate, width=0.5, tfac=1, toffs=0):
1005 """Set optimal time limits for EOD recording.
1007 Parameters
1008 ----------
1009 ax: matplotlib axes
1010 Axes used for plotting.
1011 eod_props: list of dict or None
1012 List of EOD properties from which EOD frequencies and fish types
1013 are extracted.
1014 data: 1D ndarray
1015 Recorded data to be plotted.
1016 rate: float
1017 Sampling rate of the data in Hertz.
1018 width: float
1019 Default width of central data segment to be plotted in seconds.
1020 tfac: float
1021 Factor that has been used to multiply time.
1022 toffs: float
1023 Time of first data value in seconds.
1024 """
1025 # optimal width:
1026 if width > len(data)/rate:
1027 width = len(data)/rate
1028 if eod_props is not None and len(eod_props) > 0:
1029 # collect EOD frequencies:
1030 wave_eodfs = []
1031 pulse_eodfs = []
1032 for props in eod_props:
1033 if not 'EODf' in props:
1034 continue
1035 if 'type' in props and props['type'] == 'wave':
1036 wave_eodfs.append(props['EODf'])
1037 else:
1038 pulse_eodfs.append(props['EODf'])
1040 # best time window:
1041 if len(wave_eodfs) + len(pulse_eodfs) > 0:
1042 max_wave_period = 0
1043 max_pulse_period = 0
1044 min_wave_period = 0
1045 min_pulse_period = 0
1046 if len(wave_eodfs) > 0:
1047 max_wave_period = 1/np.min(wave_eodfs)
1048 min_wave_period = 1/np.max(wave_eodfs)
1049 if len(pulse_eodfs) > 0:
1050 max_pulse_period = 1/np.min(pulse_eodfs)
1051 min_pulse_period = 1/np.max(pulse_eodfs)
1052 max_period = max(max_wave_period, max_pulse_period)
1053 width = max(5*max_period, 8*min_pulse_period, 20*min_wave_period)
1054 width = (1 + width//0.01)*0.01
1055 # center in data:
1056 t_center = toffs + len(data)/rate/2
1057 t0 = t_center - width/2
1058 t1 = t_center + width/2
1059 ax.set_xlim(tfac*t0, tfac*t1)
1062def plot_eod_snippets(ax, data, rate, tmin, tmax, eod_times,
1063 n_snippets=10, flip=False, aoffs=0,
1064 snippet_style=dict(scaley=False,
1065 lw=0.5, color='0.6')):
1066 """Plot a few EOD waveform snippets.
1068 Parameters
1069 ----------
1070 ax: matplotlib axes
1071 Axes used for plotting.
1072 data: 1D ndarray
1073 Recorded data from which the snippets are taken.
1074 rate: float
1075 Sampling rate of the data in Hertz.
1076 tmin: float
1077 Start time of each snippet.
1078 tmax: float
1079 End time of each snippet.
1080 eod_times: 1-D array
1081 EOD peak times from which a few are selected to be plotted.
1082 n_snippets: int
1083 Number of snippets to be plotted. If zero do not plot anything.
1084 flip: bool
1085 If True flip the snippets upside down.
1086 aoffs: float
1087 Offset that was subtracted from the average EOD waveform.
1088 snippet_style: dict
1089 Arguments passed on to the plot command for plotting the snippets.
1090 """
1091 if data is None or n_snippets <= 0:
1092 return
1093 i0 = int(tmin*rate)
1094 i1 = int(tmax*rate)
1095 time = 1000.0*np.arange(i0, i1)/rate
1096 step = len(eod_times)//n_snippets
1097 if step < 1:
1098 step = 1
1099 for t in eod_times[n_snippets//2::step]:
1100 idx = int(np.round(t*rate))
1101 if idx + i0 < 0 or idx + i1 >= len(data):
1102 continue
1103 snippet = data[idx + i0:idx + i1] - aoffs
1104 if flip:
1105 snippet *= -1
1106 ax.plot(time, snippet, zorder=-5, **snippet_style)
1109file_types = ['waveeodfs', 'wavefish', 'pulsefish', 'eodwaveform', 'wavephases',
1110 'wavespectrum', 'pulsephases', 'pulsegaussians', 'pulsespectrum', 'pulsetimes']
1111"""List of all file types generated and supported by the `save_*` and `load_*` functions."""
1114def save_eod_waveform(mean_eod, unit, idx, basename, **kwargs):
1115 """Save mean EOD waveform to file.
1117 Parameters
1118 ----------
1119 mean_eod: 2D array of floats
1120 Averaged EOD waveform as returned by `eod_waveform()`,
1121 `analyze_wave()`, and `analyze_pulse()`.
1122 unit: str
1123 Unit of the waveform data.
1124 idx: int or None
1125 Index of fish.
1126 basename: str or Path or stream
1127 If string, path and basename of file.
1128 If `basename` does not have an extension,
1129 '-eodwaveform', the fish index, and a file extension are appended.
1130 If stream, write EOD waveform data into this stream.
1131 kwargs:
1132 Arguments passed on to `TableData.write()`.
1134 Returns
1135 -------
1136 filename: Path
1137 Path and full name of the written file in case of `basename`
1138 being a string. Otherwise, the file name and extension that
1139 would have been appended to a basename.
1141 See Also
1142 --------
1143 load_eod_waveform()
1144 """
1145 td = TableData(mean_eod[:, :3]*[1000.0, 1.0, 1.0],
1146 ['time', 'mean', 'sem'],
1147 ['ms', unit, unit],
1148 ['%.3f', '%.6g', '%.6g'])
1149 if mean_eod.shape[1] > 3:
1150 td.append('fit', unit, '%.5f', value=mean_eod[:, 3])
1151 if mean_eod.shape[1] > 4:
1152 td.append('tailfit', unit, '%.5f', value=mean_eod[:, 4])
1153 fp = ''
1154 ext = Path(basename).suffix if not hasattr(basename, 'write') else ''
1155 if not ext:
1156 fp = '-eodwaveform'
1157 if idx is not None:
1158 fp += f'-{idx}'
1159 return td.write_file_stream(basename, fp, **kwargs)
1162def load_eod_waveform(file_handle):
1163 """Load EOD waveform from file.
1165 Parameters
1166 ----------
1167 file_handle: str, Path, or stream
1168 The file to be loaded.
1169 If str or Path and file does not exist, try to open
1170 possible corresponding zip file and read file from there.
1172 Returns
1173 -------
1174 mean_eod: 2D array of floats
1175 Averaged EOD waveform: time in seconds, mean, standard deviation, fit.
1176 unit: str
1177 Unit of EOD waveform.
1179 Raises
1180 ------
1181 FileNotFoundError:
1182 If `file_path` does not exist.
1184 See Also
1185 --------
1186 save_eod_waveform()
1187 """
1188 file_handle, zf = open_from_zip(file_handle)
1189 data = TableData(file_handle)
1190 close_zip(file_handle, zf)
1191 mean_eod = data.array()
1192 mean_eod[:, 0] *= 0.001
1193 return mean_eod, data.unit('mean')
1196def save_analysis(output_basename, zip_file, eod_props, mean_eods, spec_data,
1197 phase_data, pulse_data, wave_eodfs, wave_indices, unit,
1198 verbose, **kwargs):
1199 """Save EOD analysis results to files.
1201 Parameters
1202 ----------
1203 output_basename: str or Path
1204 Path and basename of files to be saved.
1205 zip_file: bool
1206 If `True`, write all analysis results into a zip archive.
1207 eod_props: list of dict
1208 Properties of EODs as returned by `analyze_wave()` and
1209 `analyze_pulse()`.
1210 mean_eods: list of 2D array of floats
1211 Averaged EOD waveforms as returned by `eod_waveform()`,
1212 `analyze_wave()`, and `analyze_pulse()`.
1213 spec_data: list of 2D array of floats
1214 Energy spectra of single pulses as returned by
1215 `analyze_pulse()`.
1216 phase_data: list of dict
1217 Properties of phases of pulse EODs as returned by
1218 `analyze_pulse()` and `analyze_pulse_phases()`.
1219 pulse_data: list of dict
1220 For each pulse fish a dictionary with phase times, amplitudes and standard
1221 deviations of Gaussians fitted to the pulse waveform.
1222 wave_eodfs: list of 2D array of float
1223 Each item is a matrix with the frequencies and powers
1224 (columns) of the fundamental and harmonics (rows) as returned
1225 by `harmonics.harmonic_groups()`.
1226 wave_indices: array of int
1227 Indices identifying each fish in `wave_eodfs` or NaN.
1228 unit: str
1229 Unit of the waveform data.
1230 verbose: int
1231 Verbosity level.
1232 kwargs:
1233 Arguments passed on to `TableData.write()`.
1234 """
1235 def write_file_zip(zf, save_func, output, *args, **kwargs):
1236 if zf is None:
1237 fp = save_func(*args, basename=output, **kwargs)
1238 if verbose > 0 and fp is not None:
1239 print('wrote file', fp)
1240 else:
1241 with io.StringIO() as df:
1242 fp = save_func(*args, basename=df, **kwargs)
1243 if fp is not None:
1244 fp = Path(output.stem + str(fp))
1245 zf.writestr(fp.name, df.getvalue())
1246 if verbose > 0:
1247 print('zipped file', fp.name)
1250 output_basename = Path(output_basename)
1251 if 'table_format' in kwargs and kwargs['table_format'] == 'py':
1252 with open(output_basename.with_suffix('.py'), 'w') as f:
1253 name = output_basename.stem
1254 for k in range(len((spec_data))):
1255 species = eod_props[k].get('species', '')
1256 if len(pulse_data[k]) > 0:
1257 fish = normalize_pulsefish(pulse_data[k])
1258 export_pulsefish(fish, f'{name}-{k}_phases',
1259 species, f)
1260 f.write('\n')
1261 else:
1262 sdata = spec_data[k]
1263 if len(sdata) > 0 and sdata.shape[1] > 2:
1264 fish = dict(amplitudes=sdata[:, 3], phases=sdata[:, 5])
1265 fish = normalize_wavefish(fish)
1266 export_wavefish(fish, f'{name}-{k}_harmonics',
1267 species, f)
1268 f.write('\n')
1269 else:
1270 zf = None
1271 if zip_file:
1272 zf = ZipFile(output_basename.with_suffix('.zip'), 'w')
1273 # all wave fish in wave_eodfs:
1274 if len(wave_eodfs) > 0:
1275 write_file_zip(zf, save_wave_eodfs, output_basename,
1276 wave_eodfs, wave_indices, **kwargs)
1277 # all wave and pulse fish:
1278 for i, (mean_eod, sdata, pdata, pulse, props) in enumerate(zip(mean_eods, spec_data, phase_data,
1279 pulse_data, eod_props)):
1280 write_file_zip(zf, save_eod_waveform, output_basename,
1281 mean_eod, unit, i, **kwargs)
1282 # spectrum:
1283 if len(sdata)>0:
1284 if sdata.shape[1] <= 3:
1285 write_file_zip(zf, save_pulse_spectrum, output_basename,
1286 sdata, unit, i, **kwargs)
1287 else:
1288 write_file_zip(zf, save_wave_spectrum, output_basename,
1289 sdata, unit, i, **kwargs)
1290 # phases:
1291 if 'areas' in pdata:
1292 write_file_zip(zf, save_pulse_phases, output_basename,
1293 pdata, unit, i, **kwargs)
1294 else:
1295 write_file_zip(zf, save_wave_phases, output_basename,
1296 pdata, unit, i, **kwargs)
1297 # pulses:
1298 write_file_zip(zf, save_pulse_gaussians, output_basename,
1299 pulse, unit, i, **kwargs)
1300 # times:
1301 write_file_zip(zf, save_pulse_times, output_basename,
1302 props, i, **kwargs)
1303 # wave fish properties:
1304 write_file_zip(zf, save_wave_fish, output_basename,
1305 eod_props, unit, **kwargs)
1306 # pulse fish properties:
1307 write_file_zip(zf, save_pulse_fish, output_basename,
1308 eod_props, unit, **kwargs)
1309 if zf is not None:
1310 zf.close()
1313def load_analysis(file_pathes):
1314 """Load all EOD analysis files.
1316 Parameters
1317 ----------
1318 file_pathes: list of str or Path
1319 Pathes of the analysis files of a single recording to be loaded.
1320 This list may contain also a single zip filecontaining all the
1321 analysis files.
1323 Returns
1324 -------
1325 mean_eods: list of 2D array of floats
1326 Averaged EOD waveforms: time in seconds, mean, standard deviation, fit.
1327 wave_eodfs: 2D array of floats
1328 EODfs and power of wave type fish.
1329 wave_indices: array of ints
1330 Corresponding indices of fish, can contain negative numbers to
1331 indicate frequencies without fish.
1332 eod_props: list of dict
1333 Properties of EODs. The 'index' property is an index into the
1334 reurned lists.
1335 spec_data: list of 2D array of floats
1336 Amplitude and phase spectrum of wave-type EODs with columns
1337 harmonics, frequency, amplitude, relative amplitude in dB,
1338 relative power in dB, phase, data power in unit squared.
1339 Energy spectrum of single pulse-type EODs with columns
1340 frequency and energy.
1341 phase_data: list of dict
1342 Properties of phases of pulse-type EODs with keys
1343 indices, times, amplitudes, relamplitudes, widths, areas, relareas, zeros
1344 pulse_data: list of dict
1345 For each pulse fish a dictionary with phase times, amplitudes and standard
1346 deviations of Gaussians fitted to the pulse waveform. Use the
1347 functions provided in thunderfish.fakefish to simulate pulse
1348 fish EODs from this data.
1349 recording: str
1350 Path and base name of the recording file.
1351 channel: int
1352 Analysed channel of the recording.
1353 unit: str
1354 Unit of EOD waveform.
1355 """
1356 recording = None
1357 channel = -1
1358 eod_props = []
1359 zf = None
1360 if len(file_pathes) == 1 and Path(file_pathes[0]).suffix[1:] == 'zip':
1361 zf = ZipFile(file_pathes[0], 'r')
1362 file_pathes = sorted(zf.namelist())
1363 # read wave- and pulse-fish summaries:
1364 pulse_fish = False
1365 wave_fish = False
1366 for f in file_pathes:
1367 recording, _, channel, _, ftype, _, _ = parse_filename(f)
1368 if zf is not None:
1369 f = io.TextIOWrapper(zf.open(f, 'r'))
1370 if ftype == 'wavefish':
1371 eod_props.extend(load_wave_fish(f))
1372 wave_fish = True
1373 elif ftype == 'pulsefish':
1374 eod_props.extend(load_pulse_fish(f))
1375 pulse_fish = True
1376 idx_offs = 0
1377 if wave_fish and not pulse_fish:
1378 idx_offs = sorted([ep['index'] for ep in eod_props])[0]
1379 # load all other files:
1380 neods = len(eod_props)
1381 if neods < 1:
1382 neods = 1
1383 eod_props = [None]
1384 wave_eodfs = np.array([])
1385 wave_indices = np.array([])
1386 mean_eods = [None]*neods
1387 spec_data = [None]*neods
1388 phase_data = [None]*neods
1389 pulse_data = [None]*neods
1390 unit = None
1391 for f in file_pathes:
1392 recording, _, channel, _, ftype, idx, _ = parse_filename(f)
1393 if neods == 1 and idx > 0:
1394 idx = 0
1395 idx -= idx_offs
1396 if zf is not None:
1397 f = io.TextIOWrapper(zf.open(f, 'r'))
1398 if ftype == 'waveeodfs':
1399 wave_eodfs, wave_indices = load_wave_eodfs(f)
1400 elif ftype == 'eodwaveform':
1401 mean_eods[idx], unit = load_eod_waveform(f)
1402 elif ftype == 'wavespectrum':
1403 spec_data[idx], unit = load_wave_spectrum(f)
1404 elif ftype == 'wavephases':
1405 phase_data[idx], unit = load_wave_phases(f)
1406 elif ftype == 'pulsephases':
1407 phase_data[idx], unit = load_pulse_phases(f)
1408 elif ftype == 'pulsegaussians':
1409 pulse_data[idx], unit = load_pulse_gaussians(f)
1410 elif ftype == 'pulsetimes':
1411 pulse_times = load_pulse_times(f)
1412 eod_props[idx]['times'] = pulse_times
1413 eod_props[idx]['peaktimes'] = pulse_times
1414 elif ftype == 'pulsespectrum':
1415 spec_data[idx] = load_pulse_spectrum(f)
1416 if zf is not None:
1417 zf.close()
1418 # fix wave spectra:
1419 wave_eodfs = [fish.reshape(1, 2) if len(fish)>0 else fish
1420 for fish in wave_eodfs]
1421 if len(wave_eodfs) > 0 and len(spec_data) > 0:
1422 eodfs = []
1423 for idx, fish in zip(wave_indices, wave_eodfs):
1424 if idx >= 0:
1425 spec = spec_data[idx]
1426 specd = np.zeros((np.sum(np.isfinite(spec[:, -1])),
1427 2))
1428 specd[:, 0] = spec[np.isfinite(spec[:, -1]),1]
1429 specd[:, 1] = spec[np.isfinite(spec[:, -1]),-1]
1430 eodfs.append(specd)
1431 else:
1432 specd = np.zeros((10, 2))
1433 specd[:, 0] = np.arange(len(specd))*fish[0,0]
1434 specd[:, 1] = np.nan
1435 eodfs.append(specd)
1436 wave_eodfs = eodfs
1437 return mean_eods, wave_eodfs, wave_indices, eod_props, spec_data, \
1438 phase_data, pulse_data, recording, channel, unit
1441def load_recording(file_path, channel=0, load_kwargs={},
1442 eod_props=None, verbose=0):
1443 """Load recording.
1445 Parameters
1446 ----------
1447 file_path: str or Path
1448 Full path of the file with the recorded data.
1449 Extension is optional. If absent, look for the first file
1450 with a reasonable extension.
1451 channel: int
1452 Channel of the recording to be returned.
1453 load_kwargs: dict
1454 Keyword arguments that are passed on to the
1455 format specific loading functions.
1456 eod_props: list of dict or None
1457 List of EOD properties from which start and end times of
1458 analysis window are extracted.
1459 verbose: int
1460 Verbosity level passed on to load function.
1462 Returns
1463 -------
1464 data: array of float
1465 Data of the requested `channel`.
1466 rate: float
1467 Sampling rate in Hertz.
1468 idx0: int
1469 Start index of the analysis window.
1470 idx1: int
1471 End index of the analysis window.
1472 info_dict: dict
1473 Dictionary with path, name, species, channel, chanstr, time.
1474 """
1475 data = None
1476 rate = 0.0
1477 idx0 = 0
1478 idx1 = 0
1479 info_dict = dict(path='',
1480 name='',
1481 species='',
1482 channel=0,
1483 chanstr='',
1484 time='')
1485 for k in range(1, 10):
1486 info_dict[f'part{k}'] = ''
1487 data_file = Path()
1488 file_path = Path(file_path)
1489 if len(file_path.suffix) > 1:
1490 data_file = file_path
1491 else:
1492 data_files = file_path.parent.glob(file_path.stem + '*')
1493 for dfile in data_files:
1494 if not dfile.suffix[1:] in ['zip'] + list(TableData.ext_formats.values()):
1495 data_file = dfile
1496 break
1497 if data_file.is_file():
1498 all_data = DataLoader(data_file, verbose=verbose, **load_kwargs)
1499 rate = all_data.rate
1500 unit = all_data.unit
1501 ampl_max = all_data.ampl_max
1502 data = all_data[:, channel]
1503 species = get_str(all_data.metadata(), ['species'], default='')
1504 if len(species) > 0:
1505 species += ' '
1506 info_dict.update(path=os.fsdecode(all_data.filepath),
1507 name=all_data.basename(),
1508 species=species,
1509 channel=channel)
1510 offs = 1
1511 for k, part in enumerate(all_data.filepath.parts):
1512 if k == 0 and part == all_data.filepath.anchor:
1513 offs = 0
1514 continue
1515 if part == all_data.filepath.name:
1516 break
1517 info_dict[f'part{k + offs}'] = part
1518 if all_data.channels > 1:
1519 if all_data.channels > 100:
1520 info_dict['chanstr'] = f'-c{channel:03d}'
1521 elif all_data.channels > 10:
1522 info_dict['chanstr'] = f'-c{channel:02d}'
1523 else:
1524 info_dict['chanstr'] = f'-c{channel:d}'
1525 else:
1526 info_dict['chanstr'] = ''
1527 idx0 = 0
1528 idx1 = len(data)
1529 if eod_props is not None and len(eod_props) > 0 and 'twin' in eod_props[0]:
1530 idx0 = int(eod_props[0]['twin']*rate)
1531 if len(eod_props) > 0 and 'window' in eod_props[0]:
1532 idx1 = idx0 + int(eod_props[0]['window']*rate)
1533 info_dict['time'] = f'-t{idx0/rate:.0f}s'
1534 all_data.close()
1536 return data, rate, idx0, idx1, info_dict
1539def add_eod_analysis_config(cfg, win_fac=2.0, min_win=0.01, max_eods=None,
1540 min_sem=False, unfilter_cutoff=0.0):
1541 """Add all parameters needed for the eod analysis functions as a new
1542 section to a configuration.
1544 Parameters
1545 ----------
1546 cfg: ConfigFile
1547 The configuration.
1549 See `eod_waveform()`, `analyze_wave()`, and `analyze_pulse()` for
1550 details on the remaining arguments.
1551 """
1552 cfg.add_section('EOD analysis:')
1553 cfg.add('eodSnippetFac', win_fac, '', 'The duration of EOD snippets is the EOD period times this factor.')
1554 cfg.add('eodMinSnippet', min_win, 's', 'Minimum duration of cut out EOD snippets.')
1555 cfg.add('eodMaxEODs', max_eods or 0, '', 'The maximum number of EODs used to compute the average EOD. If 0 use all EODs.')
1556 cfg.add('eodMinSem', min_sem, '', 'Use minimum of s.e.m. to set maximum number of EODs used to compute the average EOD.')
1557 cfg.add('unfilterCutoff', unfilter_cutoff, 'Hz', 'If non-zero remove effect of high-pass filter of recording device using the specified cutoff frequency of the filter.')
1560def eod_waveform_args(cfg):
1561 """Translates a configuration to the respective parameter names of
1562 the function `eod_waveform()`.
1564 The return value can then be passed as key-word arguments to this
1565 function.
1567 Parameters
1568 ----------
1569 cfg: ConfigFile
1570 The configuration.
1572 Returns
1573 -------
1574 a: dict
1575 Dictionary with names of arguments of the `eod_waveform()` function
1576 and their values as supplied by `cfg`.
1577 """
1578 a = cfg.map(win_fac='eodSnippetFac',
1579 min_win='eodMinSnippet',
1580 max_eods='eodMaxEODs',
1581 min_sem='eodMinSem')
1582 return a
1585def add_species_config(cfg, species_file='none', wave_max_rms=0.2,
1586 pulse_max_rms=0.2):
1587 """Add parameters needed for assigning EOD waveforms to species.
1589 Parameters
1590 ----------
1591 cfg: ConfigFile
1592 The configuration.
1593 species_file: str
1594 File path to a file containing species names and corresponding
1595 file names of EOD waveform templates. If 'none', no species
1596 assignemnt is performed.
1597 wave_max_rms: float
1598 Maximum allowed rms difference (relative to standard deviation
1599 of EOD waveform) to an EOD waveform template for assignment to
1600 a wave fish species.
1601 pulse_max_rms: float
1602 Maximum allowed rms difference (relative to standard deviation
1603 of EOD waveform) to an EOD waveform template for assignment to
1604 a pulse fish species.
1605 """
1606 cfg.add_section('Species assignment:')
1607 cfg.add('speciesFile', species_file, '', 'File path to a file containing species names and corresponding file names of EOD waveform templates.')
1608 cfg.add('maximumWaveSpeciesRMS', wave_max_rms, '', 'Maximum allowed rms difference (relative to standard deviation of EOD waveform) to an EOD waveform template for assignment to a wave fish species.')
1609 cfg.add('maximumPulseSpeciesRMS', pulse_max_rms, '', 'Maximum allowed rms difference (relative to standard deviation of EOD waveform) to an EOD waveform template for assignment to a pulse fish species.')
1612def add_eod_quality_config(cfg, max_clipped_frac=0.1, max_phases=4,
1613 max_rms_sem=0.0, max_rms_error=0.05,
1614 min_power=-100.0, max_thd=0.0, max_db_diff=20.0,
1615 max_relampl_harm2=0.0, max_relampl_harm3=0.0,
1616 max_relampl_harm4=0.0):
1617 """Add parameters for `wave_quality()` and `pulse_quality()` to configuration.
1619 Parameters
1620 ----------
1621 cfg: ConfigFile
1622 The configuration.
1624 See `wave_quality()` and `pulse_quality()` for details on the remaining arguments.
1625 """
1626 cfg.add_section('EOD selection:')
1627 cfg.add('maximumClippedFraction', 100*max_clipped_frac, '%', 'Take waveform of the fish with the highest power only if the fraction of clipped EODs is below this value.')
1628 cfg.add('maximumVariance', max_rms_sem, '', 'Skip waveform if the root-mean-squared standard deviation of the extracted waveform relative to the peak-to-peak amplitude is larger than this number. If set to zero do not check.')
1629 cfg.add('maximumRMSError', max_rms_error, '', 'Skip waveform of wave fish if the root-mean-squared difference between waveform and Fourier decomposition relative to the peak-to-peak amplitude is larger than this number. If set to zero do not check.')
1630 cfg.add('maximumPhases', max_phases, '', 'Maximum number of phases per EOD period of a wave fish.')
1631 cfg.add('minimumPower', min_power, 'dB', 'Skip waveform of wave fish if its power is smaller than this value.')
1632 cfg.add('maximumTotalHarmonicDistortion', max_thd, '', 'Skip waveform of wave fish if its total harmonic distortion is larger than this value. If set to zero do not check.')
1633 cfg.add('maximumPowerDifferences', max_db_diff, '', 'Skip waveform of wave fish if the standard deviation of the differences between powers of harmonics is larger than this value. If set to zero do not check.')
1634 cfg.add('maximumSecondHarmonicAmplitude', max_relampl_harm2, '', 'Skip waveform of wave fish if the amplitude of the second harmonic is higher than this factor times the amplitude of the fundamental (=first harmonics). That is, the waveform appears to have twice the frequency than the fundamental. If set to zero do not check.')
1635 cfg.add('maximumThirdHarmonicAmplitude', max_relampl_harm3, '', 'Skip waveform of wave fish if the ampltude of the third harmonic is higher than this factor times the amplitude of the fundamental (=first harmonics). If set to zero do not check.')
1636 cfg.add('maximumFourthHarmonicAmplitude', max_relampl_harm4, '', 'Skip waveform of wave fish if the ampltude of the fourth harmonic is higher than this factor times the amplitude of the fundamental (=first harmonics). If set to zero do not check.')
1639def main():
1640 import matplotlib.pyplot as plt
1641 from .fakefish import pulsefish_eods
1642 from .pulseanalysis import analyze_pulse, plot_pulse_eod, plot_pulse_spectrum
1644 print('Analysis of EOD waveforms.')
1646 # data:
1647 rate = 96_000
1648 data = pulsefish_eods('Triphasic', 83.0, rate, 5.0, noise_std=0.02)
1649 unit = 'mV'
1650 eod_idx, _ = detect_peaks(data, 1.0)
1651 eod_times = eod_idx/rate
1653 # analyse EOD:
1654 mean_eod, eod_times = eod_waveform(data, rate, eod_times)
1655 mean_eod, props, peaks, pulses, energy = \
1656 analyze_pulse(mean_eod, None, eod_times)
1658 # plot:
1659 fig, axs = plt.subplots(1, 2)
1660 plot_pulse_eod(axs[0], mean_eod, props, peaks, unit=unit)
1661 axs[0].set_title(f'{props["type"]} fish: EODf = {props["EODf"]:.1f} Hz')
1662 plot_pulse_spectrum(axs[1], energy, props)
1663 plt.show()
1666if __name__ == '__main__':
1667 main()