SignalProcessingTools 1.2.0__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 @@
1
+ from .__version__ import __version__
@@ -0,0 +1,3 @@
1
+ __title__ = "SignalProcessingTools"
2
+ __version__ = "1.2.0"
3
+ __author__ = "Bruno Zuada Coelho, Aron Noordam"
@@ -0,0 +1,189 @@
1
+ from typing import Optional, List, Tuple
2
+ import numpy as np
3
+ import numpy.typing as npt
4
+ from .time_signal import TimeSignalProcessing, FilterDesign, Windows
5
+
6
+
7
+ class SpaceSignalProcessing:
8
+ """
9
+ SignalProcessing class for processing signals in space.
10
+ """
11
+ def __init__(self, x: npt.NDArray[np.float64], values: npt.NDArray[np.float64], Fs: Optional[float] = None):
12
+ """
13
+ Initializes the ProcessSignal object.
14
+
15
+ Parameters
16
+ ----------
17
+ :param x (npt.NDArray[np.float64]): coordinates of the signal
18
+ :param values (npt.NDArray[np.float64]): signal values
19
+ :param Fs (Optional[float]): sampling frequency of the signal (optional: default None)
20
+ """
21
+
22
+ self.coordinates = x
23
+ self.signal_raw = values
24
+ self.n_points = values.shape[0]
25
+
26
+ # acquisition frequency
27
+ if Fs is None:
28
+ self.fs = int(np.ceil(1 / np.mean(np.diff(x))))
29
+
30
+ else:
31
+ self.fs = Fs
32
+
33
+ # track quality indexes
34
+ self.d0 = None
35
+ self.d1 = None
36
+ self.d2 = None
37
+ self.d3 = None
38
+ # track descriptors
39
+ self.rms_bands = None
40
+ self.max_fast = None
41
+ self.max_fast_Dx = None
42
+
43
+
44
+ def compute_track_longitudinal_levels(self):
45
+ """
46
+ Computes the track longitudinal levels, following EN 13848-1:2006.
47
+
48
+ The method computes the D0, D1, D2, and D3 components of the signal.
49
+ It uses the following frequency bands:
50
+ - D0: 1m < lambda <= 5m (1/5 Hz < f <= 1 Hz)
51
+ - D1: 3m < lambda <= 25m (1/25 Hz < f <= 1/3 Hz)
52
+ - D2: 25m < lambda <= 70m (1/70 Hz < f <= 1/25 Hz)
53
+ - D3: 70m < lambda <= 150m (1/150 Hz < f <= 1/70 Hz)
54
+ """
55
+
56
+ sig = TimeSignalProcessing(self.coordinates, self.signal_raw, Fs=self.fs)
57
+ sig.filter([1/5., 1.], 4, type_filter="bandpass", filter_design=FilterDesign.BUTTERWORTH)
58
+ self.d0 = sig.signal
59
+
60
+ sig.reset()
61
+ sig.filter([1/25., 1/3.], 4, type_filter="bandpass", filter_design=FilterDesign.BUTTERWORTH)
62
+ self.d1 = sig.signal
63
+ sig.reset()
64
+
65
+ sig.filter([1/70., 1/25.], 4, type_filter="bandpass", filter_design=FilterDesign.BUTTERWORTH)
66
+ self.d2 = sig.signal
67
+ sig.reset()
68
+
69
+ sig.filter([1/150., 1/70.], 4, type_filter="bandpass", filter_design=FilterDesign.BUTTERWORTH)
70
+ self.d3 = sig.signal
71
+ sig.reset()
72
+
73
+ def compute_Hmax(self, convert_m2mm: bool = True):
74
+ """
75
+ Computes the descriptor Hmax and Hrms according to Zandberg et al. (2022)
76
+ 'Deriving parameters for the characterisation of the railway track quality in
77
+ relation to environmental vibration'
78
+
79
+ Parameters
80
+ ----------
81
+ :param convert_m2mm (optional, default = True): if True, converts the results from m to mm
82
+ """
83
+
84
+ # octave bands used for the processing
85
+ one_third_octave_bands = [[.08, .10],
86
+ [.10, .126],
87
+ [.126, .16],
88
+ [.16, .20],
89
+ [.20, .253],
90
+ [.253, .32],
91
+ [.32, .40],
92
+ [.40, .50],
93
+ [.50, .63],
94
+ ]
95
+
96
+
97
+ # setting for the processing
98
+ self.DXmaxFast = 1
99
+ nb_fft_min = 256 # minimum number of samples for the power spectral density
100
+ derivative = [0, 0, 0, 2, 2, 2, 2, 2, 2] # number of times that each frequency band is derived
101
+
102
+ # RMS of the square root of the power spectral density
103
+ self.rms_bands = np.zeros(len(one_third_octave_bands))
104
+ # maximum effective value over the entire signal
105
+ self.max_fast = np.zeros(len(one_third_octave_bands))
106
+ # maximum effective value over the length Dx
107
+ self.max_fast_Dx = np.zeros(len(one_third_octave_bands))
108
+
109
+ # convert the signal from m to mm
110
+ if convert_m2mm:
111
+ self.signal = self.signal_raw * 1000
112
+
113
+ # compute the power spectral density
114
+ n_fft = int(np.max([2 ** (np.ceil(np.log2(len(self.signal)))), nb_fft_min]))
115
+ # if signal is odd length, add a zero to make it even
116
+ if len(self.signal) % 2 != 0:
117
+ signal = np.append(self.signal, 0)
118
+ coordinates = np.append(self.coordinates, self.coordinates[-1] + (self.coordinates[1] - self.coordinates[0]))
119
+ else:
120
+ signal = self.signal
121
+ coordinates = self.coordinates
122
+ sig = TimeSignalProcessing(coordinates, signal, Fs=self.fs, window=Windows.HAMMING,
123
+ window_size=len(signal))
124
+ sig.psd(nb_points=n_fft, detrend=False)
125
+
126
+ # compute the rsm psd
127
+ self.__rms_effective(sig.frequency_Pxx, sig.Pxx, one_third_octave_bands, derivative)
128
+ # compute the effective values
129
+ self.__effective_values(one_third_octave_bands, derivative)
130
+
131
+
132
+ def __rms_effective(self, frequency: npt.NDArray[np.float64], Pxx: npt.NDArray[np.float64],
133
+ one_third_octave_bands: List[Tuple[float, float]], derivative: List[int]):
134
+ """
135
+ Computes RMS square root of power spectral density
136
+
137
+ Parameters
138
+ ----------
139
+ :param frequency (npt.NDArray[np.float64]): frequency vector
140
+ :param Pxx (npt.NDArray[np.float64]): power spectral density
141
+ :param one_third_octave_bands (List[Tuple[float, float]]): frequency bands
142
+ :param derivative (List[int]): derivative order
143
+ """
144
+ # frequency step
145
+ delta_f = frequency[1] - frequency[0]
146
+
147
+ # compute the rms value at each frequency band
148
+ for i, band in enumerate(one_third_octave_bands):
149
+ # find indexes where the bands exist
150
+ idx = np.where((frequency >= band[0]) & (frequency < band[1]))[0]
151
+ Pxx[idx] = (2 * np.pi * frequency[idx]) ** (2 * derivative[i]) * Pxx[idx]
152
+ self.rms_bands[i] = np.sqrt(np.sum(Pxx[idx] * delta_f))
153
+
154
+ def __effective_values(self, one_third_octave_bands: List[Tuple[float, float]], derivative: List[int]):
155
+ """
156
+ Computes the effective values of the signal
157
+
158
+ Parameters
159
+ ----------
160
+ :param one_third_octave_bands (List[Tuple[float, float]]): frequency bands
161
+ :param derivative (List[int]): derivative order
162
+ """
163
+
164
+ n = 4 # number of time constants
165
+ tau = 2 # time constant
166
+
167
+ fout = 1 / (1 - np.exp(-n))
168
+
169
+ dx = self.coordinates[1] - self.coordinates[0]
170
+
171
+ for i, band in enumerate(one_third_octave_bands):
172
+ derivative_value = derivative[i]
173
+ sig = TimeSignalProcessing(self.coordinates, self.signal, Fs=self.fs)
174
+ sig.filter(np.array(band), N=3, type_filter="bandpass", filter_design=FilterDesign.BUTTERWORTH)
175
+ new_signal = sig.signal
176
+
177
+ while derivative_value != 0:
178
+ new_signal = np.diff(new_signal) / dx
179
+ derivative_value -= 1
180
+
181
+ ksi = np.linspace(0, n * tau, int(n * tau / dx + 1))
182
+ g = fout * np.exp(-ksi / tau)
183
+
184
+ convoluted_signal = np.sqrt(np.convolve(new_signal**2, g) * dx / tau)
185
+ self.max_fast[i] = np.max(convoluted_signal)
186
+ idx = np.floor((len(self.signal) - np.floor(self.DXmaxFast / dx)) / 2) + \
187
+ np.linspace(0, np.floor(self.DXmaxFast / dx)-1, int(np.floor(self.DXmaxFast / dx)))
188
+
189
+ self.max_fast_Dx[i] = np.max(convoluted_signal[idx.astype(int)])
@@ -0,0 +1,572 @@
1
+ from typing import Optional
2
+ import sys
3
+ import numpy as np
4
+ import numpy.typing as npt
5
+ from scipy import integrate, signal
6
+ from enum import Enum
7
+
8
+
9
+ class FilterDesign(Enum):
10
+ """
11
+ Filter design types
12
+ """
13
+ BUTTERWORTH = 1
14
+ CHEBYSHEV = 2
15
+ ELLIPTIC = 3
16
+
17
+ class IntegrationRules(Enum):
18
+ """
19
+ Integration rules
20
+ """
21
+ TRAPEZOID = 1
22
+ SIMPSON = 2
23
+
24
+ class Windows(Enum):
25
+ """
26
+ Windows types
27
+
28
+ The values are the same as in `scipy.signal`. This is used for the PSD.
29
+ """
30
+
31
+ HANN = 'hann'
32
+ HAMMING = 'hamming'
33
+ BLACKMAN = 'blackman'
34
+ RECTANGULAR = 'boxcar'
35
+ TRIANG = 'triang'
36
+
37
+ class TimeSignalProcessing:
38
+ """
39
+ Signal processing class for time signals
40
+ """
41
+ def __init__(self,
42
+ time: npt.NDArray[np.float64],
43
+ signal: npt.NDArray[np.float64],
44
+ Fs: Optional[int] = None,
45
+ window: Optional[Windows] = None,
46
+ window_size: int = 0):
47
+ """
48
+ Signal processing
49
+
50
+ Parameters
51
+ ----------
52
+ :param time (npt.NDArray[np.float64]): Time vector
53
+ :param signal (npt.NDArray[np.float64]): Signal vector
54
+ :param Fs (Optional[int]): Acquisition frequency (optional: default None. Fs is computed based on time)
55
+ :param window (Optional[Windows]): Type of window to use (optional: default None - uses rectangular
56
+ window for entire signal)
57
+ :param window_size (int): Size of the window (optional: default 0 - uses signal length when window is None)
58
+ """
59
+ self.time = time
60
+ self.signal = signal
61
+ self.signal_org = signal
62
+ self.frequency = None
63
+ self.amplitude = None
64
+ self.phase = None
65
+ self.spectrum = None
66
+ self.Pxx = None
67
+ self.frequency_Pxx = None
68
+ self.signal_inv = None
69
+ self.time_inv = None
70
+ self.v_eff = None
71
+ self.Sxx = None
72
+ self.frequency_Sxx = None
73
+ self.time_Sxx = None
74
+ self.fft_settings = {"nb_points": None,
75
+ "half_representation": False}
76
+ # Track operations performed on the signal
77
+ self.operations = []
78
+
79
+ # acquisition frequency
80
+ if not Fs:
81
+ Fs = int(np.ceil(1 / np.mean(np.diff(time))))
82
+ self.Fs = Fs
83
+
84
+ # windowing
85
+ signal_length = len(self.signal)
86
+
87
+ # When window is None, process entire signal with rectangular window
88
+ if window is None:
89
+ self.window = np.ones(signal_length)
90
+ self.window_size = signal_length
91
+ self.window_type = Windows.RECTANGULAR
92
+ self.nb_windows = 1
93
+ self.use_window = False
94
+ else:
95
+ if window_size == 0:
96
+ raise ValueError("When using a window the `window_size` must be specified")
97
+ if window_size % 2 != 0:
98
+ raise ValueError("Window length must be even")
99
+ if window not in Windows:
100
+ raise ValueError(f"Window type {window} not supported. Available types: {list(Windows)}")
101
+ if window_size > signal_length:
102
+ raise ValueError(f"Window length ({window_size}) cannot be greater than signal length ({signal_length}).")
103
+
104
+ self.window = self.__create_window(window, window_size)
105
+ self.window_size = window_size
106
+ self.window_type = window
107
+ self.nb_windows = int(np.ceil((signal_length / window_size) * 2 - 2))
108
+ self.use_window = True
109
+
110
+ # pad signal at the end if necessary to get full windows
111
+ if signal_length % window_size != 0:
112
+ self.signal = np.append(self.signal, np.zeros(window_size - (signal_length % window_size)))
113
+ self.time = np.append(self.time, np.zeros(window_size - (signal_length % window_size)))
114
+ self.operations.append(f"Signal padded with zeros (original length: {signal_length}, new length: {len(self.signal)})")
115
+
116
+ def __str__(self) -> str:
117
+ """
118
+ String representation of the SignalProcessing object
119
+ showing the current state and operations performed.
120
+
121
+ Returns
122
+ -------
123
+ str: A formatted string with information about the signal processing instance
124
+ """
125
+ # Create basic signal info
126
+ info = [
127
+ f"SignalProcessing Object",
128
+ f"------------------------",
129
+ f"Signal length: {len(self.signal)} samples",
130
+ f"Sampling frequency: {self.Fs} Hz",
131
+ f"Signal duration: {self.time[-1]:.3f} seconds",
132
+ ]
133
+
134
+ # Window information
135
+ info.append(f"Window type: {self.window_type.name}")
136
+ info.append(f"Window size: {self.window_size} samples")
137
+ if self.use_window:
138
+ info.append(f"Number of windows: {self.nb_windows}")
139
+
140
+ # Show operations that have been performed
141
+ if self.operations:
142
+ info.append("\nOperations performed:")
143
+ for i, op in enumerate(self.operations):
144
+ info.append(f"- {i + 1}. {op}")
145
+ else:
146
+ info.append("\nNo operations performed yet")
147
+
148
+ return "\n".join(info)
149
+
150
+ @staticmethod
151
+ def __create_window(window_type: Windows, size: int) -> npt.NDArray[np.float64]:
152
+ """
153
+ Create a window array of specified type and size
154
+
155
+ Parameters
156
+ ----------
157
+ :param window_type (Windows): Type of window from Windows enum
158
+ :param size (int): Size of the window
159
+ :return (npt.NDArray[np.float64]): Window array of specified size
160
+ """
161
+ if window_type == Windows.RECTANGULAR:
162
+ return np.ones(size)
163
+ elif window_type == Windows.HANN:
164
+ return np.hanning(size)
165
+ elif window_type == Windows.HAMMING:
166
+ return np.hamming(size)
167
+ elif window_type == Windows.BLACKMAN:
168
+ return np.blackman(size)
169
+ elif window_type == Windows.TRIANG:
170
+ return signal.triang(size)
171
+ else:
172
+ raise ValueError(f"Window type {window_type} not supported"
173
+ f"Available types: {list(Windows)}")
174
+
175
+ def fft(self,
176
+ nb_points: Optional[int] = None,
177
+ half_representation: bool = True):
178
+ """
179
+ FFT of signal
180
+ If window is used, the FFT is computed for each window and averaged.
181
+
182
+ Parameters
183
+ ----------
184
+ :param nb_points (Optional[int]): number of points for FFT (optional: default None)
185
+ :param half_representation (bool): true if fft should be computed in half representation
186
+ (optional: default True)
187
+ """
188
+
189
+ # if window is used, set nfft to window size
190
+ if self.use_window:
191
+ nfft = self.window_size
192
+ sig = self.signal
193
+ odd_length = False
194
+ else:
195
+ # if nb_points is None: nb_points is signal length
196
+ if nb_points is None:
197
+ nfft = len(self.signal)
198
+ sig = self.signal
199
+ odd_length = False
200
+
201
+ # if length is even
202
+ if nfft % 2 != 0:
203
+ nfft = len(self.signal) + 1
204
+ sig = np.append(self.signal, 0.)
205
+ self.window = np.ones(nfft)
206
+ self.window_size = nfft
207
+ odd_length = True
208
+
209
+ # Normalize by the sum of the window samples.
210
+ # This compensates for the energy reduction caused by non-rectangular windows, aiming to preserve the
211
+ # peak amplitude of stationary sinusoids.
212
+ normalise_fct = np.sum(self.window)
213
+
214
+ spectrum_w = np.zeros((nfft, self.nb_windows), dtype="complex128")
215
+ hop_size = int(self.window_size * (1 - 0.5))
216
+
217
+ # for each window
218
+ for w in range(self.nb_windows):
219
+
220
+ idx_ini = w * hop_size
221
+ idx_end = idx_ini + self.window_size
222
+
223
+ # window signal
224
+ signal_w = self.window * sig[idx_ini:idx_end]
225
+
226
+ # fft window signal
227
+ spectrum_w[:, w] = np.fft.fft(signal_w, nfft) / normalise_fct
228
+
229
+
230
+ self.amplitude = np.mean(np.abs(spectrum_w), axis=1)
231
+ # self.phase = np.unwrap(np.angle(np.mean(spectrum_w, axis=1)))
232
+ self.phase = np.angle(np.mean(np.exp(1j * np.angle(spectrum_w)), axis=1))
233
+
234
+ # compute frequency
235
+ self.frequency = np.linspace(0, 1, nfft) * self.Fs
236
+
237
+ # half representation
238
+ if half_representation:
239
+ self.frequency = self.frequency[:int(nfft / 2)]
240
+ self.amplitude = 2 * self.amplitude[:int(nfft / 2)]
241
+ self.phase = self.phase[:int(nfft / 2)]
242
+
243
+ # FFT settings: needed to perform inverse FFT
244
+ self.fft_settings = {"nb_points": nfft,
245
+ "half_representation": half_representation,
246
+ "odd_length": odd_length}
247
+
248
+ # Add to operations list
249
+ op_info = f"FFT (points: {nfft}, half representation: {half_representation})"
250
+ self.operations.append(op_info)
251
+
252
+ def inv_fft(self):
253
+ """
254
+ Inverse FFT of signal
255
+
256
+ If the signal was processed with a window during FFT,
257
+ the inverse FFT will also use the same windowed approach
258
+ with proper overlap-add reconstruction.
259
+ """
260
+ # check if FFT was computed
261
+
262
+ if self.amplitude is None or self.phase is None:
263
+ raise ValueError("No FFT computed. Please compute FFT first.")
264
+
265
+ if self.fft_settings["half_representation"]:
266
+ raise NotImplementedError("Half representation not supported for inverse FFT. " \
267
+ "Please compute FFT with full representation.")
268
+
269
+ if self.use_window:
270
+ raise ValueError("Cannot perform inverse FFT on the windowed signal.")
271
+
272
+ # get FFT settings
273
+ odd_length = self.fft_settings["odd_length"]
274
+
275
+ # get FFT
276
+ amplitude = self.amplitude
277
+ phase = self.phase
278
+
279
+ # compute spectrum from amplitude and phase
280
+ spectrum = amplitude * np.exp(1j * phase)
281
+ spectrum_inv = np.fft.ifft(spectrum, len(spectrum))
282
+ # inverse of the FFT signal
283
+ self.signal_inv = np.real(spectrum_inv) * len(spectrum)
284
+ # time from frequency
285
+ self.time_inv = np.cumsum(np.ones(len(spectrum)) * 1 / self.Fs) - 1 / self.Fs
286
+
287
+ if odd_length:
288
+ # remove last sample
289
+ self.signal_inv = self.signal_inv[:-1]
290
+ self.time_inv = self.time_inv[:-1]
291
+
292
+ # Add to operations list
293
+ self.operations.append("Inverse FFT" + (" with windowing" if self.use_window else ""))
294
+
295
+ def integrate(self, rule: IntegrationRules = IntegrationRules.TRAPEZOID,
296
+ baseline: bool = False, moving: bool = False, hp: bool = False, ini_cond: float = 0.,
297
+ fpass: float = 0.5, n: int = 6):
298
+ """
299
+ Numerical integration of signal
300
+
301
+ Parameters
302
+ ----------
303
+ :param rule (IntegrationRules): integration rule (optional: default TRAPEZOID)
304
+ :param baseline (bool): base line correction (optional: default False)
305
+ :param moving (bool): moving average correction (optional: default False)
306
+ :param hp (bool): highpass filter correction at fpass (optional: default False)
307
+ :param ini_cond (float): initial conditions. (optional: default 0.0)
308
+ :param fpass (float): cut off frequency [Hz]. only used if hp=True (optional: default 0.5)
309
+ :param n (int): order of the filter. only used if hp=True (optional: default 6)
310
+ """
311
+ # mean average correction
312
+ if moving:
313
+ self.signal = self.signal - np.mean(self.signal)
314
+
315
+ # integration rule
316
+ if rule == IntegrationRules.TRAPEZOID:
317
+ self.signal = integrate.cumulative_trapezoid(self.signal, self.time, initial=ini_cond)
318
+ elif rule == IntegrationRules.SIMPSON:
319
+ self.signal = integrate.cumulative_simpson(self.signal, x=self.time, initial=ini_cond)
320
+ else:
321
+ sys.exit("Integration rule not supported")
322
+
323
+ # baseline correction
324
+ if baseline:
325
+ fit = np.polyfit(self.time, self.signal, 2)
326
+ fit_int = np.polyval(fit, self.time)
327
+ self.signal = self.signal - fit_int
328
+
329
+ # high pass filter
330
+ if hp:
331
+ self.filter(fpass, n, type_filter="highpass")
332
+
333
+ # Add to operations list
334
+ op_details = []
335
+ op_details.append(f"rule: {rule.name}")
336
+ if baseline:
337
+ op_details.append("baseline correction")
338
+ if moving:
339
+ op_details.append("moving average correction")
340
+ if hp:
341
+ op_details.append(f"highpass filter (cutoff: {fpass} Hz, order: {n})")
342
+
343
+ self.operations.append(f"Integration ({', '.join(op_details)})")
344
+
345
+
346
+ def filter(self, Fpass: float, N: int, filter_design: FilterDesign = FilterDesign.ELLIPTIC,
347
+ type_filter: str = "lowpass", rp: float = 0.01, rs: int = 60):
348
+ """
349
+ Filter signal
350
+
351
+ Parameters
352
+ ----------
353
+ :param Fpass (float): cut off frequency [Hz]
354
+ :param N (int): order of the filter
355
+ :param filter_design (FilterDesign): filter design (optional: default ELLIPTIC)
356
+ :param type_filter (str): type of the filter (optional: default lowpass)
357
+ :param rp (float): maximum ripple allowed below unity gain in the passband. Specified in decibels, as a
358
+ positive number. (optional: default 0.01)
359
+ :param rs (int): minimum attenuation required in the stop band. Specified in decibels, as a positive number
360
+ (optional: default 60)
361
+ """
362
+ # types allowed
363
+ types = ["lowpass", "highpass", "bandpass"]
364
+
365
+ # check if filter type is supported
366
+ if type_filter not in types:
367
+ sys.exit(f"ERROR: Type filter '{type_filter}' not available\n"
368
+ "Filter type must be in {types}")
369
+
370
+ # design filter
371
+ if filter_design == FilterDesign.ELLIPTIC:
372
+ z, p, k = signal.ellip(N, rp, rs, np.array(Fpass) / (self.Fs / 2), btype=type_filter, output='zpk')
373
+ elif filter_design == FilterDesign.BUTTERWORTH:
374
+ z, p, k = signal.butter(N, np.array(Fpass) / (self.Fs / 2), btype=type_filter, output='zpk')
375
+ elif filter_design == FilterDesign.CHEBYSHEV:
376
+ z, p, k = signal.cheby1(N, rp, np.array(Fpass) / (self.Fs / 2), btype=type_filter, output='zpk')
377
+
378
+ sos = signal.zpk2sos(z, p, k)
379
+
380
+ # Applies twice the filter to the signal to avoid phase distortion
381
+ self.signal = signal.sosfiltfilt(sos, self.signal)
382
+
383
+ # Add to operations list
384
+ self.operations.append(f"Filter ({type_filter}, cutoff: {Fpass} Hz, order: {N})")
385
+
386
+
387
+ def psd(self, detrend: str = "linear", nb_points: Optional[int] = None):
388
+ """
389
+ PSD of signal
390
+
391
+ Parameters
392
+ ----------
393
+ :param detrend (str): detrend method (optional: default linear)
394
+ :param nb_points (Optional[int]): number of points for FFT (optional: default None)
395
+ """
396
+
397
+ if detrend not in ["linear", False]:
398
+ raise ValueError("Detrend method not supported. Available methods: ['linear', False]")
399
+
400
+ # check if window is initialized
401
+ if not self.use_window:
402
+ raise ValueError("No window defined. Please define a window when initialising SignalProcessing.")
403
+
404
+ # if nb_points is None: nb_points is window length
405
+ if nb_points is None:
406
+ nfft = self.window_size
407
+ else:
408
+ nfft = nb_points
409
+
410
+ # compute PSD using Welch method
411
+ self.frequency_Pxx, self.Pxx = signal.welch(self.signal, fs=self.Fs, nperseg=self.window_size, nfft=nfft,
412
+ window=self.window_type.value, scaling='density', detrend=detrend)
413
+
414
+ # Add to operations list
415
+ self.operations.append(f"PSD (window: {self.window_type.name}, size: {self.window_size})")
416
+
417
+
418
+ def v_eff_SBR(self, n: int = 4, tau: float = 0.125):
419
+ """
420
+ Compute v_eff of signal based on SBR deel B Hinder voor personen in gebouwen (2006)
421
+
422
+ Parameters
423
+ ----------
424
+ :param n:(int) number of time constants. (optional: default 4)
425
+ :param tau: (float) time constant for the exponential decay (optional: default 0.125)
426
+ """
427
+
428
+ # Create exponential decay function `g` for running RMS calculation
429
+ fout = 1 / (1 - np.exp(-n))
430
+ qsi = np.linspace(0, n * tau, int(n * tau * self.Fs + 1))
431
+ g = fout * np.exp(-qsi / tau)
432
+
433
+
434
+ # Frequency weighting parameters
435
+ v0 = 1 / 1000 # Reference velocity [m/s]
436
+ f0 = 5.6 # Reference frequency [Hz]
437
+
438
+ # Handle even/odd signal length for FFT
439
+ if self.signal.shape[0] % 2 != 0:
440
+ nv1 = int(self.signal.shape[0] / 2 + 0.5)
441
+ nv2 = int(self.signal.shape[0] / 2 - 0.5)
442
+ else:
443
+ nv1 = int(self.signal.shape[0] / 2)
444
+ nv2 = int(self.signal.shape[0] / 2)
445
+
446
+ # Calculate frequency resolution
447
+ df = 1 / (1 / self.Fs * self.signal.shape[0])
448
+ freq = np.arange(df, (nv1 + 1) * df, df)
449
+
450
+ # Create high-pass weighting filter (human perception curve)
451
+ Hv = (1 / v0) * 1 / (np.sqrt(1 + (f0 / freq) ** 2))
452
+ Hv = np.append(0, Hv) # Add DC component
453
+
454
+ # Create low-pass filter with 50 Hz cutoff
455
+ cut_off_number = int(np.ceil(50 / df))
456
+ if cut_off_number < nv1:
457
+ Hv2 = np.zeros(Hv.shape[0])
458
+ Hv2[:cut_off_number+1] = 1
459
+ else:
460
+ Hv2 = np.ones(Hv.shape[0])
461
+
462
+ # Applies the frequency weighting functions
463
+ Fv = np.fft.fft(self.signal)
464
+ Fhv = Hv2 * Hv * Fv[:nv1+1]
465
+ Fv = np.append(Fhv, np.flipud(np.conj(Fhv[1:nv2])))
466
+ v_eff = np.real(np.fft.ifft(Fv))
467
+
468
+ # moving root-mean-square through convolution with the exponential decay function `g`
469
+ v_eff = np.sqrt( np.convolve(v_eff**2, g) * (1 / self.Fs) /tau)
470
+
471
+ self.v_eff = v_eff[:self.signal.shape[0]]
472
+
473
+ # Add to operations list
474
+ self.operations.append(f"Effective velocity (SBR) (n={n}, tau={tau})")
475
+
476
+ def reset(self):
477
+ """
478
+ Reset signal to original signal and clear all processing results.
479
+ """
480
+
481
+ # Reset signal to original
482
+ self.signal = self.signal_org.copy()
483
+
484
+ # Reset all processed data
485
+ self.frequency = None
486
+ self.amplitude = None
487
+ self.phase = None
488
+ self.spectrum = None
489
+ self.Pxx = None
490
+ self.frequency_Pxx = None
491
+ self.signal_inv = None
492
+ self.time_inv = None
493
+ self.v_eff = None
494
+ self.Sxx = None
495
+ self.frequency_Sxx = None
496
+ self.time_Sxx = None
497
+
498
+ # Reset FFT settings
499
+ self.fft_settings = {"nb_points": None, "half_representation": False}
500
+
501
+ # Clear operations history
502
+ self.operations = []
503
+
504
+ def spectrogram(self):
505
+ """
506
+ Compute spectrogram of signal
507
+ """
508
+ # compute spectrogram
509
+ f, t, Sxx = signal.spectrogram(self.signal, fs=self.Fs, window=self.window_type.value,
510
+ nperseg=self.window_size, noverlap=self.window_size // 8)
511
+ self.Sxx = Sxx
512
+ self.frequency_Sxx = f
513
+ self.time_Sxx = t
514
+
515
+ # Add to operations list
516
+ self.operations.append(f"Spectrogram (nperseg: {self.window_size}, noverlap: {self.window_size // 8})")
517
+
518
+ def one_third_octave_bands(self):
519
+ """
520
+ Compute octave bands of the signal
521
+
522
+ It uses the base 2 calculation for the one-third octave bands, according to ISO 18405:2017.
523
+
524
+ """
525
+ # ranges of the nominal one-third octave bands according to ISO 18405:2017
526
+ # https://en.wikipedia.org/wiki/Octave_band
527
+ initial_frequency_band_number = -20
528
+ final_frequency_band_number = 33
529
+ names = ("10", "12.5", "16", "20", "25", "31.5", "40", "50", "63", "80", "100", "125", "160", "200", "250", "315", "400",
530
+ "500", "630", "800", "1000", "1250", "1600", "2000", "2500", "3.150", "4000", "5000", "6300", "8000",
531
+ "10000", "12500", "16000", "20000")
532
+
533
+ # compute centre frequencies of the bands
534
+ f_centre = 1000 * (2 ** (np.arange(initial_frequency_band_number, final_frequency_band_number) / 3))
535
+ f_upper = f_centre * (2 ** (1 / 6))
536
+ f_lower = f_centre / (2 ** (1 / 6))
537
+
538
+ # sum the signal for the bands
539
+ if (self.Pxx is None) and (self.amplitude is None):
540
+ raise ValueError("No PSD nor FFT computed. Please compute either first.")
541
+
542
+ if self.Pxx is not None:
543
+ # determine the frequency bands
544
+ idx = np.where((f_lower < np.max(self.frequency_Pxx)) & (f_upper > np.min(self.frequency_Pxx)))[0]
545
+ if len(idx) == 0:
546
+ raise ValueError("No frequency bands found in the PSD. Please check the frequency bands.")
547
+
548
+ delta_f = self.frequency_Pxx[1] - self.frequency_Pxx[0]
549
+
550
+ # compute the PSD for the bands
551
+ self.octave_bands_Pxx = np.zeros(len(idx))
552
+ self.octave_bands_Pxx_power = np.zeros(len(idx))
553
+
554
+ for i, val in enumerate(idx):
555
+ self.octave_bands_Pxx[i] = float(names[val])
556
+ mask = (self.frequency_Pxx >= f_lower[val]) & (self.frequency_Pxx < f_upper[val])
557
+ self.octave_bands_Pxx_power[i] = np.sum(self.Pxx[mask] * delta_f)
558
+
559
+ if self.amplitude is not None:
560
+ # determine the frequency bands
561
+ idx = np.where((f_lower < np.max(self.frequency)) & (f_upper > np.min(self.frequency)))[0]
562
+ if len(idx) == 0:
563
+ raise ValueError("No frequency bands found in the FFT. Please check the frequency bands.")
564
+
565
+ # compute the FFT for the bands
566
+ self.octave_bands_fft = np.zeros(len(idx))
567
+ self.octave_bands_fft_power = np.zeros(len(idx))
568
+
569
+ for i, val in enumerate(idx):
570
+ self.octave_bands_fft[i] = float(names[val])
571
+ mask = (self.frequency >= f_lower[val]) & (self.frequency < f_upper[val])
572
+ self.octave_bands_fft_power[i] = np.sum(self.amplitude[mask]**2)
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: SignalProcessingTools
3
+ Version: 1.2.0
4
+ Summary: Signal processing tools
5
+ Author: attr: SignalProcessingTools.__author__
6
+ Author-email: bruno.zuadacoelho@deltares.nl, aron.noordam@deltares.nl
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: matplotlib>=3.10
13
+ Requires-Dist: numpy>=2.2
14
+ Requires-Dist: scipy>=1.15
15
+ Provides-Extra: testing
16
+ Requires-Dist: pytest>=8.3; extra == "testing"
17
+ Requires-Dist: tox>=4.24; extra == "testing"
18
+
19
+ # SignalProcessingTools
20
+
21
+ ![Tests](https://github.com/StemVibrations/STEM/actions/workflows/tests.yml/badge.svg)
22
+
23
+ A comprehensive Python package for time and space domain signal processing operations with a focus on vibration analysis and frequency-domain transformations.
24
+ The space domain operations focus on railway applications, while the time domain operations are more general.
25
+
26
+ ## Overview
27
+
28
+ SignalProcessingTools provides a suite of tools for analyzing, transforming, and processing data.
29
+
30
+ ### Time domain operations:
31
+ * Fast Fourier Transforms (FFT) and inverse FFT
32
+ * Signal filtering
33
+ * Integration
34
+ * Power Spectral Density (PSD) using Welch's method
35
+ * Spectrogram generation
36
+ * Effective velocity calculations using SBR method
37
+ * 1/3 octave band analysis
38
+ * Windowing functions (Hann, Hamming, Blackman, etc.)
39
+
40
+
41
+ ### Space domain operations:
42
+ * D0, D1, D2, and D3 track longitudinal levels, following EN 13848-1:2006.
43
+ * Hmax and Hrms according to Zandberg et al. (2022).
44
+
45
+ ## Installation
46
+
47
+ ### Install from PyPI
48
+ You can install the package directly from PyPI using pip:
49
+
50
+ ```bash
51
+ pip install SignalProcessingTools
52
+ ```
53
+
54
+ ### Install from Source
55
+ To install the package from the source, clone the repository and run the following commands:
56
+
57
+ ```bash
58
+ git clone https://github.com/PlatypusBytes/SignalProcessing.git
59
+ cd SignalProcessing
60
+ pip install -e .
61
+ ```
62
+
63
+ ## Usage
64
+
65
+ ### Basic Example of Time Domain Operations
66
+
67
+ #### FFT and signal integration
68
+ ```python
69
+ import numpy as np
70
+ from SignalProcessingTools.time_signal import SignalProcessing, Windows
71
+
72
+ # Create a test signal
73
+ t = np.linspace(0, 10, 5001)
74
+ y = 1.75 * np.sin(2 * np.pi * 6 * t)
75
+
76
+ # Initialize the signal processor
77
+ sig = SignalProcessing(t, y)
78
+
79
+ # Perform FFT
80
+ sig.fft()
81
+
82
+ # Integrate the signal
83
+ sig.integrate(baseline=True, hp=True, fpass=1, n=6)
84
+ ```
85
+
86
+ #### Windowed Processing and PSD and spectrogram
87
+
88
+ ```python
89
+ # Create a signal processor with Hamming window
90
+ sig = SignalProcessing(t, y, window=Windows.HAMMING, window_size=4096)
91
+
92
+ # Calculate Power Spectral Density
93
+ sig.psd()
94
+
95
+ # Generate a spectrogram
96
+ sig.spectrogram()
97
+ ```
98
+
99
+ #### Signal Filtering
100
+
101
+ ```python
102
+ # Apply a low-pass filter to remove high frequency noise
103
+ sig.filter(10, 4, type_filter="lowpass")
104
+ ```
105
+
106
+ #### Effective Velocity Calculation (SBR-B Method)
107
+
108
+ ```python
109
+ # Calculate effective velocity using SBR method
110
+ sig.v_eff_SBR()
111
+ ```
112
+
113
+ ### Basic Example of Spatial Domain Operations
114
+
115
+ #### D0, D1, D2, and D3 Calculation
116
+
117
+ ```python
118
+ import numpy as np
119
+ from SignalProcessingTools.space_signal import SpatialSignal
120
+ from SignalProcessingTools.space_signal import EN13848
121
+
122
+ # Create test data
123
+ x = np.linspace(0, 100, 50001)
124
+ omega = 2 * np.pi * 6
125
+ y = 1.75 * np.sin(omega * x)
126
+ y_noise = y + 0.01 * np.sin(120 * x)
127
+
128
+ sig = SpaceSignalProcessing(x, y_noise)
129
+ # Compute track longitudinal levels
130
+ sig.compute_track_longitudinal_levels()
131
+ ```
132
+
133
+ #### Hmax and Hrms Calculation
134
+
135
+ ```python
136
+ x_track = np.linspace(0, 500, 25001)
137
+ track_irregularity = (
138
+ 0.002 * np.sin(2 * np.pi * 0.1 * x_track) +
139
+ 0.001 * np.sin(2 * np.pi * 0.2 * x_track) +
140
+ 0.0005 * np.sin(2 * np.pi * 0.4 * x_track) +
141
+ 0.0002 * np.random.randn(len(x_track))
142
+ )
143
+
144
+ sig_hmax = SpaceSignalProcessing(x_track, track_irregularity)
145
+ # Compute Hmax parameters
146
+ sig_hmax.compute_Hmax(convert_m2mm=True)
147
+ ```
148
+
149
+ ## Example Files
150
+
151
+ A comprehensive example demonstrating all features is provided for the [time signal](./example_time_signal.py) and [space signal](./example_space_signal.py).
152
+
153
+
154
+ ## License
155
+
156
+ This project is licensed under the MIT License - see the License file for details.
157
+
@@ -0,0 +1,8 @@
1
+ SignalProcessingTools/__init__.py,sha256=nMXg9GFsk8gCcyBnh1MAeUPvo4Zcuv3sdwGHZrxfpPE,36
2
+ SignalProcessingTools/__version__.py,sha256=n9uA3v0QoZoBPG8Ly-lXIYBpXp1Rjvx9jkmhMjs3LYU,106
3
+ SignalProcessingTools/space_signal.py,sha256=Pq_S2EimxSIjBWdz7pkaNDypOFdI6YXZE7PrsVbwueg,7572
4
+ SignalProcessingTools/time_signal.py,sha256=Ws8_BKx8X-bpnCoNNUng8nItNDoWNV-mq6Hj0zDp7Mg,22129
5
+ signalprocessingtools-1.2.0.dist-info/METADATA,sha256=xfkGVC8ee7nuIYGCA1FpVVMlWOGY76eWoqU-ltspxL4,4159
6
+ signalprocessingtools-1.2.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
7
+ signalprocessingtools-1.2.0.dist-info/top_level.txt,sha256=BzkLqG2CNDmcYLCI_-5cmnV91uDRfxNGotNulq6m4pk,22
8
+ signalprocessingtools-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ SignalProcessingTools