prpy 0.2.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.
prpy/numpy/signal.py ADDED
@@ -0,0 +1,649 @@
1
+ # Copyright (c) 2024 Philipp Rouast
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in all
11
+ # copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+
21
+ import logging
22
+ import math
23
+ import numpy as np
24
+ from scipy import signal, interpolate, fft, stats
25
+ from scipy.sparse import spdiags
26
+ from scipy.ndimage import uniform_filter1d
27
+ from typing import Union, Tuple, Callable
28
+
29
+ from prpy.numpy.stride_tricks import window_view, resolve_1d_window_view
30
+
31
+ def div0(
32
+ a: Union[np.ndarray, float, int],
33
+ b: Union[np.ndarray, float, int],
34
+ fill: Union[float, int] = np.nan
35
+ ) -> np.ndarray:
36
+ """Divide after accounting for zeros in divisor, e.g.:
37
+
38
+ - div0( [-1, 0, 1], 0, fill=np.nan) -> [nan nan nan]
39
+ - div0( 1, 0, fill=np.inf ) -> inf
40
+
41
+ Source: https://stackoverflow.com/a/35696047/3595278
42
+
43
+ Args:
44
+ a: Dividend
45
+ b: Divisor
46
+ fill: Use this value to fill where b == 0.
47
+ Returns:
48
+ c: safe a/b
49
+ """
50
+ assert isinstance(fill, (int, float))
51
+ with np.errstate(divide='ignore', invalid='ignore'):
52
+ c = np.true_divide(a, b)
53
+ if np.isscalar(c):
54
+ return c if np.isfinite(c) else fill
55
+ else:
56
+ c[~np.isfinite(c)] = fill
57
+ return c
58
+
59
+ def normalize(
60
+ x: np.ndarray,
61
+ axis: Union[int, tuple, None] = -1
62
+ ) -> np.ndarray:
63
+ """Perform normalization
64
+
65
+ Args:
66
+ x: The input data
67
+ axis: Axis or axes over which to normalize
68
+ Returns:
69
+ x: The normalized data
70
+ """
71
+ assert axis is None or isinstance(axis, int) or (isinstance(axis, tuple) and all(isinstance(i, int) for i in axis))
72
+ x = np.asarray(x)
73
+ x -= np.mean(x, axis=axis, keepdims=x.ndim>0)
74
+ return x
75
+
76
+ def standardize(
77
+ x: np.ndarray,
78
+ axis: Union[int, None] = -1
79
+ ) -> np.ndarray:
80
+ """Perform standardization
81
+
82
+ - Note: Returns zero if std == 0
83
+
84
+ Args:
85
+ x: The input data
86
+ axis: Axis over which to standardize
87
+ Returns:
88
+ x: The standardized data
89
+ """
90
+ assert axis is None or isinstance(axis, int) or (isinstance(axis, tuple) and all(isinstance(i, int) for i in axis))
91
+ x = np.asarray(x)
92
+ x -= np.mean(x, axis=axis, keepdims=x.ndim>0)
93
+ std = np.std(x, axis=axis, keepdims=x.ndim>0)
94
+ x = div0(x, std, fill=0)
95
+ return x
96
+
97
+ def moving_average(
98
+ x: np.ndarray,
99
+ size: int,
100
+ axis: Union[int, None] = -1,
101
+ pad_method: str = 'reflect',
102
+ scale: bool = False,
103
+ scale_factor: float = 1000000000.
104
+ ) -> np.ndarray:
105
+ """Perform moving average
106
+
107
+ Args:
108
+ x: The input data
109
+ size: The size of the moving average window
110
+ axis: Axis over which to calculate moving average
111
+ pad_method: Method for padding ends to keep same dims
112
+ scale: Internally scale the input data up before applying filter
113
+ scale_factor: Factor to use for scaling
114
+ Returns:
115
+ x: The averaged data
116
+ """
117
+ assert axis is None or isinstance(axis, int) or (isinstance(axis, tuple) and all(isinstance(i, int) for i in axis))
118
+ assert isinstance(size, int) and size > 0
119
+ assert isinstance(scale, bool)
120
+ assert isinstance(scale_factor, float)
121
+ x = np.asarray(x)
122
+ if np.isnan(x).any():
123
+ return x
124
+ if scale:
125
+ x *= scale_factor
126
+ y = uniform_filter1d(x, size, mode=pad_method, origin=0, axis=axis)
127
+ if scale:
128
+ y /= scale_factor
129
+ return y
130
+
131
+ def moving_average_size_for_response(
132
+ sampling_freq: Union[float, int],
133
+ cutoff_freq: Union[float, int]
134
+ ) -> int:
135
+ """Estimate the required moving average size to achieve a given response
136
+
137
+ Args:
138
+ sampling_freq: The sampling frequency [Hz]
139
+ cutoff_freq: The desired cutoff frequency [Hz]
140
+ Returns:
141
+ size: The estimated moving average size
142
+ """
143
+ assert isinstance(sampling_freq, (float, int))
144
+ assert isinstance(cutoff_freq, (float, int))
145
+ assert cutoff_freq > 0, "Cutoff frequency needs to be greater than zero"
146
+ # Adapted from https://dsp.stackexchange.com/a/14648
147
+ # cutoff freq in Hz
148
+ F = cutoff_freq / sampling_freq
149
+ size = int(math.sqrt(0.196202 + F * F) / F)
150
+ return max(size, 1)
151
+
152
+ def moving_std(
153
+ x: np.ndarray,
154
+ size: int,
155
+ overlap: int,
156
+ fill_method: str = 'mean'
157
+ ) -> np.ndarray:
158
+ """Compute moving standard deviation
159
+
160
+ Args:
161
+ x: The data to be computed. Shape (n,)
162
+ size: The size of the moving window
163
+ overlap: The overlap of the moving windows
164
+ fill_method: Method to fill the edges.
165
+ Options: 'zero', 'mean' (default), or 'start'
166
+ Returns:
167
+ std: The moving standard deviations
168
+ """
169
+ x = np.asarray(x)
170
+ assert len(x.shape) == 1, "Only 1-D arrays supported"
171
+ assert isinstance(size, int)
172
+ assert isinstance(overlap, int)
173
+ x_view, _, pad_end = window_view(
174
+ x=x,
175
+ min_window_size=size,
176
+ max_window_size=size,
177
+ overlap=overlap)
178
+ y_view = np.std(x_view, axis=-1)
179
+ y = resolve_1d_window_view(
180
+ x=y_view,
181
+ window_size=size,
182
+ overlap=overlap,
183
+ pad_end=pad_end,
184
+ fill_method=fill_method)
185
+ return y
186
+
187
+ def detrend(
188
+ z: np.ndarray,
189
+ Lambda: int,
190
+ axis: int = -1
191
+ ) -> np.ndarray:
192
+ """Detrend signal(s)
193
+
194
+ Vectorized implementation of the detrending method by
195
+ Tarvainen et al. (2002). Based on code listing in the Appendix.
196
+
197
+ Args:
198
+ z: The input signal. Shape (b, n) or (n,)
199
+ Lambda: The lambda parameter
200
+ axis: The axis along which should be detrended
201
+ Returns:
202
+ proc_z: The detrended signal
203
+ """
204
+ assert isinstance(Lambda, int)
205
+ assert isinstance(axis, int) and (axis == 0 or axis == 1 or axis == -1)
206
+ axis = 1 if axis == -1 else axis
207
+ z = np.asarray(z) # Make sure z is np array
208
+ z = np.nan_to_num(z) # Replace NAs with 0
209
+ if len(z.shape) == 1:
210
+ z = np.expand_dims(z, axis=1-axis)
211
+ assert z.ndim == 2, "z.ndim must equal 2"
212
+ T = z.shape[axis]
213
+ if T < 3:
214
+ return z
215
+ # Identity matrix
216
+ I = np.identity(T)
217
+ # Regularization matrix
218
+ D2 = spdiags(
219
+ [np.ones(T), -2*np.ones(T), np.ones(T)],
220
+ [0, 1, 2], (T-2), T).toarray()
221
+ # Inverse of I+lambda^2*D2’*D2
222
+ inv = np.linalg.inv(I + (Lambda**2) * np.dot(D2.T, D2))
223
+ # Compute the detrending operation (vectorized)
224
+ if axis == 0:
225
+ z = np.transpose(z)
226
+ proc_z = np.matmul((I - inv), z.T)
227
+ if axis == 1:
228
+ proc_z = np.transpose(proc_z)
229
+ # Squeeze if necessary
230
+ proc_z = np.squeeze(proc_z)
231
+ # Return
232
+ return proc_z
233
+
234
+ # TODO write tests
235
+ def windowed_standardize(
236
+ x: np.ndarray,
237
+ window_size: int,
238
+ windowed_mean: bool = True,
239
+ windowed_std: bool = True
240
+ ) -> np.ndarray:
241
+ """Perform dynamic standardization based on windowed mean and std
242
+
243
+ Args:
244
+ x: The input data
245
+ window_size: The size of the moving window
246
+ windowed_mean: Boolean indicating whether mean should be windowed
247
+ windowed_std: Boolean indicating whether std should be windowed
248
+ Returns:
249
+ y: The standardized data
250
+ """
251
+ x = np.asarray(x)
252
+ if windowed_mean:
253
+ mean = moving_average(x, size=window_size, scale=True)
254
+ else:
255
+ mean = np.mean(x)
256
+ if windowed_std:
257
+ std = moving_std(x, size=window_size, overlap=window_size-1)
258
+ else:
259
+ std = np.std(x)
260
+ x -= mean
261
+ x /= std
262
+ return x
263
+
264
+ # TODO write tests
265
+ def butter_bandpass(
266
+ x: np.ndarray,
267
+ lowcut: Union[int, float],
268
+ highcut: Union[int, float],
269
+ fs: Union[int, float],
270
+ axis: Union[int, tuple, None] = -1,
271
+ order: int = 5
272
+ ):
273
+ """Apply a butterworth bandpass filter.
274
+
275
+ Args:
276
+ x: The signal data
277
+ lowcut: The lower cutoff frequency
278
+ highcut: The higher cutoff frequency
279
+ fs: The sampling frequency
280
+ axis: The axis along which to apply the filter
281
+ order: The order of the filter
282
+ Returns:
283
+ y: The filtered signal data
284
+ """
285
+ assert axis is None or isinstance(axis, int) or (isinstance(axis, tuple) and all(isinstance(i, int) for i in axis))
286
+ assert isinstance(lowcut, (int, float))
287
+ assert isinstance(highcut, (int, float))
288
+ assert isinstance(fs, (int, float))
289
+ assert isinstance(order, int)
290
+ def butter_bandpass_filter(
291
+ lowcut: Union[int, float],
292
+ highcut: Union[int, float],
293
+ fs: Union[int, float],
294
+ order: int = 5
295
+ ) -> Tuple[np.ndarray, np.ndarray]:
296
+ nyq = 0.5 * fs
297
+ low = lowcut / nyq
298
+ high = highcut / nyq
299
+ return signal.butter(order, [low, high], btype='band')
300
+ b, a = butter_bandpass_filter(
301
+ lowcut=lowcut, highcut=highcut, fs=fs, order=order)
302
+ y = signal.lfilter(
303
+ b=b, a=a, x=x, axis=axis)
304
+ return y
305
+
306
+ def estimate_freq(
307
+ x: np.ndarray,
308
+ f_s: Union[float, int],
309
+ f_range: tuple = None,
310
+ f_res: float = None,
311
+ method: str = 'fft',
312
+ max_periodicity_deviation: float = 0.5,
313
+ axis: int = -1
314
+ ) -> np.ndarray:
315
+ """Determine maximum frequencies in x.
316
+
317
+ Args:
318
+ x: The signal data. Shape: (n_data,) or (n_sig, n_data)
319
+ f_s: The sampling frequency [Hz]
320
+ f_range: Optional expected range of freqs [Hz] - (min, max)
321
+ f_res: Optional frequency resolution for analysis [Hz]
322
+ (useful if signal small; applies only to periodogram)
323
+ method: The method to be used [fft, peak, or periodogram]
324
+ max_periodicity_deviation: Maximum relative deviation of peaks from regular periodicity
325
+ axis: The axis along which to estimate frequencies
326
+ Returns:
327
+ f_out: The maximum frequencies [Hz]. Shape: (n_sig,)
328
+ """
329
+ assert isinstance(method, str)
330
+ if method == 'fft':
331
+ return estimate_freq_fft(x, f_s=f_s, f_range=f_range, axis=axis)
332
+ elif method == 'peak':
333
+ return estimate_freq_peak(x, f_s=f_s, f_range=f_range, max_periodicity_deviation=max_periodicity_deviation, axis=axis)
334
+ elif method == 'periodogram':
335
+ return estimate_freq_periodogram(x, f_s=f_s, f_range=f_range, f_res=f_res, axis=axis)
336
+ else:
337
+ return ValueError("method should be 'peak', 'fft', or 'periodogram' but was {}".format(method))
338
+
339
+ def estimate_freq_fft(
340
+ x: np.ndarray,
341
+ f_s: Union[float, int],
342
+ f_range: Union[tuple, None] = None,
343
+ axis: int = -1
344
+ ) -> np.ndarray:
345
+ """Use a fourier transform to determine maximum frequencies.
346
+
347
+ Args:
348
+ x: The signal data. Shape: (n_data,) or (n_sig, n_data)
349
+ f_s: The sampling frequency [Hz]
350
+ f_range: Optional expected range of freqs [Hz] - (min, max)
351
+ axis: The axis along which to estimate frequencies
352
+ Returns:
353
+ f_out: The maximum frequencies [Hz]. Shape: (n_sig,)
354
+ """
355
+ assert isinstance(f_s, (float, int)) and f_s > 0
356
+ assert f_range is None or (isinstance(f_range, tuple) and len(f_range) == 2 and all(isinstance(i, (int, float)) for i in f_range))
357
+ assert isinstance(axis, int) and (axis == 0 or axis == 1 or axis == -1)
358
+ x = np.asarray(x)
359
+ # Change to 2-dim array if necessary
360
+ if len(x.shape) == 1:
361
+ x = np.expand_dims(x, axis=0)
362
+ # Run the fourier transform
363
+ w = fft.rfft(x, axis=axis)
364
+ f = fft.rfftfreq(x.shape[axis], 1/f_s)
365
+ # Restrict by range if necessary
366
+ if f_range is not None:
367
+ # Bandpass: Set w outside of range to zero
368
+ f_min = min(np.amax(f), f_range[0])
369
+ f_max = max(np.amin(f), f_range[1])
370
+ w = np.where(np.logical_or(f < f_min, f > f_max), 0, w)
371
+ # Determine maximum frequency component
372
+ idx = np.argmax(np.abs(w), axis=axis)
373
+ # Derive frequency in Hz
374
+ f_out = abs(f[idx])
375
+ # Squeeze if necessary
376
+ f_out = np.squeeze(f_out)
377
+ # Return
378
+ return f_out
379
+
380
+ def estimate_freq_peak(
381
+ x: np.ndarray,
382
+ f_s: Union[float, int],
383
+ f_range: Union[tuple, None] = None,
384
+ max_periodicity_deviation: float = 0.5,
385
+ axis: int = -1
386
+ ) -> np.ndarray:
387
+ """Use peak detection to determine maximum frequencies in x.
388
+
389
+ Args:
390
+ x: The signal data. Shape: (n_data,) or (n_sig, n_data)
391
+ f_s: The sampling frequency [Hz]
392
+ f_range: Optional expected range of freqs [Hz] - (min, max)
393
+ max_periodicity_deviation: Maximum relative deviation of peaks from regular periodicity
394
+ axis: The axis along which to estimate frequencies
395
+ Returns:
396
+ f_out: The maximum frequencies [Hz]. Shape: (n_sig,)
397
+ """
398
+ assert isinstance(f_s, (float, int)) and f_s > 0
399
+ assert f_range is None or (isinstance(f_range, tuple) and len(f_range) == 2 and all(isinstance(i, (int, float)) for i in f_range))
400
+ assert isinstance(max_periodicity_deviation, float)
401
+ assert isinstance(axis, int) and (axis == 0 or axis == 1 or axis == -1)
402
+ x = np.asarray(x)
403
+ # Change to 2-dim array if necessary
404
+ if len(x.shape) == 1:
405
+ x = np.expand_dims(x, axis=0)
406
+ # Derive minimum distance between peaks if necessary
407
+ min_dist = max(1/f_range[1]*f_s*(1-max_periodicity_deviation), 0) if f_range is not None else 0
408
+ # Peak detection is only available for 1-D tensors
409
+ def estimate_freq_peak_for_single_axis(x):
410
+ # Find peaks in the signal
411
+ det_idxs, _ = signal.find_peaks(x, height=0, distance=min_dist)
412
+ # Calculate mean distance between peaks
413
+ mean_idx_dist = np.mean(np.diff(det_idxs), axis=-1)
414
+ # Derive the frequency
415
+ return f_s/mean_idx_dist
416
+ # Apply function
417
+ f_out = np.apply_along_axis(estimate_freq_peak_for_single_axis, axis=axis, arr=x)
418
+ # Squeeze if necessary
419
+ f_out = np.squeeze(f_out)
420
+ # Return
421
+ return f_out
422
+
423
+ def estimate_freq_periodogram(
424
+ x: np.ndarray,
425
+ f_s: Union[float, int],
426
+ f_range: Union[tuple, None] = None,
427
+ f_res: Union[float, None] = None,
428
+ axis: int = -1
429
+ ) -> np.ndarray:
430
+ """Use a periodigram to estimate maximum frequencies at f_res.
431
+
432
+ When signal is sampled at a lower frequency than f_res, this is essentially done
433
+ by interpolating in the frequency domain.
434
+
435
+ Args:
436
+ x: The signal data. Shape: (n_data,) or (n_sig, n_data)
437
+ f_s: The sampling frequency [Hz]
438
+ f_range: Optional expected range of freqs [Hz] - (min, max)
439
+ f_res: Optional frequency resolution for analysis [Hz]
440
+ axis: The axis along which to estimate frequencies
441
+ Returns:
442
+ f_out: The maximum frequencies [Hz]. Shape: (n_sig,)
443
+ """
444
+ assert isinstance(f_s, (float, int)) and f_s > 0
445
+ assert f_range is None or (isinstance(f_range, tuple) and len(f_range) == 2 and all(isinstance(i, (int, float)) for i in f_range))
446
+ assert f_res is None or (isinstance(f_res, (float, int)) and f_res > 0)
447
+ assert isinstance(axis, int) and (axis == 0 or axis == 1 or axis == -1)
448
+ x = np.asarray(x)
449
+ # Change to 2-dim array if necessary
450
+ if len(x.shape) == 1:
451
+ x = np.expand_dims(x, axis=0)
452
+ # Determine the length of the fft if f_res specified
453
+ # Large nfft > x.length leads to zero padding of x before fft (like interpolating frequency domain)
454
+ nfft = None if f_res is None else int(f_s // f_res)
455
+ # Compute
456
+ f, pxx = signal.periodogram(x, fs=f_s, nfft=nfft, detrend=False, axis=axis)
457
+ # Restrict by range if necessary
458
+ if f_range is not None:
459
+ # Bandpass: Set w outside of range to zero
460
+ f_min = min(np.amax(f), f_range[0])
461
+ f_max = max(np.amin(f), f_range[1])
462
+ pxx = np.where(np.logical_or(f < f_min, f > f_max), 0, pxx)
463
+ # Determine maximum frequency component
464
+ idx = np.argmax(pxx, axis=axis)
465
+ # Maximum frequency in Hz
466
+ f_out = f[idx]
467
+ # Squeeze if necessary
468
+ f_out = np.squeeze(f_out)
469
+ # Return
470
+ return f_out
471
+
472
+ def interpolate_vals(
473
+ x: np.ndarray,
474
+ val_fn: Callable[[np.ndarray], np.ndarray] = lambda x: np.isnan(x)
475
+ ) -> np.ndarray:
476
+ """Linearly interpolate vals matching val_fn
477
+
478
+ Args:
479
+ x: The values, shape (n_vals,)
480
+ val_fn: The function, values matching which will be interpolated
481
+ Returns:
482
+ x: The interpolated values, shape (n_vals,)
483
+ """
484
+ assert callable(val_fn)
485
+ x = np.asarray(x)
486
+ assert len(x.shape) == 1, "Only 1-D arrays supported"
487
+ if val_fn(x).all():
488
+ logging.debug("All elements in x fulfilled val_fn. Not doing anything.")
489
+ return x
490
+ mask = val_fn(x)
491
+ x[mask] = np.interp(np.flatnonzero(mask), np.flatnonzero(~mask), x[~mask])
492
+ return x
493
+
494
+ def interpolate_cubic_spline(
495
+ x: np.ndarray,
496
+ y: np.ndarray,
497
+ xs: np.ndarray,
498
+ axis: int = 0
499
+ ) -> np.ndarray:
500
+ """Interpolate data with a cubic spline.
501
+ Args:
502
+ x: The x values of the data we want to interpolate. 1-dim.
503
+ y: The y values of the data we want to interpolate. Along the given axis,
504
+ shape of y must match shape of x.
505
+ xs: The x values at which we want to interpolate. 1-dim.
506
+ Returns:
507
+ ys: The interpolated y values
508
+ """
509
+ assert isinstance(axis, int)
510
+ x = np.asarray(x)
511
+ y = np.asarray(y)
512
+ xs = np.asarray(xs)
513
+ x = np.nan_to_num(x) # Replace NAs with 0
514
+ y = np.nan_to_num(y) # Replace NAs with 0
515
+ if np.array_equal(x, xs):
516
+ return y
517
+ cs = interpolate.CubicSpline(x, y, axis=axis)
518
+ return cs(xs)
519
+
520
+ def interpolate_linear_sequence_outliers(
521
+ t: np.ndarray,
522
+ max_diff_rel: float = 1.0,
523
+ max_diff_abs: Union[float, None] = None
524
+ ) -> np.ndarray:
525
+ """Interpolate outliers in an otherwise linear sequence.
526
+
527
+ - For example: Measurement timestamps
528
+ - I.e., goal is to make the sequence strictly increasing with approx. constant diff.
529
+
530
+ Args:
531
+ t: The sequence vals to fix. 1-dim.
532
+ max_diff_rel: Maximum relative difference from regular linear increasing value [%]
533
+ Used if `max_diff_abs` is None
534
+ max_diff_abs: Maximum absolute difference from regular linear increasing value
535
+ Used if not None
536
+ Returns:
537
+ t: The interpolated sequence of strictly increasing vals. 1-dim.
538
+ """
539
+ assert isinstance(max_diff_rel, float)
540
+ assert max_diff_abs is None or isinstance(max_diff_abs, float)
541
+ from sklearn.linear_model import RANSACRegressor
542
+ t = np.asarray(t)
543
+ assert len(t.shape) == 1
544
+ size = len(t)
545
+ indices = np.arange(size)
546
+ # Calculate max diff
547
+ max_diff = max_diff_abs if max_diff_abs is not None else np.abs(np.median(np.diff(t)) * max_diff_rel)
548
+ # Stage 1
549
+ def interpolate_regression_outliers(t, max_diff):
550
+ # Stage 1: Fit robust regression model
551
+ reg = RANSACRegressor(random_state=0).fit(indices[:,np.newaxis], t)
552
+ # Stage 1: Identify idxs for regression outliers
553
+ t_preds = reg.predict(indices[:,np.newaxis])
554
+ not_reg_outlier = np.abs(t_preds - t) < max_diff
555
+ # Stage 1: Fix regression outliers
556
+ f = interpolate.interp1d(indices[not_reg_outlier], t[not_reg_outlier],
557
+ kind='linear', fill_value="extrapolate")
558
+ return f(indices)
559
+ t = interpolate_regression_outliers(t, max_diff=max_diff)
560
+ # Stage 2
561
+ def interpolate_non_strictly_increasing(t):
562
+ not_si_outlier = np.concatenate([[True], t[1:] - t[:-1] > 0])
563
+ f = interpolate.interp1d(indices[not_si_outlier], t[not_si_outlier],
564
+ kind='linear', fill_value="extrapolate")
565
+ return f(indices)
566
+ while not np.logical_and.reduce(np.diff(t) > 0):
567
+ t = interpolate_non_strictly_increasing(t)
568
+ # Assert strictly increasing
569
+ assert np.logical_and.reduce(np.diff(t) > 0)
570
+ return t
571
+
572
+ def interpolate_data_outliers(
573
+ x: np.ndarray,
574
+ z_score: Union[int, float] = 3
575
+ ) -> np.ndarray:
576
+ """Recursively interpolate outliers in sensor data.
577
+
578
+ - Example: ECG signal
579
+ - Goal is to remove outliers in sensor data which may be caused by electrical interference etc.
580
+
581
+ Args:
582
+ vals: The signal data to interpolate. 1-dim.
583
+ z_score: Significance score required for a data point to be interpolated
584
+ Returns:
585
+ vals: The interpolated signal data. 1-dim.
586
+ """
587
+ def interpolation_step(
588
+ x: np.ndarray,
589
+ z_score: Union[int, float]
590
+ ) -> np.ndarray:
591
+ x_z_score = stats.zscore(x)
592
+ if np.isnan(x_z_score).all():
593
+ return x
594
+ not_outlier = np.abs(x_z_score) <= z_score
595
+ indices = np.arange(len(x))
596
+ interp = interpolate.interp1d(indices[not_outlier], x[not_outlier],
597
+ kind='linear', fill_value='extrapolate')
598
+ new_x = interp(indices)
599
+ new_outlier = np.abs(stats.zscore(new_x)) > z_score
600
+ if len(np.where(new_outlier)[0]) > 0:
601
+ if new_outlier[0]:
602
+ # Set first to mean if it is an outlier to avoid infinite recursion
603
+ new_x[0] = np.mean(new_x[~new_outlier])
604
+ if new_outlier[-1]:
605
+ # Set last to mean if it is an outlier to avoid infinite recursion
606
+ new_x[-1] = np.mean(new_x[~new_outlier])
607
+ return interpolation_step(new_x, z_score)
608
+ else:
609
+ return new_x
610
+ # Recursive interpolation of data outliers
611
+ return interpolation_step(x=x, z_score=z_score)
612
+
613
+ def _component_periodicity(x: np.ndarray) -> list:
614
+ """Compute the periodicity of the maximum frequency components
615
+
616
+ Args:
617
+ x: The signal data. Shape (n_sig, n_data)
618
+ Returns:
619
+ result: The periodicities. Shape (n_sig,)
620
+ """
621
+ x = np.asarray(x) # Make sure x is np array
622
+ x = np.nan_to_num(x) # Replace NAs with 0
623
+ assert x.ndim == 2, "x.ndim must equal 2"
624
+ # Perform FFT
625
+ w = fft.rfft(x, axis=1)
626
+ # Determine maximum frequency component of each dim
627
+ w_ = np.square(np.abs(w))
628
+ w_ = div0(w_, np.sum(w_, axis=1)[:, np.newaxis], fill=0)
629
+ idxs = np.argmax(w_, axis=1)
630
+ # Compute periodicity for maximum frequency component
631
+ return [w_[i,idx] for i, idx in enumerate(idxs)]
632
+
633
+ def select_most_periodic(x: np.ndarray) -> np.ndarray:
634
+ """Select the most periodic signal
635
+
636
+ Args:
637
+ x: The signal data. Shape (n_sig, n_data)
638
+ Returns:
639
+ y: Signal with highest periodicity
640
+ """
641
+ x = np.asarray(x) # Make sure x is np array
642
+ x = np.nan_to_num(x) # Replace NAs with 0
643
+ assert x.ndim == 2, "x.ndim must equal 2"
644
+ # Compute component periodicity
645
+ p = _component_periodicity(x)
646
+ idx = np.argmax(p)
647
+ y = x[idx]
648
+ assert x.shape[1] == y.shape[0]
649
+ return y