From cdc169e3388586b9c9b926868f12bbe7b648666d Mon Sep 17 00:00:00 2001 From: Dietrich Brunn Date: Sat, 23 Sep 2017 20:11:23 +0200 Subject: [PATCH] Factored out and reworked spectrum and psd from mlab weiter im Text Traitlets komplett rausgeschmissen weiter im Text Refactor out mlab's spectral functions --- doc/api/index.rst | 1 + doc/api/spectral_api.rst | 27 + examples/spectral/README.txt | 9 + examples/spectral/psd_shotnoise.py | 48 + examples/spectral/spectrum_density.py | 59 ++ examples/spectral/spectrum_phase.py | 41 + examples/spectral/spectrum_scalings.py | 80 ++ examples/spectral/window_spectrums.py | 58 ++ examples/ticks_and_spines/README.txt | 1 + lib/matplotlib/spectral.py | 1208 ++++++++++++++++++++++++ 10 files changed, 1532 insertions(+) create mode 100644 doc/api/spectral_api.rst create mode 100644 examples/spectral/README.txt create mode 100644 examples/spectral/psd_shotnoise.py create mode 100644 examples/spectral/spectrum_density.py create mode 100644 examples/spectral/spectrum_phase.py create mode 100644 examples/spectral/spectrum_scalings.py create mode 100644 examples/spectral/window_spectrums.py create mode 100644 lib/matplotlib/spectral.py diff --git a/doc/api/index.rst b/doc/api/index.rst index 30668cdfdddd..54ce632f1434 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -48,6 +48,7 @@ rcsetup_api.rst sankey_api.rst scale_api.rst + spectral_api.rst spines_api.rst style_api.rst text_api.rst diff --git a/doc/api/spectral_api.rst b/doc/api/spectral_api.rst new file mode 100644 index 000000000000..d49935f0f1d8 --- /dev/null +++ b/doc/api/spectral_api.rst @@ -0,0 +1,27 @@ + +.. _spectral_api: + +******** +spectral +******** + +This module provides the classes `Spectrum` and `Periodgramm` for +performing spectral analysis on signals. In the example section +:ref:`spectral_examples` some applications are shown. + + +Class `Spectrum` +================ + +.. autoclass:: matplotlib.spectral.Spectrum + :members: + :inherited-members: + + +Class `Periodogram` +=================== + +.. autoclass:: matplotlib.spectral.Periodogram + :inherited-members: + :members: + diff --git a/examples/spectral/README.txt b/examples/spectral/README.txt new file mode 100644 index 000000000000..fef5a0e4006f --- /dev/null +++ b/examples/spectral/README.txt @@ -0,0 +1,9 @@ + +.. _spectral_examples: + +Spectral Analysis +================= + +This section covers examples using the :ref:`spectral module ` +which utilizes the Fast Fourier Transform (FFT). + diff --git a/examples/spectral/psd_shotnoise.py b/examples/spectral/psd_shotnoise.py new file mode 100644 index 000000000000..6c3c66a42eea --- /dev/null +++ b/examples/spectral/psd_shotnoise.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +r""" +================================================= +Power Spectral Density (PSD) using Welch's Method +================================================= + +When investigating noisy signals, Periodograms utilizing +`Welch's Method `_ +(i.e., Hann Window and 50% overlap) are useful. + +This example shows a signal with white noise dominating above 1 KHz and +`flickr noise `_ (or 1/f noise) +dominating below 1 KHz. This kind of noise is typical for analog electrial +circuits. + +The plot has double logarithmic scaling with the left y-scale depicting the +relative power spectral density and the right one showing the relative spectral +power. + +Checkout the :ref:`sphx_glr_gallery_spectral_spectrum_density.py` example for +the influence of the avaraging on the result. +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.spectral import Periodogram + +np.random.seed(1648142464) # for reproducibility + +n = 2**13 # number of samples +T = 1e-5 # sampling interval +t = np.arange(n) * T # time points +f0, sig_w0, sig_w1 = 5e3, .02, 10 +# generate shot nose via low path filtering white noise: +fW = np.fft.rfftfreq(n, T) +W1 = np.random.randn(len(fW)) * sig_w1/2 +W1[0] = 0 +W1[1:] = 1/np.sqrt(fW[1:]) +w1 = np.fft.irfft(W1) * n +# the signal: +x = np.sin(2*np.pi*f0*t) + sig_w0 * np.random.randn(n) + w1 + + +PS = Periodogram(x, f_s=1/T, window='hann', nperseg=n//8, noverlap=n//16, + detrend='mean', unit='V') +fig1, axx1 = PS.plot(density=True, yscale='db', xscale='log', fig_num=1) +axx1.set_xlim(PS.f[1], PS.f_s/2) + +plt.show() diff --git a/examples/spectral/spectrum_density.py b/examples/spectral/spectrum_density.py new file mode 100644 index 000000000000..45d2b612f0e3 --- /dev/null +++ b/examples/spectral/spectrum_density.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +r""" +================================ +Spectrum versus Spectral density +================================ + +To demonstrate the difference of a spectrum and a spectral density, this +example investigates a 20 Hz sine signal with amplitude of 1 Volt +corrupted by additive white noise. +with different averaging factors. Increasing the averaging lowers +variance of spectrum (or density) as well as decreasew the frequency +resolution. + +The left plot shows the spectrum scaled to amplitude (unit V) and the right +plot depicts the amplitude density (unit V :math:`/\sqrt{Hz}`) of the analytic +signal. A Hanning window with an overlap of 50% is utilized, which corresponds +to `Welch's Method `_. + +Note that in the spectrum, the height of the peak at 2 Hz stays is more or less +constant over the different averaging factors. The variations are due to the +influnce of the noise. For longer signals (large `n`) the peak will converge to +its theoretical value of 1 V. +In the density, on the other hand, the noise floor has +a constant magnitude. In summary: Use a spectrum for determining height of +peaks and a density for the magnitude of the noise. + +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.spectral import Periodogram +plt.style.use('seaborn-darkgrid') # less intrusive grid lines in this style + +np.random.seed(2243049845) # for reproducibility + +n = 2**10 # number of samples +T = 10/n # sampling interval (for a duration of 10 s) +t = np.arange(n) * T # time points +f0, sig_w = 20, 2 +x = 2*np.sin(2*np.pi*f0*t) + sig_w * np.random.randn(n) + +PS = Periodogram(x, f_s=1/T, window='hann', unit='V') +PS.oversampling(8) + +fig1, axx1 = plt.subplots(1, 2, sharex=True, num=1, clear=True) +for c, nperseg in enumerate([n//8, n//16, n//32]): + PS.nperseg, PS.noverlap = nperseg, nperseg//2 + fa, Xa = PS.spectrum(yscale='amp') + fd, Xd = PS.density(yscale='amp') + axx1[0].plot(fa, Xa, alpha=.8, label=r"$%d\times$ avg." % PS.seg_num) + axx1[1].plot(fd, Xd, alpha=.8, label=r"$%d\times$ avg." % PS.seg_num) + +axx1[0].set(title="Avg. Spectrum (Analytic)", ylabel="Amplitude in V") +axx1[1].set(title="Avg. Spectral Density (Analytic)", + ylabel=r"Amplitude in V/$\sqrt{Hz}$") +for ax in axx1: + ax.set_xlabel("Frequency in Hertz") + ax.legend() +fig1.tight_layout() +plt.show() diff --git a/examples/spectral/spectrum_phase.py b/examples/spectral/spectrum_phase.py new file mode 100644 index 000000000000..69d5a168d3a2 --- /dev/null +++ b/examples/spectral/spectrum_phase.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +r""" +============================ +Amplitude and Phase Spectrum +============================ + +The signal :math:`x(t)` of a damped +`harmonic oscillator `_ with +frequency :math:`f_0=10` Hz, (initial) amplitude :math:`a=10` Volt (V), and a +damping ratio of :math:`D=.01` can be written as + +.. math:: + + x(t) = a\, e^{-2\pi f_0 D t}\, \cos(2 \pi \sqrt{1-D^2} f_0 t)\ . + +The first plot shows a two-sided spectrum with the scaling set to amplitude +(`yscale='amp'`), meaning there's a peak at :math:`\pm f_0`. +The phase spectrum in the second plot shows that the phase is rising +proportionally with the frequency except around :math:`\pm f_0`, +where the phase shifts by almost -180°. Would :math:`D` be zero, then the phase +shift would be exactly -180°. +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.spectral import Spectrum +# plt.style.use('seaborn-darkgrid') + +n = 2**9 # number of samples +T = 10/n # sampling interval (for a duration of 10 s) +t = np.arange(n) * T # time points +a, w0, D = 10, 2*np.pi*10, .01 # Amplitude, angular frequency, damping ratio + +x = a * np.exp(-D * w0 * t) * np.cos(np.sqrt(1-D**2) * w0 * t) + +SP = Spectrum(x, f_s=1/T, unit='V') +fig1, ax1 = SP.plot(yscale='amp', fft_repr='twosided', right_yaxis=True, + fig_num=1) +fig2, ax2 = SP.plot_phase(yunit='deg', fft_repr='twosided', + plt_kw=dict(color='C1'), fig_num=2) + +plt.show() diff --git a/examples/spectral/spectrum_scalings.py b/examples/spectral/spectrum_scalings.py new file mode 100644 index 000000000000..6824c5254a81 --- /dev/null +++ b/examples/spectral/spectrum_scalings.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +r""" +====================== +Scalings of a Spectrum +====================== + +Different scalings of a sampled cosine signal :math:`x(t)` with frequency +:math:`f_0 = 2` Hz and amplitude :math:`a = 2` V (Volt), i.e., + +.. math:: + + x(t) = a\, \cos(2 \pi f_0 t) + = a\, \frac{e^{i 2 \pi f_0 t} + e^{-i 2 \pi f_0 t}}{2} + +are presented in this example. +There are three common representations of a singled-sided Spectrum or +FFT of a real signal (property `fft_repr`): + +1. Don't scale the amplitudes ('single'). +2. Scale the amplitude by a factor of :math:`\sqrt{2}` representing the power + of the signal ('rms', i.e., root-mean-square). +3. Scale by a factor of :math:`2` for representing the amplitude of the + signal ('analytic'). + +Furthermore, there are three common scalings of the :math:`y`-axis (`yscale`): + +a. Unscaled magnitude, revealing the amplitude :math:`a` ('amp'). +b. Squared magnitude, which is proportional to the signal's power ('power'). +c. Relative power scaled logarithmically in Decibel. The notation dB(1V²) means + :math:`y = 10\, \log_{10}(P_y) - 10\, \log_{10}(1V^2)` ('dB'). + +The first plot shows the combination of different single-sided FFT. The height +of the peak is denoted in the plot's upper right corner. +The second plot shows the two-sided amplitude spectrum, with the scaling being +set to amplitude (`yscale='amp'`) meaning there's a frequency component of +:math:`a/2` at :math:`\pm f_0`. +""" +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.spectral import Spectrum +plt.style.use('seaborn-darkgrid') # grid is less intrusive with this style + +n = 2**7 # number of samples +T = 10/n # sampling interval (for a duration of 10 s) +t = np.arange(n) * T # time points +a, f0 = 2, 2 +x = a*np.cos(2*np.pi*f0*t) # the signal + +SP = Spectrum(x, f_s=1/T, unit='V') + + +fig1, axx1 = plt.subplots(3, 3, sharex=True, num=1, figsize=(10, 5.5), + clear=True) +# Legend labels: +lb = [(r"$|a|/2$", r"$|a|/\sqrt{2}$", "$|a|$"), + (r"$|a|^2/4$", r"$|a|^2/2$", "$|a|^2$")] +lb.append([r"$10\,\log_{10}(%s)$" % s[1:-1] for s in lb[1]]) + +for p, yscale in enumerate(('amp', 'power', 'dB')): + for q, side in enumerate(('onesided', 'rms', 'analytic')): + ax_ = SP.plot(yscale=yscale, fft_repr=side, right_yaxis=False, + plt_kw=dict(color=f'C{q}'), ax=axx1[p, q]) + if q != 1 or p != 2: # only one xlabel + ax_.set_xlabel("") + ax_.text(.98, .9, lb[p][q], horizontalalignment='right', + verticalalignment='top', transform=ax_.transAxes) +axx1[0, 0].set_xlim(0, SP.f_s/2) +for p in range(3): + axx1[0, p].set_ylim(-0.1, 2.2) + axx1[1, p].set_ylim(-0.2, 4.4) +fig1.tight_layout() + + +fig2, ax2 = SP.plot(yscale='amp', fft_repr='twosided', right_yaxis=False, + fig_num=2) +ax2.set_xlim(-SP.f_s/2, +SP.f_s/2) +ax2.text(.98, .95, lb[0][0], horizontalalignment='right', + verticalalignment='top', transform=ax2.transAxes) + +plt.show() diff --git a/examples/spectral/window_spectrums.py b/examples/spectral/window_spectrums.py new file mode 100644 index 000000000000..30a34d45c6c2 --- /dev/null +++ b/examples/spectral/window_spectrums.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +r""" +=========================== +Spectra of Window Functions +=========================== + +Windowing allows trading frequency resolution for better suppression of side +lobes. This can be illustrated by looking at the spectrum of single frequency +signal with amplitude one and length of one second, i. e., + +.. math:: + + z(t) = \exp(i 2 \pi f_0 t) + +In the plot the frequency :math:`f_0` has been shifted to zero by setting +the center frequency `f_c=f_0`. It shows the spectra of the standard window +functions, where :math:`\Delta f_r` denotes the distance from the maximum +to the first minimum. The high oversampling (`nfft=1024`) improves the +resolution from :math:`\Delta f=1` Hz to :math:`\Delta f=7.8` mHz (property +`df`). The left plot has amplitude scaling, the right depicts a dB-scale. Note +that with dB scaling, the minimas are not depicted correctly due to the limited +resolution in `f`. + +The plot shows that the unwindowed spectrum (`window='rect'`) has the +thinnest main lobe, but the highest side lobes. The Blackman window has the +best side lobe suppression at the cost of a third of the frequency resolution. +Frequently the Hann window is used, because it is a good compromise between the +width of the main lobe and the suppression of the higher order side lobes. +""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.spectral import Spectrum, WINDOWS + +plt.style.use('seaborn-darkgrid') # grid is less intrusive with this style + +n, tau = 2**3, 1 +T, f0 = tau / n, 2 +t = T * np.arange(n) +z = np.exp(2j*np.pi*f0*t) # the signal + +SP = Spectrum(z, 1/T, nfft=2**10, fft_repr='twosided', f_c=f0) + +fig, axx = plt.subplots(1, 2, sharex=True) +axx[0].set(title="Amplitude Spectra", ylabel="Normalized Amplitude", + xlabel="Normalized Frequency", xlim=(-SP.f_s/2, +SP.f_s/2)) +axx[1].set(title="Rel. Power Spectra", xlabel="Normalized Frequency", + ylabel="Relative Power in dB(1)") +wins = [k for k in WINDOWS if k != 'user'] # extract valid window names +for w in wins: + f, X_amp = SP.spectrum(yscale='amp', window=w) + _, X_db = SP.spectrum(yscale='db') + lb_str = w.title() + r", $\Delta f_r=%g$" % SP.f_res + axx[0].plot(f, X_amp, label=lb_str, alpha=.7) + axx[1].plot(f, X_db, label=lb_str, alpha=.7) +axx[1].set_ylim(-120, 5) +axx[1].legend(loc='best', framealpha=1, frameon=True, fancybox=True) +fig.tight_layout() diff --git a/examples/ticks_and_spines/README.txt b/examples/ticks_and_spines/README.txt index e7869c5a08d1..751d0fbfb90c 100644 --- a/examples/ticks_and_spines/README.txt +++ b/examples/ticks_and_spines/README.txt @@ -2,3 +2,4 @@ Ticks and spines ================ + diff --git a/lib/matplotlib/spectral.py b/lib/matplotlib/spectral.py new file mode 100644 index 000000000000..e2771ee6c257 --- /dev/null +++ b/lib/matplotlib/spectral.py @@ -0,0 +1,1208 @@ +# -*- coding: utf-8 -*- +"""Calculating Spectrum and Periodogramm with an OO interface. + +""" +# from functools import partial +import matplotlib.pyplot as plt +import numpy as np + + +def _detrend(x, kind, axis=-1): + """Detrend one or two-dimensonal array along an axis. + + This helper exists to avoid circular dependencies with + :py:mod:`matplotlib.mlab`. + + Parameters + ---------- + x : (n,m) array or (n,) array + One or two-dimensional input array + kind : {'mean', 'linear', 'none', None} (case insensitive) + Remove offset ('mean'), trend line ('linear') or do nothing ('none'). + axis : {-1, 0, 1} + Along which dimension of `x` the detrending will be performed. + + Returns + ------- + xd : (n,m) array or (n,) array + The detrended array + """ + kind = kind.lower() + if kind == 'none' or kind is None: + return x + elif kind == 'mean': + return x - x.mean(axis=axis) + elif kind == 'linear': + if axis != 0: + x = x.T + n = x.shape[0] + HH = np.vstack((np.arange(n), np.ones(n))).T + res = np.linalg.lstsq(HH, x)[1] + return res if axis == 0 else res.T + raise ValueError("Parameter 'kind' (= {}) must be one of: ".format(kind) + + "the following strings: 'mean', 'linear', 'none'") + + +def _yscale(Px, yscale): + """Scale Power to amplitude or decibel. + + The string `yscale`, determines scaling behavior: + + ======= ============ + yscale Returns + ======= ============ + 'amp' sqrt(Px) + 'power' Px + 'db' 10*log10(Px) + ======= ============ + + Note the parameter `yscale` is not case-sensitve. Also, non-positive `Px` + are set to `-np.inf` in the dB scale. + """ + + yscale = yscale.lower() + if yscale == 'amp': + return np.sqrt(Px) + elif yscale == 'power': + return Px + elif yscale == 'db': + ii = Px > 0 + P_db = 10 * np.log10(Px, where=ii) + if np.any(~ii): + P_db[ii] = -np.inf + return P_db + raise ValueError("Parameter yscale (='{}') must be one ".format(yscale) + + "of: 'amp', 'power', 'db'!") + + +def _to_peridoc_winfun(win_fun): + """Converts Numpy's symmetric window functions into periodic ones. + + Symmetric windows are used for filter design, periodic ones for sepctral + analysis. Truncating a window by one sample changes it from a symmetric + into a periodic window or vice versa. + + An alternative would be using :py:func:`scipy.signal.get_window`, + but SciPy is not a Matplotlib dependency. + """ + fname = win_fun.__qualname__ + dstr = ("Wraps %s() for return a periodic window." % fname, + "", + "Numpy's window function return symmetric windows suited for " + + "filter design. For spectral analysis unsymmetric, i.e., " + + "periodic windows are required.", + "", + "This function was generated by :func:`._to_peridoc_winfun()`.", + "", "", + "Docstring of original function %s()" % fname, + "---------------------------------" + "-"*len(fname), "") + + def pwfun(m): + """The wrapper function. """ + return win_fun(m+1)[:-1] + + pwfun.__name__ = "p_" + win_fun.__name__ + pwfun.__doc__ = "\n".join([" " + l for l in dstr]) + win_fun.__doc__ + return pwfun + + +#: Standard windows with relative bin widths: +WINDOWS = {'rect': (np.ones, 1), + 'hann': (_to_peridoc_winfun(np.hanning), 2), + 'hamming': (_to_peridoc_winfun(np.hamming), 2), + 'bartlett': (_to_peridoc_winfun(np.bartlett), 2), + 'blackman': (_to_peridoc_winfun(np.blackman), 3), + 'user': (None, None)} + + +class _SpectralBaseClass: + """Abstract base class for sharing code between the classes + :class:`Spectrum` and :class:`Periodogram`. + + It provides setter and getter functions for properties. This + allows basic consistecy checking, caching of calculated values by utilizing + the attribute :attr:`.recalc_needed` and the :meth:`.clear_results()` + method. + It also makes the :meth:`.set()` method possible. Furthermore, it provides + convience attributes, e.g., the signal length :attr:`.tau`, which are + derived from other attributes as well as methods for setting windows types + and generating plot labels. + """ + # Window related: + _win_func, _win_name = None, '' + _win_binwidth = None + _win_array = np.array([], dtype=np.float, ndmin=2) + _win_sum, _win_sum2 = np.nan, np.nan # Factors of window function + + def __init__(self, x, f_s, **kwargs): + """Initializer """ + if self.__class__ is _SpectralBaseClass: + raise NotImplementedError("Instanciating this abstract base class " + "is not allowed!") + # To enable set() method, the attributes are kept in a local dict: + self._attributes = dict(window='nan') + # fill self._attributes with default values: + self.x, self.f_s = x, f_s + self.nfft = len(self.x) + self.detrend, self.fft_repr = 'none', 'onesided', + self.f_c, self.unit, self.f_unit = 0, 'Unit', 'Hz' + self.window = 'rect' + self.recalc_needed = True + + self.set(**kwargs) + + def set(self, **kwargs): + """Set any property via keyword argument. """ + for k, v in kwargs.items(): + if k not in self._attributes.keys(): + raise ValueError("Attribute '%s' does not exist!" % k) + setattr(self, k, v) + + def properties(self): + """Return a list of property names, which can be modified. """ + return self._attributes.keys() + + def _clear_results(self, clear_win=False): + """Clear all cached calculated attributes. """ + self.recalc_needed = True + if clear_win: + self._win_array = np.array([], dtype=np.float, ndmin=2) + self._win_sum, self._win_sum2 = np.nan, np.nan + + @property + def x(self): + """The input signal x in form of a (n, chans) array. + + The input signal is made up of :attr:`.n` samples and :attr:`.chans` + channels (i.e, number of lines to plot) + """ + return self._attributes['x'] + + @x.setter + def x(self, val): + """ Ensures that x can be converted into a (n, chans) array. """ + self._clear_results(clear_win=len(val) == len(self._win_array)) + # convert to 1D array if needed: + x = np.squeeze(val) + if x.ndim == 1: + x = np.reshape(x, (len(x), 1)) # ensure (n, 1) array + elif x.ndim != 2 or x.shape[0] <= 0: + raise TypeError("Could not convert parameter 'x' to nonempty" + "(n, chans) array!") + self._attributes['x'] = x + +# @property +# def y(self): +# """The second input signal y has the same shape as x or is None. """ +# return self._attributes['y'] +# +# @y.setter +# def y(self, val): +# """Ensure that y has a valid shape. """ +# self._clear_results(clear_win=len(val) == len(self._win_array))) +# if val is not None: +# y = np.squeeze(val) +# if y.ndim == 1: +# y = np.reshape(y, (len(y), 1)) # ensure (n, 1) array +# if y.shape != self.x.shape: +# estr = "x.shape != y.shape ({} !={})!" +# raise ValueError(estr.format(self.x.shape, y.shape)) +# self._attributes['y'] = y + + @property + def f_s(self): + """Sampling frequency. """ + return self._attributes['f_s'] + + @f_s.setter + def f_s(self, val): + """Ensures that sampling frequncy is > 0. """ + self._clear_results(clear_win=False) + if not val > 0: + raise ValueError("Sampling frequency f_s has to be > 0!") + self._attributes['f_s'] = val + + @property + def nfft(self): + """ Number of samples of FFT. + + If 0, then signal is padded with + zeros. + """ + return self._attributes['nfft'] + + @nfft.setter + def nfft(self, m): + """Ensure nfft > 0 holds when nfft is set.""" + self._clear_results() + if not (m > 0 and isinstance(m, int)): + raise ValueError("'Length 'nfft' (={}) must a ".format(m) + + "non-negative integer!") + self._attributes['nfft'] = m + + @property + def detrend(self): + """How to detrend the input signal (string). + + Either remove the offset ('mean'), the trend line ('linear') or does + nothing ('none').""" + return self._attributes['detrend'] + + @detrend.setter + def detrend(self, dstr): + """Ensure that detrend is one of {'mean', 'linear', 'none'}. """ + self._clear_results() + dstr = dstr.lower() + if dstr not in ('mean', 'linear', 'none'): + raise ValueError("detrend (={}) is not one of ".format(dstr) + + "'mean', 'linear', 'none'!") + self._attributes['detrend'] = dstr + + @property + def fft_repr(self): + r"""Representation of Fourier transform (as string). + + There are four common representations of a Fourier transform: + + ============== ================== ============================= + Representation Amplitude Scaling Frequency Range + ============== ================== ============================= + 'twosided' :math:`a/2` :math:`-f_s/2 < f \leq f_s/2` + 'onesided' :math:`a/2` :math:`0 \leq f \leq f_s/2` + 'analytic' :math:`a` :math:`0 \leq f \leq f_s/2` + 'rms' :math:`a/\sqrt{2}` :math:`0 \leq f \leq f_s/2` + ============== ================== ============================= + + The 'two-sided' representation is required for complex-valued signals, + since :math:`X(-f) = X^*(f)` holds only for real-valued signals. + + The amplitude scaling is best interpreted as the value :math:`X(f_0)` + of the Fourier transform at frequency :math:`f_0` of a sine signal with + amplitude :math:`a`, i.e. of + + .. math:: + + x(t) = a\, \sin(2\pi f_0 t) + + Thus, 'analytic' represents the amplitude of the sine (as does an + analytic signal) and 'rms' represents the root-mean-square value of + the amplitude. + """ + return self._attributes['fft_repr'] + + @fft_repr.setter + def fft_repr(self, name): + """Ensure valid string. """ + self._clear_results() + name = name.lower() + valid_types = ('twosided', 'onesided', 'analytic', 'rms') + if name not in valid_types: + raise ValueError("fft_repr (={}) must be one of: ".format(name) + + ", ".join("'%s'" % n for n in valid_types)) + self._attributes['fft_repr'] = name + + @property + def f_c(self): + """Center frequency (default 0) + + If `f_c` != 0 the input signal is demodulated, i.e., shifted by `-f_c` + in the spectral domain. :attr:`.fft_repr` needs to be to `twosided` in + this case. + """ + return self._attributes['f_c'] + + @f_c.setter + def f_c(self, val): + """Ensures f_c is numeric """ + self._clear_results() + if abs(val) > self.f_s/2: + estr = "Parameter f_c (={}) does not fulfill: |f_c| <= f_s/2={}!" + raise ValueError(estr.format(val, self.f_s)) + self._attributes['f_c'] = val + + @property + def unit(self): + """Unit of input signal (str). + + This string is only used for labeling the axes. """ + return self._attributes['unit'] + + @unit.setter + def unit(self, name): + if not isinstance(name, str): + estr = "Parameter unit (={}) must be a string!" + raise ValueError(estr.format(name)) + self._attributes['unit'] = name + + @property + def f_unit(self): + """Unit of frequency (str). + + This string is only used for labeling the axes. The default is 'Hz'.""" + return self._attributes['f_unit'] + + @f_unit.setter + def f_unit(self, name): + if not isinstance(name, str): + estr = "Parameter f_unit (={}) must be a string!" + raise ValueError(estr.format(name)) + self._attributes['f_unit'] = name + + @property + def window(self): + """Name of utilized window function (string). + + `window` can be set to the following values (the setting is + case-insensitve, but it is stored in lower-case): + 'rect', 'hann', 'hamming', 'bartlett', 'blackman'. + + Use :meth:`.set_kaiser_win` to use a parametric Kaiser window. This + property will then be of the form 'kaiser(beta)' where beta is a + non-negative parameter. + + With the method :meth:`.set_window` user defined windows or weights + can be used. This property is then set to 'user'. + + The standard windows are stored in :obj:`spectral.WINDOWS` dictionary, + which entries are of the form: `'window': (win_func, binwidth)`. + 'window' corresponds to this property, `win_func` is the window + function and `bindwidth` is the distance from the maximum to the first + minimum of the window, which is used for calculating the property + :attr:`.f_res`. + + See Also + -------- + :meth:`.set_window`, :meth:`.set_kaiser_win`, :meth:`set_window_func`, + :meth:`set_window_weights`, :attr:`.f_res`, :func:`numpy.hanning`, + :func:`numpy.hamming`, :func:`numpy.bartlett`, + :func:`numpy.blackman`. + """ + return self._attributes['window'] + + @window.setter + def window(self, name): + """Select a non-parametric window """ + name = name.lower() + if name is self._attributes['window'] and \ + self._win_func is WINDOWS[name][0]: + return # nothing changed + + if name not in WINDOWS or name == 'user': + wkeys = ", ".join('%s' % k for k in WINDOWS if k != 'user') + estr = "Parameter 'win' (={}) must be one of: " + wkeys + raise ValueError(estr.format(name)) + + self._clear_results(clear_win=True) + self._win_func, self._win_binwidth = WINDOWS[name] + self._attributes['window'] = name + + def set_window_func(self, wfunc, bin_width=None): + """Set window by function + + Parameters + ---------- + wfunc : function + A window function which the signal length `n` as parameter and + returns (n,) float array containing the window weights. + bin_width : non-negative float or None + The distance betwenn maximum and first in minimum in bins. This + value is solely used for calculating :attr:`.f_res`. There's no + standard way to determine the bin_width of a given window - + numerical root finding is always option. + + See Also + -------- + :attr:`.window`, :meth:`.set_kaiser_win`, :meth:`.set_window_weights`, + :meth:`.get_window_weights`, :attr:`.f_res`, + :meth:`scipy.signal.get_window`, :meth:`scipy.signal.flattop` + + Examples + -------- + The windows provided by :mod:`scipy.signal` (SciPy is not a Matplotlib + dependency) can be used in the following ways:: + + from functools import partial + from scipy import signal + + wf0 = partial(signal.flattop, sym=False) + wf1 = partial(signal.tukey, alpha=0.7, sym=False) + wf2 = partial(signal.get_window, ('chebwin', 50), fftbins=True) + + ``wf0, wf1, wf2`` are a suitable function to be passed as + parameter `wfunc`. + """ + if not hasattr(wfunc, '__call__'): + raise ValueError("Parameter 'wfunc' must be a function!") + self._clear_results(clear_win=True) + self._win_name, self._win_func = 'user', wfunc + self._win_binwidth = bin_width + self._attributes['window'] = 'user' + + def set_window_weights(self, weights, bin_width=None): + """Set window by array + + Parameters + ---------- + weights : (n,) array + A float array of signal length :meth:`.n` containing the window + weights. + bin_width : non-negative float or None + The distance betwenn maximum and first in minimum in bins. This + value is soley used for calculating :attr:`.f_res`. There's no + standard way to determine the bin_width of a given window - + numerical root finding is always option. + + See Also + -------- + :attr:`.window`, :meth:`.get_window_weights`, :meth:`.set_kaiser_win`, + :meth:`set_window_func`, :attr:`.f_res` + :func:`scipy.signal.get_window` + """ + if len(weights) != self.n: + raise ValueError("Parameter 'weights' must be an array of " + + "signal length n = %d!" % self.n) + if bin_width is not None and not bin_width > 0: + estr = "Parameter `bin_widths' (={}) must be scalar > 0 or None!" + raise ValueError(estr.format(bin_width)) + + self._clear_results(clear_win=True) + self._win_array = np.reshape(np.squeeze(weights), (-1, 1)) + self._win_name, self._win_func = 'user', None + self._win_binwidth = bin_width + self._attributes['window'] = 'user' + + def set_kaiser_win(self, beta): + r"""Use Kaiser window function with shape parameter. + + The Kaiser can approximate other windows by varying the beta parameter + (Some literature uses :math:`\alpha = \beta/\pi`). [3]: + + ==== ======================= + beta Window shape + ==== ======================= + 0 Rectangular + 5 Similar to a Hamming + 6 Similar to a Hanning + 8.6 Similar to a Blackman + ==== ======================= + + See Also + -------- + :attr:`.window`, :meth:`.set_window`, :func:`numpy.kaiser`, + :func:`scipy.signal.kaiser` + + References + ---------- + .. [1] J. F. Kaiser, "Digital Filters" - Ch 7 in "Systems analysis by + digital computer", Editors: F.F. Kuo and J.F. Kaiser, p 218-285. + John Wiley and Sons, New York, (1966). + .. [2] Wikipedia, "Window function", + http://en.wikipedia.org/wiki/Window_function + .. [3] Scipy's Kaiser Window implementation + :func:`scipy.signal.kaiser` + """ + self._clear_results() + # clear existing window data: + self._win_array = np.array([], dtype=np.float, ndmin=2) + self._win_sum = self._win_sum2 = np.nan + + if beta < 0: + raise ValueError("Parameter beta (={}) must >= 0!".format(beta)) + + def win_fun(m): + """Wrapper for window function. """ + return np.kaiser(m+1, beta)[:-1] # make periodic window + + self._win_func = win_fun + self._win_binwidth = np.sqrt(1 + beta**2) + self._attributes['window'] = 'kaiser(%g)' % beta + + def get_window_weights(self, m=None): + """Calculate window weights + + The last calculated weights are cached. Note that one of + :meth:`.set_window`, :meth:`.set_window_func`, + :meth:`.set_window_weights` or :meth:`.set_kaiser_win` must have been + called before. + + Parameters + ---------- + m : int or None + Length of array. If ``None`` the signal length `n` is used. + + Returns + ------- + w : (m, 1) array + Array with window values + """ + if m is None: + m = self.n + if len(self._win_array) == m: + return self._win_array + w = self._win_func(m) + self._win_array = np.reshape(w, (m, 1)) + self._win_sum = np.sum(self._win_array) + self._win_sum2 = np.sum(self._win_array**2) + return self._win_array + + # Properties derived from other attributes: + @property + def n(self): + """Number of samples in signal (read only). """ + return len(self.x) + + @property + def chans(self): + """Number of channels, i.e., lines in plot, in signal (read only). """ + return self.x.shape[1] + + @property + def T(self): + """Sampling interval of signal (=1/f_s). """ + return 1/self.f_s + + @T.setter + def T(self, val): + """Wrapper for setting f_s. """ + if not val > 0: + raise ValueError("Sampling interval T (={}) ".format(val) + + "must be non-negative!") + self._clear_results() + self.f_s = 1 / val + + @property + def tau(self): + """Duration of signal (=T*n) (read only). """ + return self.n * self.T + + @property + def df(self): + r"""Spacing of frequency bins (read-only). + + This distance between to points on the x-axis denoted by + :math:`\Delta f` in the plot. It can be reduced by oversampling, i.e., + by increasing the value of :attr:`.nfft`. + + See also + -------- + :attr:`.f_res`, :attr:`.ENBW` + """ + return 1 / (self.nfft * self.T) + + @property + def f_res(self): + r"""Frequency resolution of spectrum or spectral density (read only). + + The frequency resolution :math:`\Delta f_r` is a measure for the + ability to descriminate two close peaks in a spectrum (or spectral + density). It denotes the distance between the maxium and the first zero + of a single peak spectrum. Since :math:`\Delta f` (:attr:`.df`) does + not take the influence of oversampling and window shapes into account, + it is a more meaningful measure. For a rectangular, i.e., no window, + without oversampling, :math:`\Delta f_r=\Delta f`. + + To calculate :math:`\Delta f_r`, the quotient + :math:`\Delta f_r/\Delta f`, termed window bin width, needs to be + specified for each utilized window. If not specified, i.e., standard + window is used, ``None`` is returned. + + See Also + -------- + :attr:`.df`, :attr:`.ENBW`, :meth:`.set_window_func`, + :meth:`.set_window_weights` + """ + if self._win_binwidth is None: + return None + return self._win_binwidth / (self.n*self.T) + + @property + def NENBW(self): + """Normalized equivalent noise bandwidth (unit: freq. bins, read only). + + This is the :attr:`ENBW` normalized to frequency bins. + + See Also + -------- + :attr:`.ENBW` + """ + self.get_window_weights(self.n) # ensure window array is calculated + return self.nfft * self._win_sum2 / self._win_sum**2 + + @property + def ENBW(self): + r"""Effective noise bandwidth (unit Hz, read only). + + The quotient of the power spectral density (Unit²/Hz) and + the power spectrum (Unit²) results for any given frequency in the + effective noise bandwidth [1]. It is defined as + + .. math:: + + ENBW := \frac{ \sum_{k=0}^{n-1} w_k^2}{ + \left( \sum_{k=0}^{n-1} w_k \right)^2} + + where :math:`w_k` is the `k`-th window weight for a signal of + length :attr:`n`. + + See Also + -------- + :attr:`.NENBW` + + References + ---------- + + .. [1] G. Heinzel, A. Ruediger and R. Schilling, "Spectrum and + spectral density estimation by the Discrete Fourier transform + (DFT), including a comprehensive list of window functions and + some new at-top windows", 2002, + http://hdl.handle.net/11858/00-001M-0000-0013-557A-5 + """ + self.get_window_weights(self.n) # ensure window array is calculated + return self.f_s * self._win_sum2 / self._win_sum**2 + + def _xlabel(self): + """Generate xlabel string for plotting. """ + lstr0 = r"$\Delta f = %.3g\,$%s" % (self.df, self.f_unit) + lstr1 = (r", $\Delta f_r = %.3g\,$%s" % (self.f_res, self.f_unit) + if self.f_res is not None else "") + return "Frequency in %s (%s%s)" % (self.f_unit, lstr0, lstr1) + + def _ylabel(self, yscale, density): + """Generate ylabel string for plotting. """ + yscale = yscale.lower() + yu2 = ("(%s)$^2" if len(self.unit) > 1 else "%s$^2") % self.unit + if yscale == 'amp': + ys = "Amplitude in %s" % self.unit + return (ys if not density else + "Diff." + ys + r"$\, / \sqrt{%s}$" % self.f_unit) + elif yscale == 'power': + ys = "Power in %s$" % yu2 + return (ys if not density else + "Diff." + ys + r"$\, / $%s" % self.f_unit) + elif yscale == 'db': + ys = r"Power in dB($ 1\, $%s" % yu2 + return ("Rel. %s$)" % ys if not density else + "Rel. Diff. " + ys + r" / \,$%s$)$" % self.f_unit) + else: + raise ValueError("Parameter yscale (={}) is not".format(yscale) + + "one of: 'amp', 'power', 'db'") + + +class Spectrum(_SpectralBaseClass): + """Calculate Spectrum of a Signal + + Attributes + ---------- + X : (m, chans) array + The resulting FFT scaled to amplitude. + f : (m,) array + The frequencies for :attr:`X`. + """ + # Results: + X = np.array([], dtype=np.complex, ndmin=2) # Fourier transfrom of signal + f = np.array([], dtype=np.float) # frequencies for X + + def _clear_results(self, clear_win=False): + """Clear all cached results. """ + super()._clear_results(clear_win) + self.X = np.array([], dtype=np.complex, ndmin=2) + self.f = np.array([], dtype=np.float) + + def oversampling(self, factor=None): + """Set/get oversampling factor. + + The oversampling factor sets the number of zeros appended to the signal + before perforiming the FFT by a multiple of `nperseg`. This is a + convience function for setting/getting the attribute as `nfft`. + + Parameters + ---------- + factor : float or None + The factor for setting `nfft = round(factor*nperseg)`. `factor` + needs to >= 1. If ``None`` only the current factor is returned + + Returns + ------- + The current newly set factor is returned. Due to rounding there may + a deviation between the parameter and the utilized value. + """ + if factor is None: + return 1 if self.nfft is None else self.nfft / self.n + if factor < 1: + raise ValueError("Parameter 'factor' needs to be >= 1!") + self.nfft = int(round(factor*self.n)) + return self.nfft / self.n + + def calc_fft(self, **kwargs): + """Calculate the Fourier transform of the signal. + + The FFT of :attr:`.x` scaled by the sum of the window weights is + stored in :attr:`.X`. The corresponding frequencies are written into + :attr:`.f`. + + If not attributes were modified, no recalculation is performed. + """ + self.set(**kwargs) + if not self.recalc_needed: + return self.f, self.X + + if self.fft_repr == 'twosided': + fft_func, fftfreq_func = np.fft.fft, np.fft.fftfreq + else: # self.ff_repr == 'onesided' and friends: + fft_func, fftfreq_func = np.fft.rfft, np.fft.rfftfreq + if np.any(np.iscomplex(self.x)): + raise ValueError("Use 'twosided' FFT for complex signals!") + elif self.f_c != 0: + estr = ("Nonzero center frequency f_c=%g Hz is not allowed for" + " 'onesided' sided FFTs! Set the attribute/parameter" + " 'fft_repr' to the value 'twosided'.") + raise ValueError(estr % self.f_c) + + # Calculate the FFT of detrended and windowed signal: + w = self.get_window_weights() + if self.f_c != 0: # demodulate with center frequency: + t = np.reshape(np.arange(self.n) * self.T, (self.n, 1)) + w = w * np.exp(-2j * np.pi * self.f_c * t) + xd = _detrend(self.x, self.detrend, axis=0) + scal_fak = {'twosided': 1, 'onesided': 1, 'rms': np.sqrt(2), + 'analytic': 2} + self.X = (fft_func(w*xd, n=self.nfft, axis=0) * + scal_fak[self.fft_repr] / self._win_sum) + self.f = fftfreq_func(self.nfft, self.T) + if self.fft_repr == 'twosided': + self.f, self.X = np.fft.fftshift(self.f), np.fft.fftshift(self.X) + self.recalc_needed = False + return self.f, self.X + + def spectrum(self, yscale='power', **kwargs): + r"""Returns tuple of (frequencies, spectral values). + + + For a spectral value :math:`X(f)` with unit V the parameter `yscale` + does the following: + + ======== ========================== ======== + `yscale` Value Unit + ======== ========================== ======== + 'amp' :math:`|X(f)|` V + 'power' :math:`|X(f)|^2` v² + 'db' :math:`20 \log_{10}|X(f)|` dB(1 V²) + ======== ========================== ======== + + Note that scale of :math:`X(f)` is also determined by the property + :attr:`.fft_repr`. + + See Also + -------- + :meth:`calc_fft`, :meth:`density`, :attr:`.fft_repr` + """ + self.calc_fft(**kwargs) + return self.f, _yscale(self.X.real**2 + self.X.imag**2, yscale) + + def density(self, yscale='power', **kwargs): + r"""Spectral tuple of (frequencies, density values). + + For a squared spectral value :math:`P_x=|X(f)|^2` with unit V² the + parameter `yscale` does the following: + + ======== =============================== ============================ + `yscale` Value Unit + ======== =============================== ============================ + 'amp' :math:`\sqrt{d P_x/d f}` :math:`V/\sqrt{Hz}` + 'power' :math:`d P_x/d f` :math:`V^2/\sqrt{Hz}` + 'db' :math:`10 \log_{10}|d P_x/d f|` dB(:math:`1\,V^2/\sqrt{Hz}`) + ======== =============================== ============================ + + Note that scale of :math:`X(f)` is also determined by the property + :attr:`.fft_repr`. + + See Also + -------- + :meth:`calc_fft`, :meth:`spectrum`, :attr:`.fft_repr` + """ + pXX = self.spectrum('power', **kwargs) / self.ENBW + return self.f, _yscale(pXX, yscale) + + def plot(self, density=False, xscale='linear', yscale='amp', + right_yaxis=True, fig_num=None, ax=None, plt_kw=None, **kwargs): + """Plot spectrum or spectral density.""" + xscale, yscale = xscale.lower(), yscale.lower() + f, yy = (self.density(scale=yscale, **kwargs) if density else + self.spectrum(yscale=yscale, **kwargs)) + + fig = None + if ax is None: + fig, ax = plt.subplots(1, 1, num=fig_num, clear=True) + elif fig_num is not None: + raise ValueError("Only either one of the parameters 'fig_num' or" + + "'ax' may not be 'None'!") + + plot_fun = ax.plot if xscale == 'linear' else plt.semilogx + if plt_kw is None: + plot_fun(f, yy) + elif isinstance(plt_kw, dict): # pass keywords + plot_fun(f, yy, **plt_kw) + else: # iterate over list of keyword dictionaries: + for y, p_kw in zip(yy.T, plt_kw): + plot_fun(f, y, p_kw) + + # Make labels: + tstr = (("Power Spectral Density" if density else "Spectrum") + + " (%s win." % self.window.title()) + if self.fft_repr in ('rms', 'analytic'): + tstr += ", RMS" if self.fft_repr == 'rms' else ", analytic" + tstr += ")" + ax.set(title=tstr, xlabel=self._xlabel(), + ylabel=self._ylabel(yscale, density)) + if right_yaxis: # TODO: Investigate why the scaling does not work + axr = ax.twinx() + axr.grid(False) + f_yr = _yscale(self.ENBW if density else 1/self.ENBW, yscale) + # print("fyr:", fyr) + + def update_axr(ax_=None): + """Callback for updating the right axis. """ + y0, y1 = ax_.get_ylim() + y0r = y0 + f_yr if yscale == 'db' else y0 * f_yr + y1r = y1 + f_yr if yscale == 'db' else y1 * f_yr + axr.set_ylim(y0r, y1r) + # print("DBG ylims_r: %.2g -> %.2g; %.2g -> %.2g" % + # (y0, y0r, y1, y1r)) + fig.canvas.draw() # (?) effect unclear + + update_axr(ax) + axr.set_ylabel(self._ylabel(yscale, not density)) + ax.callbacks.connect("ylim_changed", update_axr) + return (fig, ax) if fig is not None else ax + + def plot_phase(self, xscale='linear', yunit='deg', unwrap=True, + threshold=0, fig_num=None, ax=None, plt_kw=None, **kwargs): + """Plot phase of spectrum. """ + self.calc_fft(**kwargs) + xscale, yunit = xscale.lower(), yunit.lower() + if yunit not in ('deg', 'rad'): + raise ValueError("Parameter 'yunit' (={}) must be ".format(yunit) + + "either 'deg' or 'rad'!") + f, phi = self.f, np.angle(self.X) + phi[np.abs(self.X) < threshold] = np.nan + if unwrap: + phi = np.unwrap(phi, axis=0) + if yunit == 'deg': + phi = np.rad2deg(phi) + + fig = None + if ax is None: + fig = plt.figure(fig_num) + fig.clf() + ax = fig.add_subplot(1, 1, 1) + elif fig_num is not None: + raise ValueError("Only either one of the parameters 'fig_num' or" + + "'ax' may not be 'None'!") + + plot_fun = ax.plot if xscale == 'linear' else plt.semilogx + if plt_kw is None: + plot_fun(f, phi) + elif isinstance(plt_kw, dict): # pass keywords + plot_fun(f, phi, **plt_kw) + else: # iterate over list of keyword dictionaries: + for y, p_kw in zip(phi.T, plt_kw): + plot_fun(f, y, p_kw) + + # Make labels: + ystr = "Phase in " + ('°' if yunit == 'deg' else 'rad') + tstr = (("Unwrapped " if unwrap else "") + "Phase of Spectrum" + + " (%s win.)" % self.window.title()) + ax.set(title=tstr, xlabel=self._xlabel(), ylabel=ystr) + return fig, ax + + +class Periodogram(_SpectralBaseClass): + """Calculate Averaged Spectrum or Spectral Density of a Signal. + + The avaraging is performed over the power (Unit²) of the signal, so no + phase information is retained. + + Attributes + ---------- + Pxx : (m, chans) array + The resulting spectral power spectrum (in Unit²), which is calculated + by :meth:`calc_avg_Pxx()`. Array has size zero if no valid results + exist. + f : (m,) array + The corresponding frequencies for :attr:`Pxx`. Array has size zero if + no valid results exist. + seg_num : int + The number of segments used in calculating :attr:`Pxx`. If no valid + result exists, it is -1. + """ + + # Results: + Pxx = np.array([], dtype=np.float, ndmin=2) # averaged FFT + f = np.array([], dtype=np.float) # frequencies for Pxx + seg_num = -1 + + def __init__(self, x, f_s, nperseg=None, noverlap=None, **kwargs): + """Initializer """ + super().__init__(x, f_s, **kwargs) + self.nperseg = nperseg if nperseg is not None else self.n + self.nfft = kwargs.get('nfft', self.nperseg) + self.noverlap = noverlap if noverlap is not None else self.nfft // 2 + + def _clear_results(self, clear_win=False): + """Clear all cached results. """ + super()._clear_results(clear_win) + self.Pxx = np.array([], dtype=np.float, ndmin=2) + self.f = np.array([], dtype=np.float) + self.seg_num = -1 + + @property + def nperseg(self): + """Number of samples per segment. """ + return self._attributes['nperseg'] + + @nperseg.setter + def nperseg(self, m): + """Ensure 0 < nperseg <= n holds when nperseg is set. """ + self._clear_results() + e0 = ("Segment length 'nperseg' (={}) needs to be an int and fulfill: " + "0 < nperseg <= n (={})!") + if not (0 < m <= self.n and isinstance(m, int)): + raise ValueError(e0.format(m, self.n)) + self._attributes['nperseg'] = m + + @property + def noverlap(self): + """Number of overlapping samples between two consecutive segments. + + Use :meth:`overlapping()` for setting/getting normalized overlap + factors. + """ + return self._attributes['noverlap'] + + @noverlap.setter + def noverlap(self, m): + """Ensure 0 <= noverlap < nperseg """ + self._clear_results() + e = ("Overlapping samples 'noverlap' (={]}) needs to be an int and " + + "fulfill: 0 <= noverlap < nperseg (=%d)!") + if not (0 <= m < self.nperseg and isinstance(m, int)): + raise ValueError(e % (m, self.nperseg)) + self._attributes['noverlap'] = m + + def overlapping(self, amount=None): + """Set/get relative amount of overlapping samples (0 <= amount < 1). + + If no parameter is given, the amount of overlapping is returned. This + is a convenience function for setting/getting the property + :attr:`.noverlap`. + """ + if amount is None: + return self.noverlap / self.nperseg + if not 0 <= amount < 1: + raise ValueError("Parameter 'amount={}' must be ".format(amount) + + "in the interval [0, 1)!") + self.noverlap = int(round(self.nperseg*amount)) + return amount + + def oversampling(self, factor=None): + """Set/get oversampling factor. + + The oversampling factor sets the number of zeros appended to the signal + before perforiming the FFT by a multiple of :attr:`.nperseg`. This is a + convience function for setting/getting the attribute as :attr:`.nfft`. + + Parameters + ---------- + factor : float or None + The factor for setting `nfft = round(factor*nperseg)`. `factor` + needs to >= 1. If ``None`` only the current factor is returned + + Returns + ------- + The current newly set factor is returned. Due to rounding there may + a deviation between the parameter and the utilized, i.e., returned, + value. + """ + if factor is None: + return 1 if self.nfft is None else self.nfft / self.nperseg + if factor < 1: + raise ValueError("Parameter 'factor' needs to be >= 1!") + self.nfft = int(round(factor*self.nperseg)) + return self.nfft / self.nperseg + + @property + def f_res(self): + # Doc string is inherited from parent class + if self._win_binwidth is None: + return None + return self._win_binwidth / (self.nperseg*self.T) + + @property + def NENBW(self): + # Doc string is inherited from parent class + self.get_window_weights(self.nperseg) # ensure window is calculated + return self.nfft * self._win_sum2 / self._win_sum**2 + + @property + def ENBW(self): + # Doc string is inherited from parent class + self.get_window_weights(self.nperseg) # ensure window is calculated + return self.f_s * self._win_sum2 / self._win_sum**2 + + def calc_avg_Pxx(self, **kwargs): + """Calculate the averaged FFT over each of the `nperseg` segments. """ + self.set(**kwargs) + if not self.recalc_needed: + return self.f, self.Pxx + + if self.fft_repr == 'twosided': + self.f = np.fft.fftfreq(self.nfft, self.T) + else: # self.ff_side == 'single' and friends: + if np.any(np.iscomplex(self.x)): + raise ValueError("Use 'twosided' FFT for complex signals!") + elif self.f_c != 0: + estr = ("Nonzero center frequency f_c={} Hz is not allowed for" + " 'singlesided' FFTs! Set the attribute/parameter" + " 'fft_repr' to the value 'twosided'.") + raise ValueError(estr.format(self.f_c)) + self.f = np.fft.rfftfreq(self.nfft, self.T) + + # Calculate sum of the FFTs of detrended and windowed signal segments: + fft_func = np.fft.fft if self.fft_repr == 'two_sided' else np.fft.rfft + w = self.get_window_weights(self.nperseg) + self.Pxx = np.zeros((len(self.f), self.chans), dtype=np.float) + self.seg_num = 0 + for k0 in range(0, self.n, self.nperseg-self.noverlap): + k1 = k0 + self.nperseg + if k1 > self.n: # only use whole segments + break + if self.f_c != 0: # demodulate with center frequency: + ti = np.arange(k0, k1) * self.T + w1 = w * np.exp(-2j * np.pi * self.f_c * ti) + else: + w1 = w + x = _detrend(self.x[k0:k1], self.detrend, axis=0) + X = fft_func(x*w1, n=self.nfft, axis=0) + self.Pxx += X.real**2 + X.imag**2 + self.seg_num += 1 + + # Scale sum to average and to spectrum: + scal_fak = {'twosided': 1, 'onesided': 1, 'rms': 2, 'analytic': 4} + self.Pxx *= scal_fak[self.fft_repr] / (self.seg_num*self._win_sum**2) + if self.fft_repr == 'twosided': + self.f, self.Pxx = [np.fft.fftshift(v) for v in (self.f, self.Pxx)] + return self.f, self.Pxx + + def spectrum(self, yscale='power', **kwargs): + r"""Returns tuple of (frequencies, spectral values). + + + For a power density :math:`P_x=P_{xx}(f)` with unit V² the parameter + `yscale` does the following: + + ======== ========================= ======== + `yscale` Value Unit + ======== ========================= ======== + 'amp' :math:`\sqrt{P_x}` V + 'power' :math:`P_x` v² + 'db' :math:`10 \log_{10}(P_x)` dB(1 V²) + ======== ========================= ======== + + Note that scale of :math:`P_x` is also determined by the property + :attr:`.fft_repr`. + + See Also + -------- + :meth:`calc_avg_Pxx`, :meth:`density`, :attr:`.fft_repr` + """ + self.calc_avg_Pxx(**kwargs) + return self.f, _yscale(self.Pxx, yscale) + + def density(self, yscale='power', **kwargs): + r"""Spectral tuple of (frequencies, density values). + + For a squared spectral value :math:`P_x=P_{xx}(f)` with unit V² the + parameter `yscale` does the following: + + ======== =============================== ============================ + `yscale` Value Unit + ======== =============================== ============================ + 'amp' :math:`\sqrt{d P_x/d f}` :math:`V/\sqrt{Hz}` + 'power' :math:`d P_x/d f` :math:`V^2/\sqrt{Hz}` + 'db' :math:`10 \log_{10}|d P_x/d f|` dB(:math:`1\,V^2/\sqrt{Hz}`) + ======== =============================== ============================ + + Note that scale of :math:`P_x` is also determined by the property + :attr:`.fft_repr`. + + See Also + -------- + :meth:`calc_avg_Pxx`, :meth:`spectrum`, :attr:`.fft_repr` + """ + self.calc_avg_Pxx(**kwargs) + return self.f, _yscale(self.Pxx / self.ENBW, yscale) + + def plot(self, density=False, xscale='linear', yscale='amp', + right_yaxis=True, fig_num=None, ax=None, plt_kw=None, **kwargs): + """Plot spectrum or spectral density.""" + xscale, yscale = xscale.lower(), yscale.lower() + f, yy = (self.density(yscale=yscale, **kwargs) if density else + self.spectrum(yscale=yscale, **kwargs)) + + fig = None + if ax is None: + fig, ax = plt.subplots(1, 1, num=fig_num, clear=True) + elif fig_num is not None: + raise ValueError("Only either one of the parameters 'fig_num' or" + + "'ax' may not be 'None'!") + + if xscale == 'linear': + plot_fun = ax.plot + else: + if self.fft_repr == 'twosided': + raise ValueError("xscale = 'dB' not allowd for " + "fft_repr == 'twosided'") + plot_fun = plt.semilogx + f, yy = f[1:], yy[1:] + + if plt_kw is None: + plot_fun(f, yy) + elif isinstance(plt_kw, dict): # pass keywords + plot_fun(f, yy, **plt_kw) + else: # iterate over list of keyword dictionaries: + for y, p_kw in zip(yy.T, plt_kw): + plot_fun(f, y, p_kw) + + # Make labels: + tstr = ("Avg. " + ("Spectral Density" if density else "Spectrum") + + " (%s win." % self.window.title()) + if self.fft_repr in ('rms', 'analytic'): + tstr += ", RMS" if self.fft_repr == 'rms' else ", analytic" + if self.seg_num > 1: + if self.overlapping() > 0: + tstr += ", %.0f%% overl." % (1e2*self.overlapping()) + tstr += r", $%d\!\times$avg." % self.seg_num + tstr += ")" + ax.set(title=tstr, xlabel=self._xlabel(), + ylabel=self._ylabel(yscale, density)) + + if right_yaxis: # TODO: Investigate why the scaling does not work + axr = ax.twinx() + axr.grid(False) + f_yr = _yscale(self.ENBW if density else 1/self.ENBW, yscale) + # print(f"f_yr = {f_yr}") + + def update_axr(ax_): + """Callback for updating the right axis. """ + y0, y1 = ax_.get_ylim() + y0r = y0 + f_yr if yscale == 'db' else y0 * f_yr + y1r = y1 + f_yr if yscale == 'db' else y1 * f_yr + axr.set_ylim(y0r, y1r) + # print("DBG ylims_r: %.2g -> %.2g; %.2g -> %.2g" % + # (y0, y0r, y1, y1r)) + fig.canvas.draw() # (?) effect unclear + + update_axr(ax) + axr.set_ylabel(self._ylabel(yscale, not density)) + ax.callbacks.connect("ylim_changed", update_axr) + return (fig, ax) if fig is not None else ax