SignalForge 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- SignalForge/__init__.py +6 -0
- SignalForge/nonstationary_nongaussian.py +522 -0
- SignalForge/single_chan_signal.py +1083 -0
- SignalForge/stationary_gaussian.py +110 -0
- SignalForge/stationary_nongaussian.py +934 -0
- SignalForge/utils.py +526 -0
- signalforge-0.0.2.dist-info/METADATA +23 -0
- signalforge-0.0.2.dist-info/RECORD +11 -0
- signalforge-0.0.2.dist-info/WHEEL +5 -0
- signalforge-0.0.2.dist-info/licenses/LICENSE +21 -0
- signalforge-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import scipy as sp
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import pywt
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from .utils import *
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SingleChanSignal:
|
|
12
|
+
"""
|
|
13
|
+
SingleChanSignal is the parent class of StationaryGaussian, StationaryNonGaussian and NonStationaryNonGaussian.
|
|
14
|
+
Advanced signal analisys class.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
x: np.ndarray
|
|
19
|
+
frequency vector of the power spectral density
|
|
20
|
+
fs: float
|
|
21
|
+
power density vector of the power spectral density
|
|
22
|
+
dfpsd: float
|
|
23
|
+
frequency discretization of the psd to be stored
|
|
24
|
+
name: str
|
|
25
|
+
name of the process (used for plots)
|
|
26
|
+
var: str
|
|
27
|
+
name of the variable (used for plots)
|
|
28
|
+
unit: str
|
|
29
|
+
unit of measure of the signal (used for plots)
|
|
30
|
+
seed: int
|
|
31
|
+
seed for the random generator used in non-gaussian models
|
|
32
|
+
interp: 'lin' or 'log'
|
|
33
|
+
interpolation rule for increasing resolution on given PSD
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self, x:np.ndarray,
|
|
38
|
+
fs:float, dfpsd = 0.5,
|
|
39
|
+
name:str = "",
|
|
40
|
+
var:str = 'x',
|
|
41
|
+
unit:str = "$m/s^2$",
|
|
42
|
+
signal_type:str = 'User provided signal'
|
|
43
|
+
):
|
|
44
|
+
|
|
45
|
+
self.name = name
|
|
46
|
+
self.var = var
|
|
47
|
+
self.unit = unit
|
|
48
|
+
self.signal_type = signal_type
|
|
49
|
+
|
|
50
|
+
print('Estimating statistical parameters from timehistory')
|
|
51
|
+
self.x = np.matrix.flatten(np.array(x))
|
|
52
|
+
self.mean = np.mean(self.x)
|
|
53
|
+
self.fs = float(fs)
|
|
54
|
+
self.N = len(self.x)
|
|
55
|
+
self.T = self.N/self.fs
|
|
56
|
+
self.dt = 1/self.fs
|
|
57
|
+
self.dfpsd = dfpsd
|
|
58
|
+
self.fpsd,self.psd = self.get_psd(dfpsd)
|
|
59
|
+
|
|
60
|
+
self.central_moments = self._get_central_moments()
|
|
61
|
+
self.spectral_moments = self._get_spectral_moments()
|
|
62
|
+
self.ns_index = {}
|
|
63
|
+
print('Class correctly initialized')
|
|
64
|
+
print(self)
|
|
65
|
+
|
|
66
|
+
def __str__(self):
|
|
67
|
+
"""
|
|
68
|
+
Human-readable summary of the signal object.
|
|
69
|
+
"""
|
|
70
|
+
return (
|
|
71
|
+
f"SingleChanSignal Summary:\n"
|
|
72
|
+
f"--------------------------\n"
|
|
73
|
+
f"Name : {self.name}\n"
|
|
74
|
+
f"Type : {self.signal_type}\n"
|
|
75
|
+
f"Length : {self.N} samples\n"
|
|
76
|
+
f"Duration : {self.T:.2f} s\n"
|
|
77
|
+
f"Sampling Rate: {self.fs:.2f} Hz\n"
|
|
78
|
+
f"Mean : {self.mean:.2f}\n"
|
|
79
|
+
f"Std. Dev. : {self.central_moments['rms']:.2f} {self.unit}\n"
|
|
80
|
+
f"Skewness : {self.central_moments['skew']:.2f} {self.unit}\n"
|
|
81
|
+
f"Kurtosis : {self.central_moments['kurtosis']:.2f}\n"
|
|
82
|
+
f"Crest factor : {self.central_moments['crest_factor']:.2f}\n"
|
|
83
|
+
f"0-cross rate : {self.spectral_moments['v0']:.2f} Hz\n"
|
|
84
|
+
f"Peak rate : {self.spectral_moments['vp']:.2f} Hz"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __repr__(self):
|
|
88
|
+
"""
|
|
89
|
+
Return a developer-friendly string representation of the object.
|
|
90
|
+
Includes key attributes for quick inspection in interactive sessions.
|
|
91
|
+
"""
|
|
92
|
+
return (
|
|
93
|
+
f"<SingleChanSignal | "
|
|
94
|
+
f"N={self.N}, fs={self.fs:.2f} Hz, T={self.T:.3f} s, "
|
|
95
|
+
f"mean={np.mean(self.x):.3f}, std={np.std(self.x):.3f}, "
|
|
96
|
+
f"min={np.min(self.x):.3f}, max={np.max(self.x):.3f}>"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __add__(self, other):
|
|
100
|
+
"""
|
|
101
|
+
Overload the + operator to allow addition of:
|
|
102
|
+
- Two SingleChanSignal objects (must have same length, dt, and aligned timebase).
|
|
103
|
+
- A SingleChanSignal and a scalar (adds scalar to each sample).
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
- A new SingleChanSignal instance.
|
|
108
|
+
|
|
109
|
+
Raises
|
|
110
|
+
------
|
|
111
|
+
- TypeError: If the operand type is unsupported.
|
|
112
|
+
- ValueError: If adding two signals of different lengths or mismatched time bases.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(other, SingleChanSignal):
|
|
115
|
+
if self.N != other.N:
|
|
116
|
+
raise ValueError("Cannot add signals: different lengths.")
|
|
117
|
+
if not np.isclose(self.dt, other.dt):
|
|
118
|
+
raise ValueError("Cannot add signals: different sampling intervals.")
|
|
119
|
+
if not np.allclose(self.t, other.t):
|
|
120
|
+
raise ValueError("Cannot add signals: time vectors are not aligned.")
|
|
121
|
+
|
|
122
|
+
return SingleChanSignal(x=self.x + other.x, fs=self.fs)
|
|
123
|
+
|
|
124
|
+
elif isinstance(other, (int, float)):
|
|
125
|
+
return SingleChanSignal(x=self.x + other, fs=self.fs)
|
|
126
|
+
|
|
127
|
+
else:
|
|
128
|
+
raise TypeError(f"Unsupported operand type(s) for +: 'SingleChanSignal' and '{type(other).__name__}'")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@property # for storage efficiency
|
|
132
|
+
def t(self):
|
|
133
|
+
"""time vector"""
|
|
134
|
+
return np.linspace(0,self.T,self.N)
|
|
135
|
+
|
|
136
|
+
@property # for storage efficiency
|
|
137
|
+
def fft_ts(self):
|
|
138
|
+
"""two-sided Fourier coefficients"""
|
|
139
|
+
Xts_woShift = self.dt*np.fft.fft(self.x)
|
|
140
|
+
return np.fft.fftshift(Xts_woShift)
|
|
141
|
+
|
|
142
|
+
@property # for storage efficiency
|
|
143
|
+
def f_fft_ts(self):
|
|
144
|
+
"""two-sided frequency vector"""
|
|
145
|
+
frq_woShift = np.fft.fftfreq(self.N, self.dt)
|
|
146
|
+
return np.fft.fftshift(frq_woShift)
|
|
147
|
+
|
|
148
|
+
@property # for storage efficiency
|
|
149
|
+
def fft_os(self):
|
|
150
|
+
"""one-sided Fourier coefficients"""
|
|
151
|
+
Xts_woShift = self.dt*np.fft.fft(self.x)
|
|
152
|
+
Nos = np.ceil((self.N+1)/2) # number of one-sided Fourier coefficients
|
|
153
|
+
return Xts_woShift[0:int(Nos)]
|
|
154
|
+
|
|
155
|
+
@property # for storage efficiency
|
|
156
|
+
def f_fft_os(self):
|
|
157
|
+
"""one-sided frequency vector"""
|
|
158
|
+
frq_woShift = np.fft.fftfreq(self.N, self.dt)
|
|
159
|
+
Nos = np.ceil((self.N+1)/2) # number of one-sided Fourier coefficients
|
|
160
|
+
return np.abs(frq_woShift[0:int(Nos)])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_psd(self,df = 0.5,noverlap=None):
|
|
164
|
+
'''
|
|
165
|
+
estimate power spectral density (PSD) using the Welch method
|
|
166
|
+
-> called by __init__
|
|
167
|
+
'''
|
|
168
|
+
self.fpsd, self.psd = sp.signal.welch(self.x,self.fs,nperseg=np.round(self.fs/df), noverlap = noverlap)
|
|
169
|
+
return self.fpsd, self.psd
|
|
170
|
+
|
|
171
|
+
def _get_central_moments(self):
|
|
172
|
+
'''
|
|
173
|
+
estimate key statistical values
|
|
174
|
+
-> called by __init__
|
|
175
|
+
SOURCES
|
|
176
|
+
-------
|
|
177
|
+
A.G. Davenport, Note on the distribution of the largest value of a random function with application to gust loading, Proceedings of the Institution of Civil Engineers 28 (2) (1964) 187–196
|
|
178
|
+
'''
|
|
179
|
+
cmoms = {'rms': np.std(self.x)}
|
|
180
|
+
cmoms['var'] = np.var(self.x)
|
|
181
|
+
cmoms['skew'] = sp.stats.skew(self.x)
|
|
182
|
+
cmoms['kurtosis'] = sp.stats.kurtosis(self.x)+3
|
|
183
|
+
cmoms['crest_factor'] = np.max(np.abs(self.x)) / cmoms['rms']
|
|
184
|
+
return cmoms
|
|
185
|
+
|
|
186
|
+
def _get_spectral_moments(self):
|
|
187
|
+
'''
|
|
188
|
+
estimate key spectral descriptors
|
|
189
|
+
-> called by __init__
|
|
190
|
+
SOURCES
|
|
191
|
+
-------
|
|
192
|
+
C. L. Nikias e A. P. Petropulu, Higher-order spectra analysis: a nonlinear signal processing framework. in Prentice Hall signal processing series. Englewood Cliffs, N.J: PTR Prentice Hall, 1993
|
|
193
|
+
'''
|
|
194
|
+
smoms = {'smom0': get_stat_mom(self.fpsd, self.psd, 0),
|
|
195
|
+
'smom1': get_stat_mom(self.fpsd, self.psd, 1),
|
|
196
|
+
'smom2': get_stat_mom(self.fpsd, self.psd, 2),
|
|
197
|
+
'smom4': get_stat_mom(self.fpsd, self.psd, 4),
|
|
198
|
+
'smom6': get_stat_mom(self.fpsd, self.psd, 6)}
|
|
199
|
+
smoms['v0'] = np.sqrt(smoms['smom2']/smoms['smom0'])
|
|
200
|
+
smoms['vp'] = np.sqrt(smoms['smom4']/smoms['smom2'])
|
|
201
|
+
smoms['vm'] = smoms['smom1']/smoms['smom0']
|
|
202
|
+
smoms['xm'] = smoms['vm']/ smoms['vp']
|
|
203
|
+
smoms['alpha1'] = alpha_spec_index(self.fpsd, self.psd, 1, 0)
|
|
204
|
+
smoms['alpha2'] = alpha_spec_index(self.fpsd, self.psd, 2, 0)
|
|
205
|
+
smoms['alpha075'] = alpha_spec_index(self.fpsd, self.psd, .75, 0)
|
|
206
|
+
smoms['alphadx'] = alpha_spec_index(self.fpsd, self.psd, 2, 1)
|
|
207
|
+
return smoms
|
|
208
|
+
|
|
209
|
+
def get_pdf(self, bins='auto'):
|
|
210
|
+
"""
|
|
211
|
+
Estimate the empirical probability density function (PDF) of the signal.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
bins : int, sequence, or str, optional
|
|
216
|
+
Binning strategy for histogram computation. Passed directly to `np.histogram`.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
grid : ndarray
|
|
221
|
+
Center values of the histogram bins.
|
|
222
|
+
prob : ndarray
|
|
223
|
+
Normalized histogram values representing the PDF.
|
|
224
|
+
"""
|
|
225
|
+
prob, bin_edges = np.histogram(self.x, bins = bins, density=True)
|
|
226
|
+
grid = (bin_edges[1:]+bin_edges[0:-1])/2
|
|
227
|
+
return grid,prob
|
|
228
|
+
|
|
229
|
+
def get_bestfit_gaussian_pdf(self,bins = 'auto', grid = None):
|
|
230
|
+
"""
|
|
231
|
+
Compute the best-fit Gaussian PDF based on the signal's mean and variance.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
bins : int, sequence, or str, optional
|
|
236
|
+
Used to compute a default grid if one is not provided.
|
|
237
|
+
grid : ndarray, optional
|
|
238
|
+
The grid (support) over which to evaluate the Gaussian PDF. If None, computed from histogram.
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
grid : ndarray
|
|
243
|
+
Grid points where the Gaussian PDF is evaluated.
|
|
244
|
+
pgauss : ndarray
|
|
245
|
+
Gaussian PDF values on the grid.
|
|
246
|
+
"""
|
|
247
|
+
if grid is None:
|
|
248
|
+
_, bin_edges = np.histogram(self.x, bins, density=True)
|
|
249
|
+
grid = (bin_edges[1:]+bin_edges[0:-1])/2
|
|
250
|
+
|
|
251
|
+
# Compute Gaussian PDF using the sample mean and variance
|
|
252
|
+
pgauss = gaussian_bell(grid = grid, var = self.central_moments['var'], mean = self.mean)
|
|
253
|
+
return grid, pgauss
|
|
254
|
+
|
|
255
|
+
def get_sftf(self, nperseg:int = 2**10, hop = 2**5, nargout = 1):
|
|
256
|
+
"""
|
|
257
|
+
Compute the Short-Time Fourier Transform (STFT) of the signal.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
nperseg : int, optional
|
|
262
|
+
Length of each segment (window size) for the STFT.
|
|
263
|
+
hop : int, optional
|
|
264
|
+
Hop size (step between windows).
|
|
265
|
+
nargout : int, optional
|
|
266
|
+
If >1, also returns the SFTF object.
|
|
267
|
+
|
|
268
|
+
Returns
|
|
269
|
+
-------
|
|
270
|
+
Sx : ndarray
|
|
271
|
+
STFT magnitude spectrum.
|
|
272
|
+
SFT : ShortTimeFFT, optional
|
|
273
|
+
SFTF object containing additional metadata and transform options.
|
|
274
|
+
"""
|
|
275
|
+
w = sp.signal.windows.hann(nperseg)
|
|
276
|
+
SFT = sp.signal.ShortTimeFFT(w, hop=hop, fs=self.fs, mfft=nperseg*2, scale_to='magnitude')
|
|
277
|
+
Sx = SFT.stft(self.x)
|
|
278
|
+
if nargout>1:
|
|
279
|
+
return Sx, SFT
|
|
280
|
+
return Sx
|
|
281
|
+
|
|
282
|
+
def get_spectrogram(self, nperseg:int = 2**10, hop = 2**5, nargout = 1):
|
|
283
|
+
"""
|
|
284
|
+
Compute the Spectrogram of the signal via Short-Time Fourier Transform (STFT).
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
nperseg : int, optional
|
|
289
|
+
Length of each segment (window size) for the STFT.
|
|
290
|
+
hop : int, optional
|
|
291
|
+
Hop size (step between windows).
|
|
292
|
+
nargout : int, optional
|
|
293
|
+
If >1, also returns the SFTF object.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
Sxx : ndarray
|
|
298
|
+
STFT power densitt spectrum.
|
|
299
|
+
SFT : ShortTimeFFT, optional
|
|
300
|
+
SFTF object containing additional metadata and transform options.
|
|
301
|
+
"""
|
|
302
|
+
w = sp.signal.windows.hann(nperseg)
|
|
303
|
+
SFT = sp.signal.ShortTimeFFT(w, hop=hop, fs=self.fs, mfft=nperseg*2, scale_to='psd')
|
|
304
|
+
Sxx = SFT.stft(self.x)
|
|
305
|
+
if nargout>1:
|
|
306
|
+
return Sxx, SFT
|
|
307
|
+
return Sxx
|
|
308
|
+
|
|
309
|
+
def get_hilbert(self, *args, **kwargs):
|
|
310
|
+
"""
|
|
311
|
+
Compute the analytic signal using the Hilbert transform.
|
|
312
|
+
|
|
313
|
+
Parameters
|
|
314
|
+
----------
|
|
315
|
+
*args, **kwargs : optional
|
|
316
|
+
Additional arguments passed to `scipy.signal.hilbert`.
|
|
317
|
+
|
|
318
|
+
Returns
|
|
319
|
+
-------
|
|
320
|
+
analytic_signal : ndarray
|
|
321
|
+
Complex-valued analytic signal.
|
|
322
|
+
amplitude_envelope : ndarray
|
|
323
|
+
Instantaneous amplitude (envelope) of the signal.
|
|
324
|
+
instantaneous_phase : ndarray
|
|
325
|
+
Unwrapped instantaneous phase of the signal.
|
|
326
|
+
"""
|
|
327
|
+
analytic_signal = sp.signal.hilbert(self.x, *args, **kwargs)
|
|
328
|
+
amplitude_envelope = np.abs(analytic_signal)
|
|
329
|
+
instantaneous_phase = np.unwrap(np.angle(analytic_signal))
|
|
330
|
+
return analytic_signal, amplitude_envelope, instantaneous_phase
|
|
331
|
+
|
|
332
|
+
def get_wavelet_transform(self, wavetype = 'morl', **kwargs):
|
|
333
|
+
"""
|
|
334
|
+
Compute the Continuous Wavelet Transform (CWT) of the signal.
|
|
335
|
+
|
|
336
|
+
Parameters
|
|
337
|
+
----------
|
|
338
|
+
wavetype : str, optional
|
|
339
|
+
Type of mother wavelet to use (e.g., 'morl', 'cmor').
|
|
340
|
+
**kwargs : dict, optional
|
|
341
|
+
Additional keyword arguments passed to `pywt.cwt`.
|
|
342
|
+
|
|
343
|
+
Returns
|
|
344
|
+
-------
|
|
345
|
+
data : ndarray
|
|
346
|
+
Wavelet coefficient matrix.
|
|
347
|
+
f : ndarray
|
|
348
|
+
Frequencies corresponding to wavelet scales.
|
|
349
|
+
"""
|
|
350
|
+
print(f'Calculating continuyous wavelet transform with "{wavetype}" wave')
|
|
351
|
+
wavelet = pywt.ContinuousWavelet(wavetype)
|
|
352
|
+
scales = pywt.central_frequency(wavelet) * self.fs / np.arange(10, 150, 1)
|
|
353
|
+
data, f = pywt.cwt(self.x, scales, wavelet, sampling_period= 1/self.fs, **kwargs)
|
|
354
|
+
return data, f
|
|
355
|
+
|
|
356
|
+
def get_banded_spectral_kurtosis(self, n_bins = 10, fl : int = 0, fu : int = 0):
|
|
357
|
+
"""
|
|
358
|
+
Compute banded spectral kurtosis over specified frequency bands.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
n_bins : int, optional
|
|
363
|
+
Number of frequency bands to divide the spectrum into.
|
|
364
|
+
fl : int, optional
|
|
365
|
+
Lower frequency limit (Hz).
|
|
366
|
+
fu : int, optional
|
|
367
|
+
Upper frequency limit (Hz).
|
|
368
|
+
|
|
369
|
+
Returns
|
|
370
|
+
-------
|
|
371
|
+
fvec : ndarray
|
|
372
|
+
Center frequencies of each band.
|
|
373
|
+
SK : ndarray
|
|
374
|
+
Spectral kurtosis values for each frequency band.
|
|
375
|
+
"""
|
|
376
|
+
fvec, SK = get_banded_spectral_kurtosis(self.x, self.dt, n_bins = n_bins, fl = fl, fu = fu)
|
|
377
|
+
return fvec, SK
|
|
378
|
+
|
|
379
|
+
def get_welch_spectral_kurtosis(self, Nfft:int = None, noverlap:int = 0):
|
|
380
|
+
"""
|
|
381
|
+
Compute Welch-based spectral kurtosis of the signal.
|
|
382
|
+
|
|
383
|
+
Parameters
|
|
384
|
+
----------
|
|
385
|
+
Nfft : int, optional
|
|
386
|
+
FFT length used in the Welch method. Defaults to length of `self.psd`.
|
|
387
|
+
noverlap : int, optional
|
|
388
|
+
Number of overlapping points between segments.
|
|
389
|
+
|
|
390
|
+
Returns
|
|
391
|
+
-------
|
|
392
|
+
f_norm : ndarray
|
|
393
|
+
Frequency vector (in Hz).
|
|
394
|
+
SK : ndarray
|
|
395
|
+
Spectral kurtosis values across the frequency range.
|
|
396
|
+
"""
|
|
397
|
+
if Nfft is None:
|
|
398
|
+
Nfft = len(self.psd)
|
|
399
|
+
|
|
400
|
+
f_norm, SK = get_welch_spectral_kurtosis(self.x, Nfft, noverlap)
|
|
401
|
+
return f_norm*self.fs, SK
|
|
402
|
+
|
|
403
|
+
def get_kurtogram(self, n_bins:tuple = (10,100,10), _plot_call = False):
|
|
404
|
+
"""
|
|
405
|
+
Calculate the kurtogram of the signal.
|
|
406
|
+
|
|
407
|
+
Parameters
|
|
408
|
+
----------
|
|
409
|
+
ax : matplotlib.axes.Axes, optional
|
|
410
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
411
|
+
n_bins : tuple, optional
|
|
412
|
+
The range of bins to use for the kurtogram. (start, finish, step)
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
ax : matplotlib.axes.Axes
|
|
417
|
+
The axes with the plotted kurtogram.
|
|
418
|
+
"""
|
|
419
|
+
print(f'Calculating Kurtogram')
|
|
420
|
+
np_freqs = self.N//2
|
|
421
|
+
try:
|
|
422
|
+
n_divisions = np.arange(*n_bins)
|
|
423
|
+
except:
|
|
424
|
+
raise ValueError('n_bins argument does not meet requirement. Try with a tuple with np.arange() format "(start, finish, step)"')
|
|
425
|
+
max_Nfft = np_freqs//np.min(n_divisions)
|
|
426
|
+
ref_fvec = np.linspace(0,1,max_Nfft)*self.fs/2
|
|
427
|
+
kurtogram = np.zeros((max_Nfft,len(n_divisions)))
|
|
428
|
+
for i, divisions in tqdm(enumerate(n_divisions)):
|
|
429
|
+
Nfft = np_freqs//(divisions)
|
|
430
|
+
fvec, spectral_kurtosis = get_welch_spectral_kurtosis(self.x,Nfft = Nfft, noverlap = round(3/8*Nfft))
|
|
431
|
+
kurtogram[:,i] = step_interp(ref_fvec, fvec*self.fs, spectral_kurtosis)
|
|
432
|
+
if _plot_call:
|
|
433
|
+
return kurtogram, n_divisions, max_Nfft
|
|
434
|
+
else:
|
|
435
|
+
return kurtogram
|
|
436
|
+
|
|
437
|
+
def get_stat_history(self, winsize:int, olap:int = 0, idx_type = 'rms'):
|
|
438
|
+
"""
|
|
439
|
+
Compute a statistical index over a moving window.
|
|
440
|
+
|
|
441
|
+
Parameters:
|
|
442
|
+
signal : np.ndarray
|
|
443
|
+
Time history signal.
|
|
444
|
+
winsize : int:
|
|
445
|
+
Window size.
|
|
446
|
+
olap : int, optional
|
|
447
|
+
The number of points to overlap on the moving window. If not specified, no overlap is considered
|
|
448
|
+
idx_type : str
|
|
449
|
+
Index type: 'mean', 'rms', 'var', or 'kurtosis'.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
stat_history: np.ndarray
|
|
453
|
+
Evolution of the statistical index over time.
|
|
454
|
+
|
|
455
|
+
"""
|
|
456
|
+
stat_th = get_stat_history(self.x, winsize=winsize, olap = olap, idx_type = idx_type)
|
|
457
|
+
return stat_th
|
|
458
|
+
|
|
459
|
+
def get_nonstat_index(self, idx_type = 'nnst',*args, **kwargs):
|
|
460
|
+
"""
|
|
461
|
+
Computes the requested non stationary test from the given one dimensional time history
|
|
462
|
+
|
|
463
|
+
Parameters
|
|
464
|
+
----------
|
|
465
|
+
signal: array-like
|
|
466
|
+
One dimensional time history
|
|
467
|
+
index_type : str, optional
|
|
468
|
+
Type of non stationary index to be assessed. Supported methods are: Non-Stationarity index [1]: 'nnst', Augmented Dickey-Fuller [2]: 'adf', Kwiatkowski-Phillips-Schmidt-Shin [3]: 'kpss'
|
|
469
|
+
kwargs: optional
|
|
470
|
+
Argument to be passed to the specific method implementation
|
|
471
|
+
|
|
472
|
+
Returns
|
|
473
|
+
-------
|
|
474
|
+
test_results: dict
|
|
475
|
+
Results of the non-stationary test
|
|
476
|
+
|
|
477
|
+
Source
|
|
478
|
+
------
|
|
479
|
+
[1] L. Capponi, M. Česnik, J. Slavič, F. Cianetti, e M. Boltežar, «Non-stationarity index in vibration fatigue: Theoretical and experimental research», Int. J. Fatigue, vol. 104, pp. 221–230, nov. 2017, doi: 10.1016/j.ijfatigue.2017.07.020.
|
|
480
|
+
[2] Dickey, D. A., and W. A. Fuller. "Distribution of the Estimators for Autoregressive Time Series with a Unit Root." Journal of the American Statistical Association. Vol. 74, 1979, pp. 427–431.
|
|
481
|
+
[3] Kwiatkowski, D., Phillips, P. C. B., Schmidt, P., Shin, Y. (1992). "Testing the null hypothesis of stationarity against the alternative of a unit root". Journal of Econometrics. 54 (1–3): 159–178. doi:10.1016/0304-4076(92)90104-Y.
|
|
482
|
+
|
|
483
|
+
"""
|
|
484
|
+
# ns_index = get_nonstat_test(self.x, idx_type, *args, **kwargs)
|
|
485
|
+
idx_types = {'nnst': get_nnst_index,
|
|
486
|
+
'adf': get_adf_index,
|
|
487
|
+
'kpss': get_kpss_index}
|
|
488
|
+
|
|
489
|
+
method_existance_check(idx_type, idx_types)
|
|
490
|
+
|
|
491
|
+
ns_index = idx_types[idx_type](self.x, *args, **kwargs)
|
|
492
|
+
print(f'Nonstat evaluation via "{idx_type}" method:')
|
|
493
|
+
print_nonstat_results(ns_index, indent=4)
|
|
494
|
+
self.ns_index[idx_type] = ns_index
|
|
495
|
+
return ns_index
|
|
496
|
+
|
|
497
|
+
def plot(
|
|
498
|
+
self, ax = None,
|
|
499
|
+
xlims:list = None,
|
|
500
|
+
ylims:list= None
|
|
501
|
+
):
|
|
502
|
+
"""
|
|
503
|
+
Plot the time history of the signal.
|
|
504
|
+
|
|
505
|
+
Parameters
|
|
506
|
+
----------
|
|
507
|
+
ax : matplotlib.axes.Axes, optional
|
|
508
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
509
|
+
xlims : list, optional
|
|
510
|
+
The limits for the x-axis.
|
|
511
|
+
ylims : list, optional
|
|
512
|
+
The limits for the y-axis.
|
|
513
|
+
|
|
514
|
+
Returns
|
|
515
|
+
-------
|
|
516
|
+
ax : matplotlib.axes.Axes
|
|
517
|
+
The axes with the plotted time history.
|
|
518
|
+
"""
|
|
519
|
+
print(f'Plotting Timehistory')
|
|
520
|
+
if ax is None:
|
|
521
|
+
_, ax = plt.subplots(figsize=(10, 4))
|
|
522
|
+
s_rms = self.central_moments['rms']
|
|
523
|
+
s_skew = self.central_moments['skew']
|
|
524
|
+
s_kurt = self.central_moments['kurtosis']
|
|
525
|
+
label = f"{self.name}: rms = {s_rms:.2f} [{self.unit}] | $skew $ = {s_skew:.2f} [-] | $kurt $ = {s_kurt:.2f} [-]"
|
|
526
|
+
ax.plot(self.t,self.x,'k',label=label, linewidth=0.5)
|
|
527
|
+
ax.set_xlabel("Time [s]")
|
|
528
|
+
ax.set_ylabel(f"Amplitude [{self.unit}]")
|
|
529
|
+
ax.legend()
|
|
530
|
+
ax.grid(True,which = 'both')
|
|
531
|
+
ax.minorticks_on()
|
|
532
|
+
ax.set_title(f'Timehistory {self.name}')
|
|
533
|
+
if xlims is not None:
|
|
534
|
+
ax.set_xlim(xlims)
|
|
535
|
+
if ylims is not None:
|
|
536
|
+
ax.set_ylim(ylims)
|
|
537
|
+
plt.show(block=False)
|
|
538
|
+
return ax
|
|
539
|
+
|
|
540
|
+
def plot_pdf(
|
|
541
|
+
self,
|
|
542
|
+
ax = None,
|
|
543
|
+
xlims:list = None,
|
|
544
|
+
log:bool = False,
|
|
545
|
+
plot_gaussian_bestfit = True):
|
|
546
|
+
|
|
547
|
+
"""
|
|
548
|
+
Plot the Probability Density Function (PDF) of the signal.
|
|
549
|
+
|
|
550
|
+
Parameters
|
|
551
|
+
----------
|
|
552
|
+
ax : matplotlib.axes.Axes, optional
|
|
553
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
554
|
+
xlims : list, optional
|
|
555
|
+
The limits for the x-axis.
|
|
556
|
+
log : bool, optional
|
|
557
|
+
Whether to plot on a logarithmic scale.
|
|
558
|
+
plot_gaussian_bestfit: bool, optional
|
|
559
|
+
Whether to plot the best-fit Gaussian distribution.
|
|
560
|
+
|
|
561
|
+
Returns
|
|
562
|
+
-------
|
|
563
|
+
ax : matplotlib.axes.Axes
|
|
564
|
+
The axes with the plotted PDF.
|
|
565
|
+
"""
|
|
566
|
+
print(f"Plotting Probability densisy function of signal's points")
|
|
567
|
+
if ax is None:
|
|
568
|
+
_, ax = plt.subplots(figsize=(6, 4))
|
|
569
|
+
# s_rms = self.central_moments['rms']
|
|
570
|
+
s_kurt = self.central_moments['kurtosis']
|
|
571
|
+
grid, pdf = self.get_pdf()
|
|
572
|
+
|
|
573
|
+
label = f"{self.name}: $kurt$ = {s_kurt:.2f} [-] "
|
|
574
|
+
|
|
575
|
+
ax.plot(grid, pdf,'k',label=label)
|
|
576
|
+
|
|
577
|
+
grid_gauss, pdf_gauss = self.get_bestfit_gaussian_pdf(grid=grid) # estimating gaussian counterpart for limits
|
|
578
|
+
if plot_gaussian_bestfit:
|
|
579
|
+
ax.plot(grid_gauss, pdf_gauss, 'r', label="Best-fit Gaussian")
|
|
580
|
+
|
|
581
|
+
ax.set_xlabel(f"Amplitude [{self.unit}]")
|
|
582
|
+
ax.set_ylabel(f"$PDF$ [-]")
|
|
583
|
+
ax.legend()
|
|
584
|
+
ax.grid(True,which = 'both')
|
|
585
|
+
ax.minorticks_on()
|
|
586
|
+
ax.set_title(f'Probability Density Function {self.name}')
|
|
587
|
+
ax.set_ylim([np.min(pdf_gauss), 1.1*np.max([np.max(pdf_gauss), np.max(pdf)])])
|
|
588
|
+
if xlims is not None:
|
|
589
|
+
ax.set_xlim(xlims)
|
|
590
|
+
if log:
|
|
591
|
+
ax.set_yscale("log")
|
|
592
|
+
plt.show(block=False)
|
|
593
|
+
return ax
|
|
594
|
+
|
|
595
|
+
def plot_fft(
|
|
596
|
+
self,
|
|
597
|
+
ax = None,
|
|
598
|
+
xlims: list = None,
|
|
599
|
+
ylims: list = None
|
|
600
|
+
):
|
|
601
|
+
"""
|
|
602
|
+
Plot the Fourier Transform (FT) of the signal.
|
|
603
|
+
|
|
604
|
+
Parameters
|
|
605
|
+
----------
|
|
606
|
+
ax : matplotlib.axes.Axes, optional
|
|
607
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
608
|
+
xlims : list, optional
|
|
609
|
+
The limits for the x-axis.
|
|
610
|
+
ylims : list, optional
|
|
611
|
+
The limits for the y-axis.
|
|
612
|
+
|
|
613
|
+
Returns
|
|
614
|
+
-------
|
|
615
|
+
ax : matplotlib.axes.Axes
|
|
616
|
+
The axes with the plotted FT.
|
|
617
|
+
"""
|
|
618
|
+
print(f'Plotting Fourier Transform of the signal')
|
|
619
|
+
|
|
620
|
+
if ax is None:
|
|
621
|
+
_, ax = plt.subplots(nrows=2, figsize=(10, 4), sharex='all', tight_layout=True)
|
|
622
|
+
|
|
623
|
+
# Convert to array for uniform handling
|
|
624
|
+
ax_array = np.atleast_1d(ax)
|
|
625
|
+
is_array = len(ax_array) > 1
|
|
626
|
+
|
|
627
|
+
s_rms = self.central_moments['rms']
|
|
628
|
+
label = f"$FT_{{{self.var}}}$: rms = {s_rms:.2f} [{self.unit}]"
|
|
629
|
+
|
|
630
|
+
ax0 = ax_array[0]
|
|
631
|
+
ax0.plot(self.f_fft_os, np.abs(self.fft_os), 'k', label=label)
|
|
632
|
+
ax0.set_ylabel(f"$FT_{{{self.var}}}$ [{self.unit}]")
|
|
633
|
+
ax0.legend()
|
|
634
|
+
ax0.grid(True, which='both')
|
|
635
|
+
ax0.minorticks_on()
|
|
636
|
+
ax0.set_title(f'Fourier transform {self.name}')
|
|
637
|
+
|
|
638
|
+
if is_array:
|
|
639
|
+
ax_array[1].plot(self.f_fft_os, np.angle(self.fft_os), 'k', label=label)
|
|
640
|
+
ax_array[1].set_ylabel(f"$\\phi_{{{self.var}}}$ [rad]")
|
|
641
|
+
ax_array[1].grid(True, which='both')
|
|
642
|
+
ax_array[1].minorticks_on()
|
|
643
|
+
ax_array[1].set_xlabel("Frequency [Hz]")
|
|
644
|
+
ax0.sharex(ax_array[1])
|
|
645
|
+
else:
|
|
646
|
+
ax0.set_xlabel("Frequency [Hz]")
|
|
647
|
+
|
|
648
|
+
if xlims is not None:
|
|
649
|
+
for a in ax_array:
|
|
650
|
+
a.set_xlim(xlims)
|
|
651
|
+
|
|
652
|
+
if ylims is not None:
|
|
653
|
+
ax_array[0].set_ylim(ylims)
|
|
654
|
+
|
|
655
|
+
plt.show(block=False)
|
|
656
|
+
return ax
|
|
657
|
+
|
|
658
|
+
def plot_psd(
|
|
659
|
+
self,
|
|
660
|
+
ax = None,
|
|
661
|
+
xlims:list = None,
|
|
662
|
+
ylims:list= None,
|
|
663
|
+
log:bool = False
|
|
664
|
+
):
|
|
665
|
+
"""
|
|
666
|
+
Plot the Power Spectral Density (PSD) of the signal.
|
|
667
|
+
|
|
668
|
+
Parameters
|
|
669
|
+
----------
|
|
670
|
+
ax : matplotlib.axes.Axes, optional
|
|
671
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
672
|
+
xlims : list, optional
|
|
673
|
+
The limits for the x-axis.
|
|
674
|
+
ylims : list, optional
|
|
675
|
+
The limits for the y-axis.
|
|
676
|
+
log : str, optional
|
|
677
|
+
Specifies the logarithmic scale for x, y, or both axes.
|
|
678
|
+
|
|
679
|
+
Returns
|
|
680
|
+
-------
|
|
681
|
+
ax : matplotlib.axes.Axes
|
|
682
|
+
The axes with the plotted PSD.
|
|
683
|
+
"""
|
|
684
|
+
print(f'Plotting Power spectral density')
|
|
685
|
+
if ax is None:
|
|
686
|
+
_, ax = plt.subplots(figsize=(10, 4),tight_layout=True)
|
|
687
|
+
s_rms = self.central_moments['rms']
|
|
688
|
+
label = f"$G_{{{self.var}{self.var}}}$: rms = {s_rms:.2f} [{self.unit}]"
|
|
689
|
+
ax.plot(self.fpsd,self.psd,'k',label=label)
|
|
690
|
+
ax.set_xlabel("Frequency [Hz]")
|
|
691
|
+
ax.set_ylabel(f"$G_{{{self.var}{self.var}}}$ [({self.unit})$^2$/Hz]")
|
|
692
|
+
ax.legend()
|
|
693
|
+
ax.grid(True,which = 'both')
|
|
694
|
+
ax.minorticks_on()
|
|
695
|
+
ax.set_title(f'Power Spectral Density {self.name}')
|
|
696
|
+
if xlims is not None:
|
|
697
|
+
ax.set_xlim(xlims)
|
|
698
|
+
if ylims is not None:
|
|
699
|
+
ax.set_ylim(ylims)
|
|
700
|
+
if log == "x":
|
|
701
|
+
ax.set_xscale("log")
|
|
702
|
+
elif log == "y":
|
|
703
|
+
ax.set_yscale("log")
|
|
704
|
+
elif log == "both":
|
|
705
|
+
ax.set_xscale("log")
|
|
706
|
+
ax.set_yscale("log")
|
|
707
|
+
plt.show(block=False)
|
|
708
|
+
return ax
|
|
709
|
+
|
|
710
|
+
def plot_spectrogram(
|
|
711
|
+
self,
|
|
712
|
+
ax = None,
|
|
713
|
+
window:tuple = ('tukey', 1),
|
|
714
|
+
nperseg:int = 2**10,
|
|
715
|
+
ylims:list = None,
|
|
716
|
+
clims:list = None
|
|
717
|
+
):
|
|
718
|
+
"""
|
|
719
|
+
Plot the spectrogram of the signal.
|
|
720
|
+
|
|
721
|
+
Parameters
|
|
722
|
+
----------
|
|
723
|
+
ax : matplotlib.axes.Axes, optional
|
|
724
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
725
|
+
window : tuple, optional
|
|
726
|
+
The window type and parameter for the spectrogram.
|
|
727
|
+
nperseg : int, optional
|
|
728
|
+
The number of data points per segment for the spectrogram.
|
|
729
|
+
ylims : list, optional
|
|
730
|
+
The limits for the y-axis.
|
|
731
|
+
clims : list, optional
|
|
732
|
+
The color limits for the spectrogram.
|
|
733
|
+
|
|
734
|
+
Returns
|
|
735
|
+
-------
|
|
736
|
+
ax : matplotlib.axes.Axes
|
|
737
|
+
The axes with the plotted spectrogram.
|
|
738
|
+
"""
|
|
739
|
+
print(f'Calculating spectrogram')
|
|
740
|
+
f, t, Sxx = sp.signal.spectrogram(self.x, fs = self.fs, window = window, nperseg = nperseg)
|
|
741
|
+
print(f'Plotting spectrogram')
|
|
742
|
+
if ax is None:
|
|
743
|
+
fig, ax = plt.subplots(figsize=(10, 4), tight_layout=True)
|
|
744
|
+
spec = ax.pcolormesh(t, f, Sxx, vmin=Sxx.min(), vmax=Sxx.max(), shading='auto')
|
|
745
|
+
ax.set_ylabel('Frequency [Hz]')
|
|
746
|
+
ax.set_xlabel('Time [sec]')
|
|
747
|
+
ax.set_title('Spectrogram')
|
|
748
|
+
cbar = fig.colorbar(spec)
|
|
749
|
+
cbar.ax.set_ylabel('PSD [(m/$s^2$)$^2$/Hz]')
|
|
750
|
+
if clims is not None:
|
|
751
|
+
spec.set_clim(clims)
|
|
752
|
+
else:
|
|
753
|
+
spec.set_clim(0,1.1*np.max(self.psd))
|
|
754
|
+
if ylims is not None:
|
|
755
|
+
ax.set_ylim(ylims)
|
|
756
|
+
plt.show(block=False)
|
|
757
|
+
return ax
|
|
758
|
+
|
|
759
|
+
def plot_stft(
|
|
760
|
+
self,
|
|
761
|
+
nperseg:int = 2**10,
|
|
762
|
+
hop = 2**8,
|
|
763
|
+
flims:list = None,
|
|
764
|
+
fig = None,
|
|
765
|
+
ax = None):
|
|
766
|
+
"""
|
|
767
|
+
Plot the Short-Time Fourier Transform (STFT) of the signal.
|
|
768
|
+
|
|
769
|
+
Parameters
|
|
770
|
+
----------
|
|
771
|
+
nperseg : int, optional
|
|
772
|
+
The number of data points per segment for the STFT.
|
|
773
|
+
hop : int, optional
|
|
774
|
+
The number of data points between segments.
|
|
775
|
+
|
|
776
|
+
Returns
|
|
777
|
+
-------
|
|
778
|
+
ax : matplotlib.axes.Axes
|
|
779
|
+
The axes with the plotted STFT.
|
|
780
|
+
"""
|
|
781
|
+
print(f'Calculating STFT')
|
|
782
|
+
Sx, SFT = self.get_sftf(nperseg = nperseg, hop = hop, nargout=2)
|
|
783
|
+
print(f'Plotting STFT')
|
|
784
|
+
if ax is None and fig is None:
|
|
785
|
+
fig, ax = plt.subplots(figsize=(10, 4), sharex='all', tight_layout=True)
|
|
786
|
+
|
|
787
|
+
t_lo, t_hi = SFT.extent(self.N)[:2] # time range of plot
|
|
788
|
+
ax.set_title(rf"Short time Fourier transform {self.name}")
|
|
789
|
+
ax.set(xlabel=f"Time [s] ({SFT.p_num(self.N)} slices, " +
|
|
790
|
+
rf"$\Delta t = {SFT.delta_t:.3f}\,$s)",
|
|
791
|
+
ylabel=f"Frequency [Hz] ({SFT.f_pts} bins, " +
|
|
792
|
+
rf"$\Delta f = {SFT.delta_f:.3f}\,$Hz)",
|
|
793
|
+
xlim=(t_lo, t_hi))
|
|
794
|
+
|
|
795
|
+
im1 = ax.imshow(abs(Sx), origin='lower', aspect='auto',
|
|
796
|
+
extent=SFT.extent(self.N))
|
|
797
|
+
fig.colorbar(im1, label=f"Magnitude $|S_{self.var}(t, f)|$ [{self.unit}]")
|
|
798
|
+
if flims:
|
|
799
|
+
plt.ylim(flims)
|
|
800
|
+
plt.show(block=False)
|
|
801
|
+
|
|
802
|
+
return ax
|
|
803
|
+
|
|
804
|
+
def plot_spectral_kurtosis(
|
|
805
|
+
self,
|
|
806
|
+
ax = None,
|
|
807
|
+
n_bins:int = None,
|
|
808
|
+
fl : int = None,
|
|
809
|
+
fu : int = None,
|
|
810
|
+
ylims:list = None
|
|
811
|
+
):
|
|
812
|
+
"""
|
|
813
|
+
Plot the spectral kurtosis of the signal.
|
|
814
|
+
|
|
815
|
+
Parameters
|
|
816
|
+
----------
|
|
817
|
+
ax : matplotlib.axes.Axes, optional
|
|
818
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
819
|
+
n_bins : int, optional
|
|
820
|
+
The number of bins for the spectral kurtosis calculation.
|
|
821
|
+
fl : int, optional
|
|
822
|
+
The lower frequency bound.
|
|
823
|
+
fu : int, optional
|
|
824
|
+
The upper frequency bound.
|
|
825
|
+
ylims : list, optional
|
|
826
|
+
The limits for the y-axis.
|
|
827
|
+
|
|
828
|
+
Returns
|
|
829
|
+
-------
|
|
830
|
+
ax : matplotlib.axes.Axes
|
|
831
|
+
The axes with the plotted spectral kurtosis.
|
|
832
|
+
"""
|
|
833
|
+
if n_bins and fl and fu:
|
|
834
|
+
fvec, spectral_kurtosis = get_banded_spectral_kurtosis(self.x,self.dt,n_bins, fl, fu)
|
|
835
|
+
else:
|
|
836
|
+
fvec, spectral_kurtosis = get_welch_spectral_kurtosis(self.x,Nfft = 2**10, noverlap = 2**8)
|
|
837
|
+
|
|
838
|
+
print(f'Plotting spectral kurtosis from {fvec[0]:.1f} Hz to {fvec[-1]*self.fs/2:.1f} Hz')
|
|
839
|
+
if ax is None:
|
|
840
|
+
_, ax = plt.subplots(figsize=(10, 4))
|
|
841
|
+
s_kur = self.central_moments['kurtosis']
|
|
842
|
+
glob = 'global'
|
|
843
|
+
label = f"$SK_{self.var}$(f): $kurt_{{{glob}}}$ = {s_kur:.2f} [-]"
|
|
844
|
+
ax.step(fvec*self.fs/2, spectral_kurtosis,'k',label=label, where = 'mid')
|
|
845
|
+
ax.set_xlabel("frequency [Hz]")
|
|
846
|
+
ax.set_ylabel(f"$SK_{self.var}$ [-]")
|
|
847
|
+
ax.legend()
|
|
848
|
+
ax.grid(True,which = 'minor')
|
|
849
|
+
ax.minorticks_on()
|
|
850
|
+
ax.set_title('Spectral kurtosis ' + self.name + ' (0 = Gaussian)')
|
|
851
|
+
ax.set_ylim([0,1.5*np.max(spectral_kurtosis)])
|
|
852
|
+
if ylims is not None:
|
|
853
|
+
ax.set_ylim(ylims)
|
|
854
|
+
plt.show(block=False)
|
|
855
|
+
return ax
|
|
856
|
+
|
|
857
|
+
def plot_kurtogram(
|
|
858
|
+
self,
|
|
859
|
+
ax = None,
|
|
860
|
+
n_bins:tuple = (10,100,10),
|
|
861
|
+
ylims:list = None
|
|
862
|
+
):
|
|
863
|
+
"""
|
|
864
|
+
Plot the kurtogram of the signal.
|
|
865
|
+
|
|
866
|
+
Parameters
|
|
867
|
+
----------
|
|
868
|
+
ax : matplotlib.axes.Axes, optional
|
|
869
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
870
|
+
n_bins : tuple, optional
|
|
871
|
+
The range of bins to use for the kurtogram. (start, finish, step)
|
|
872
|
+
ylims : list, optional
|
|
873
|
+
The limits for the y-axis.
|
|
874
|
+
|
|
875
|
+
Returns
|
|
876
|
+
-------
|
|
877
|
+
ax : matplotlib.axes.Axes
|
|
878
|
+
The axes with the plotted kurtogram.
|
|
879
|
+
"""
|
|
880
|
+
kurtogram, n_divisions, max_Nfft = self.get_kurtogram(n_bins = n_bins, _plot_call = True)
|
|
881
|
+
print(f'Plotting Kurtogram')
|
|
882
|
+
if ax is None:
|
|
883
|
+
fig, ax = plt.subplots(figsize=(10, 4), tight_layout=True)
|
|
884
|
+
freq_edges = np.linspace(0, self.fs / 2, max_Nfft + 1)
|
|
885
|
+
band_edges = np.append(n_divisions, n_divisions[-1] + (n_divisions[1] - n_divisions[0]))
|
|
886
|
+
FE, BE = np.meshgrid(np.flip(band_edges), freq_edges)
|
|
887
|
+
mesh = ax.pcolormesh(BE, FE, kurtogram, shading='auto')
|
|
888
|
+
plt.gca().invert_yaxis()
|
|
889
|
+
cbar = fig.colorbar(mesh, ax = ax)
|
|
890
|
+
cbar.ax.set_ylabel('Spectral kurtosis')
|
|
891
|
+
ax.set_xlabel('Frequency (Hz)')
|
|
892
|
+
ax.set_ylabel('Number of bands')
|
|
893
|
+
ax.set_title(f'Kurtogram {self.name}')
|
|
894
|
+
if ylims is not None:
|
|
895
|
+
ax.set_ylim(ylims)
|
|
896
|
+
plt.show(block=False)
|
|
897
|
+
return ax
|
|
898
|
+
|
|
899
|
+
def plot_stat_history(
|
|
900
|
+
self,
|
|
901
|
+
winsize:int,
|
|
902
|
+
olap:int = 0,
|
|
903
|
+
idx_type:str='rms',
|
|
904
|
+
ax = None,
|
|
905
|
+
xlims:list = None,
|
|
906
|
+
ylims:list= None
|
|
907
|
+
):
|
|
908
|
+
"""
|
|
909
|
+
Plot the statistical indicator time history of the signal on a moving window.
|
|
910
|
+
|
|
911
|
+
Parameters
|
|
912
|
+
----------
|
|
913
|
+
winsize : int
|
|
914
|
+
The window size for the non-stationarity index calculation.
|
|
915
|
+
olap : int, optional
|
|
916
|
+
The number of points to overlap on the moving window. If not specified, no overlap is considered
|
|
917
|
+
idx_type : str, optional
|
|
918
|
+
The type of index to calculate ('rms' or 'kurt').
|
|
919
|
+
ax : matplotlib.axes.Axes, optional
|
|
920
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
921
|
+
xlims : list, optional
|
|
922
|
+
The limits for the x-axis.
|
|
923
|
+
ylims : list, optional
|
|
924
|
+
The limits for the y-axis.
|
|
925
|
+
|
|
926
|
+
Returns
|
|
927
|
+
-------
|
|
928
|
+
ax :: matplotlib.axes.Axes
|
|
929
|
+
The axes with the plotted non-stationarity index.
|
|
930
|
+
"""
|
|
931
|
+
ns_index = self.get_stat_history(winsize=winsize, olap = olap, idx_type = idx_type)
|
|
932
|
+
print(f'Plotting Non-stationarity index: idx_type = {idx_type}')
|
|
933
|
+
t_ns_index = np.linspace(0,1,len(ns_index))*self.T
|
|
934
|
+
if ax is None:
|
|
935
|
+
_, ax = plt.subplots(figsize=(10, 4))
|
|
936
|
+
s_rms = self.central_moments['rms']
|
|
937
|
+
s_kurt = self.central_moments['kurtosis']
|
|
938
|
+
label = f"{self.name}: rms = {s_rms:.2f} [{self.unit}] | $kurt $ = {s_kurt:.2f} [-]"
|
|
939
|
+
ax.plot(self.t,self.x,'k',label=label, alpha = 0.5)
|
|
940
|
+
ax.step(t_ns_index, ns_index, 'k', where = 'mid', label = f'{idx_type} history')
|
|
941
|
+
ax.set_xlabel("Time [s]")
|
|
942
|
+
ax.set_ylabel(f"Amplitude [{self.unit}]")
|
|
943
|
+
ax.legend()
|
|
944
|
+
ax.grid(True,which = 'both')
|
|
945
|
+
ax.minorticks_on()
|
|
946
|
+
ax.set_title(f'Non-stationarity index {self.name}')
|
|
947
|
+
if xlims is not None:
|
|
948
|
+
ax.set_xlim(xlims)
|
|
949
|
+
if ylims is not None:
|
|
950
|
+
ax.set_ylim(ylims)
|
|
951
|
+
plt.show(block=False)
|
|
952
|
+
return ax
|
|
953
|
+
|
|
954
|
+
def plot_hilbert(self, ax = None):
|
|
955
|
+
"""
|
|
956
|
+
Plot the Hilbert transform of the signal, showing the amplitude envelope and instantaneous frequency.
|
|
957
|
+
|
|
958
|
+
Parameters
|
|
959
|
+
----------
|
|
960
|
+
ax : matplotlib.axes.Axes, optional
|
|
961
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
962
|
+
|
|
963
|
+
Returns
|
|
964
|
+
-------
|
|
965
|
+
ax : matplotlib.axes.Axes
|
|
966
|
+
The axes with the plotted Hilbert transform.
|
|
967
|
+
"""
|
|
968
|
+
_, amplitude_envelope, instantaneous_phase = self.get_hilbert()
|
|
969
|
+
print(f'Plotting Hilbert transform')
|
|
970
|
+
instantaneous_frequency = np.diff(instantaneous_phase) / (2*np.pi) * self.fs
|
|
971
|
+
time = self.t
|
|
972
|
+
if ax is None:
|
|
973
|
+
_, ax = plt.subplots(nrows=2, figsize=(10, 4), sharex='all', tight_layout=True)
|
|
974
|
+
ax[0].set_title(f"Hilbert transform {self.name}")
|
|
975
|
+
ax[0].set_ylabel(f"Amplitude [{self.unit}]")
|
|
976
|
+
ax[0].plot(time, self.x, 'k', label=f'Signal')
|
|
977
|
+
ax[0].plot(time, amplitude_envelope, 'r', label='Envelope')
|
|
978
|
+
ax[0].legend()
|
|
979
|
+
ax[0].grid(True,which = 'both')
|
|
980
|
+
ax[0].minorticks_on()
|
|
981
|
+
ax[1].set(xlabel="Time [s]", ylabel="Frequency [Hz]")
|
|
982
|
+
ax[1].plot(time[1:], abs(instantaneous_frequency), 'k', label = 'Instantaneous frequency')
|
|
983
|
+
ax[1].legend()
|
|
984
|
+
ax[1].grid(True,which = 'both')
|
|
985
|
+
ax[1].minorticks_on()
|
|
986
|
+
plt.show(block=False)
|
|
987
|
+
return ax
|
|
988
|
+
|
|
989
|
+
def _plot_hilbert_spectrum(self, ax = None): #! WIP
|
|
990
|
+
"""
|
|
991
|
+
Plot the Hilbert spectrum of the signal, showing the amplitude and phase.
|
|
992
|
+
|
|
993
|
+
Parameters
|
|
994
|
+
----------
|
|
995
|
+
ax : matplotlib.axes.Axes, optional
|
|
996
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
997
|
+
|
|
998
|
+
Returns
|
|
999
|
+
-------
|
|
1000
|
+
ax : matplotlib.axes.Axes
|
|
1001
|
+
The axes with the plotted Hilbert spectrum.
|
|
1002
|
+
"""
|
|
1003
|
+
_, amplitude_envelope, instantaneous_phase = self.get_hilbert()
|
|
1004
|
+
instantaneous_frequency = np.diff(instantaneous_phase,prepend=0) / (2*np.pi) * self.fs
|
|
1005
|
+
h_spectrum = np.fft.rfft(amplitude_envelope)/self.fs
|
|
1006
|
+
freq = self.f_fft_os
|
|
1007
|
+
if ax is None:
|
|
1008
|
+
_, ax = plt.subplots(nrows=2, figsize=(10, 4), sharex='all', tight_layout=True)
|
|
1009
|
+
ax[0].set_title(f"Hilbert spectrum {self.name}")
|
|
1010
|
+
ax[0].set_ylabel(f"Amplitude [{self.unit}]")
|
|
1011
|
+
ax[0].plot(freq, abs(h_spectrum), 'k')
|
|
1012
|
+
ax[0].legend()
|
|
1013
|
+
ax[0].grid(True,which = 'both')
|
|
1014
|
+
ax[0].minorticks_on()
|
|
1015
|
+
ax[1].set(xlabel="Frequency [Hz]", ylabel="Phase [rad]")
|
|
1016
|
+
ax[1].plot(freq, np.angle(h_spectrum), 'k')
|
|
1017
|
+
# ax[1].legend()
|
|
1018
|
+
ax[1].grid(True,which = 'both')
|
|
1019
|
+
ax[1].minorticks_on()
|
|
1020
|
+
plt.show(block=False)
|
|
1021
|
+
return ax
|
|
1022
|
+
|
|
1023
|
+
def plot_scalogram(self, ax = None, wavetype = 'morl', **kwargs):
|
|
1024
|
+
"""
|
|
1025
|
+
Plot the scalogram of the signal using wavelet transformation.
|
|
1026
|
+
|
|
1027
|
+
Parameters
|
|
1028
|
+
----------
|
|
1029
|
+
ax : matplotlib.axes.Axes, optional
|
|
1030
|
+
The axes to plot on. If None, a new figure and axes are created.
|
|
1031
|
+
wavetype : str, optional
|
|
1032
|
+
The type of wavelet to use for the transform.
|
|
1033
|
+
**kwargs:
|
|
1034
|
+
Additional keyword arguments for wavelet transformation.
|
|
1035
|
+
|
|
1036
|
+
Returns
|
|
1037
|
+
-------
|
|
1038
|
+
ax : matplotlib.axes.Axes
|
|
1039
|
+
The axes with the plotted scalogram.
|
|
1040
|
+
"""
|
|
1041
|
+
data, f = self.get_wavelet_transform(wavetype=wavetype, **kwargs)
|
|
1042
|
+
magnitude = np.log10(np.abs(data)+1)
|
|
1043
|
+
print(f'Plotting Scalogram')
|
|
1044
|
+
if ax is None:
|
|
1045
|
+
fig, ax = plt.subplots(figsize=(10, 4), tight_layout=True)
|
|
1046
|
+
spec = ax.pcolormesh(self.t, f, magnitude, vmin=magnitude.min(), vmax=magnitude.max(), shading='auto')
|
|
1047
|
+
ax.set_ylabel('Frequency [Hz]')
|
|
1048
|
+
ax.set_xlabel('Time [s]')
|
|
1049
|
+
ax.set_title(f"Scalogram with '{wavetype}' wave {self.name}")
|
|
1050
|
+
cbar = fig.colorbar(spec)
|
|
1051
|
+
cbar.ax.set_ylabel('Wavelet vector coefficiens [-]')
|
|
1052
|
+
plt.show(block=False)
|
|
1053
|
+
return ax
|
|
1054
|
+
|
|
1055
|
+
def save_to_csv(self, filename: str):
|
|
1056
|
+
"""
|
|
1057
|
+
Save the signal time history to a CSV file using numpy.savetxt.
|
|
1058
|
+
|
|
1059
|
+
Parameters
|
|
1060
|
+
----------
|
|
1061
|
+
filename : str
|
|
1062
|
+
Full path (or filename) where the CSV will be saved.
|
|
1063
|
+
"""
|
|
1064
|
+
# Stack time and signal into two columns
|
|
1065
|
+
data = np.column_stack((self.t, self.x))
|
|
1066
|
+
|
|
1067
|
+
# Ensure directory exists if specified
|
|
1068
|
+
if os.path.dirname(filename):
|
|
1069
|
+
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
1070
|
+
|
|
1071
|
+
# Save to CSV
|
|
1072
|
+
np.savetxt(filename, data, delimiter=",")
|
|
1073
|
+
|
|
1074
|
+
print(f"Signal saved to '{filename}' successfully.")
|
|
1075
|
+
|
|
1076
|
+
if __name__ == "__main__":
|
|
1077
|
+
pass
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
|