paradigma 1.0.3__py3-none-any.whl → 1.1.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.
- paradigma/__init__.py +10 -1
- paradigma/classification.py +38 -21
- paradigma/config.py +187 -123
- paradigma/constants.py +48 -35
- paradigma/feature_extraction.py +345 -255
- paradigma/load.py +476 -0
- paradigma/orchestrator.py +670 -0
- paradigma/pipelines/gait_pipeline.py +685 -246
- paradigma/pipelines/pulse_rate_pipeline.py +456 -155
- paradigma/pipelines/pulse_rate_utils.py +289 -248
- paradigma/pipelines/tremor_pipeline.py +405 -132
- paradigma/prepare_data.py +409 -0
- paradigma/preprocessing.py +500 -163
- paradigma/segmenting.py +180 -140
- paradigma/testing.py +370 -178
- paradigma/util.py +190 -101
- paradigma-1.1.0.dist-info/METADATA +229 -0
- paradigma-1.1.0.dist-info/RECORD +26 -0
- {paradigma-1.0.3.dist-info → paradigma-1.1.0.dist-info}/WHEEL +1 -1
- paradigma-1.1.0.dist-info/entry_points.txt +4 -0
- {paradigma-1.0.3.dist-info → paradigma-1.1.0.dist-info/licenses}/LICENSE +0 -1
- paradigma-1.0.3.dist-info/METADATA +0 -138
- paradigma-1.0.3.dist-info/RECORD +0 -22
paradigma/feature_extraction.py
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
import pandas as pd
|
|
3
|
-
from typing import List, Tuple
|
|
4
|
-
|
|
5
3
|
from scipy.integrate import cumulative_trapezoid
|
|
6
4
|
from scipy.signal import find_peaks, windows
|
|
7
5
|
from scipy.stats import kurtosis, skew
|
|
@@ -10,7 +8,9 @@ from sklearn.decomposition import PCA
|
|
|
10
8
|
from paradigma.config import PulseRateConfig
|
|
11
9
|
|
|
12
10
|
|
|
13
|
-
def compute_statistics(
|
|
11
|
+
def compute_statistics(
|
|
12
|
+
data: np.ndarray, statistic: str, abs_stats: bool = False
|
|
13
|
+
) -> np.ndarray:
|
|
14
14
|
"""
|
|
15
15
|
Compute a specific statistical measure along the timestamps of a 2D or 3D array.
|
|
16
16
|
|
|
@@ -29,7 +29,7 @@ def compute_statistics(data: np.ndarray, statistic: str, abs_stats: bool=False)
|
|
|
29
29
|
- 'kurtosis': Kurtosis.
|
|
30
30
|
- 'skewness': Skewness.
|
|
31
31
|
abs_stats : bool, optional
|
|
32
|
-
Whether to compute the statistics on the absolute values of the data for
|
|
32
|
+
Whether to compute the statistics on the absolute values of the data for
|
|
33
33
|
the mean and median (default: False).
|
|
34
34
|
|
|
35
35
|
Returns
|
|
@@ -41,35 +41,45 @@ def compute_statistics(data: np.ndarray, statistic: str, abs_stats: bool=False)
|
|
|
41
41
|
Raises
|
|
42
42
|
------
|
|
43
43
|
ValueError
|
|
44
|
-
If the specified `statistic` is not supported or if the input data
|
|
44
|
+
If the specified `statistic` is not supported or if the input data
|
|
45
|
+
has an invalid shape.
|
|
45
46
|
"""
|
|
46
|
-
if statistic not in [
|
|
47
|
+
if statistic not in [
|
|
48
|
+
"mean",
|
|
49
|
+
"median",
|
|
50
|
+
"var",
|
|
51
|
+
"std",
|
|
52
|
+
"max",
|
|
53
|
+
"min",
|
|
54
|
+
"kurtosis",
|
|
55
|
+
"skewness",
|
|
56
|
+
]:
|
|
47
57
|
raise ValueError(f"Statistic '{statistic}' is not supported.")
|
|
48
|
-
|
|
58
|
+
|
|
49
59
|
if data.ndim > 3 or data.ndim < 2:
|
|
50
60
|
raise ValueError("Input data must be a 1D, 2D or 3D array.")
|
|
51
61
|
|
|
52
|
-
if statistic ==
|
|
62
|
+
if statistic == "mean":
|
|
53
63
|
if abs_stats:
|
|
54
64
|
return np.mean(np.abs(data), axis=1)
|
|
55
65
|
else:
|
|
56
66
|
return np.mean(data, axis=1)
|
|
57
|
-
elif statistic ==
|
|
67
|
+
elif statistic == "median":
|
|
58
68
|
if abs_stats:
|
|
59
69
|
return np.median(np.abs(data), axis=1)
|
|
60
70
|
else:
|
|
61
71
|
return np.median(data, axis=1)
|
|
62
|
-
elif statistic ==
|
|
72
|
+
elif statistic == "var":
|
|
63
73
|
return np.var(data, ddof=1, axis=1)
|
|
64
|
-
elif statistic ==
|
|
74
|
+
elif statistic == "std":
|
|
65
75
|
return np.std(data, axis=1)
|
|
66
|
-
elif statistic ==
|
|
76
|
+
elif statistic == "max":
|
|
67
77
|
return np.max(data, axis=1)
|
|
68
|
-
elif statistic ==
|
|
78
|
+
elif statistic == "min":
|
|
69
79
|
return np.min(data, axis=1)
|
|
70
|
-
elif statistic ==
|
|
80
|
+
elif statistic == "kurtosis":
|
|
71
81
|
return kurtosis(data, fisher=False, axis=1)
|
|
72
|
-
elif statistic ==
|
|
82
|
+
elif statistic == "skewness":
|
|
73
83
|
return skew(data, axis=1)
|
|
74
84
|
else:
|
|
75
85
|
raise ValueError(f"Statistic '{statistic}' is not supported.")
|
|
@@ -79,8 +89,8 @@ def compute_std_euclidean_norm(data: np.ndarray) -> np.ndarray:
|
|
|
79
89
|
"""
|
|
80
90
|
Compute the standard deviation of the Euclidean norm for each window of sensor data.
|
|
81
91
|
|
|
82
|
-
The function calculates the Euclidean norm (L2 norm) across sensor axes for each
|
|
83
|
-
timestamp within a window, and then computes the standard deviation of these norms
|
|
92
|
+
The function calculates the Euclidean norm (L2 norm) across sensor axes for each
|
|
93
|
+
timestamp within a window, and then computes the standard deviation of these norms
|
|
84
94
|
for each window.
|
|
85
95
|
|
|
86
96
|
Parameters
|
|
@@ -94,36 +104,40 @@ def compute_std_euclidean_norm(data: np.ndarray) -> np.ndarray:
|
|
|
94
104
|
Returns
|
|
95
105
|
-------
|
|
96
106
|
np.ndarray
|
|
97
|
-
A 1D array of shape (n_windows,) containing the standard deviation of the
|
|
107
|
+
A 1D array of shape (n_windows,) containing the standard deviation of the
|
|
98
108
|
Euclidean norm for each window.
|
|
99
109
|
"""
|
|
100
|
-
norms = np.linalg.norm(
|
|
110
|
+
norms = np.linalg.norm(
|
|
111
|
+
data, axis=2
|
|
112
|
+
) # Norm along the sensor axes (norm per timestamp, per window)
|
|
101
113
|
return np.std(norms, axis=1) # Standard deviation per window
|
|
102
114
|
|
|
103
115
|
|
|
104
116
|
def compute_power_in_bandwidth(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
freqs: np.ndarray,
|
|
118
|
+
psd: np.ndarray,
|
|
119
|
+
fmin: float,
|
|
120
|
+
fmax: float,
|
|
121
|
+
include_max: bool = True,
|
|
122
|
+
spectral_resolution: float = 1,
|
|
123
|
+
cumulative_sum_method: str = "trapezoid",
|
|
124
|
+
) -> np.ndarray:
|
|
113
125
|
"""
|
|
114
126
|
Compute the logarithmic power within specified frequency bands for each sensor axis.
|
|
115
127
|
|
|
116
|
-
This function integrates the power spectral density (PSD) over
|
|
117
|
-
bands and computes the logarithm of the
|
|
128
|
+
This function integrates the power spectral density (PSD) over
|
|
129
|
+
user-defined frequency bands and computes the logarithm of the
|
|
130
|
+
resulting power for each axis of the sensor.
|
|
118
131
|
|
|
119
132
|
Parameters
|
|
120
133
|
----------
|
|
121
134
|
freqs : np.ndarray
|
|
122
|
-
A 1D array of shape (n_frequencies,) containing the frequencies corresponding
|
|
135
|
+
A 1D array of shape (n_frequencies,) containing the frequencies corresponding
|
|
123
136
|
to the PSD values.
|
|
124
137
|
psd : np.ndarray
|
|
125
|
-
A 2D array of shape (n_windows, n_frequencies) or 3D array of shape
|
|
126
|
-
|
|
138
|
+
A 2D array of shape (n_windows, n_frequencies) or 3D array of shape
|
|
139
|
+
(n_windows, n_frequencies, n_axes) representing the power spectral
|
|
140
|
+
density (PSD) of the sensor data.
|
|
127
141
|
fmin : float
|
|
128
142
|
The lower bound of the frequency band in Hz.
|
|
129
143
|
fmax : float
|
|
@@ -133,9 +147,10 @@ def compute_power_in_bandwidth(
|
|
|
133
147
|
spectral_resolution : float, optional
|
|
134
148
|
The spectral resolution of the PSD in Hz (default: 1).
|
|
135
149
|
cumulative_sum_method : str, optional
|
|
136
|
-
The method used to integrate the PSD over the frequency band
|
|
137
|
-
|
|
138
|
-
- '
|
|
150
|
+
The method used to integrate the PSD over the frequency band
|
|
151
|
+
(default: 'trapezoid'). Supported values are:
|
|
152
|
+
- 'trapezoid': Trapezoidal rule.
|
|
153
|
+
- 'sum': Simple summation.
|
|
139
154
|
|
|
140
155
|
Returns
|
|
141
156
|
-------
|
|
@@ -148,55 +163,60 @@ def compute_power_in_bandwidth(
|
|
|
148
163
|
band_mask = (freqs >= fmin) & (freqs <= fmax)
|
|
149
164
|
else:
|
|
150
165
|
band_mask = (freqs >= fmin) & (freqs < fmax)
|
|
151
|
-
|
|
166
|
+
|
|
152
167
|
# Integrate PSD over the selected frequency band using the band mask
|
|
153
168
|
if psd.ndim == 2:
|
|
154
169
|
masked_psd = psd[:, band_mask]
|
|
155
170
|
elif psd.ndim == 3:
|
|
156
171
|
masked_psd = psd[:, band_mask, :]
|
|
157
172
|
|
|
158
|
-
if cumulative_sum_method ==
|
|
159
|
-
band_power = spectral_resolution * np.
|
|
160
|
-
|
|
173
|
+
if cumulative_sum_method == "trapezoid":
|
|
174
|
+
band_power = spectral_resolution * np.trapezoid(
|
|
175
|
+
masked_psd, freqs[band_mask], axis=1
|
|
176
|
+
)
|
|
177
|
+
elif cumulative_sum_method == "sum":
|
|
161
178
|
band_power = spectral_resolution * np.sum(masked_psd, axis=1)
|
|
162
179
|
else:
|
|
163
|
-
raise ValueError("cumulative_sum_method must be '
|
|
180
|
+
raise ValueError("cumulative_sum_method must be 'trapezoid' or 'sum'.")
|
|
164
181
|
|
|
165
182
|
return band_power
|
|
166
183
|
|
|
167
184
|
|
|
168
185
|
def compute_total_power(psd: np.ndarray) -> np.ndarray:
|
|
169
186
|
"""
|
|
170
|
-
Compute the total power by summing the power spectral density (PSD)
|
|
187
|
+
Compute the total power by summing the power spectral density (PSD)
|
|
188
|
+
across frequency bins.
|
|
171
189
|
|
|
172
|
-
This function calculates the total power for each window and each sensor axis by
|
|
190
|
+
This function calculates the total power for each window and each sensor axis by
|
|
173
191
|
summing the PSD values across all frequency bins.
|
|
174
192
|
|
|
175
193
|
Parameters
|
|
176
194
|
----------
|
|
177
195
|
psd : np.ndarray
|
|
178
|
-
A 3D array of shape (n_windows, n_frequencies, n_axes) representing the
|
|
196
|
+
A 3D array of shape (n_windows, n_frequencies, n_axes) representing the
|
|
179
197
|
power spectral density (PSD) of the sensor data.
|
|
180
198
|
|
|
181
199
|
Returns
|
|
182
200
|
-------
|
|
183
201
|
np.ndarray
|
|
184
|
-
A 2D array of shape (n_windows, n_axes) containing the total power for each
|
|
202
|
+
A 2D array of shape (n_windows, n_axes) containing the total power for each
|
|
185
203
|
window and each sensor axis.
|
|
186
204
|
"""
|
|
187
205
|
return np.sum(psd, axis=-1) # Sum across frequency bins
|
|
188
206
|
|
|
189
207
|
|
|
190
208
|
def extract_tremor_power(
|
|
191
|
-
freqs: np.ndarray,
|
|
209
|
+
freqs: np.ndarray,
|
|
192
210
|
total_psd: np.ndarray,
|
|
193
211
|
fmin: float = 3,
|
|
194
212
|
fmax: float = 7,
|
|
195
|
-
spectral_resolution: float = 0.25
|
|
196
|
-
|
|
213
|
+
spectral_resolution: float = 0.25,
|
|
214
|
+
) -> np.ndarray:
|
|
215
|
+
"""Computes the tremor power.
|
|
216
|
+
|
|
217
|
+
Tremor power is 1.25 Hz around the peak within the tremor
|
|
218
|
+
frequency band.
|
|
197
219
|
|
|
198
|
-
"""Computes the tremor power (1.25 Hz around the peak within the tremor frequency band)
|
|
199
|
-
|
|
200
220
|
Parameters
|
|
201
221
|
----------
|
|
202
222
|
total_psd: np.ndarray
|
|
@@ -209,16 +229,16 @@ def extract_tremor_power(
|
|
|
209
229
|
The upper bound of the tremor frequency band in Hz (default: 7)
|
|
210
230
|
spectral_resolution: float
|
|
211
231
|
The spectral resolution of the PSD in Hz (default: 0.25)
|
|
212
|
-
|
|
232
|
+
|
|
213
233
|
Returns
|
|
214
234
|
-------
|
|
215
235
|
pd.Series
|
|
216
236
|
The tremor power across windows
|
|
217
237
|
"""
|
|
218
|
-
|
|
238
|
+
|
|
219
239
|
freq_idx = (freqs >= fmin) & (freqs <= fmax)
|
|
220
240
|
peak_idx = np.argmax(total_psd[:, freq_idx], axis=1) + np.min(np.where(freq_idx)[0])
|
|
221
|
-
left_idx =
|
|
241
|
+
left_idx = np.maximum((peak_idx - 0.5 / spectral_resolution).astype(int), 0)
|
|
222
242
|
right_idx = (peak_idx + 0.5 / spectral_resolution).astype(int)
|
|
223
243
|
|
|
224
244
|
row_indices = np.arange(total_psd.shape[1])
|
|
@@ -234,24 +254,26 @@ def extract_tremor_power(
|
|
|
234
254
|
|
|
235
255
|
|
|
236
256
|
def compute_dominant_frequency(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
freqs: np.ndarray,
|
|
258
|
+
psd: np.ndarray,
|
|
259
|
+
fmin: float | None = None,
|
|
260
|
+
fmax: float | None = None,
|
|
261
|
+
) -> np.ndarray:
|
|
242
262
|
"""
|
|
243
|
-
Compute the dominant frequency within a specified frequency range for
|
|
263
|
+
Compute the dominant frequency within a specified frequency range for
|
|
264
|
+
each window and sensor axis.
|
|
244
265
|
|
|
245
|
-
The dominant frequency is defined as the frequency corresponding to the
|
|
246
|
-
power spectral density (PSD) within the specified
|
|
266
|
+
The dominant frequency is defined as the frequency corresponding to the
|
|
267
|
+
maximum power in the power spectral density (PSD) within the specified
|
|
268
|
+
range.
|
|
247
269
|
|
|
248
270
|
Parameters
|
|
249
271
|
----------
|
|
250
272
|
freqs : np.ndarray
|
|
251
|
-
A 1D array of shape (n_frequencies,) containing the frequencies corresponding
|
|
273
|
+
A 1D array of shape (n_frequencies,) containing the frequencies corresponding
|
|
252
274
|
to the PSD values.
|
|
253
275
|
psd : np.ndarray
|
|
254
|
-
A 2D array of shape (n_windows, n_frequencies) or a 3D array of shape
|
|
276
|
+
A 2D array of shape (n_windows, n_frequencies) or a 3D array of shape
|
|
255
277
|
(n_windows, n_frequencies, n_axes) representing the power spectral density.
|
|
256
278
|
fmin : float
|
|
257
279
|
The lower bound of the frequency range (inclusive).
|
|
@@ -261,10 +283,10 @@ def compute_dominant_frequency(
|
|
|
261
283
|
Returns
|
|
262
284
|
-------
|
|
263
285
|
np.ndarray
|
|
264
|
-
- If `psd` is 2D: A 1D array of shape (n_windows,) containing the
|
|
265
|
-
for each window.
|
|
266
|
-
- If `psd` is 3D: A 2D array of shape (n_windows, n_axes) containing
|
|
267
|
-
frequency for each window and each axis.
|
|
286
|
+
- If `psd` is 2D: A 1D array of shape (n_windows,) containing the
|
|
287
|
+
dominant frequency for each window.
|
|
288
|
+
- If `psd` is 3D: A 2D array of shape (n_windows, n_axes) containing
|
|
289
|
+
the dominant frequency for each window and each axis.
|
|
268
290
|
|
|
269
291
|
Raises
|
|
270
292
|
------
|
|
@@ -272,7 +294,8 @@ def compute_dominant_frequency(
|
|
|
272
294
|
If `fmin` or `fmax` is outside the bounds of the `freqs` array.
|
|
273
295
|
If `psd` is not a 2D or 3D array.
|
|
274
296
|
"""
|
|
275
|
-
# Set default values for fmin and fmax to the minimum and maximum
|
|
297
|
+
# Set default values for fmin and fmax to the minimum and maximum
|
|
298
|
+
# frequencies if not provided
|
|
276
299
|
if fmin is None:
|
|
277
300
|
fmin = freqs[0]
|
|
278
301
|
if fmax is None:
|
|
@@ -280,40 +303,46 @@ def compute_dominant_frequency(
|
|
|
280
303
|
|
|
281
304
|
# Validate the frequency range
|
|
282
305
|
if fmin < freqs[0] or fmax > freqs[-1]:
|
|
283
|
-
raise ValueError(
|
|
284
|
-
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"fmin {fmin} or fmax {fmax} are out of bounds of the frequency array."
|
|
308
|
+
)
|
|
309
|
+
|
|
285
310
|
# Find the indices corresponding to fmin and fmax
|
|
286
311
|
min_index = np.searchsorted(freqs, fmin)
|
|
287
312
|
max_index = np.searchsorted(freqs, fmax)
|
|
288
313
|
|
|
289
314
|
# Slice the PSD and frequency array to the desired range
|
|
290
|
-
psd_filtered =
|
|
315
|
+
psd_filtered = (
|
|
316
|
+
psd[:, min_index:max_index] if psd.ndim == 2 else psd[:, min_index:max_index, :]
|
|
317
|
+
)
|
|
291
318
|
freqs_filtered = freqs[min_index:max_index]
|
|
292
319
|
|
|
293
320
|
# Compute dominant frequency
|
|
294
321
|
if psd.ndim == 3:
|
|
295
322
|
# 3D: Compute for each axis
|
|
296
|
-
return np.array(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
323
|
+
return np.array(
|
|
324
|
+
[
|
|
325
|
+
freqs_filtered[np.argmax(psd_filtered[:, :, i], axis=1)]
|
|
326
|
+
for i in range(psd.shape[-1])
|
|
327
|
+
]
|
|
328
|
+
).T
|
|
300
329
|
elif psd.ndim == 2:
|
|
301
330
|
# 2D: Compute for each window
|
|
302
331
|
return freqs_filtered[np.argmax(psd_filtered, axis=1)]
|
|
303
332
|
else:
|
|
304
333
|
raise ValueError("PSD array must be 2D or 3D.")
|
|
305
|
-
|
|
334
|
+
|
|
306
335
|
|
|
307
336
|
def extract_frequency_peak(
|
|
308
337
|
freqs: np.ndarray,
|
|
309
338
|
psd: np.ndarray,
|
|
310
339
|
fmin: float | None = None,
|
|
311
340
|
fmax: float | None = None,
|
|
312
|
-
include_max: bool = True
|
|
313
|
-
|
|
341
|
+
include_max: bool = True,
|
|
342
|
+
) -> pd.Series:
|
|
343
|
+
"""Extract the frequency of the peak in the power spectral density
|
|
344
|
+
within the specified frequency band.
|
|
314
345
|
|
|
315
|
-
"""Extract the frequency of the peak in the power spectral density within the specified frequency band.
|
|
316
|
-
|
|
317
346
|
Parameters
|
|
318
347
|
----------
|
|
319
348
|
freqs: pd.Series
|
|
@@ -321,17 +350,19 @@ def extract_frequency_peak(
|
|
|
321
350
|
psd: pd.Series
|
|
322
351
|
The total power spectral density of the gyroscope signal
|
|
323
352
|
fmin: float
|
|
324
|
-
The lower bound of the frequency band in Hz (default: None).
|
|
353
|
+
The lower bound of the frequency band in Hz (default: None).
|
|
354
|
+
If not provided, the minimum frequency is used.
|
|
325
355
|
fmax: float
|
|
326
|
-
The upper bound of the frequency band in Hz (default: None).
|
|
356
|
+
The upper bound of the frequency band in Hz (default: None).
|
|
357
|
+
If not provided, the maximum frequency is used.
|
|
327
358
|
include_max: bool
|
|
328
359
|
Whether to include the maximum frequency in the search range (default: True)
|
|
329
|
-
|
|
360
|
+
|
|
330
361
|
Returns
|
|
331
362
|
-------
|
|
332
363
|
pd.Series
|
|
333
364
|
The frequency of the peak across windows
|
|
334
|
-
"""
|
|
365
|
+
"""
|
|
335
366
|
# Set fmin and fmax to maximum range if not provided
|
|
336
367
|
if fmin is None:
|
|
337
368
|
fmin = freqs[0]
|
|
@@ -340,9 +371,9 @@ def extract_frequency_peak(
|
|
|
340
371
|
|
|
341
372
|
# Find the indices corresponding to fmin and fmax
|
|
342
373
|
if include_max:
|
|
343
|
-
freq_idx = np.where((freqs>=fmin) & (freqs<=fmax))[0]
|
|
374
|
+
freq_idx = np.where((freqs >= fmin) & (freqs <= fmax))[0]
|
|
344
375
|
else:
|
|
345
|
-
freq_idx = np.where((freqs>=fmin) & (freqs<fmax))[0]
|
|
376
|
+
freq_idx = np.where((freqs >= fmin) & (freqs < fmax))[0]
|
|
346
377
|
|
|
347
378
|
peak_idx = np.argmax(psd[:, freq_idx], axis=1)
|
|
348
379
|
frequency_peak = freqs[freq_idx][peak_idx]
|
|
@@ -351,12 +382,11 @@ def extract_frequency_peak(
|
|
|
351
382
|
|
|
352
383
|
|
|
353
384
|
def compute_relative_power(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
config: PulseRateConfig
|
|
357
|
-
) -> list:
|
|
385
|
+
freqs: np.ndarray, psd: np.ndarray, config: PulseRateConfig
|
|
386
|
+
) -> list:
|
|
358
387
|
"""
|
|
359
|
-
Calculate relative power within the dominant frequency band in the
|
|
388
|
+
Calculate relative power within the dominant frequency band in the
|
|
389
|
+
physiological range (0.75 - 3 Hz).
|
|
360
390
|
|
|
361
391
|
Parameters
|
|
362
392
|
----------
|
|
@@ -365,40 +395,50 @@ def compute_relative_power(
|
|
|
365
395
|
psd: np.ndarray
|
|
366
396
|
The power spectral density of the signal.
|
|
367
397
|
config: PulseRateConfig
|
|
368
|
-
The configuration object containing the parameters for the feature
|
|
369
|
-
attributes are used:
|
|
398
|
+
The configuration object containing the parameters for the feature
|
|
399
|
+
extraction. The following attributes are used:
|
|
370
400
|
- freq_band_physio: tuple
|
|
371
401
|
The frequency band for physiological pulse rate (default: (0.75, 3)).
|
|
372
402
|
- bandwidth: float
|
|
373
|
-
The bandwidth around the peak frequency to consider for
|
|
403
|
+
The bandwidth around the peak frequency to consider for
|
|
404
|
+
relative power calculation (default: 0.5).
|
|
374
405
|
|
|
375
406
|
Returns
|
|
376
407
|
-------
|
|
377
408
|
list
|
|
378
|
-
The relative power within the dominant frequency band in the
|
|
379
|
-
|
|
409
|
+
The relative power within the dominant frequency band in the
|
|
410
|
+
physiological range (0.75 - 3 Hz).
|
|
411
|
+
|
|
380
412
|
"""
|
|
381
|
-
hr_range_mask = (freqs >= config.freq_band_physio[0]) & (
|
|
413
|
+
hr_range_mask = (freqs >= config.freq_band_physio[0]) & (
|
|
414
|
+
freqs <= config.freq_band_physio[1]
|
|
415
|
+
)
|
|
382
416
|
hr_range_idx = np.where(hr_range_mask)[0]
|
|
383
417
|
peak_idx = np.argmax(psd[:, hr_range_idx], axis=1)
|
|
384
418
|
peak_freqs = freqs[hr_range_idx[peak_idx]]
|
|
385
419
|
|
|
386
|
-
dom_band_idx = [
|
|
387
|
-
|
|
420
|
+
dom_band_idx = [
|
|
421
|
+
np.where(
|
|
422
|
+
(freqs >= peak_freq - config.bandwidth)
|
|
423
|
+
& (freqs <= peak_freq + config.bandwidth)
|
|
424
|
+
)[0]
|
|
425
|
+
for peak_freq in peak_freqs
|
|
426
|
+
]
|
|
427
|
+
rel_power = [
|
|
428
|
+
np.trapezoid(psd[j, idx], freqs[idx]) / np.trapezoid(psd[j, :], freqs)
|
|
429
|
+
for j, idx in enumerate(dom_band_idx)
|
|
430
|
+
]
|
|
388
431
|
return rel_power
|
|
389
432
|
|
|
390
433
|
|
|
391
|
-
def compute_spectral_entropy(
|
|
392
|
-
psd: np.ndarray,
|
|
393
|
-
n_samples: int
|
|
394
|
-
) -> np.ndarray:
|
|
434
|
+
def compute_spectral_entropy(psd: np.ndarray, n_samples: int) -> np.ndarray:
|
|
395
435
|
"""
|
|
396
436
|
Calculate the spectral entropy from the normalized power spectral density.
|
|
397
437
|
|
|
398
438
|
Parameters
|
|
399
439
|
----------
|
|
400
440
|
psd: np.ndarray
|
|
401
|
-
The power spectral density of the signal.
|
|
441
|
+
The power spectral density of the signal.
|
|
402
442
|
n_samples: int
|
|
403
443
|
The number of samples in the window.
|
|
404
444
|
|
|
@@ -408,37 +448,41 @@ def compute_spectral_entropy(
|
|
|
408
448
|
The spectral entropy of the power spectral density.
|
|
409
449
|
"""
|
|
410
450
|
psd_norm = psd / np.sum(psd, axis=1, keepdims=True)
|
|
411
|
-
spectral_entropy = -np.sum(psd_norm * np.log2(psd_norm), axis=1) / np.log2(
|
|
412
|
-
|
|
451
|
+
spectral_entropy = -np.sum(psd_norm * np.log2(psd_norm), axis=1) / np.log2(
|
|
452
|
+
n_samples
|
|
453
|
+
)
|
|
454
|
+
|
|
413
455
|
return spectral_entropy
|
|
414
456
|
|
|
415
457
|
|
|
416
458
|
def compute_mfccs(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
459
|
+
total_power_array: np.ndarray,
|
|
460
|
+
config,
|
|
461
|
+
total_power_type: str = "psd",
|
|
462
|
+
mel_scale: bool = True,
|
|
463
|
+
multiplication_factor: float = 1,
|
|
464
|
+
rounding_method: str = "floor",
|
|
465
|
+
) -> np.ndarray:
|
|
424
466
|
"""
|
|
425
|
-
Generate Mel Frequency Cepstral Coefficients (MFCCs) from the total
|
|
467
|
+
Generate Mel Frequency Cepstral Coefficients (MFCCs) from the total
|
|
468
|
+
power spectral density or spectrogram of the signal.
|
|
426
469
|
|
|
427
|
-
MFCCs are commonly used features in signal processing for tasks like audio and
|
|
470
|
+
MFCCs are commonly used features in signal processing for tasks like audio and
|
|
428
471
|
vibration analysis. In this version, we adjusted the MFFCs to the human activity
|
|
429
|
-
range according to:
|
|
430
|
-
|
|
431
|
-
|
|
472
|
+
range according to:
|
|
473
|
+
https://www.sciencedirect.com/science/article/abs/pii/S016516841500331X#f0050
|
|
474
|
+
This function calculates MFCCs by applying a filterbank
|
|
475
|
+
(in either the mel scale or linear scale) to the total power of the signal,
|
|
432
476
|
followed by a Discrete Cosine Transform (DCT) to obtain coefficients.
|
|
433
477
|
|
|
434
478
|
Parameters
|
|
435
479
|
----------
|
|
436
480
|
total_power_array : np.ndarray
|
|
437
|
-
2D array of shape (n_windows, n_frequencies) containing the total
|
|
438
|
-
of the signal for each window.
|
|
481
|
+
2D array of shape (n_windows, n_frequencies) containing the total
|
|
482
|
+
power of the signal for each window.
|
|
439
483
|
OR
|
|
440
|
-
3D array of shape (n_windows, n_frequencies, n_segments) containing
|
|
441
|
-
of the signal for each window.
|
|
484
|
+
3D array of shape (n_windows, n_frequencies, n_segments) containing
|
|
485
|
+
the total spectrogram of the signal for each window.
|
|
442
486
|
config : object
|
|
443
487
|
Configuration object containing the following attributes:
|
|
444
488
|
- window_length_s : int
|
|
@@ -454,14 +498,16 @@ def compute_mfccs(
|
|
|
454
498
|
- mfcc_n_coefficients : int
|
|
455
499
|
Number of coefficients to extract (default: 12).
|
|
456
500
|
total_power_type : str, optional
|
|
457
|
-
The type of the total power array. Supported values are 'psd' and
|
|
501
|
+
The type of the total power array. Supported values are 'psd' and
|
|
502
|
+
'spectrogram' (default: 'psd').
|
|
458
503
|
mel_scale : bool, optional
|
|
459
504
|
Whether to use the mel scale for the filterbank (default: True).
|
|
460
505
|
multiplication_factor : float, optional
|
|
461
|
-
Multiplication factor for the Mel scale conversion (default: 1).
|
|
462
|
-
value is 1. For gait, this is 4.
|
|
506
|
+
Multiplication factor for the Mel scale conversion (default: 1).
|
|
507
|
+
For tremor, the recommended value is 1. For gait, this is 4.
|
|
463
508
|
rounding_method : str, optional
|
|
464
|
-
The method used to round the filter points. Supported values are
|
|
509
|
+
The method used to round the filter points. Supported values are
|
|
510
|
+
'round' and 'floor' (default: 'floor').
|
|
465
511
|
|
|
466
512
|
Returns
|
|
467
513
|
-------
|
|
@@ -475,61 +521,66 @@ def compute_mfccs(
|
|
|
475
521
|
- The function includes filterbank normalization to ensure proper scaling.
|
|
476
522
|
- DCT filters are constructed to minimize spectral leakage.
|
|
477
523
|
"""
|
|
478
|
-
|
|
524
|
+
|
|
479
525
|
# Check if total_power_type is either 'psd' or 'spectrogram'
|
|
480
|
-
if total_power_type not in [
|
|
481
|
-
raise ValueError(
|
|
526
|
+
if total_power_type not in ["psd", "spectrogram"]:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
"total_power_type should be set to either 'psd' or 'spectrogram'"
|
|
529
|
+
)
|
|
482
530
|
|
|
483
531
|
# Compute window length in samples
|
|
484
532
|
window_length = config.window_length_s * config.sampling_frequency
|
|
485
|
-
|
|
533
|
+
|
|
486
534
|
# Determine the length of subwindows used in the spectrogram computation
|
|
487
|
-
if total_power_type ==
|
|
535
|
+
if total_power_type == "spectrogram":
|
|
488
536
|
nr_subwindows = total_power_array.shape[2]
|
|
489
|
-
window_length = int(
|
|
537
|
+
window_length = int(
|
|
538
|
+
window_length
|
|
539
|
+
/ (nr_subwindows - (nr_subwindows - 1) * config.overlap_fraction)
|
|
540
|
+
)
|
|
490
541
|
|
|
491
542
|
# Generate filter points
|
|
492
543
|
if mel_scale:
|
|
493
544
|
freqs = np.linspace(
|
|
494
|
-
melscale(config.mfcc_low_frequency, multiplication_factor),
|
|
495
|
-
melscale(config.mfcc_high_frequency, multiplication_factor),
|
|
496
|
-
num=config.mfcc_n_dct_filters + 2
|
|
545
|
+
melscale(config.mfcc_low_frequency, multiplication_factor),
|
|
546
|
+
melscale(config.mfcc_high_frequency, multiplication_factor),
|
|
547
|
+
num=config.mfcc_n_dct_filters + 2,
|
|
497
548
|
)
|
|
498
549
|
freqs = inverse_melscale(freqs, multiplication_factor)
|
|
499
550
|
else:
|
|
500
551
|
freqs = np.linspace(
|
|
501
|
-
config.mfcc_low_frequency,
|
|
502
|
-
config.mfcc_high_frequency,
|
|
503
|
-
num=config.mfcc_n_dct_filters + 2
|
|
552
|
+
config.mfcc_low_frequency,
|
|
553
|
+
config.mfcc_high_frequency,
|
|
554
|
+
num=config.mfcc_n_dct_filters + 2,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if rounding_method == "round":
|
|
558
|
+
filter_points = (
|
|
559
|
+
np.round(window_length / config.sampling_frequency * freqs).astype(int) + 1
|
|
504
560
|
)
|
|
505
|
-
|
|
506
|
-
if rounding_method == 'round':
|
|
507
|
-
filter_points = np.round(
|
|
508
|
-
window_length / config.sampling_frequency * freqs
|
|
509
|
-
).astype(int) + 1
|
|
510
561
|
|
|
511
|
-
elif rounding_method ==
|
|
512
|
-
filter_points =
|
|
513
|
-
window_length / config.sampling_frequency * freqs
|
|
514
|
-
)
|
|
562
|
+
elif rounding_method == "floor":
|
|
563
|
+
filter_points = (
|
|
564
|
+
np.floor(window_length / config.sampling_frequency * freqs).astype(int) + 1
|
|
565
|
+
)
|
|
515
566
|
|
|
516
567
|
# Construct triangular filterbank
|
|
517
568
|
filters = np.zeros((len(filter_points) - 2, int(window_length / 2 + 1)))
|
|
518
569
|
for j in range(len(filter_points) - 2):
|
|
519
570
|
filters[j, filter_points[j] : filter_points[j + 2]] = windows.triang(
|
|
520
571
|
filter_points[j + 2] - filter_points[j]
|
|
521
|
-
)
|
|
572
|
+
)
|
|
522
573
|
# Normalize filter coefficients
|
|
523
574
|
filters[j, :] /= (
|
|
524
|
-
config.sampling_frequency/window_length * np.sum(filters[j
|
|
525
|
-
)
|
|
575
|
+
config.sampling_frequency / window_length * np.sum(filters[j, :])
|
|
576
|
+
)
|
|
526
577
|
|
|
527
578
|
# Apply filterbank to total power
|
|
528
|
-
if total_power_type ==
|
|
529
|
-
power_filtered = np.tensordot(total_power_array, filters.T, axes=(1,0))
|
|
530
|
-
elif total_power_type ==
|
|
579
|
+
if total_power_type == "spectrogram":
|
|
580
|
+
power_filtered = np.tensordot(total_power_array, filters.T, axes=(1, 0))
|
|
581
|
+
elif total_power_type == "psd":
|
|
531
582
|
power_filtered = np.dot(total_power_array, filters.T)
|
|
532
|
-
|
|
583
|
+
|
|
533
584
|
# Convert power to logarithmic scale
|
|
534
585
|
log_power_filtered = np.log10(power_filtered + 1e-10)
|
|
535
586
|
|
|
@@ -538,16 +589,20 @@ def compute_mfccs(
|
|
|
538
589
|
dct_filters[0, :] = 1.0 / np.sqrt(config.mfcc_n_dct_filters)
|
|
539
590
|
|
|
540
591
|
samples = (
|
|
541
|
-
np.arange(1, 2 * config.mfcc_n_dct_filters, 2)
|
|
592
|
+
np.arange(1, 2 * config.mfcc_n_dct_filters, 2)
|
|
593
|
+
* np.pi
|
|
594
|
+
/ (2.0 * config.mfcc_n_dct_filters)
|
|
542
595
|
)
|
|
543
596
|
|
|
544
597
|
for i in range(1, config.mfcc_n_coefficients):
|
|
545
|
-
dct_filters[i, :] = np.cos(i * samples) * np.sqrt(
|
|
598
|
+
dct_filters[i, :] = np.cos(i * samples) * np.sqrt(
|
|
599
|
+
2.0 / config.mfcc_n_dct_filters
|
|
600
|
+
)
|
|
546
601
|
|
|
547
602
|
# Compute MFCCs
|
|
548
|
-
mfccs = np.dot(log_power_filtered, dct_filters.T)
|
|
603
|
+
mfccs = np.dot(log_power_filtered, dct_filters.T)
|
|
549
604
|
|
|
550
|
-
if total_power_type ==
|
|
605
|
+
if total_power_type == "spectrogram":
|
|
551
606
|
mfccs = np.mean(mfccs, axis=1)
|
|
552
607
|
|
|
553
608
|
return mfccs
|
|
@@ -562,15 +617,17 @@ def melscale(x: np.ndarray, multiplication_factor: float = 1) -> np.ndarray:
|
|
|
562
617
|
x : np.ndarray
|
|
563
618
|
Linear frequency values to be converted to the Mel scale.
|
|
564
619
|
multiplication_factor : float, optional
|
|
565
|
-
Multiplication factor for the Mel scale conversion (default: 1).
|
|
566
|
-
value is 1. For gait, this is 4.
|
|
620
|
+
Multiplication factor for the Mel scale conversion (default: 1).
|
|
621
|
+
For tremor, the recommended value is 1. For gait, this is 4.
|
|
567
622
|
|
|
568
623
|
Returns
|
|
569
624
|
-------
|
|
570
625
|
np.ndarray
|
|
571
626
|
Frequency values mapped to the Mel scale.
|
|
572
627
|
"""
|
|
573
|
-
return (64.875 / multiplication_factor) * np.log10(
|
|
628
|
+
return (64.875 / multiplication_factor) * np.log10(
|
|
629
|
+
1 + x / (17.5 / multiplication_factor)
|
|
630
|
+
)
|
|
574
631
|
|
|
575
632
|
|
|
576
633
|
def inverse_melscale(x: np.ndarray, multiplication_factor: float = 1) -> np.ndarray:
|
|
@@ -578,7 +635,8 @@ def inverse_melscale(x: np.ndarray, multiplication_factor: float = 1) -> np.ndar
|
|
|
578
635
|
Maps values from the Mel scale back to linear frequencies.
|
|
579
636
|
|
|
580
637
|
This function performs the inverse transformation of the Mel scale,
|
|
581
|
-
converting perceptual frequency values to their corresponding linear
|
|
638
|
+
converting perceptual frequency values to their corresponding linear
|
|
639
|
+
frequency values.
|
|
582
640
|
|
|
583
641
|
Parameters
|
|
584
642
|
----------
|
|
@@ -590,17 +648,19 @@ def inverse_melscale(x: np.ndarray, multiplication_factor: float = 1) -> np.ndar
|
|
|
590
648
|
np.ndarray
|
|
591
649
|
Linear frequency values corresponding to the given Mel scale values.
|
|
592
650
|
"""
|
|
593
|
-
return (17.5 / multiplication_factor) * (
|
|
651
|
+
return (17.5 / multiplication_factor) * (
|
|
652
|
+
10 ** (x / (64.875 / multiplication_factor)) - 1
|
|
653
|
+
)
|
|
594
654
|
|
|
595
655
|
|
|
596
656
|
def pca_transform_gyroscope(
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
657
|
+
df: pd.DataFrame,
|
|
658
|
+
y_gyro_colname: str,
|
|
659
|
+
z_gyro_colname: str,
|
|
600
660
|
) -> np.ndarray:
|
|
601
661
|
"""
|
|
602
662
|
Perform principal component analysis (PCA) on gyroscope data to estimate velocity.
|
|
603
|
-
|
|
663
|
+
|
|
604
664
|
Parameters
|
|
605
665
|
----------
|
|
606
666
|
df : pd.DataFrame
|
|
@@ -609,7 +669,7 @@ def pca_transform_gyroscope(
|
|
|
609
669
|
The column name for the y-axis gyroscope data.
|
|
610
670
|
z_gyro_colname : str
|
|
611
671
|
The column name for the z-axis gyroscope data.
|
|
612
|
-
|
|
672
|
+
|
|
613
673
|
Returns
|
|
614
674
|
-------
|
|
615
675
|
np.ndarray
|
|
@@ -623,7 +683,7 @@ def pca_transform_gyroscope(
|
|
|
623
683
|
fit_data = np.column_stack((y_gyro_array, z_gyro_array))
|
|
624
684
|
full_data = fit_data
|
|
625
685
|
|
|
626
|
-
pca = PCA(n_components=2, svd_solver=
|
|
686
|
+
pca = PCA(n_components=2, svd_solver="auto", random_state=22)
|
|
627
687
|
pca.fit(fit_data)
|
|
628
688
|
velocity = pca.transform(full_data)[:, 0] # First principal component
|
|
629
689
|
|
|
@@ -632,26 +692,24 @@ def pca_transform_gyroscope(
|
|
|
632
692
|
|
|
633
693
|
def compute_angle(time_array: np.ndarray, velocity_array: np.ndarray) -> np.ndarray:
|
|
634
694
|
"""
|
|
635
|
-
Compute the angle from the angular velocity using cumulative
|
|
636
|
-
|
|
695
|
+
Compute the angle from the angular velocity using cumulative
|
|
696
|
+
trapezoidal integration.
|
|
697
|
+
|
|
637
698
|
Parameters
|
|
638
699
|
----------
|
|
639
700
|
time_array : np.ndarray
|
|
640
701
|
The time array corresponding to the angular velocity data.
|
|
641
702
|
velocity_array : np.ndarray
|
|
642
703
|
The angular velocity data to integrate.
|
|
643
|
-
|
|
704
|
+
|
|
644
705
|
Returns
|
|
645
706
|
-------
|
|
646
707
|
np.ndarray
|
|
647
|
-
The estimated angle based on the cumulative trapezoidal integration
|
|
708
|
+
The estimated angle based on the cumulative trapezoidal integration
|
|
709
|
+
of the angular velocity.
|
|
648
710
|
"""
|
|
649
711
|
# Perform integration and apply absolute value
|
|
650
|
-
angle_array = cumulative_trapezoid(
|
|
651
|
-
y=velocity_array,
|
|
652
|
-
x=time_array,
|
|
653
|
-
initial=0
|
|
654
|
-
)
|
|
712
|
+
angle_array = cumulative_trapezoid(y=velocity_array, x=time_array, initial=0)
|
|
655
713
|
return np.abs(angle_array)
|
|
656
714
|
|
|
657
715
|
|
|
@@ -665,31 +723,30 @@ def remove_moving_average_angle(angle_array: np.ndarray, fs: float) -> pd.Series
|
|
|
665
723
|
The angle array to remove the moving average from.
|
|
666
724
|
fs : float
|
|
667
725
|
The sampling frequency of the data.
|
|
668
|
-
|
|
726
|
+
|
|
669
727
|
Returns
|
|
670
728
|
-------
|
|
671
729
|
pd.Series
|
|
672
730
|
The angle array with the moving average removed.
|
|
673
731
|
"""
|
|
674
732
|
window_size = int(2 * (fs * 0.5) + 1)
|
|
675
|
-
angle_ma = np.array(
|
|
676
|
-
|
|
677
|
-
min_periods=1,
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
733
|
+
angle_ma = np.array(
|
|
734
|
+
pd.Series(angle_array)
|
|
735
|
+
.rolling(window=window_size, min_periods=1, center=True, closed="both")
|
|
736
|
+
.mean()
|
|
737
|
+
)
|
|
738
|
+
|
|
682
739
|
return angle_array - angle_ma
|
|
683
740
|
|
|
684
741
|
|
|
685
742
|
def extract_angle_extremes(
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
743
|
+
angle_array: np.ndarray,
|
|
744
|
+
sampling_frequency: float,
|
|
745
|
+
max_frequency_activity: float = 1.75,
|
|
746
|
+
) -> tuple[list[int], list[int], list[int]]:
|
|
690
747
|
"""
|
|
691
748
|
Extract extrema (minima and maxima) indices from the angle array.
|
|
692
|
-
|
|
749
|
+
|
|
693
750
|
Parameters
|
|
694
751
|
----------
|
|
695
752
|
angle_array : np.ndarray
|
|
@@ -698,25 +755,21 @@ def extract_angle_extremes(
|
|
|
698
755
|
The sampling frequency of the data.
|
|
699
756
|
max_frequency_activity : float, optional
|
|
700
757
|
The maximum frequency of human activity in Hz (default: 1.75).
|
|
701
|
-
|
|
758
|
+
|
|
702
759
|
Returns
|
|
703
760
|
-------
|
|
704
761
|
tuple
|
|
705
762
|
A tuple containing the indices of the angle extrema, minima, and maxima.
|
|
706
763
|
"""
|
|
707
764
|
distance = sampling_frequency / max_frequency_activity
|
|
708
|
-
prominence = 2
|
|
765
|
+
prominence = 2
|
|
709
766
|
|
|
710
767
|
# Find minima and maxima indices for each window
|
|
711
768
|
minima_indices = find_peaks(
|
|
712
|
-
x=-angle_array,
|
|
713
|
-
distance=distance,
|
|
714
|
-
prominence=prominence
|
|
769
|
+
x=-angle_array, distance=distance, prominence=prominence
|
|
715
770
|
)[0]
|
|
716
771
|
maxima_indices = find_peaks(
|
|
717
|
-
x=angle_array,
|
|
718
|
-
distance=distance,
|
|
719
|
-
prominence=prominence
|
|
772
|
+
x=angle_array, distance=distance, prominence=prominence
|
|
720
773
|
)[0]
|
|
721
774
|
|
|
722
775
|
minima_indices = np.array(minima_indices, dtype=object)
|
|
@@ -728,14 +781,20 @@ def extract_angle_extremes(
|
|
|
728
781
|
# Start with a minimum
|
|
729
782
|
while i_pks < minima_indices.size - 1 and i_pks < maxima_indices.size:
|
|
730
783
|
if minima_indices[i_pks + 1] < maxima_indices[i_pks]:
|
|
731
|
-
if
|
|
784
|
+
if (
|
|
785
|
+
angle_array[minima_indices[i_pks + 1]]
|
|
786
|
+
< angle_array[minima_indices[i_pks]]
|
|
787
|
+
):
|
|
732
788
|
minima_indices = np.delete(minima_indices, i_pks)
|
|
733
789
|
else:
|
|
734
790
|
minima_indices = np.delete(minima_indices, i_pks + 1)
|
|
735
791
|
i_pks -= 1
|
|
736
792
|
|
|
737
793
|
if i_pks >= 0 and minima_indices[i_pks] > maxima_indices[i_pks]:
|
|
738
|
-
if
|
|
794
|
+
if (
|
|
795
|
+
angle_array[maxima_indices[i_pks]]
|
|
796
|
+
< angle_array[maxima_indices[i_pks - 1]]
|
|
797
|
+
):
|
|
739
798
|
maxima_indices = np.delete(maxima_indices, i_pks)
|
|
740
799
|
else:
|
|
741
800
|
maxima_indices = np.delete(maxima_indices, i_pks - 1)
|
|
@@ -746,14 +805,20 @@ def extract_angle_extremes(
|
|
|
746
805
|
# Start with a maximum
|
|
747
806
|
while i_pks < maxima_indices.size - 1 and i_pks < minima_indices.size:
|
|
748
807
|
if maxima_indices[i_pks + 1] < minima_indices[i_pks]:
|
|
749
|
-
if
|
|
808
|
+
if (
|
|
809
|
+
angle_array[maxima_indices[i_pks + 1]]
|
|
810
|
+
< angle_array[maxima_indices[i_pks]]
|
|
811
|
+
):
|
|
750
812
|
maxima_indices = np.delete(maxima_indices, i_pks + 1)
|
|
751
813
|
else:
|
|
752
814
|
maxima_indices = np.delete(maxima_indices, i_pks)
|
|
753
815
|
i_pks -= 1
|
|
754
816
|
|
|
755
817
|
if i_pks >= 0 and maxima_indices[i_pks] > minima_indices[i_pks]:
|
|
756
|
-
if
|
|
818
|
+
if (
|
|
819
|
+
angle_array[minima_indices[i_pks]]
|
|
820
|
+
< angle_array[minima_indices[i_pks - 1]]
|
|
821
|
+
):
|
|
757
822
|
minima_indices = np.delete(minima_indices, i_pks - 1)
|
|
758
823
|
else:
|
|
759
824
|
minima_indices = np.delete(minima_indices, i_pks)
|
|
@@ -766,17 +831,19 @@ def extract_angle_extremes(
|
|
|
766
831
|
return list(angle_extrema_indices), list(minima_indices), list(maxima_indices)
|
|
767
832
|
|
|
768
833
|
|
|
769
|
-
def compute_range_of_motion(
|
|
834
|
+
def compute_range_of_motion(
|
|
835
|
+
angle_array: np.ndarray, extrema_indices: list[int]
|
|
836
|
+
) -> np.ndarray:
|
|
770
837
|
"""
|
|
771
838
|
Compute the range of motion of a time series based on the angle extrema.
|
|
772
|
-
|
|
839
|
+
|
|
773
840
|
Parameters
|
|
774
841
|
----------
|
|
775
842
|
angle_array : np.ndarray
|
|
776
843
|
The angle array to compute the range of motion from.
|
|
777
844
|
extrema_indices : List[int]
|
|
778
845
|
The indices of the angle extrema.
|
|
779
|
-
|
|
846
|
+
|
|
780
847
|
Returns
|
|
781
848
|
-------
|
|
782
849
|
np.ndarray
|
|
@@ -787,9 +854,11 @@ def compute_range_of_motion(angle_array: np.ndarray, extrema_indices: List[int])
|
|
|
787
854
|
raise TypeError("extrema_indices must be a list of integers.")
|
|
788
855
|
|
|
789
856
|
# Check bounds
|
|
790
|
-
if np.any(np.array(extrema_indices) < 0) or np.any(
|
|
857
|
+
if np.any(np.array(extrema_indices) < 0) or np.any(
|
|
858
|
+
np.array(extrema_indices) >= len(angle_array)
|
|
859
|
+
):
|
|
791
860
|
raise ValueError("extrema_indices contains out-of-bounds indices.")
|
|
792
|
-
|
|
861
|
+
|
|
793
862
|
# Extract angle amplitudes (minima and maxima values)
|
|
794
863
|
angle_extremas = angle_array[extrema_indices]
|
|
795
864
|
|
|
@@ -801,7 +870,7 @@ def compute_range_of_motion(angle_array: np.ndarray, extrema_indices: List[int])
|
|
|
801
870
|
|
|
802
871
|
def compute_peak_angular_velocity(
|
|
803
872
|
velocity_array: np.ndarray,
|
|
804
|
-
angle_extrema_indices:
|
|
873
|
+
angle_extrema_indices: list[int],
|
|
805
874
|
) -> np.ndarray:
|
|
806
875
|
"""
|
|
807
876
|
Compute the peak angular velocity of a time series based on the angle extrema.
|
|
@@ -812,19 +881,21 @@ def compute_peak_angular_velocity(
|
|
|
812
881
|
The angular velocity array to compute the peak angular velocity from.
|
|
813
882
|
angle_extrema_indices : List[int]
|
|
814
883
|
The indices of the angle extrema.
|
|
815
|
-
|
|
884
|
+
|
|
816
885
|
Returns
|
|
817
886
|
-------
|
|
818
887
|
np.ndarray
|
|
819
888
|
The peak angular velocities of the time series.
|
|
820
889
|
"""
|
|
821
|
-
if np.any(np.array(angle_extrema_indices) < 0) or np.any(
|
|
890
|
+
if np.any(np.array(angle_extrema_indices) < 0) or np.any(
|
|
891
|
+
np.array(angle_extrema_indices) >= len(velocity_array)
|
|
892
|
+
):
|
|
822
893
|
raise ValueError("angle_extrema_indices contains out-of-bounds indices.")
|
|
823
|
-
|
|
894
|
+
|
|
824
895
|
if len(angle_extrema_indices) < 2:
|
|
825
896
|
raise ValueError("angle_extrema_indices must contain at least two indices.")
|
|
826
|
-
|
|
827
|
-
# Initialize a list to store the peak velocities
|
|
897
|
+
|
|
898
|
+
# Initialize a list to store the peak velocities
|
|
828
899
|
pav = []
|
|
829
900
|
|
|
830
901
|
# Compute peak angular velocities
|
|
@@ -841,10 +912,10 @@ def compute_peak_angular_velocity(
|
|
|
841
912
|
|
|
842
913
|
def compute_forward_backward_peak_angular_velocity(
|
|
843
914
|
velocity_array: np.ndarray,
|
|
844
|
-
angle_extrema_indices:
|
|
845
|
-
minima_indices:
|
|
846
|
-
maxima_indices:
|
|
847
|
-
) ->
|
|
915
|
+
angle_extrema_indices: list[int],
|
|
916
|
+
minima_indices: list[int],
|
|
917
|
+
maxima_indices: list[int],
|
|
918
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
848
919
|
"""
|
|
849
920
|
Compute the peak angular velocity of a time series based on the angle extrema.
|
|
850
921
|
|
|
@@ -858,21 +929,24 @@ def compute_forward_backward_peak_angular_velocity(
|
|
|
858
929
|
The indices of the minima.
|
|
859
930
|
maxima_indices : List[int]
|
|
860
931
|
The indices of the maxima.
|
|
861
|
-
|
|
932
|
+
|
|
862
933
|
Returns
|
|
863
934
|
-------
|
|
864
935
|
Tuple[np.ndarray, np.ndarray]
|
|
865
|
-
A tuple containing the forward and backward peak angular velocities
|
|
936
|
+
A tuple containing the forward and backward peak angular velocities
|
|
937
|
+
for minima and maxima.
|
|
866
938
|
"""
|
|
867
|
-
if np.any(np.array(angle_extrema_indices) < 0) or np.any(
|
|
939
|
+
if np.any(np.array(angle_extrema_indices) < 0) or np.any(
|
|
940
|
+
np.array(angle_extrema_indices) >= len(velocity_array)
|
|
941
|
+
):
|
|
868
942
|
raise ValueError("angle_extrema_indices contains out-of-bounds indices.")
|
|
869
|
-
|
|
943
|
+
|
|
870
944
|
if len(angle_extrema_indices) < 2:
|
|
871
945
|
raise ValueError("angle_extrema_indices must contain at least two indices.")
|
|
872
|
-
|
|
946
|
+
|
|
873
947
|
if len(minima_indices) == 0:
|
|
874
948
|
raise ValueError("No minima indices found.")
|
|
875
|
-
|
|
949
|
+
|
|
876
950
|
if len(maxima_indices) == 0:
|
|
877
951
|
raise ValueError("No maxima indices found.")
|
|
878
952
|
|
|
@@ -887,7 +961,8 @@ def compute_forward_backward_peak_angular_velocity(
|
|
|
887
961
|
next_peak_idx = angle_extrema_indices[i + 1]
|
|
888
962
|
segment = velocity_array[current_peak_idx:next_peak_idx]
|
|
889
963
|
|
|
890
|
-
# Check if the current peak is a minimum or maximum and
|
|
964
|
+
# Check if the current peak is a minimum or maximum and
|
|
965
|
+
# calculate peak velocity accordingly
|
|
891
966
|
if current_peak_idx in minima_indices:
|
|
892
967
|
forward_pav.append(np.max(np.abs(segment)))
|
|
893
968
|
elif current_peak_idx in maxima_indices:
|
|
@@ -900,12 +975,10 @@ def compute_forward_backward_peak_angular_velocity(
|
|
|
900
975
|
return forward_pav, backward_pav
|
|
901
976
|
|
|
902
977
|
|
|
903
|
-
def compute_signal_to_noise_ratio(
|
|
904
|
-
ppg_windowed: np.ndarray
|
|
905
|
-
) -> np.ndarray:
|
|
978
|
+
def compute_signal_to_noise_ratio(ppg_windowed: np.ndarray) -> np.ndarray:
|
|
906
979
|
"""
|
|
907
980
|
Compute the signal to noise ratio of the PPG signal.
|
|
908
|
-
|
|
981
|
+
|
|
909
982
|
Parameters
|
|
910
983
|
----------
|
|
911
984
|
ppg_windowed: np.ndarray
|
|
@@ -916,21 +989,25 @@ def compute_signal_to_noise_ratio(
|
|
|
916
989
|
np.ndarray
|
|
917
990
|
The signal to noise ratio of the PPG signal.
|
|
918
991
|
"""
|
|
919
|
-
|
|
992
|
+
|
|
920
993
|
arr_signal = np.var(ppg_windowed, axis=1)
|
|
921
994
|
arr_noise = np.var(np.abs(ppg_windowed), axis=1)
|
|
922
995
|
signal_to_noise_ratio = arr_signal / arr_noise
|
|
923
|
-
|
|
996
|
+
|
|
924
997
|
return signal_to_noise_ratio
|
|
925
998
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
fs: int
|
|
929
|
-
) -> np.ndarray:
|
|
999
|
+
|
|
1000
|
+
def compute_auto_correlation(ppg_windowed: np.ndarray, fs: int) -> np.ndarray:
|
|
930
1001
|
"""
|
|
931
|
-
Compute the biased autocorrelation of the PPG signal. The autocorrelation
|
|
932
|
-
|
|
933
|
-
|
|
1002
|
+
Compute the biased autocorrelation of the PPG signal. The autocorrelation
|
|
1003
|
+
is computed up to 3 seconds. The highest peak value is selected as the
|
|
1004
|
+
autocorrelation value. If no peaks are found, the value is set to 0.
|
|
1005
|
+
The biased autocorrelation is computed using the biased_autocorrelation
|
|
1006
|
+
function. It differs from the unbiased autocorrelation in that the
|
|
1007
|
+
normalization factor is the length of the original signal, and boundary
|
|
1008
|
+
effects are considered. This results in a smoother autocorrelation
|
|
1009
|
+
function.
|
|
1010
|
+
|
|
934
1011
|
Parameters
|
|
935
1012
|
----------
|
|
936
1013
|
ppg_windowed: np.ndarray
|
|
@@ -944,21 +1021,26 @@ def compute_auto_correlation(
|
|
|
944
1021
|
The autocorrelation of the PPG signal.
|
|
945
1022
|
"""
|
|
946
1023
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1024
|
+
# Compute the biased autocorrelation of the PPG signal up to 3 seconds
|
|
1025
|
+
auto_correlations = biased_autocorrelation(ppg_windowed, fs * 3)
|
|
1026
|
+
# Find the peaks of the autocorrelation
|
|
1027
|
+
peaks = [find_peaks(x, height=0.01)[0] for x in auto_correlations]
|
|
1028
|
+
# Sort the peak values in descending order
|
|
1029
|
+
sorted_peak_values = [
|
|
1030
|
+
np.sort(auto_correlations[i, indices])[::-1] for i, indices in enumerate(peaks)
|
|
1031
|
+
]
|
|
1032
|
+
# Get the highest peak value if there are any peaks, otherwise set to 0
|
|
1033
|
+
auto_correlations = [x[0] if len(x) > 0 else 0 for x in sorted_peak_values]
|
|
951
1034
|
|
|
952
1035
|
return np.asarray(auto_correlations)
|
|
953
1036
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
max_lag: int
|
|
957
|
-
) -> np.ndarray:
|
|
1037
|
+
|
|
1038
|
+
def biased_autocorrelation(ppg_windowed: np.ndarray, max_lag: int) -> np.ndarray:
|
|
958
1039
|
"""
|
|
959
|
-
Compute the biased autocorrelation of a signal (similar to
|
|
960
|
-
|
|
961
|
-
|
|
1040
|
+
Compute the biased autocorrelation of a signal (similar to matlab's
|
|
1041
|
+
autocorr function), where the normalization factor is the length of the
|
|
1042
|
+
original signal, and boundary effects are considered.
|
|
1043
|
+
|
|
962
1044
|
Parameters
|
|
963
1045
|
----------
|
|
964
1046
|
ppg_windowed: np.ndarray
|
|
@@ -972,13 +1054,21 @@ def biased_autocorrelation(
|
|
|
972
1054
|
The biased autocorrelation of the PPG signal.
|
|
973
1055
|
|
|
974
1056
|
"""
|
|
975
|
-
zero_mean_ppg = ppg_windowed - np.mean(
|
|
976
|
-
|
|
1057
|
+
zero_mean_ppg = ppg_windowed - np.mean(
|
|
1058
|
+
ppg_windowed, axis=1, keepdims=True
|
|
1059
|
+
) # Remove the mean of the signal to make it zero-mean
|
|
1060
|
+
n_samples = zero_mean_ppg.shape[1]
|
|
977
1061
|
autocorr_values = np.zeros((zero_mean_ppg.shape[0], max_lag + 1))
|
|
978
|
-
|
|
1062
|
+
|
|
979
1063
|
for lag in range(max_lag + 1):
|
|
980
1064
|
# Compute autocorrelation for current lag
|
|
981
|
-
overlapping_points =
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1065
|
+
overlapping_points = (
|
|
1066
|
+
zero_mean_ppg[:, : n_samples - lag] * zero_mean_ppg[:, lag:]
|
|
1067
|
+
)
|
|
1068
|
+
autocorr_values[:, lag] = (
|
|
1069
|
+
np.sum(overlapping_points, axis=1) / n_samples
|
|
1070
|
+
) # Divide by n_samples (biased normalization)
|
|
1071
|
+
|
|
1072
|
+
return (
|
|
1073
|
+
autocorr_values / autocorr_values[:, 0, np.newaxis]
|
|
1074
|
+
) # Normalize the autocorrelation values
|