Coverage for src / thunderfish / waveanalysis.py: 34%
403 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
1"""
2Analysis of wave-type EOD waveforms.
4## Analysis of wave-type EODs
6- `waveeod_waveform()`: retrieve average EOD waveform via Fourier transform.
7- `analyze_wave()`: analyze the EOD waveform of a wave fish.
9## Visualization
11- `plot_wave_spectrum()`: plot and annotate spectrum of wave EODs.
13## Storage
15- `save_wave_eodfs()`: save frequencies of wave EODs to file.
16- `load_wave_eodfs()`: load frequencies of wave EODs from file.
17- `save_wave_fish()`: save properties of wave EODs to file.
18- `load_wave_fish()`: load properties of wave EODs from file.
19- `save_wave_spectrum()`: save amplitude and phase spectrum of wave EOD to file.
20- `load_wave_spectrum()`: load amplitude and phase spectrum of wave EOD from file.
22## Fit functions
24- `fourier_series()`: Fourier series of sine waves with amplitudes and phases.
25- `exp_decay()`: exponential decay.
26"""
28import numpy as np
30try:
31 from matplotlib.ticker import MultipleLocator
32 from matplotlib.artist import setp
33except ImportError:
34 pass
36from pathlib import Path
37from scipy.optimize import curve_fit
38from numba import jit
39from thunderlab.eventdetection import threshold_crossings, threshold_crossing_times, merge_events
40from thunderlab.powerspectrum import decibel
41from thunderlab.tabledata import TableData
43from .harmonics import fundamental_freqs_and_power
46def waveeod_waveform(data, rate, freq, win_fac=2.0):
47 """Retrieve average EOD waveform via Fourier transform.
49 TODO: use power spectra to check minimum data segment needed and
50 check for changes in frequency over several segments!
52 TODO: return waveform with higher samplign rate? (important for
53 2kHz waves on 24kHz sampling). But this seems to render some EODs
54 inacceptable in the further thunderfish processing pipeline.
56 Parameters
57 ----------
58 data: 1-D array of float
59 The data to be analysed.
60 rate: float
61 Sampling rate of the data in Hertz.
62 freq: float
63 EOD frequency.
64 win_fac: float
65 The snippet size is the EOD period times `win_fac`. The EOD period
66 is determined as the minimum interval between EOD times.
68 Returns
69 -------
70 mean_eod: 2-D array
71 Average of the EOD snippets. First column is time in seconds,
72 second column the mean eod, third column the standard error.
73 eod_times: 1-D array
74 Times of EOD peaks in seconds that have been actually used to
75 calculate the averaged EOD waveform.
77 """
79 @jit(nopython=True)
80 def fourier_wave(data, rate, freq):
81 """
82 extracting wave via fourier coefficients
83 """
84 twave = np.arange(0, (1+win_fac)/freq, 1/rate)
85 wave = np.zeros(len(twave))
86 t = np.arange(len(data))/rate
87 for k in range(0, 31):
88 Xk = np.trapz(data*np.exp(-1j*2*np.pi*k*freq*t), t)*2/t[-1]
89 wave += np.real(Xk*np.exp(1j*2*np.pi*k*freq*twave))
90 return wave
92 @jit(nopython=True)
93 def fourier_range(data, rate, f0, f1, df):
94 wave = np.zeros(1)
95 freq = f0
96 for f in np.arange(f0, f1, df):
97 w = fourier_wave(data, rate, f)
98 if np.max(w) - np.min(w) > np.max(wave) - np.min(wave):
99 wave = w
100 freq = f
101 return wave, freq
103 # TODO: parameterize!
104 tsnippet = 2
105 min_corr = 0.98
106 min_ampl_frac = 0.5
107 frange = 0.1
108 fstep = 0.1
109 waves = []
110 freqs = []
111 times = []
112 step = int(tsnippet*rate)
113 for i in range(0, len(data) - step//2, step//2):
114 w, f = fourier_range(data[i:i + step], rate, freq - frange,
115 freq + frange + fstep/2, fstep)
116 waves.append(w)
117 freqs.append(f)
118 """
119 waves.append(np.zeros(1))
120 freqs.append(freq)
121 for f in np.arange(freq - frange, freq + frange + fstep/2, fstep):
122 w = fourier_wave(data[i:i + step], rate, f)
123 if np.max(w) - np.min(w) > np.max(waves[-1]) - np.min(waves[-1]):
124 waves[-1] = w
125 freqs[-1] = f
126 """
127 times.append(np.arange(i/rate, (i + step)/rate, 1/freqs[-1]))
128 eod_freq = np.mean(freqs)
129 mean_eod = np.zeros((0, 3))
130 eod_times = np.zeros((0))
131 if len(waves) == 0:
132 return mean_eod, eod_times
133 for k in range(len(waves)):
134 period = int(np.ceil(rate/freqs[k]))
135 i = np.argmax(waves[k][:period])
136 waves[k] = waves[k][i:]
137 n = np.min([len(w) for w in waves])
138 waves = np.array([w[:n] for w in waves])
139 # only snippets that are similar:
140 if len(waves) > 1:
141 corr = np.corrcoef(waves)
142 nmax = np.argmax(np.sum(corr > min_corr, axis=1))
143 if nmax <= 1:
144 nmax = 2
145 select = np.sum(corr > min_corr, axis=1) >= nmax
146 waves = waves[select]
147 times = [times[k] for k in range(len(times)) if select[k]]
148 if len(waves) == 0:
149 return mean_eod, eod_times
150 # only the largest snippets:
151 ampls = np.std(waves, axis=1)
152 select = ampls >= min_ampl_frac*np.max(ampls)
153 waves = waves[select]
154 times = [times[k] for k in range(len(times)) if select[k]]
155 if len(waves) == 0:
156 return mean_eod, eod_times
157 """
158 #plt.plot(freqs)
159 plt.plot(waves.T)
160 plt.show()
161 """
162 mean_eod = np.zeros((n, 3))
163 mean_eod[:, 0] = np.arange(len(mean_eod))/rate
164 mean_eod[:, 1] = np.mean(waves, axis=0)
165 mean_eod[:, 2] = np.std(waves, axis=0)
166 eod_times = np.concatenate(times)
167 return mean_eod, eod_times
170def fourier_series(t, freq, *ap):
171 """Fourier series of sine waves with amplitudes and phases.
173 x(t) = sum_{i=0}^n ap[2*i]*sin(2 pi (i+1) freq t + ap[2*i+1])
175 Parameters
176 ----------
177 t: float or array
178 Time.
179 freq: float
180 Fundamental frequency.
181 *ap: list of floats
182 The amplitudes and phases (in rad) of the fundamental and harmonics.
184 Returns
185 -------
186 x: float or array
187 The Fourier series evaluated at times `t`.
188 """
189 omega = 2.0*np.pi*freq
190 x = 0.0
191 for i, (a, p) in enumerate(zip(ap[0:-1:2], ap[1::2])):
192 x += a*np.sin((i+1)*omega*t+p)
193 return x
196def analyze_wave(eod, ratetime, freq, n_harm=10, power_n_harmonics=0,
197 n_harmonics=3, flip_wave='none'):
198 """Analyze the EOD waveform of a wave fish.
200 Parameters
201 ----------
202 eod: 1-D or 2-D array
203 The eod waveform to be analyzed.
204 If an 1-D array, then this is the waveform and you
205 need to also pass a time array or sampling rate in `ratetime`.
206 If a 2-D array, then first column is time in seconds, second
207 column the EOD waveform, third column, if present, is the
208 standard error of the EOD waveform. Further columns are
209 optional and are not used.
210 ratetime: None or float or array of float
211 If a 1-D array is passed on to `eod` then either the sampling
212 rate in Hertz or the time array corresponding to `eod`.
213 freq: float or 2-D array
214 The frequency of the EOD or the list of harmonics (rows) with
215 frequency and peak height (columns) as returned from
216 `harmonics.harmonic_groups()`.
217 n_harm: int
218 Maximum number of harmonics used for the Fourier decomposition.
219 power_n_harmonics: int
220 Sum over the first `power_n_harmonics` harmonics for computing
221 the total power. If 0 sum over all harmonics.
222 n_harmonics: int
223 The maximum power of higher harmonics is computed from
224 harmonics higher than the maximum harmonics within the first
225 three harmonics plus `n_harmonics`.
226 flip_wave: 'auto', 'none', 'flip'
227 - 'auto' flip waveform such that the larger extremum is positive.
228 - 'flip' flip waveform.
229 - 'none' do not flip waveform.
231 Returns
232 -------
233 meod: 2-D array of floats
234 The eod waveform. First column is time in seconds, second
235 column the eod waveform. Further columns are kept from the
236 input `eod`. And a column is added with the fit of the fourier
237 series to the waveform.
238 props: dict
239 A dictionary with properties of the analyzed EOD waveform.
241 - type: set to 'wave'.
242 - EODf: is set to the EOD fundamental frequency.
243 - p-p-amplitude: peak-to-peak amplitude of the Fourier fit.
244 - flipped: True if the waveform was flipped.
245 - amplitude: amplitude factor of the Fourier fit.
246 - noise: root-mean squared standard error mean of the averaged
247 EOD waveform relative to the p-p amplitude.
248 - rmserror: root-mean-square error between Fourier-fit and
249 EOD waveform relative to the p-p amplitude. If larger than
250 about 0.05 the data are bad.
251 - ncrossings: number of zero crossings per period
252 - peakwidth: width of the peak at the averaged amplitude relative
253 to EOD period.
254 - troughwidth: width of the trough at the averaged amplitude
255 relative to EOD period.
256 - minwidth: peakwidth or troughwidth, whichever is smaller.
257 - leftpeak: time from positive zero crossing to peak relative
258 to EOD period.
259 - rightpeak: time from peak to negative zero crossing relative to
260 EOD period.
261 - lefttrough: time from negative zero crossing to trough relative to
262 EOD period.
263 - righttrough: time from trough to positive zero crossing relative to
264 EOD period.
265 - p-p-distance: time between peak and trough relative to EOD period.
266 - min-p-p-distance: p-p-distance or EOD period minus p-p-distance,
267 whichever is smaller, relative to EOD period.
268 - relpeakampl: amplitude of peak or trough, whichever is larger,
269 relative to p-p amplitude.
270 - power: summed power of all harmonics of the extracted EOD waveform
271 in decibel relative to one.
272 - datapower: summed power of all harmonics of the original data in
273 decibel relative to one. Only if `freq` is a list of harmonics.
274 - thd: total harmonic distortion, i.e. square root of sum of
275 amplitudes squared of harmonics relative to amplitude
276 of fundamental.
277 - dbdiff: smoothness of power spectrum as standard deviation of
278 differences in decibel power.
279 - maxdb: maximum power of higher harmonics relative to peak power
280 in decibel.
282 spec_data: 2-D array of floats
283 First six columns are from the spectrum of the extracted
284 waveform. First column is the index of the harmonics, second
285 column its frequency, third column its amplitude, fourth
286 column its amplitude relative to the fundamental, fifth column
287 is power of harmonics relative to fundamental in decibel, and
288 sixth column the phase shift relative to the fundamental.
289 If `freq` is a list of harmonics, a seventh column is added to
290 `spec_data` that contains the powers of the harmonics from the
291 original power spectrum of the raw data. Rows are the
292 harmonics, first row is the fundamental frequency with index
293 0, relative amplitude of one, relative power of 0dB, and phase
294 shift of zero.
295 error_str: string
296 If fitting of the fourier series failed,
297 this is reported in this string.
299 Raises
300 ------
301 IndexError:
302 EOD data is less than one period long.
303 """
304 if eod.ndim == 2:
305 if eod.shape[1] > 2:
306 eeod = eod
307 else:
308 eeod = np.column_stack((eod, np.zeros(len(eod))))
309 else:
310 if isinstance(ratetime, (list, tuple, np.ndarray)):
311 time = ratetime
312 else:
313 time = np.arange(len(eod))/ratetime
314 eeod = np.zeros((len(eod), 3))
315 eeod[:, 0] = time
316 eeod[:, 1] = eod
317 # storage:
318 meod = np.zeros((eeod.shape[0], eeod.shape[1] + 1))
319 meod[:, :eeod.shape[1]] = eeod
320 meod[:, -1] = np.nan
322 freq0 = freq
323 if hasattr(freq, 'shape'):
324 freq0 = freq[0][0]
326 error_str = ''
328 # subtract mean and flip:
329 period = 1.0/freq0
330 pinx = int(np.ceil(period/(meod[1,0]-meod[0,0])))
331 maxn = (len(meod)//pinx)*pinx
332 if maxn < pinx: maxn = len(meod)
333 offs = (len(meod) - maxn)//2
334 meod[:, 1] -= np.mean(meod[offs:offs+pinx,1])
335 flipped = False
336 if 'flip' in flip_wave or ('auto' in flip_wave and -np.min(meod[:, 1]) > np.max(meod[:, 1])):
337 meod[:, 1] = -meod[:, 1]
338 flipped = True
340 # move peak of waveform to zero:
341 offs = len(meod)//4
342 maxinx = offs+np.argmax(meod[offs:3*offs,1])
343 meod[:, 0] -= meod[maxinx,0]
345 # indices of exactly one or two periods around peak:
346 if len(meod) < pinx:
347 raise IndexError('data need to contain at least one EOD period')
348 if len(meod) >= 2*pinx:
349 i0 = maxinx - pinx if maxinx >= pinx else 0
350 i1 = i0 + 2*pinx
351 if i1 > len(meod):
352 i1 = len(meod)
353 i0 = i1 - 2*pinx
354 else:
355 i0 = maxinx - pinx//2 if maxinx >= pinx//2 else 0
356 i1 = i0 + pinx
358 # subtract mean:
359 meod[:, 1] -= np.mean(meod[i0:i1,1])
361 # zero crossings:
362 ui, di = threshold_crossings(meod[:, 1], 0.0)
363 ut, dt = threshold_crossing_times(meod[:, 0], meod[:, 1], 0.0, ui, di)
364 ut, dt = merge_events(ut, dt, 0.02/freq0)
365 ncrossings = int(np.round((len(ut) + len(dt))/(meod[-1,0]-meod[0,0])/freq0))
366 if np.any(ut<0.0):
367 up_time = ut[ut<0.0][-1]
368 else:
369 up_time = 0.0
370 error_str += '%.1f Hz wave fish: no upward zero crossing. ' % freq0
371 if np.any(dt>0.0):
372 down_time = dt[dt>0.0][0]
373 else:
374 down_time = 0.0
375 error_str += '%.1f Hz wave fish: no downward zero crossing. ' % freq0
376 peak_width = down_time - up_time
377 trough_width = period - peak_width
378 peak_time = 0.0
379 trough_time = meod[maxinx+np.argmin(meod[maxinx:maxinx+pinx,1]),0]
380 phase1 = peak_time - up_time
381 phase2 = down_time - peak_time
382 phase3 = trough_time - down_time
383 phase4 = up_time + period - trough_time
384 distance = trough_time - peak_time
385 min_distance = distance
386 if distance > period/2:
387 min_distance = period - distance
389 # fit fourier series:
390 ampl = 0.5*(np.max(meod[:, 1])-np.min(meod[:, 1]))
391 while n_harm > 1:
392 params = [freq0]
393 for i in range(1, n_harm+1):
394 params.extend([ampl/i, 0.0])
395 try:
396 popt, pcov = curve_fit(fourier_series, meod[i0:i1,0],
397 meod[i0:i1,1], params, maxfev=2000)
398 break
399 except (RuntimeError, TypeError):
400 error_str += '%.1f Hz wave fish: fit of fourier series failed for %d harmonics. ' % (freq0, n_harm)
401 n_harm //= 2
402 # store fourier fit:
403 meod[:, -1] = fourier_series(meod[:, 0], *popt)
404 # make all amplitudes positive:
405 for i in range(n_harm):
406 if popt[i*2+1] < 0.0:
407 popt[i*2+1] *= -1.0
408 popt[i*2+2] += np.pi
409 # phases relative to fundamental:
410 # phi0 = 2*pi*f0*dt <=> dt = phi0/(2*pi*f0)
411 # phik = 2*pi*i*f0*dt = i*phi0
412 phi0 = popt[2]
413 for i in range(n_harm):
414 popt[i*2+2] -= (i + 1)*phi0
415 # all phases in the range -pi to pi:
416 popt[i*2+2] %= 2*np.pi
417 if popt[i*2+2] > np.pi:
418 popt[i*2+2] -= 2*np.pi
419 # store fourier spectrum:
420 if hasattr(freq, 'shape'):
421 n = n_harm
422 n += np.sum(freq[:, 0] > (n_harm+0.5)*freq[0,0])
423 spec_data = np.zeros((n, 7))
424 spec_data[:, :] = np.nan
425 k = 0
426 for i in range(n_harm):
427 while k < len(freq) and freq[k,0] < (i+0.5)*freq0:
428 k += 1
429 if k >= len(freq):
430 break
431 if freq[k,0] < (i+1.5)*freq0:
432 spec_data[i,6] = freq[k,1]
433 k += 1
434 for i in range(n_harm, n):
435 if k >= len(freq):
436 break
437 spec_data[i,:2] = [np.round(freq[k,0]/freq0)-1, freq[k,0]]
438 spec_data[i,6] = freq[k,1]
439 k += 1
440 else:
441 spec_data = np.zeros((n_harm, 6))
442 for i in range(n_harm):
443 spec_data[i,:6] = [i, (i+1)*freq0, popt[i*2+1], popt[i*2+1]/popt[1],
444 decibel((popt[i*2+1]/popt[1])**2.0), popt[i*2+2]]
445 # smoothness of power spectrum:
446 db_powers = decibel(spec_data[:n_harm,2]**2)
447 db_diff = np.std(np.diff(db_powers))
448 # maximum relative power of higher harmonics:
449 p_max = np.argmax(db_powers[:3])
450 db_powers -= db_powers[p_max]
451 if len(db_powers[p_max+n_harmonics:]) == 0:
452 max_harmonics_power = -100.0
453 else:
454 max_harmonics_power = np.max(db_powers[p_max+n_harmonics:])
455 # total harmonic distortion:
456 thd = np.sqrt(np.nansum(spec_data[1:, 3]))
458 # peak-to-peak and trough amplitudes:
459 ppampl = np.max(meod[i0:i1,1]) - np.min(meod[i0:i1,1])
460 relpeakampl = max(np.max(meod[i0:i1,1]), np.abs(np.min(meod[i0:i1,1])))/ppampl
462 # variance and fit error:
463 rmssem = np.sqrt(np.mean(meod[i0:i1,2]**2.0))/ppampl if meod.shape[1] > 2 else None
464 rmserror = np.sqrt(np.mean((meod[i0:i1,1] - meod[i0:i1,-1])**2.0))/ppampl
466 # store results:
467 props = {}
468 props['type'] = 'wave'
469 props['EODf'] = freq0
470 props['p-p-amplitude'] = ppampl
471 props['flipped'] = flipped
472 props['amplitude'] = 0.5*ppampl # remove it
473 props['rmserror'] = rmserror
474 if rmssem:
475 props['noise'] = rmssem
476 props['ncrossings'] = ncrossings
477 props['peakwidth'] = peak_width/period
478 props['troughwidth'] = trough_width/period
479 props['minwidth'] = min(peak_width, trough_width)/period
480 props['leftpeak'] = phase1/period
481 props['rightpeak'] = phase2/period
482 props['lefttrough'] = phase3/period
483 props['righttrough'] = phase4/period
484 props['p-p-distance'] = distance/period
485 props['min-p-p-distance'] = min_distance/period
486 props['relpeakampl'] = relpeakampl
487 pnh = power_n_harmonics if power_n_harmonics > 0 else n_harm
488 pnh = min(n_harm, pnh)
489 props['power'] = decibel(np.sum(spec_data[:pnh,2]**2.0))
490 if hasattr(freq, 'shape'):
491 props['datapower'] = decibel(np.sum(freq[:pnh,1]))
492 props['thd'] = thd
493 props['dbdiff'] = db_diff
494 props['maxdb'] = max_harmonics_power
496 return meod, props, spec_data, error_str
499def plot_wave_spectrum(axa, axp, spec, props, unit=None,
500 ampl_style=dict(ls='', marker='o', color='tab:blue', markersize=6),
501 ampl_stem_style=dict(color='tab:blue', alpha=0.5, lw=2),
502 phase_style=dict(ls='', marker='p', color='tab:blue', markersize=6),
503 phase_stem_style=dict(color='tab:blue', alpha=0.5, lw=2)):
504 """Plot and annotate spectrum of wave EOD.
506 Parameters
507 ----------
508 axa: matplotlib axes
509 Axes for amplitude plot.
510 axp: matplotlib axes
511 Axes for phase plot.
512 spec: 2-D array
513 The amplitude spectrum of a single pulse as returned by
514 `analyze_wave()`. First column is the index of the harmonics,
515 second column its frequency, third column its amplitude,
516 fourth column its amplitude relative to the fundamental, fifth
517 column is power of harmonics relative to fundamental in
518 decibel, and sixth column the phase shift relative to the
519 fundamental.
520 props: dict
521 A dictionary with properties of the analyzed EOD waveform as
522 returned by `analyze_wave()`.
523 unit: string
524 Optional unit of the data used for y-label.
525 ampl_style: dict
526 Properties of the markers of the amplitude plot.
527 ampl_stem_style: dict
528 Properties of the stems of the amplitude plot.
529 phase_style: dict
530 Properties of the markers of the phase plot.
531 phase_stem_style: dict
532 Properties of the stems of the phase plot.
533 """
534 n = min(9, np.sum(np.isfinite(spec[:, 2])))
535 # amplitudes:
536 markers, stemlines, _ = axa.stem(spec[:n, 0] + 1, spec[:n, 2],
537 basefmt='none')
538 setp(markers, clip_on=False, **ampl_style)
539 setp(stemlines, **ampl_stem_style)
540 axa.set_xlim(0.5, n + 0.5)
541 axa.set_ylim(bottom=0)
542 axa.xaxis.set_major_locator(MultipleLocator(1))
543 axa.tick_params('x', direction='out')
544 if unit:
545 axa.set_ylabel(f'Amplitude [{unit}]')
546 else:
547 axa.set_ylabel('Amplitude')
548 # phases:
549 phases = spec[:n, 5]
550 phases[phases<0.0] = phases[phases<0.0] + 2*np.pi
551 markers, stemlines, _ = axp.stem(spec[:n, 0] + 1, phases[:n],
552 basefmt='none')
553 setp(markers, clip_on=False, **phase_style)
554 setp(stemlines, **phase_stem_style)
555 axp.set_xlim(0.5, n + 0.5)
556 axp.xaxis.set_major_locator(MultipleLocator(1))
557 axp.tick_params('x', direction='out')
558 axp.set_ylim(0, 2*np.pi)
559 axp.set_yticks([0, np.pi, 2*np.pi])
560 axp.set_yticklabels(['0', '\u03c0', '2\u03c0'])
561 axp.set_xlabel('Harmonics')
562 axp.set_ylabel('Phase')
565def save_wave_eodfs(wave_eodfs, wave_indices, basename, **kwargs):
566 """Save frequencies of wave EODs to file.
568 Parameters
569 ----------
570 wave_eodfs: list of 2D arrays
571 Each item is a matrix with the frequencies and powers
572 (columns) of the fundamental and harmonics (rows) as returned
573 by `harmonics.harmonic_groups()`.
574 wave_indices: array
575 Indices identifying each fish or NaN.
576 If None no index column is inserted.
577 basename: string or stream
578 If string, path and basename of file.
579 If `basename` does not have an extension,
580 '-waveeodfs' and a file extension according to `kwargs` are appended.
581 If stream, write EOD frequencies data into this stream.
582 kwargs:
583 Arguments passed on to `TableData.write()`.
585 Returns
586 -------
587 filename: Path
588 Path and full name of the written file in case of `basename`
589 being a string. Otherwise, the file name and extension that
590 would have been appended to a basename.
592 See Also
593 --------
594 load_wave_eodfs()
596 """
597 eodfs = fundamental_freqs_and_power(wave_eodfs)
598 td = TableData()
599 if wave_indices is not None:
600 td.append('index', '', '%d',
601 value=[wi if wi >= 0 else np.nan
602 for wi in wave_indices])
603 td.append('EODf', 'Hz', '%7.2f', value=eodfs[:, 0])
604 td.append('datapower', 'dB', '%7.2f', value=eodfs[:, 1])
605 ext = Path(basename).suffix if not hasattr(basename, 'write') else ''
606 fp = '-waveeodfs' if not ext else ''
607 return td.write_file_stream(basename, fp, **kwargs)
610def load_wave_eodfs(file_path):
611 """Load frequencies of wave EODs from file.
613 Parameters
614 ----------
615 file_path: string
616 Path of the file to be loaded.
618 Returns
619 -------
620 eodfs: 2D array of floats
621 EODfs and power of wave type fish.
622 indices: array of ints
623 Corresponding indices of fish, can contain negative numbers to
624 indicate frequencies without fish.
626 Raises
627 ------
628 FileNotFoundError:
629 If `file_path` does not exist.
631 See Also
632 --------
633 save_wave_eodfs()
634 """
635 data = TableData(file_path)
636 eodfs = data.array()
637 if 'index' in data:
638 indices = data[:, 'index']
639 indices[~np.isfinite(indices)] = -1
640 indices = np.array(indices, dtype=int)
641 eodfs = eodfs[:, 1:]
642 else:
643 indices = np.zeros(data.rows(), dtype=int) - 1
644 return eodfs, indices
647def save_wave_fish(eod_props, unit, basename, **kwargs):
648 """Save properties of wave EODs to file.
650 Parameters
651 ----------
652 eod_props: list of dict
653 Properties of EODs as returned by `analyze_wave()` and
654 `analyze_pulse()`. Only properties of wave fish are saved.
655 unit: string
656 Unit of the waveform data.
657 basename: string or stream
658 If string, path and basename of file.
659 If `basename` does not have an extension,
660 '-wavefish' and a file extension are appended.
661 If stream, write wave fish properties into this stream.
662 kwargs:
663 Arguments passed on to `TableData.write()`.
665 Returns
666 -------
667 filename: Path or None
668 Path and full name of the written file in case of `basename`
669 being a string. Otherwise, the file name and extension that
670 would have been appended to a basename.
671 None if no wave fish are contained in eod_props and
672 consequently no file was written.
674 See Also
675 --------
676 load_wave_fish()
677 """
678 wave_props = [p for p in eod_props if p['type'] == 'wave']
679 if len(wave_props) == 0:
680 return None
681 td = TableData()
682 if 'twin' in wave_props[0] or 'rate' in wave_props[0] or \
683 'nfft' in wave_props[0]:
684 td.append_section('recording')
685 if 'twin' in wave_props[0]:
686 td.append('twin', 's', '%7.2f', value=wave_props)
687 td.append('window', 's', '%7.2f', value=wave_props)
688 td.append('winclipped', '%', '%.2f',
689 value=wave_props, fac=100.0)
690 if 'samplerate' in wave_props[0]:
691 td.append('samplerate', 'kHz', '%.3f',
692 value=wave_props, fac=0.001)
693 if 'nfft' in wave_props[0]:
694 td.append('nfft', '', '%d', value=wave_props)
695 td.append('dfreq', 'Hz', '%.2f', value=wave_props)
696 td.append_section('waveform')
697 td.append('index', '', '%d', value=wave_props)
698 td.append('EODf', 'Hz', '%7.2f', value=wave_props)
699 td.append('p-p-amplitude', unit, '%.5f', value=wave_props)
700 td.append('power', 'dB', '%7.2f', value=wave_props)
701 if 'datapower' in wave_props[0]:
702 td.append('datapower', 'dB', '%7.2f', value=wave_props)
703 td.append('thd', '%', '%.2f', value=wave_props, fac=100.0)
704 td.append('dbdiff', 'dB', '%7.2f', value=wave_props)
705 td.append('maxdb', 'dB', '%7.2f', value=wave_props)
706 if 'noise' in wave_props[0]:
707 td.append('noise', '%', '%.1f', value=wave_props, fac=100.0)
708 td.append('rmserror', '%', '%.2f', value=wave_props, fac=100.0)
709 if 'clipped' in wave_props[0]:
710 td.append('clipped', '%', '%.1f', value=wave_props, fac=100.0)
711 td.append('flipped', '', '%d', value=wave_props)
712 td.append('n', '', '%5d', value=wave_props)
713 td.append_section('timing')
714 td.append('ncrossings', '', '%d', value=wave_props)
715 td.append('peakwidth', '%', '%.2f', value=wave_props, fac=100.0)
716 td.append('troughwidth', '%', '%.2f', value=wave_props, fac=100.0)
717 td.append('minwidth', '%', '%.2f', value=wave_props, fac=100.0)
718 td.append('leftpeak', '%', '%.2f', value=wave_props, fac=100.0)
719 td.append('rightpeak', '%', '%.2f', value=wave_props, fac=100.0)
720 td.append('lefttrough', '%', '%.2f', value=wave_props, fac=100.0)
721 td.append('righttrough', '%', '%.2f', value=wave_props, fac=100.0)
722 td.append('p-p-distance', '%', '%.2f', value=wave_props, fac=100.0)
723 td.append('min-p-p-distance', '%', '%.2f',
724 value=wave_props, fac=100.0)
725 td.append('relpeakampl', '%', '%.2f', value=wave_props, fac=100.0)
726 ext = Path(basename).suffix if not hasattr(basename, 'write') else ''
727 fp = '-wavefish' if not ext else ''
728 return td.write_file_stream(basename, fp, **kwargs)
731def load_wave_fish(file_path):
732 """Load properties of wave EODs from file.
734 All times are scaled to seconds, all frequencies to Hertz and all
735 percentages to fractions.
737 Parameters
738 ----------
739 file_path: string
740 Path of the file to be loaded.
742 Returns
743 -------
744 eod_props: list of dict
745 Properties of EODs.
747 Raises
748 ------
749 FileNotFoundError:
750 If `file_path` does not exist.
752 See Also
753 --------
754 save_wave_fish()
756 """
757 data = TableData(file_path)
758 eod_props = data.dicts()
759 for props in eod_props:
760 if 'winclipped' in props:
761 props['winclipped'] /= 100
762 if 'samplerate' in props:
763 props['samplerate'] *= 1000
764 if 'nfft' in props:
765 props['nfft'] = int(props['nfft'])
766 props['index'] = int(props['index'])
767 props['n'] = int(props['n'])
768 props['type'] = 'wave'
769 props['thd'] /= 100
770 props['noise'] /= 100
771 props['rmserror'] /= 100
772 if 'clipped' in props:
773 props['clipped'] /= 100
774 props['ncrossings'] = int(props['ncrossings'])
775 props['peakwidth'] /= 100
776 props['troughwidth'] /= 100
777 props['minwidth'] /= 100
778 props['leftpeak'] /= 100
779 props['rightpeak'] /= 100
780 props['lefttrough'] /= 100
781 props['righttrough'] /= 100
782 props['p-p-distance'] /= 100
783 props['min-p-p-distance'] /= 100
784 props['relpeakampl'] /= 100
785 return eod_props
788def save_wave_spectrum(spec_data, unit, idx, basename, **kwargs):
789 """Save amplitude and phase spectrum of wave EOD to file.
791 Parameters
792 ----------
793 spec_data: 2D array of floats
794 Amplitude and phase spectrum of wave EOD as returned by
795 `analyze_wave()`.
796 unit: string
797 Unit of the waveform data.
798 idx: int or None
799 Index of fish.
800 basename: string or stream
801 If string, path and basename of file.
802 If `basename` does not have an extension,
803 '-wavespectrum', the fish index, and a file extension are appended.
804 If stream, write wave spectrum into this stream.
805 kwargs:
806 Arguments passed on to `TableData.write()`.
808 Returns
809 -------
810 filename: Path
811 Path and full name of the written file in case of `basename`
812 being a string. Otherwise, the file name and extension that
813 would have been appended to a basename.
815 See Also
816 --------
817 load_wave_spectrum()
819 """
820 td = TableData(spec_data[:, :6]*[1.0, 1.0, 1.0, 100.0, 1.0, 1.0],
821 ['harmonics', 'frequency', 'amplitude',
822 'relampl', 'relpower', 'phase'],
823 ['', 'Hz', unit, '%', 'dB', 'rad'],
824 ['%.0f', '%.2f', '%.6f', '%10.2f', '%6.2f', '%8.4f'])
825 if spec_data.shape[1] > 6:
826 td.append('datapower', '%s^2/Hz' % unit, '%11.4e',
827 value=spec_data[:, 6])
828 fp = ''
829 ext = Path(basename).suffix if not hasattr(basename, 'write') else ''
830 if not ext:
831 fp = '-wavespectrum'
832 if idx is not None:
833 fp += f'-{idx}'
834 return td.write_file_stream(basename, fp, **kwargs)
837def load_wave_spectrum(file_path):
838 """Load amplitude and phase spectrum of wave EOD from file.
840 Parameters
841 ----------
842 file_path: string
843 Path of the file to be loaded.
845 Returns
846 -------
847 spec: 2D array of floats
848 Amplitude and phase spectrum of wave EOD:
849 harmonics, frequency, amplitude, relative amplitude in dB,
850 relative power in dB, phase, data power in unit squared.
851 Can contain NaNs.
852 unit: string
853 Unit of amplitudes.
855 Raises
856 ------
857 FileNotFoundError:
858 If `file_path` does not exist.
860 See Also
861 --------
862 save_wave_spectrum()
863 """
864 data = TableData(file_path)
865 spec = data.array()
866 spec[:, 3] *= 0.01
867 return spec, data.unit('amplitude')
870def main():
871 import matplotlib.pyplot as plt
872 from thunderlab.eventdetection import snippets
873 from .fakefish import wavefish_eods, export_wavefish
874 from .eodanalysis import plot_eod_waveform
876 print('Analysis of wave-type EOD waveforms.')
878 # data:
879 eodf = 456 # Hz
880 rate = 96_000
881 tmax = 5 # s
882 data = wavefish_eods('Eigenmannia', eodf, rate, tmax, noise_std=0.02)
883 unit = 'mV'
884 eod_times = np.arange(0.5/eodf, tmax - 1/eodf, 1/eodf)
885 eod_idx = (eod_times*rate).astype(int)
887 # mean EOD:
888 snips = snippets(data, eod_idx, start=-300, stop=300)
889 mean_eod = np.mean(snips, 0)
891 # analyse EOD:
892 mean_eod, props, power, error_str = \
893 analyze_wave(mean_eod, rate, eodf)
895 # write out as python code:
896 export_wavefish(power, '', 'Eigenmannia spec')
898 # plot:
899 fig, axs = plt.subplot_mosaic('wa\nwp', layout='constrained')
900 plot_eod_waveform(axs['w'], mean_eod, props, unit=unit)
901 axs['w'].set_title(f'wave fish: EODf = {props["EODf"]:.1f} Hz')
902 plot_wave_spectrum(axs['a'], axs['p'], power, props)
903 plt.show()
906if __name__ == '__main__':
907 main()