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,934 @@
1
+ import numpy as np
2
+ import scipy as sp
3
+ import matplotlib.pyplot as plt
4
+
5
+ from .utils import *
6
+ from .single_chan_signal import SingleChanSignal
7
+
8
+
9
+ '''
10
+ supporting functions / 'static methods' (no need of self object)
11
+ - get_winterstein: calculate non-gaussian signal using the Winterstein method
12
+ - get_cubic_polinomial: calculate non-gaussian signal using the cubic polymomial method
13
+ - get_zheng: calculate non-gaussian signal using the hyperbolic method
14
+ - get_sarkani: calculate non-gaussian signal using the Sarkani method
15
+ - get_zmnl: calculate non-gaussian signal using the improved non-linear method
16
+ - get_steinwolf: calculate non-gaussian signal using the Steinwolf method
17
+ - get_smallwood: calculate non-gaussian signal using the Smallwood shotnoise method
18
+ - get_vanbaren: calculate non-gaussian signal using the Vanbaren method
19
+ - get_arma: calculate non-gaussian signal using the ARMA method
20
+
21
+ '''
22
+
23
+
24
+ def _get_signal_statistics(signal: np.ndarray) -> tuple:
25
+ """
26
+ Internal function to compute basic statistics and theoretical Gaussian PDF for a signal.
27
+
28
+ Parameters
29
+ ----------
30
+ signal : np.ndarray
31
+ Input signal array.
32
+
33
+ Returns
34
+ ----------
35
+ rms : float
36
+ Root mean square (standard deviation) of the signal.
37
+ grid : np.ndarray
38
+ Grid over which the PDF is evaluated.
39
+ pdf_x : np.ndarray
40
+ Bestfit Gaussian PDF values evaluated over the grid.
41
+ """
42
+ # Compute the root mean square (standard deviation) of the signal
43
+ rms = np.std(signal)
44
+
45
+ # Generate a grid of values spanning ±10 times the RMS
46
+ grid = np.linspace(-10 * rms, 10 * rms, 5000)
47
+
48
+ # Evaluate a Gaussian PDF with variance equal to rms squared over the grid
49
+ pdf_x = gaussian_bell(grid, var=rms**2)
50
+
51
+ return rms, grid, pdf_x
52
+
53
+ def get_winterstein(
54
+ signal: np.ndarray,
55
+ input_skewness: float,
56
+ input_kurtosis: float,
57
+ params: float = None
58
+ ) -> tuple:
59
+ """
60
+ Applies the Winterstein method to generate a non-Gaussian signal
61
+ with specified skewness and kurtosis from a Gaussian input.
62
+
63
+ Parameters
64
+ ----------
65
+ signal : np.ndarray
66
+ Input signal.
67
+ input_skewness : float
68
+ Desired skewness of the output signal.
69
+ input_kurtosis : float
70
+ Desired kurtosis of the output signal.
71
+ params : float
72
+ Optional dictionary of parameters. Currently supports:
73
+ - 2 or 3, for second or third order Winterstein transformation.
74
+
75
+ Returns
76
+ ---------
77
+ ng_signal : np.ndarray
78
+ Transformed signal
79
+ opt_params : dict
80
+ Dictionary of optimal parameters
81
+
82
+ Source
83
+ ------
84
+ S. R. Winerstein, «Nonlinear Vibration Models for Extremes and Fatigue», J. Eng. Mech., vol. 114, fasc. 10, pp. 1772-1790, ott. 1988, doi: 10.1061/(ASCE)0733-9399(1988)114:10(1772)
85
+ """
86
+
87
+ if input_kurtosis > 12:
88
+ print(
89
+ 'Kurtosis requested may be too high for correct representation via '
90
+ 'Winterstein method. Consider using alternative methods.'
91
+ )
92
+
93
+ K = input_kurtosis
94
+ S = input_skewness
95
+
96
+ # Normalize input signal to zero-mean, unit-variance
97
+ input_rms = sp.stats.tstd(signal)
98
+ x = signal / input_rms
99
+
100
+ # Choose transformation order
101
+ if params is not None and params == 2:
102
+ print('Estimating Winterstein second order non-linear transform...')
103
+ h_3 = S / (4 + 2 * np.sqrt(1 + 1.5 * (K - 3)))
104
+ h_4 = (np.sqrt(1 + 1.5 * (K - 3)) - 1) / 18
105
+ else:
106
+ print('Estimating Winterstein third order non-linear transform...')
107
+ h_3 = (S / 6) * (1 - 0.015 * np.abs(S) + 0.3 * S**2) / (1 + 0.2 * (K - 3))
108
+ h_4 = ((1 + 1.12 * (K - 3))**(1/3) - 1) / 10
109
+ h_4 *= (1 - 1.43 * S**2 / (K - 3))**(1 - 0.1 * K**0.8)
110
+
111
+ h_0 = 1 / np.sqrt(1 + 2 * h_3**2 + 6 * h_4**2)
112
+
113
+ # Apply the appropriate transformation
114
+ if K > 3:
115
+ ng_signal = h_0 * (x + h_3 * (x**2 - 1) + h_4 * (x**3 - 3 * x))
116
+ else:
117
+ c_0 = -h_3 / (3 * h_4)
118
+ c_1 = (1 / (2 * h_4)) * (h_3 / (3 * h_4) + x / h_0) - (h_3 / (3 * h_4))**3
119
+ c_2 = 1 / (3 * h_4) - (h_3**2 / (9 * h_4**2)) - 1
120
+ ng_signal = (
121
+ c_0
122
+ + (c_1 + np.sqrt(c_1**2 + c_2**3))**(1 / 3)
123
+ + (c_1 - np.sqrt(c_1**2 + c_2**3))**(1 / 3)
124
+ )
125
+
126
+ # Rescale to original RMS
127
+ output_rms = sp.stats.tstd(ng_signal)
128
+ ng_signal *= input_rms / output_rms
129
+
130
+ opt_params = None
131
+ return ng_signal, opt_params
132
+
133
+ def get_cubic_polinomial(signal:np.ndarray, input_kurtosis:float, input_skewness:float = 0, params = None):
134
+ """
135
+ Applies a cubic polynomial transformation to a signal to match specified skewness and kurtosis.
136
+
137
+ Parameters
138
+ -------
139
+ signal : np.ndarray
140
+ Input Gaussian signal.
141
+ input_kurtosis : float
142
+ Target kurtosis.
143
+ input_skewness : float
144
+ Target skewness.
145
+ param : dict {'a':[float, float, float]}
146
+ Transformation parameters to bypass the optimization procedure
147
+
148
+ Returns
149
+ ------
150
+ ng_signal : np.ndarray
151
+ Transformed signal
152
+ opt_params : dict
153
+ Dictionary of optimal parameters
154
+ Source
155
+ ----------
156
+ S. R. Winterstein, C. H. Lange, e S. Kumar, «Fitting: Subroutine to fit four-moment probability distributions to data», SAND--94-3039, 10123319, gen. 1995. doi: 10.2172/10123319.
157
+ """
158
+
159
+ def cubic_transform(x, a):
160
+ """Cubic polynomial transformation."""
161
+ x_ng = a[0]*x+a[1]*x**2+a[2]*x**3
162
+ return x_ng
163
+
164
+ def nonlinear_constraint(a):
165
+ """Nonlinear constraint to ensure a physically feasible cubic polynomial."""
166
+ a1, a2, a3 = a
167
+ return np.sqrt(3*a1*a3) - np.abs(a2) # Must be >= 0
168
+
169
+ ## * OBJECTIVE FUNCTION WITH TIMEHISTORY
170
+ # def cubic_obj_fun(var):
171
+ # z = cubic_transform(x, var)
172
+ # obj_val = abs(np.abs(sp.stats.kurtosis(z) + 3 - input_kurtosis) + np.abs(sp.stats.skew(z) - input_skewness))
173
+ # return obj_val
174
+
175
+ def cubic_obj_fun(var):
176
+ """
177
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
178
+ """
179
+ z = cubic_transform(grid, var)
180
+
181
+ ## * FOR EQUIVALENCE TO TIMESERIES TEST
182
+ # zt = cubic_transform(x, var)
183
+ # time_kurt = sp.stats.kurtosis(zt) + 3
184
+
185
+ transformed_kurtosis = np.trapz(pdf_x*z**4,grid)/np.trapz(pdf_x*z**2,grid)**2
186
+ transformed_skewness = np.trapz(pdf_x*z**3,grid)/np.trapz(pdf_x*z**2,grid)**(3/2)
187
+ obj_val = np.abs(transformed_kurtosis - input_kurtosis) + np.abs(transformed_skewness - input_skewness)
188
+ return obj_val
189
+
190
+ x = signal
191
+ input_rms, grid, pdf_x = _get_signal_statistics(x)
192
+
193
+ if params is not None: # Optimization bypass
194
+ try:
195
+ ng_signal = cubic_transform(x, params['a'])
196
+ except:
197
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'a':[float, float, float]}")
198
+ return ng_signal, params
199
+
200
+ constraint = {'type': 'ineq', 'fun': nonlinear_constraint}
201
+ optim_res = sp.optimize.minimize(
202
+ cubic_obj_fun,
203
+ x0=[0.1, 0, 0.1],
204
+ bounds=[(0, 100), (-100, 100), (0, 100)],
205
+ constraints=[constraint]
206
+ )
207
+ ng_signal = cubic_transform(x, optim_res.x)
208
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal))
209
+ opt_params = {'a': optim_res.x}
210
+ return ng_signal, opt_params
211
+
212
+ def get_zheng(signal:np.ndarray, input_kurtosis:float, input_skewness:float = 0, params = None):
213
+ """
214
+ Applies Zheng's transformation to achieve a desired kurtosis and skewness.
215
+
216
+ Parameters
217
+ ----------
218
+ signal (np.ndarray):
219
+ Input Gaussian signal.
220
+ input_kurtosis (float):
221
+ Target kurtosis.
222
+ input_skewness (float, optional):
223
+ Target skewness. If None, assumes symmetry.
224
+ param : dict {'a': float, 'b': float}
225
+ Transformation parameters to bypass the optimization procedure
226
+
227
+ Returns
228
+ -------
229
+ ng_signal : np.ndarray
230
+ Transformed signal
231
+ opt_params : dict
232
+ Dictionary of optimal parameters
233
+ Source
234
+ ------
235
+ R. Zheng, H. Chen, e X. He, «Control Method for Multiple-Input Multiple-Output Non-Gaussian Random Vibration Test: MIMO Non-Gaussian Random Vibration Test», Packag. Technol. Sci., vol. 30, fasc. 7, pp. 331–345, lug. 2017, doi: 10.1002/pts.2303.
236
+ """
237
+ def zheng_transform_leptokurtic(x, a, b):
238
+ """zheng polynomial transformation for leptokurtic processes."""
239
+ return np.exp(a*x)-np.exp(-b*x)
240
+
241
+ def zheng_transform_platykurtic(x, a, b):
242
+ """zheng polynomial transformation for platykurtic processes."""
243
+ return np.log(a*x+np.sqrt((b*x)**2+1))
244
+
245
+ def zheng_obj_fun(var, tranform_fun):
246
+ """
247
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
248
+ """
249
+ if len(var)==1:
250
+ z = tranform_fun(grid, var, var)
251
+ else:
252
+ z = tranform_fun(grid, var[0], var[1])
253
+
254
+ ## * FOR EQUIVALENCE TO TIMESERIES TEST
255
+ # zt = cubic_transform(x, var)
256
+ # time_kurt = sp.stats.kurtosis(zt) + 3
257
+
258
+ transformed_kurtosis = np.trapz(pdf_x*z**4,grid)/np.trapz(pdf_x*z**2,grid)**2
259
+ transformed_skewness = np.trapz(pdf_x*z**3,grid)/np.trapz(pdf_x*z**2,grid)**(3/2)
260
+ obj_val = np.abs(transformed_kurtosis - input_kurtosis) + np.abs(transformed_skewness - input_skewness)
261
+ return obj_val
262
+
263
+ input_rms = np.std(signal)
264
+ x = signal / np.max(np.abs(signal)) # input signal needs to be normalized to its maximum value
265
+ _, grid, pdf_x = _get_signal_statistics(x)
266
+
267
+ if input_kurtosis>3:
268
+ transform_function = zheng_transform_leptokurtic
269
+ init_approx = 3.48*(input_kurtosis-3)**0.171-2 # only for a==b -> skewness = 0
270
+ else:
271
+ transform_function = zheng_transform_platykurtic
272
+ init_approx = 2.36*np.exp(3-input_kurtosis)-2 **0.171-2 # only for a==b -> skewness = 0
273
+
274
+ if params is not None: # Optimization bypass
275
+ try:
276
+ ng_signal = transform_function(x, params['a'], params['b'])
277
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal)) # bring back rms to original value
278
+ except:
279
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'a': float, 'b': float}")
280
+ return ng_signal, params
281
+
282
+ if input_skewness:
283
+ optim_res = sp.optimize.minimize(zheng_obj_fun, args = transform_function, x0=[2, 2], bounds=[(0, 10), (1, 3)])
284
+ a = optim_res.x[0]
285
+ b = optim_res.x[1]
286
+
287
+ else:
288
+ # * Apoximated formulation a = b only used as initial guess for optimizator
289
+ optim_res = sp.optimize.minimize(zheng_obj_fun, args = transform_function, x0=init_approx, bounds=[(0, 10)])
290
+ a = optim_res.x[0]
291
+ b = a
292
+
293
+ ng_signal = transform_function(x, a, b)
294
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal)) # bring back rms to original value
295
+ opt_params = {'a': a, 'b': b}
296
+ return ng_signal, opt_params
297
+
298
+ def get_sarkani(signal:np.ndarray, input_kurtosis:float, input_skewness:float = 0, params = None):
299
+ """
300
+ Applies Sarkani's transformation to achieve a desired kurtosis and skewness.
301
+
302
+ Parameters
303
+ ----------
304
+ signal : np.ndarray
305
+ Input Gaussian signal.
306
+ input_kurtosis : float
307
+ Target kurtosis.
308
+ input_skewness : float, optional
309
+ Target skewness. If None, assumes symmetry.
310
+ param : dict {'b1': float, 'b2': float}
311
+ Transformation parameters to bypass the optimization procedure
312
+
313
+ Returns
314
+ -------
315
+ ng_signal : np.ndarray
316
+ Transformed signal
317
+ opt_params : dict
318
+ Dictionary of optimal parameters
319
+ Source
320
+ ------
321
+ S. Sarkani, D. P. Kihl, e J. E. Beach, «Fatigue of welded joints under narrowband non-Gaussian loadings», Probabilistic Eng. Mech., vol. 9, fasc. 3, pp. 179–190, gen. 1994, doi: 10.1016/0266-8920(94)90003-5.
322
+ """
323
+ def sarkani_transform(x, b1, b2):
324
+ """Sarkani's nonlinear transformation."""
325
+ return x + b1*np.sign(x)*np.abs(x)**b2
326
+
327
+ ## * OBJECTIVE FUNCTION WITH TIMEHISTORY
328
+ # def sarkani_time_obj_fun(var):
329
+ # beta = var[0]
330
+ # n = var[1]
331
+ # # C = np.sqrt(1+(2**(1/2/(n+1)*n*sp.special.gamma(n/2)*sx**(n-1)))*beta/(np.sqrt(np.pi))+(2**n*sp.special.gamma(n+0.5)*sx**(2*(n-1)))*beta**2/((np.sqrt(np.pi))))
332
+ # z = sarkani_transform(x, beta, n)
333
+ # obj_val = np.abs(sp.stats.kurtosis(z) + 3 - input_kurtosis)
334
+ # return obj_val
335
+
336
+ # * OBJECTIVE FUNCTION WITH PDF
337
+ def sarkani_obj_fun(var):
338
+ """
339
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
340
+ """
341
+ b1 = var[0]
342
+ b2 = var[1]
343
+ z = sarkani_transform(grid, b1, b2)
344
+
345
+ ## * FOR EQUIVALENCE TO TIMESERIES TEST
346
+ # zt = sarkani_transform(x, beta, n)
347
+ # time_kurt = sp.stats.kurtosis(zt) + 3
348
+
349
+ transformed_kurtosis = np.trapz(pdf_x*z**4,grid)/np.trapz(pdf_x*z**2,grid)**2
350
+ obj_val = np.abs(transformed_kurtosis - input_kurtosis)
351
+ return obj_val
352
+
353
+ if input_skewness:
354
+ raise UserWarning('Skewness correction for sarkani method is not available. Please, set skewness = 0 or consider using another method')
355
+
356
+ input_rms, grid, pdf_x = _get_signal_statistics(signal)
357
+
358
+ if params is not None: # Optimization bypass
359
+ try:
360
+ ng_signal = sarkani_transform(signal, params['b1'], params['b2'])
361
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal)) # bring back rms to original value
362
+ except:
363
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'b1': float, 'b2': float}")
364
+ return ng_signal, params
365
+
366
+ optim_res = sp.optimize.minimize(sarkani_obj_fun, x0=[2, 2], bounds=[(0, 5), (1, 5)])
367
+ b1 = optim_res.x[0]
368
+ b2 = optim_res.x[1]
369
+ ng_signal = sarkani_transform(signal, b1, b2)
370
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal))
371
+ opt_params = {'b1': optim_res.x[0], 'b2': optim_res.x[1]}
372
+ return ng_signal, opt_params
373
+
374
+ def get_zmnl(signal:np.ndarray, fs:float, input_kurtosis:float, input_skewness:float = None, params = None):
375
+ """
376
+ Applies the improved non linear transformation to achieve a desired kurtosis.
377
+
378
+ Parameters
379
+ ----------
380
+ signal : np.ndarray
381
+ Input Gaussian signal.
382
+ fs : float
383
+ Sampling frequency of the signal
384
+ input_kurtosis : float
385
+ Target kurtosis.
386
+ input_skewness : float, optional
387
+ Target skewness. If None, assumes symmetry.
388
+ param : dict {'beta': float, 'n': float}
389
+ Transformation parameters to bypass the optimization procedure
390
+
391
+ Returns
392
+ -------
393
+ ng_signal : np.ndarray
394
+ Transformed signal
395
+ opt_params : dict
396
+ Dictionary of optimal parameters
397
+ Source
398
+ ------
399
+ G. Wise, A. Traganitis, e J. Thomas, «The effect of a memoryless nonlinearity on the spectrum of a random process», IEEE Trans. Inf. Theory, vol. 23, fasc. 1, pp. 84–89, gen. 1977, doi: 10.1109/TIT.1977.1055658.
400
+ """
401
+ def zengh_transform(x, beta, n, fs):
402
+ """Improved nonlinear transformation."""
403
+ z = (x+beta*np.sign(x)*np.abs(x)**n)
404
+ z = z*(input_rms/np.std(z))
405
+ Xts_woShift = (1/fs)*np.fft.fft(z)
406
+ Nos = np.ceil((len(z)+1)/2)
407
+ Xos = Xts_woShift[0:int(Nos)]
408
+ transform_FFT_phi = np.angle(Xos)
409
+ z_amplitude_coherent= np.fft.irfft((FFT_amplitudes*np.exp(transform_FFT_phi*1j))*fs)
410
+ return z_amplitude_coherent
411
+
412
+ # * OBJECTIVE FUNCTION WITH TIMEHISTORY
413
+ def zmnl_obj_fun(var):
414
+ """
415
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
416
+ """
417
+ beta = var[0]
418
+ n = var[1]
419
+ z = zengh_transform(signal, beta, n, fs)
420
+ tranform_kurtosis = sp.stats.kurtosis(z) + 3
421
+ obj_val = np.abs(tranform_kurtosis - input_kurtosis)
422
+ return obj_val
423
+
424
+ if input_skewness:
425
+ raise UserWarning('Skewness correction for the improved non-linear method is not available. Please, set skewness = 0 or consider using another method')
426
+
427
+ input_rms = np.std(signal)
428
+ Xts_woShift = (1/fs)*np.fft.fft(signal)
429
+ Nos = np.ceil((len(signal)+1)/2)
430
+ Xos = Xts_woShift[0:int(Nos)]
431
+ FFT_amplitudes = np.abs(Xos)
432
+
433
+ if params is not None: # Optimization bypass
434
+ try:
435
+ ng_signal = zengh_transform(signal, params['beta'], params['n'])
436
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal)) # bring back rms to original value
437
+ except:
438
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'beta': float, 'n': float}")
439
+ return ng_signal, params
440
+
441
+ optim_res = sp.optimize.minimize(zmnl_obj_fun, x0=[2, 2], bounds=[(0, 5), (0, 5)])
442
+ beta = optim_res.x[0]
443
+ n = optim_res.x[1]
444
+ ng_signal = zengh_transform(signal, beta, n, fs)
445
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal))
446
+ opt_params = {'beta': optim_res.x[0], 'n': optim_res.x[1]}
447
+ return ng_signal, opt_params
448
+
449
+ def get_steinwolf(
450
+ signal:np.ndarray,
451
+ fs:float,
452
+ input_kurtosis:float,
453
+ input_skewness:float = None,
454
+ params = None
455
+ ): #! FIX: INCONSISTENT AND WRONG RESULTS
456
+ """
457
+ Applies the Steinwolf phase transformation to achieve a desired kurtosis.
458
+
459
+ Parameters
460
+ ----------
461
+ signal : np.ndarray
462
+ Input Gaussian signal.
463
+ fs : float
464
+ Sampling frequency of the signal
465
+ input_kurtosis : float
466
+ Target kurtosis.
467
+ input_skewness : float, optional
468
+ Target skewness. If None, assumes symmetry.
469
+ param : dict {'nd': int, 'seed': int}
470
+ Transformation parameters to bypass the optimization procedure
471
+
472
+ Returns
473
+ -------
474
+ ng_signal : np.ndarray
475
+ Transformed signal
476
+ opt_params : dict
477
+ Dictionary of optimal parameters
478
+ Source
479
+ ------
480
+ A. Steinwolf, «Random vibration testing with kurtosis control by IFFT phase manipulation», Mech. Syst. Signal Process., vol. 28, pp. 561–573, apr. 2012, doi: 10.1016/j.ymssp.2011.11.001.
481
+ """
482
+ def get_Sz(A, phi): #! NOT TO BE USED (WIP)
483
+ """Returns skewness obtained from the Fourier transform of the signal."""
484
+ N = len(A)
485
+ Sz = 0.5 * np.sum(A**2)
486
+ term1 = (3/4) * np.sum([A[j] * A[k]**2 * np.cos(phi[j] - 2*phi[k]) for k in range(N) for j in range(2*k, N)])
487
+ term2 = (3/2) * np.sum([A[j] * A[k] * A[m] * np.cos(phi[m] - phi[j] - phi[k])
488
+ for j in range(N) for k in range(j+1, N) for m in range(j+k, N)])
489
+ return Sz * (term1 + term2)
490
+
491
+ def get_Kz(A, phi): #! NOT TO BE USED (WIP)
492
+ """Returns kurtosis obtained from the Fourier transform of the signal."""
493
+ N = len(A)
494
+ Kz = np.sum(A**2)**(-2)
495
+ term1 = (3/2) * np.sum(A**4) + 2 * np.sum([A[j]**3 * np.cos(phi[j] - 3*phi[k])
496
+ for k in range(N) for j in range(3*k, N)])
497
+ term2 = 6 * np.sum([A[j] * A[k] * A[n]**2 * np.cos(phi[j] - phi[k] - 2*phi[n])
498
+ for k in range(N) for n in range(N) if n != k for j in range(k+2*n, N)])
499
+ term3 = 6 * np.sum([A[j] * A[k] * A[n]**2 * np.cos(phi[j] + phi[k] - 2*phi[n])
500
+ for n in range(N) for j in range(N) for k in range(2*n-j,N) if j<k])
501
+ term4 = 12 * np.sum([A[j] * A[k] * A[n] * A[m] * np.cos(phi[j] + phi[k] - phi[n] - phi[m])
502
+ for j in range(N) for k in range(j+1, N) for n in range(j+1, N) for m in range(n+1, N) if j+k == n+m])
503
+ term5 = 12 * np.sum([A[j] * A[k] * A[n] * A[m] * np.cos(phi[j] + phi[k] + phi[n] - phi[m])
504
+ for j in range(N) for k in range(j+1, N) for n in range(k+1, N) for m in range(j+k+n, N)])
505
+
506
+ return 3+ Kz * (term1 + term2 + term3 + term4 + term5)
507
+
508
+ def steinwolf_transform(a, b, deterministic_armonics_id, fs):
509
+ """Steinwolf phase transformation non-gaussian signal builder."""
510
+ deterministic_armonics_id = deterministic_armonics_id.astype(int)
511
+ E = np.zeros(len(deterministic_armonics_id))
512
+ D = np.zeros(len(deterministic_armonics_id))
513
+ for idx, k in enumerate(deterministic_armonics_id):
514
+ E[idx] = np.sum([a[n]*(a[j]**2-b[j]**2)-2*b[n]*a[j]*b[j]
515
+ for n in range(N) if n != k for j in range(k+2*n, N)])
516
+ D[idx] = np.sum([b[n]*(a[j]**2-b[j]**2)+2*a[j]*a[n]*b[j]
517
+ for n in range(N) if n != k for j in range(k+2*n, N)])
518
+ bk = A[deterministic_armonics_id]*np.sqrt(D**2/(D**2+E**2))*np.sign(D)
519
+ ak = np.sqrt(A[deterministic_armonics_id]**2-bk**2)*np.sign(E)
520
+ a[deterministic_armonics_id] = ak
521
+ b[deterministic_armonics_id] = bk
522
+ dt = 1/fs
523
+ Xos = np.array([complex(ai,bi) for ai, bi in zip(a, b)])
524
+ x = np.fft.irfft(Xos/dt)
525
+ return x
526
+
527
+ def steinwolf_obj_fun(params,fs, relevant_armonics):
528
+ """
529
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
530
+ """
531
+ deterministic_freqs_ids = relevant_armonics[range(params[0].astype(int))]
532
+ x = steinwolf_transform(a, b, deterministic_freqs_ids , fs)
533
+ transformed_kurtosis = sp.stats.kurtosis(x) + 3
534
+ return np.abs(transformed_kurtosis - input_kurtosis)
535
+
536
+ if input_skewness:
537
+ raise UserWarning('Skewness correction for the smallwood method is not available. Please, set skewness = 0 or consider using another method')
538
+
539
+ seed = np.random.randint(1e6,1e7-1)
540
+ input_rms = np.std(signal)
541
+ Xts_woShift = (1/fs)*np.fft.fft(signal)
542
+ Nos = np.ceil((len(signal)+1)/2)
543
+ Xos = Xts_woShift[0:int(Nos)]
544
+ A = np.abs(Xos)
545
+ relevant_armonics = np.flatnonzero(A > (input_rms / 10))
546
+ phi = np.angle(Xos)
547
+ N = len(A)
548
+ a = A*np.cos(phi)
549
+ b = -A*np.sin(phi)
550
+
551
+ if params is not None: # Optimization bypass
552
+ try:
553
+ rng = np.random.default_rng(seed=params['seed'])
554
+ rng.shuffle(relevant_armonics)
555
+ ng_signal = steinwolf_transform(a, b, params['nd'], fs)
556
+ ng_signal = ng_signal*(input_rms/np.std(ng_signal)) # bring back rms to original value
557
+ except:
558
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'nd': int, 'seed'}")
559
+ return ng_signal, params
560
+ rng = np.random.default_rng(seed=seed)
561
+ rng.shuffle(relevant_armonics)
562
+ optim_res = sp.optimize.minimize(steinwolf_obj_fun, args = (fs, relevant_armonics.flatten()), x0=[1], bounds=[(1,len(relevant_armonics)-1)])
563
+ ng_signal = steinwolf_transform(a, b, relevant_armonics[range(optim_res.x[0].astype(int))], fs)
564
+ ng_signal*(input_rms/np.std(ng_signal))
565
+ opt_params = {'nd': optim_res.x[0].astype(int), 'seed': seed}
566
+ return ng_signal, opt_params
567
+
568
+ def get_smallwood(
569
+ fpsd:np.ndarray,
570
+ psd: np.ndarray,
571
+ T: float,
572
+ input_kurtosis: float,
573
+ input_skewness: float = None,
574
+ seed: int = None,
575
+ params = None
576
+ ):# ! FIX: NOT WORKING
577
+ """
578
+ Uses the Smallwood filtered poisson shot to achieve a desired kurtosis.
579
+
580
+ Parameters
581
+ ----------
582
+ fpsd : np.ndarray
583
+ Frequency vector of the power spectral density
584
+ psd : np.ndarray
585
+ Power density vector of the power spectral density
586
+ T : float
587
+ Time length of the final signal
588
+ input_kurtosis : float
589
+ Target kurtosis.
590
+ input_skewness : float, optional
591
+ Target skewness. If None, assumes symmetry.
592
+ seed : int
593
+ Seed for the random generator for the Poisson shot times and amplitudes.
594
+ param : dict {'lam': float, 'I': float, 'seed': int}
595
+ Transformation parameters to bypass the optimization procedure
596
+
597
+ Returns
598
+ -------
599
+ ng_signal : np.ndarray
600
+ Transformed signal
601
+ opt_params : dict
602
+ Dictionary of optimal parameters
603
+ Source
604
+ ------
605
+ D. O. Smallwood, «Generation of Stationary Non-Gaussian Time Histories with a Specified Cross-spectral Density», Shock Vib., vol. 4, fasc. 5–6, pp. 361–377, 1997, doi: 10.1155/1997/713593.
606
+ """
607
+ def get_shot_noise_smallwood(A, lam, p, I, N, seed = None):
608
+ # nonlocal psd, fs
609
+ N = round(N)
610
+ h_t = get_psd_impulse_response(psd, fs, N)
611
+ if seed: np.random.seed(seed=seed)
612
+ tau = np.round(np.random.exponential(scale=lam, size=round(N/I))*fs).astype(int)
613
+ Ax = A * np.sign(np.random.binomial(size=round(N/I), n=1, p= p)-0.5)
614
+ np.random.seed(None)
615
+ shot_impulse = np.zeros(len(h_t))
616
+ for tau_k, Ai in zip(np.cumsum(tau),Ax):
617
+ if tau_k <len(h_t):
618
+ shot_impulse[tau_k] = Ai
619
+ shot_noise = np.convolve(shot_impulse,h_t,'same')
620
+ return shot_noise
621
+
622
+ def smallwood_opt_fun(params, seed):
623
+ """
624
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
625
+ """
626
+ A = 1
627
+ lam = params[0]
628
+ I = params[1]
629
+ ng_signal = get_shot_noise_smallwood(A, lam, p, I, N, seed=seed)
630
+ transformed_kurtosis = sp.stats.kurtosis(ng_signal) + 3
631
+ return np.abs(transformed_kurtosis - input_kurtosis)
632
+
633
+ if input_skewness:
634
+ raise UserWarning('Skewness correction for the smallwood method is not available. Please, set skewness = 0 or consider using another method')
635
+
636
+ if seed is None: seed = np.random.randint(1e6,1e7-1)
637
+ fs = 2*fpsd[-1]
638
+ N = round(T*fs)
639
+ inp_rms = np.sqrt(np.trapz(psd,fpsd))
640
+ h_t = get_psd_impulse_response(fpsd, psd, N)
641
+ h2 = np.sum((h_t)**2)/fs
642
+ h4 = np.sum((h_t)**4)/fs
643
+ lam_0 = h4/h2**2*(1/(input_kurtosis**4-3))
644
+ p = 0.5
645
+
646
+ if params is not None: # Optimization bypass
647
+ try:
648
+ seed = params['seed']
649
+ ng_signal = get_shot_noise_smallwood(A = 1, lam = params['lam'], N=N, p = p, I = params['I'])
650
+ ng_signal = ng_signal*(inp_rms/np.std(ng_signal)) # bring back rms to original value
651
+ except:
652
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'beta': float, 'n': float, 'seed': int}")
653
+ return ng_signal, params
654
+
655
+ optim_res = sp.optimize.minimize(smallwood_opt_fun,args = (seed), x0=[lam_0, 1.5], bounds=[(0.001,100), (1,2)])
656
+ ng_signal = get_shot_noise_smallwood(A = 1, lam = optim_res.x[0], N=N, p = p, I = optim_res.x[1])
657
+ ng_signal = ng_signal*(inp_rms/np.std(ng_signal))
658
+ opt_params = {'lam': optim_res.x[0], 'I': optim_res.x[1], 'seed': seed}
659
+ return ng_signal, opt_params
660
+
661
+ def get_vanbaren(
662
+ fpsd:np.ndarray,
663
+ psd: np.ndarray,
664
+ T: float,
665
+ input_kurtosis: float,
666
+ input_skewness: float = None,
667
+ seed: int = None,
668
+ params = None
669
+ ):
670
+ """
671
+ Uses the Vanbaren filtered poisson shot to achieve a desired kurtosis.
672
+
673
+ Parameters
674
+ ----------
675
+ fpsd : np.ndarray
676
+ Frequency vector of the power spectral density
677
+ psd : np.ndarray
678
+ Power density vector of the power spectral density
679
+ T : float
680
+ Time length of the final signal
681
+ input_kurtosis : float
682
+ Target kurtosis.
683
+ input_skewness : float, optional
684
+ Target skewness. If None, assumes symmetry.
685
+ seed : int
686
+ Seed for the random generator for the Poisson shot times and amplitudes.
687
+ param : dict {'alpha': float, 'seed': int}
688
+ Transformation parameters to bypass the optimization procedure
689
+ Returns
690
+ -------
691
+ ng_signal : np.ndarray
692
+ Transformed signal
693
+ opt_params : dict
694
+ Dictionary of optimal parameters
695
+ Source
696
+ ------
697
+ P. Van Baren, «System and method for simultaneously controlling spectrum and kurtosis of a random vibration», US20070185620
698
+ """
699
+ def get_shot_noise_vanbaren(alpha, h_t, seed = None):
700
+ N = len(h_t)
701
+ gamma = 1/alpha
702
+ if seed: np.random.seed(seed=seed)
703
+ beta = np.random.exponential(scale=1/(alpha-1), size=N)
704
+ shot_impulse = np.abs(np.random.randn(N))<gamma
705
+ np.random.seed(seed=None)
706
+ shot_noise = np.convolve(h_t,beta*shot_impulse, 'same')
707
+ return shot_noise
708
+
709
+ def vanbaren_opt_fun(param, h_t, seed):
710
+ """
711
+ Objective function to minimize the absolute error in kurtosis and skewness after transformation.
712
+ """
713
+ alpha = param[0]
714
+ ng_signal =get_shot_noise_vanbaren(alpha, h_t, seed)
715
+ transformed_kurtosis = sp.stats.kurtosis(ng_signal) + 3
716
+ transformed_skewness = sp.stats.skew(ng_signal)
717
+
718
+ return np.abs(transformed_kurtosis - input_kurtosis) + np.abs(transformed_skewness)
719
+
720
+ if input_skewness:
721
+ raise UserWarning('Skewness correction for the smallwood method is not available. Please, set skewness = 0 or consider using another method')
722
+
723
+ if seed is None: seed = np.random.randint(1e6,1e7-1)
724
+ fs = 2*fpsd[-1]
725
+ N = round(T*fs)
726
+ inp_rms = np.sqrt(np.trapz(psd,fpsd))
727
+ # _, gauss_signal = get_stationary_gaussian(fpsd, psd/inp_rms**2, T)
728
+ h_t = get_psd_impulse_response(fpsd, psd, N//2)
729
+
730
+ if params is not None: # Optimization bypass
731
+ try:
732
+ ng_signal = get_shot_noise_vanbaren(alpha = params['alpha'], h_t = h_t, seed = params['seed'])
733
+ ng_signal = ng_signal*(inp_rms/np.std(ng_signal)) # bring back rms to original value
734
+ except:
735
+ raise KeyError("The 'param' argument must be a dictionary with the format: {'alpha': float, 'seed': int}")
736
+ return ng_signal, params
737
+
738
+ optim_res = sp.optimize.minimize(vanbaren_opt_fun, method = 'Nelder-Mead', args = (h_t, seed), x0=[10], bounds=[(1.001,100)], options = {'disp': True})
739
+ shot_noise = get_shot_noise_vanbaren(alpha = optim_res.x[0], h_t = h_t, seed = seed)
740
+ ng_signal = shot_noise
741
+ ng_signal = ng_signal*(inp_rms/np.std(ng_signal))
742
+ opt_params = {'alpha': optim_res.x[0], 'seed': seed}
743
+ return ng_signal, opt_params
744
+
745
+
746
+ class StationaryNonGaussian(SingleChanSignal):
747
+ """
748
+ Generates a stationary non-Gaussian signal with specified skewness and kurtosis
749
+ using a transformation method applied to a Gaussian signal with a target PSD.
750
+ StationaryNonGaussian is a child class of SingleChanSignal.
751
+
752
+ Parameters
753
+ ----------
754
+ fpsd: np.ndarray
755
+ frequency vector of the power spectral density
756
+ psd: np.ndarray
757
+ power density vector of the power spectral density
758
+ T: float
759
+ time length of the signal
760
+ kurtosis: int
761
+ input kurtosis for the transformation model
762
+ dfpsd: float
763
+ frequency discretization of the psd to be stored
764
+ skewness: int
765
+ input kurtosis for the transformation model
766
+ method: str
767
+ tranformation method to be used for generating the non-gaussian signal
768
+ params: dict
769
+ optional parameters for the transformation model if optimization wants to be skipped
770
+ name: str
771
+ name of the process (used for plots)
772
+ var: str
773
+ name of the variable (used for plots)
774
+ unit: str
775
+ unit of measure of the signal (used for plots)
776
+ seed: int
777
+ seed for the random generator used in non-gaussian models
778
+ interp: 'lin' or 'log'
779
+ interpolation rule for increasing resolution on given PSD
780
+ """
781
+
782
+ def __init__(
783
+ self,
784
+ fpsd: np.ndarray,
785
+ psd: np.ndarray,
786
+ T: float,
787
+ kurtosis: int,
788
+ fs : float = None,
789
+ dfpsd: float = 0.5,
790
+ skewness: int = 0,
791
+ method: str = 'winter',
792
+ params=None,
793
+ name: str = "",
794
+ var: str = 'x',
795
+ unit: str = "$m/s^2$",
796
+ seed: int = None,
797
+ interp: str = 'lin'
798
+ ):
799
+ method = method.lower()
800
+ interp = interp.lower()
801
+
802
+ # Define interpolation methods
803
+ interps = {
804
+ 'lin': lin_interp_psd,
805
+ 'log': log_interp_psd
806
+ }
807
+
808
+ # Validate interpolation method
809
+ method_existance_check(interp, interps)
810
+ self._interp = interp
811
+
812
+ if len(fpsd) != len(psd):
813
+ raise AttributeError('The frequency and PSD vectors must have the same length.')
814
+
815
+ self.fpsd = fpsd
816
+ self.psd = psd
817
+ self.T = T
818
+ if fs is None:
819
+ self.fs = fpsd[-1] * 2 # Nyquist frequency
820
+ else:
821
+ self.fs = fs
822
+
823
+ # Generate the non-Gaussian signal
824
+ self.transform_params, self.x = self._get_nongaussian_timehistory(
825
+ seed=seed,
826
+ method=method,
827
+ skewness=skewness,
828
+ kurtosis=kurtosis,
829
+ params=params
830
+ )
831
+
832
+ self.signal_type = f'Stationary - NonGaussian ({method})'
833
+
834
+ super().__init__(
835
+ x=self.x,
836
+ fs=self.fs,
837
+ dfpsd=dfpsd,
838
+ name=name,
839
+ var=var,
840
+ unit=unit,
841
+ signal_type=self.signal_type
842
+ )
843
+
844
+ def _get_gaussian_timehistory(self, seed=None) -> np.ndarray:
845
+ """
846
+ Internal function called by __init__
847
+ Generate a stationary Gaussian signal from the given PSD.
848
+
849
+ Returns
850
+ --------
851
+ opt_params : dict
852
+ Dictionary of optimal parameters
853
+ signal : np.ndarray
854
+ Transformed signal
855
+ """
856
+ _, signal = get_stationary_gaussian(self.fpsd, self.psd, self.T, fs = self.fs, seed = seed, interp= self._interp)
857
+ return signal
858
+
859
+ def _get_nongaussian_timehistory(
860
+ self,
861
+ skewness: float,
862
+ kurtosis: float,
863
+ method: str = 'winter',
864
+ params=None,
865
+ seed=None
866
+ ) -> tuple:
867
+ """
868
+ Internal function called by __init__
869
+ Transforms a Gaussian signal into a non-Gaussian signal using the specified method.
870
+
871
+ Returns:
872
+ tuple: (parameters used for the transformation, transformed signal)
873
+ """
874
+ # Generate Gaussian signal
875
+ gaussian_signal = self._get_gaussian_timehistory(seed=seed)
876
+
877
+ # Define transformation methods
878
+ methods = {
879
+ 'winter': get_winterstein,
880
+ 'cubic': get_cubic_polinomial,
881
+ 'zheng': get_zheng,
882
+ 'sarkani': get_sarkani,
883
+ 'zmnl': get_zmnl,
884
+ 'steinwolf': get_steinwolf,
885
+ 'smallwood': get_smallwood,
886
+ 'vanbaren': get_vanbaren
887
+ }
888
+
889
+ # Validate method
890
+ method_existance_check(method, methods)
891
+
892
+ print(
893
+ f'Estimating non-Gaussian signal from parameters '
894
+ f'(skew = {skewness}, kurtosis = {kurtosis}) using "{method}" method.'
895
+ )
896
+
897
+ if method in ['zmnl', 'steinwolf']:
898
+ signal, opt_params = methods[method](
899
+ gaussian_signal,
900
+ input_skewness=skewness,
901
+ input_kurtosis=kurtosis,
902
+ fs=self.fs,
903
+ params=params
904
+ )
905
+ elif method in ['vanbaren', 'smallwood']:
906
+ signal, opt_params = methods[method](
907
+ self.fpsd,
908
+ self.psd,
909
+ self.T,
910
+ input_skewness=skewness,
911
+ input_kurtosis=kurtosis,
912
+ params=params
913
+ )
914
+ else:
915
+ signal, opt_params = methods[method](
916
+ gaussian_signal,
917
+ input_skewness=skewness,
918
+ input_kurtosis=kurtosis,
919
+ params=params
920
+ )
921
+
922
+ # Output statistics
923
+ output_skew = sp.stats.skew(signal)
924
+ output_kurt = sp.stats.kurtosis(signal) + 3
925
+ print(
926
+ f'Non-Gaussian signal generated with "{method}" method: '
927
+ f'skew = {output_skew:.1f}, kurt = {output_kurt:.1f}'
928
+ )
929
+
930
+ return opt_params, signal
931
+
932
+
933
+ if __name__ == "__main__":
934
+ pass