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.
@@ -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
+