paradigma 1.0.2__py3-none-any.whl → 1.0.4__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/classification.py +28 -11
- paradigma/config.py +158 -101
- paradigma/constants.py +39 -34
- paradigma/feature_extraction.py +270 -211
- paradigma/pipelines/gait_pipeline.py +286 -190
- paradigma/pipelines/pulse_rate_pipeline.py +202 -133
- paradigma/pipelines/pulse_rate_utils.py +144 -142
- paradigma/pipelines/tremor_pipeline.py +139 -95
- paradigma/preprocessing.py +179 -110
- paradigma/segmenting.py +138 -113
- paradigma/testing.py +359 -172
- paradigma/util.py +171 -80
- {paradigma-1.0.2.dist-info → paradigma-1.0.4.dist-info}/METADATA +39 -36
- paradigma-1.0.4.dist-info/RECORD +23 -0
- {paradigma-1.0.2.dist-info → paradigma-1.0.4.dist-info}/WHEEL +1 -1
- paradigma-1.0.4.dist-info/entry_points.txt +4 -0
- {paradigma-1.0.2.dist-info → paradigma-1.0.4.dist-info/licenses}/LICENSE +0 -1
- paradigma-1.0.2.dist-info/RECORD +0 -22
|
@@ -1,24 +1,37 @@
|
|
|
1
|
-
import
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
2
3
|
import numpy as np
|
|
3
|
-
import os
|
|
4
4
|
import pandas as pd
|
|
5
|
-
from pathlib import Path
|
|
6
5
|
from scipy.signal import welch
|
|
7
6
|
from scipy.signal.windows import hamming, hann
|
|
8
|
-
import tsdf
|
|
9
|
-
from typing import List
|
|
10
7
|
|
|
11
8
|
from paradigma.classification import ClassifierPackage
|
|
12
|
-
from paradigma.constants import DataColumns
|
|
13
9
|
from paradigma.config import PulseRateConfig
|
|
14
|
-
from paradigma.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
from paradigma.constants import DataColumns
|
|
11
|
+
from paradigma.feature_extraction import (
|
|
12
|
+
compute_auto_correlation,
|
|
13
|
+
compute_dominant_frequency,
|
|
14
|
+
compute_relative_power,
|
|
15
|
+
compute_signal_to_noise_ratio,
|
|
16
|
+
compute_spectral_entropy,
|
|
17
|
+
compute_statistics,
|
|
18
|
+
)
|
|
19
|
+
from paradigma.pipelines.pulse_rate_utils import (
|
|
20
|
+
assign_sqa_label,
|
|
21
|
+
extract_pr_from_segment,
|
|
22
|
+
extract_pr_segments,
|
|
23
|
+
)
|
|
24
|
+
from paradigma.segmenting import WindowedDataExtractor, tabulate_windows
|
|
18
25
|
from paradigma.util import aggregate_parameter
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
|
|
28
|
+
def extract_signal_quality_features(
|
|
29
|
+
df_ppg: pd.DataFrame,
|
|
30
|
+
ppg_config: PulseRateConfig,
|
|
31
|
+
df_acc: pd.DataFrame | None = None,
|
|
32
|
+
acc_config: PulseRateConfig | None = None,
|
|
33
|
+
) -> pd.DataFrame:
|
|
34
|
+
"""
|
|
22
35
|
Extract signal quality features from the PPG signal.
|
|
23
36
|
The features are extracted from the temporal and spectral domain of the PPG signal.
|
|
24
37
|
The temporal domain features include variance, mean, median, kurtosis, skewness, signal-to-noise ratio, and autocorrelation.
|
|
@@ -39,62 +52,78 @@ def extract_signal_quality_features(df_ppg: pd.DataFrame, df_acc: pd.DataFrame,
|
|
|
39
52
|
-------
|
|
40
53
|
df_features : pd.DataFrame
|
|
41
54
|
The DataFrame containing the extracted signal quality features.
|
|
42
|
-
|
|
55
|
+
|
|
43
56
|
"""
|
|
44
57
|
# Group sequences of timestamps into windows
|
|
45
|
-
|
|
58
|
+
ppg_windowed_colnames = [ppg_config.time_colname, ppg_config.ppg_colname]
|
|
46
59
|
ppg_windowed = tabulate_windows(
|
|
47
|
-
df=df_ppg,
|
|
48
|
-
columns=
|
|
60
|
+
df=df_ppg,
|
|
61
|
+
columns=ppg_windowed_colnames,
|
|
49
62
|
window_length_s=ppg_config.window_length_s,
|
|
50
63
|
window_step_length_s=ppg_config.window_step_length_s,
|
|
51
|
-
fs=ppg_config.sampling_frequency
|
|
64
|
+
fs=ppg_config.sampling_frequency,
|
|
52
65
|
)
|
|
53
66
|
|
|
54
67
|
# Extract data from the windowed PPG signal
|
|
55
|
-
extractor = WindowedDataExtractor(
|
|
56
|
-
idx_time = extractor.get_index(
|
|
68
|
+
extractor = WindowedDataExtractor(ppg_windowed_colnames)
|
|
69
|
+
idx_time = extractor.get_index(ppg_config.time_colname)
|
|
57
70
|
idx_ppg = extractor.get_index(ppg_config.ppg_colname)
|
|
58
|
-
start_time_ppg = np.min(
|
|
71
|
+
start_time_ppg = np.min(
|
|
72
|
+
ppg_windowed[:, :, idx_time], axis=1
|
|
73
|
+
) # Start time of the window is relative to the first datapoint in the PPG data
|
|
59
74
|
ppg_values_windowed = ppg_windowed[:, :, idx_ppg]
|
|
60
75
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
76
|
+
df_features = pd.DataFrame(start_time_ppg, columns=[ppg_config.time_colname])
|
|
77
|
+
|
|
78
|
+
if df_acc is not None and acc_config is not None:
|
|
79
|
+
|
|
80
|
+
acc_windowed_colnames = [
|
|
81
|
+
acc_config.time_colname
|
|
82
|
+
] + acc_config.accelerometer_colnames
|
|
83
|
+
acc_windowed = tabulate_windows(
|
|
84
|
+
df=df_acc,
|
|
85
|
+
columns=acc_windowed_colnames,
|
|
86
|
+
window_length_s=acc_config.window_length_s,
|
|
87
|
+
window_step_length_s=acc_config.window_step_length_s,
|
|
88
|
+
fs=acc_config.sampling_frequency,
|
|
89
|
+
)
|
|
69
90
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
# Extract data from the windowed accelerometer signal
|
|
92
|
+
extractor = WindowedDataExtractor(acc_windowed_colnames)
|
|
93
|
+
idx_acc = extractor.get_slice(acc_config.accelerometer_colnames)
|
|
94
|
+
acc_values_windowed = acc_windowed[:, :, idx_acc]
|
|
95
|
+
|
|
96
|
+
# Compute periodicity feature of the accelerometer signal
|
|
97
|
+
df_accelerometer_feature = extract_accelerometer_feature(
|
|
98
|
+
acc_values_windowed, ppg_values_windowed, acc_config
|
|
99
|
+
)
|
|
100
|
+
# Combine the accelerometer feature with the previously computed features
|
|
101
|
+
df_features = pd.concat([df_features, df_accelerometer_feature], axis=1)
|
|
74
102
|
|
|
75
|
-
df_features = pd.DataFrame(start_time_ppg, columns=[DataColumns.TIME])
|
|
76
103
|
# Compute features of the temporal domain of the PPG signal
|
|
77
|
-
df_temporal_features = extract_temporal_domain_features(
|
|
78
|
-
|
|
104
|
+
df_temporal_features = extract_temporal_domain_features(
|
|
105
|
+
ppg_values_windowed,
|
|
106
|
+
ppg_config,
|
|
107
|
+
quality_stats=["var", "mean", "median", "kurtosis", "skewness"],
|
|
108
|
+
)
|
|
109
|
+
|
|
79
110
|
# Combine temporal features with the start time
|
|
80
111
|
df_features = pd.concat([df_features, df_temporal_features], axis=1)
|
|
81
112
|
|
|
82
113
|
# Compute features of the spectral domain of the PPG signal
|
|
83
|
-
df_spectral_features = extract_spectral_domain_features(
|
|
114
|
+
df_spectral_features = extract_spectral_domain_features(
|
|
115
|
+
ppg_values_windowed, ppg_config
|
|
116
|
+
)
|
|
84
117
|
|
|
85
118
|
# Combine the spectral features with the previously computed temporal features
|
|
86
119
|
df_features = pd.concat([df_features, df_spectral_features], axis=1)
|
|
87
|
-
|
|
88
|
-
# Compute periodicity feature of the accelerometer signal
|
|
89
|
-
df_accelerometer_feature = extract_accelerometer_feature(acc_values_windowed, ppg_values_windowed, acc_config)
|
|
90
|
-
|
|
91
|
-
# Combine the accelerometer feature with the previously computed features
|
|
92
|
-
df_features = pd.concat([df_features, df_accelerometer_feature], axis=1)
|
|
93
120
|
|
|
94
121
|
return df_features
|
|
95
122
|
|
|
96
123
|
|
|
97
|
-
def signal_quality_classification(
|
|
124
|
+
def signal_quality_classification(
|
|
125
|
+
df: pd.DataFrame, config: PulseRateConfig, clf_package: ClassifierPackage
|
|
126
|
+
) -> pd.DataFrame:
|
|
98
127
|
"""
|
|
99
128
|
Classify the signal quality of the PPG signal using a logistic regression classifier. A probability close to 1 indicates a high-quality signal, while a probability close to 0 indicates a low-quality signal.
|
|
100
129
|
The classifier is trained on features extracted from the PPG signal. The features are extracted using the extract_signal_quality_features function.
|
|
@@ -107,28 +136,40 @@ def signal_quality_classification(df: pd.DataFrame, config: PulseRateConfig, ful
|
|
|
107
136
|
The DataFrame containing the PPG features and the accelerometer feature for signal quality classification.
|
|
108
137
|
config : PulseRateConfig
|
|
109
138
|
The configuration for the signal quality classification.
|
|
110
|
-
|
|
111
|
-
The
|
|
139
|
+
clf_package : ClassifierPackage
|
|
140
|
+
The classifier package containing the classifier and scaler.
|
|
112
141
|
|
|
113
142
|
Returns
|
|
114
143
|
-------
|
|
115
144
|
df_sqa pd.DataFrame
|
|
116
145
|
The DataFrame containing the PPG signal quality predictions (both probabilities of the PPG signal quality classification and the accelerometer label based on the threshold).
|
|
117
146
|
"""
|
|
118
|
-
|
|
147
|
+
# Set classifier
|
|
119
148
|
clf = clf_package.classifier # Load the logistic regression classifier
|
|
120
149
|
|
|
121
150
|
# Apply scaling to relevant columns
|
|
122
|
-
scaled_features = clf_package.transform_features(
|
|
151
|
+
scaled_features = clf_package.transform_features(
|
|
152
|
+
df.loc[:, clf.feature_names_in]
|
|
153
|
+
) # Apply scaling to the features
|
|
123
154
|
|
|
124
155
|
# Make predictions for PPG signal quality assessment, and assign the probabilities to the DataFrame and drop the features
|
|
125
156
|
df[DataColumns.PRED_SQA_PROBA] = clf.predict_proba(scaled_features)[:, 0]
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return df[[DataColumns.TIME, DataColumns.PRED_SQA_PROBA, DataColumns.PRED_SQA_ACC_LABEL]] # Return only the relevant columns, namely the predicted probabilities for the PPG signal quality and the accelerometer label
|
|
157
|
+
keep_cols = [config.time_colname, DataColumns.PRED_SQA_PROBA]
|
|
129
158
|
|
|
159
|
+
if DataColumns.ACC_POWER_RATIO in df.columns:
|
|
160
|
+
df[DataColumns.PRED_SQA_ACC_LABEL] = (
|
|
161
|
+
df[DataColumns.ACC_POWER_RATIO] < config.threshold_sqa_accelerometer
|
|
162
|
+
).astype(
|
|
163
|
+
int
|
|
164
|
+
) # Assign accelerometer label to the DataFrame based on the threshold
|
|
165
|
+
keep_cols += [DataColumns.PRED_SQA_ACC_LABEL]
|
|
130
166
|
|
|
131
|
-
|
|
167
|
+
return df[keep_cols]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def estimate_pulse_rate(
|
|
171
|
+
df_sqa: pd.DataFrame, df_ppg_preprocessed: pd.DataFrame, config: PulseRateConfig
|
|
172
|
+
) -> pd.DataFrame:
|
|
132
173
|
"""
|
|
133
174
|
Estimate the pulse rate from the PPG signal using the time-frequency domain method.
|
|
134
175
|
|
|
@@ -149,37 +190,58 @@ def estimate_pulse_rate(df_sqa: pd.DataFrame, df_ppg_preprocessed: pd.DataFrame,
|
|
|
149
190
|
|
|
150
191
|
# Extract NumPy arrays for faster operations
|
|
151
192
|
ppg_post_prob = df_sqa[DataColumns.PRED_SQA_PROBA].to_numpy()
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
193
|
+
|
|
194
|
+
if DataColumns.PRED_SQA_ACC_LABEL in df_sqa.columns:
|
|
195
|
+
acc_label = df_sqa[DataColumns.PRED_SQA_ACC_LABEL].to_numpy()
|
|
196
|
+
else:
|
|
197
|
+
acc_label = None
|
|
198
|
+
|
|
199
|
+
ppg_preprocessed = df_ppg_preprocessed.values
|
|
200
|
+
time_idx = df_ppg_preprocessed.columns.get_loc(
|
|
201
|
+
config.time_colname
|
|
202
|
+
) # Get the index of the time column
|
|
203
|
+
ppg_idx = df_ppg_preprocessed.columns.get_loc(
|
|
204
|
+
config.ppg_colname
|
|
205
|
+
) # Get the index of the PPG column
|
|
206
|
+
|
|
157
207
|
# Assign window-level probabilities to individual samples
|
|
158
|
-
sqa_label = assign_sqa_label(
|
|
159
|
-
|
|
160
|
-
|
|
208
|
+
sqa_label = assign_sqa_label(
|
|
209
|
+
ppg_post_prob, config, acc_label
|
|
210
|
+
) # assigns a signal quality label to every individual data point
|
|
211
|
+
v_start_idx, v_end_idx = extract_pr_segments(
|
|
212
|
+
sqa_label, config.min_pr_samples
|
|
213
|
+
) # extracts pulse rate segments based on the SQA label
|
|
214
|
+
|
|
161
215
|
v_pr_rel = np.array([])
|
|
162
216
|
t_pr_rel = np.array([])
|
|
163
217
|
|
|
164
|
-
edge_add =
|
|
218
|
+
edge_add = (
|
|
219
|
+
2 * config.sampling_frequency
|
|
220
|
+
) # Add 2s on both sides of the segment for PR estimation
|
|
165
221
|
step_size = config.pr_est_samples # Step size for PR estimation
|
|
166
222
|
|
|
167
223
|
# Estimate the maximum size for preallocation
|
|
168
|
-
valid_segments = (v_start_idx >= edge_add) & (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
224
|
+
valid_segments = (v_start_idx >= edge_add) & (
|
|
225
|
+
v_end_idx <= len(ppg_preprocessed) - edge_add
|
|
226
|
+
) # check if the segments are valid, e.g. not too close to the edges (2s)
|
|
227
|
+
valid_start_idx = v_start_idx[valid_segments] # get the valid start indices
|
|
228
|
+
valid_end_idx = v_end_idx[valid_segments] # get the valid end indices
|
|
229
|
+
max_size = np.sum(
|
|
230
|
+
(valid_end_idx - valid_start_idx) // step_size
|
|
231
|
+
) # maximum size for preallocation
|
|
232
|
+
|
|
173
233
|
# Preallocate arrays
|
|
174
|
-
v_pr_rel = np.empty(max_size, dtype=float)
|
|
175
|
-
t_pr_rel = np.empty(max_size, dtype=float)
|
|
234
|
+
v_pr_rel = np.empty(max_size, dtype=float)
|
|
235
|
+
t_pr_rel = np.empty(max_size, dtype=float)
|
|
176
236
|
|
|
177
237
|
# Track current position
|
|
178
238
|
pr_pos = 0
|
|
179
239
|
|
|
180
240
|
for start_idx, end_idx in zip(valid_start_idx, valid_end_idx):
|
|
181
241
|
# Extract extended PPG segment
|
|
182
|
-
extended_ppg_segment = ppg_preprocessed[
|
|
242
|
+
extended_ppg_segment = ppg_preprocessed[
|
|
243
|
+
start_idx - edge_add : end_idx + edge_add, ppg_idx
|
|
244
|
+
]
|
|
183
245
|
|
|
184
246
|
# Estimate pulse rate
|
|
185
247
|
pr_est = extract_pr_from_segment(
|
|
@@ -190,14 +252,16 @@ def estimate_pulse_rate(df_sqa: pd.DataFrame, df_ppg_preprocessed: pd.DataFrame,
|
|
|
190
252
|
config.kern_params,
|
|
191
253
|
)
|
|
192
254
|
n_pr = len(pr_est) # Number of pulse rate estimates
|
|
193
|
-
end_idx_time =
|
|
255
|
+
end_idx_time = (
|
|
256
|
+
n_pr * step_size + start_idx
|
|
257
|
+
) # Calculate end index for time, different from end_idx since it is always a multiple of step_size, while end_idx is not
|
|
194
258
|
|
|
195
259
|
# Extract relative time for PR estimates
|
|
196
|
-
pr_time = ppg_preprocessed[start_idx
|
|
260
|
+
pr_time = ppg_preprocessed[start_idx:end_idx_time:step_size, time_idx]
|
|
197
261
|
|
|
198
262
|
# Insert into preallocated arrays
|
|
199
|
-
v_pr_rel[pr_pos:pr_pos + n_pr] = pr_est
|
|
200
|
-
t_pr_rel[pr_pos:pr_pos + n_pr] = pr_time
|
|
263
|
+
v_pr_rel[pr_pos : pr_pos + n_pr] = pr_est
|
|
264
|
+
t_pr_rel[pr_pos : pr_pos + n_pr] = pr_time
|
|
201
265
|
pr_pos += n_pr
|
|
202
266
|
|
|
203
267
|
df_pr = pd.DataFrame({"time": t_pr_rel, "pulse_rate": v_pr_rel})
|
|
@@ -205,7 +269,9 @@ def estimate_pulse_rate(df_sqa: pd.DataFrame, df_ppg_preprocessed: pd.DataFrame,
|
|
|
205
269
|
return df_pr
|
|
206
270
|
|
|
207
271
|
|
|
208
|
-
def aggregate_pulse_rate(
|
|
272
|
+
def aggregate_pulse_rate(
|
|
273
|
+
pr_values: np.ndarray, aggregates: List[str] = ["mode", "99p"]
|
|
274
|
+
) -> dict:
|
|
209
275
|
"""
|
|
210
276
|
Aggregate the pulse rate estimates using the specified aggregation methods.
|
|
211
277
|
|
|
@@ -226,22 +292,22 @@ def aggregate_pulse_rate(pr_values: np.ndarray, aggregates: List[str] = ['mode',
|
|
|
226
292
|
|
|
227
293
|
# Initialize the dictionary for the aggregated results with the metadata
|
|
228
294
|
aggregated_results = {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
'pr_aggregates': {}
|
|
233
|
-
}
|
|
295
|
+
"metadata": {"nr_pr_est": len(pr_values)},
|
|
296
|
+
"pr_aggregates": {},
|
|
297
|
+
}
|
|
234
298
|
for aggregate in aggregates:
|
|
235
|
-
aggregated_results[
|
|
299
|
+
aggregated_results["pr_aggregates"][f"{aggregate}_{DataColumns.PULSE_RATE}"] = (
|
|
300
|
+
aggregate_parameter(pr_values, aggregate)
|
|
301
|
+
)
|
|
236
302
|
|
|
237
303
|
return aggregated_results
|
|
238
304
|
|
|
239
305
|
|
|
240
306
|
def extract_temporal_domain_features(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
307
|
+
ppg_windowed: np.ndarray,
|
|
308
|
+
config: PulseRateConfig,
|
|
309
|
+
quality_stats: List[str] = ["mean", "std"],
|
|
310
|
+
) -> pd.DataFrame:
|
|
245
311
|
"""
|
|
246
312
|
Compute temporal domain features for the ppg signal. The features are added to the dataframe. Therefore the original dataframe is modified, and the modified dataframe is returned.
|
|
247
313
|
|
|
@@ -255,29 +321,31 @@ def extract_temporal_domain_features(
|
|
|
255
321
|
|
|
256
322
|
quality_stats: list, optional
|
|
257
323
|
The statistics to be computed for the gravity component of the accelerometer signal (default: ['mean', 'std'])
|
|
258
|
-
|
|
324
|
+
|
|
259
325
|
Returns
|
|
260
326
|
-------
|
|
261
327
|
pd.DataFrame
|
|
262
328
|
The dataframe with the added temporal domain features.
|
|
263
329
|
"""
|
|
264
|
-
|
|
330
|
+
|
|
265
331
|
feature_dict = {}
|
|
266
332
|
for stat in quality_stats:
|
|
267
333
|
feature_dict[stat] = compute_statistics(ppg_windowed, stat, abs_stats=True)
|
|
268
|
-
|
|
269
|
-
feature_dict[
|
|
270
|
-
feature_dict[
|
|
334
|
+
|
|
335
|
+
feature_dict["signal_to_noise"] = compute_signal_to_noise_ratio(ppg_windowed)
|
|
336
|
+
feature_dict["auto_corr"] = compute_auto_correlation(
|
|
337
|
+
ppg_windowed, config.sampling_frequency
|
|
338
|
+
)
|
|
271
339
|
return pd.DataFrame(feature_dict)
|
|
272
340
|
|
|
273
341
|
|
|
274
342
|
def extract_spectral_domain_features(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
343
|
+
ppg_windowed: np.ndarray,
|
|
344
|
+
config: PulseRateConfig,
|
|
345
|
+
) -> pd.DataFrame:
|
|
278
346
|
"""
|
|
279
347
|
Calculate the spectral features (dominant frequency, relative power, and spectral entropy)
|
|
280
|
-
for each segment of a PPG signal using a single Welch's method computation. The features are added to the dataframe.
|
|
348
|
+
for each segment of a PPG signal using a single Welch's method computation. The features are added to the dataframe.
|
|
281
349
|
Therefore the original dataframe is modified, and the modified dataframe is returned.
|
|
282
350
|
|
|
283
351
|
Parameters
|
|
@@ -295,7 +363,7 @@ def extract_spectral_domain_features(
|
|
|
295
363
|
"""
|
|
296
364
|
d_features = {}
|
|
297
365
|
|
|
298
|
-
window = hamming(config.window_length_welch, sym
|
|
366
|
+
window = hamming(config.window_length_welch, sym=True)
|
|
299
367
|
|
|
300
368
|
n_samples_window = ppg_windowed.shape[1]
|
|
301
369
|
|
|
@@ -306,23 +374,20 @@ def extract_spectral_domain_features(
|
|
|
306
374
|
noverlap=config.overlap_welch_window,
|
|
307
375
|
nfft=max(256, 2 ** int(np.log2(n_samples_window))),
|
|
308
376
|
detrend=False,
|
|
309
|
-
axis=1
|
|
377
|
+
axis=1,
|
|
310
378
|
)
|
|
311
379
|
|
|
312
380
|
# Calculate each feature using the computed PSD and frequency array
|
|
313
|
-
d_features[
|
|
314
|
-
d_features[
|
|
315
|
-
d_features[
|
|
381
|
+
d_features["f_dom"] = compute_dominant_frequency(freqs, psd)
|
|
382
|
+
d_features["rel_power"] = compute_relative_power(freqs, psd, config)
|
|
383
|
+
d_features["spectral_entropy"] = compute_spectral_entropy(psd, n_samples_window)
|
|
316
384
|
|
|
317
385
|
return pd.DataFrame(d_features)
|
|
318
386
|
|
|
319
387
|
|
|
320
388
|
def extract_acc_power_feature(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
f2: np.ndarray,
|
|
324
|
-
PSD_ppg: np.ndarray
|
|
325
|
-
) -> np.ndarray:
|
|
389
|
+
f1: np.ndarray, PSD_acc: np.ndarray, f2: np.ndarray, PSD_ppg: np.ndarray
|
|
390
|
+
) -> np.ndarray:
|
|
326
391
|
"""
|
|
327
392
|
Extract the accelerometer power feature in the PPG frequency range.
|
|
328
393
|
|
|
@@ -342,48 +407,52 @@ def extract_acc_power_feature(
|
|
|
342
407
|
np.ndarray
|
|
343
408
|
The accelerometer power feature in the PPG frequency range
|
|
344
409
|
"""
|
|
345
|
-
|
|
410
|
+
|
|
346
411
|
# Find the index of the maximum PSD value in the PPG signal
|
|
347
412
|
max_PPG_psd_idx = np.argmax(PSD_ppg, axis=1)
|
|
348
413
|
max_PPG_freq_psd = f2[max_PPG_psd_idx]
|
|
349
|
-
|
|
414
|
+
|
|
350
415
|
# Find the neighboring indices of the maximum PSD value in the PPG signal
|
|
351
|
-
df_idx = np.column_stack(
|
|
352
|
-
|
|
416
|
+
df_idx = np.column_stack(
|
|
417
|
+
(max_PPG_psd_idx - 1, max_PPG_psd_idx, max_PPG_psd_idx + 1)
|
|
418
|
+
)
|
|
419
|
+
|
|
353
420
|
# Find the index of the closest frequency in the accelerometer signal to the first harmonic of the PPG frequency
|
|
354
|
-
corr_acc_psd_fh_idx = np.argmin(np.abs(f1[:, None] - max_PPG_freq_psd*2), axis=0)
|
|
355
|
-
fh_idx = np.column_stack(
|
|
356
|
-
|
|
421
|
+
corr_acc_psd_fh_idx = np.argmin(np.abs(f1[:, None] - max_PPG_freq_psd * 2), axis=0)
|
|
422
|
+
fh_idx = np.column_stack(
|
|
423
|
+
(corr_acc_psd_fh_idx - 1, corr_acc_psd_fh_idx, corr_acc_psd_fh_idx + 1)
|
|
424
|
+
)
|
|
425
|
+
|
|
357
426
|
# Compute the power in the ranges corresponding to the PPG frequency
|
|
358
|
-
acc_power_PPG_range = (
|
|
359
|
-
|
|
360
|
-
|
|
427
|
+
acc_power_PPG_range = np.trapz(
|
|
428
|
+
PSD_acc[np.arange(PSD_acc.shape[0])[:, None], df_idx], f1[df_idx], axis=1
|
|
429
|
+
) + np.trapz(
|
|
430
|
+
PSD_acc[np.arange(PSD_acc.shape[0])[:, None], fh_idx], f1[fh_idx], axis=1
|
|
361
431
|
)
|
|
362
432
|
|
|
363
433
|
# Compute the total power across the entire frequency range
|
|
364
434
|
acc_power_total = np.trapz(PSD_acc, f1)
|
|
365
|
-
|
|
435
|
+
|
|
366
436
|
# Compute the power ratio of the accelerometer signal in the PPG frequency range
|
|
367
437
|
acc_power_ratio = acc_power_PPG_range / acc_power_total
|
|
368
|
-
|
|
438
|
+
|
|
369
439
|
return acc_power_ratio
|
|
370
440
|
|
|
441
|
+
|
|
371
442
|
def extract_accelerometer_feature(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
config: PulseRateConfig
|
|
375
|
-
) -> pd.DataFrame:
|
|
443
|
+
acc_windowed: np.ndarray, ppg_windowed: np.ndarray, config: PulseRateConfig
|
|
444
|
+
) -> pd.DataFrame:
|
|
376
445
|
"""
|
|
377
446
|
Extract accelerometer features from the accelerometer signal in the PPG frequency range.
|
|
378
|
-
|
|
447
|
+
|
|
379
448
|
Parameters
|
|
380
|
-
----------
|
|
449
|
+
----------
|
|
381
450
|
acc_windowed: np.ndarray
|
|
382
451
|
The dataframe containing the windowed accelerometer signal
|
|
383
452
|
|
|
384
453
|
ppg_windowed: np.ndarray
|
|
385
454
|
The dataframe containing the corresponding windowed ppg signal
|
|
386
|
-
|
|
455
|
+
|
|
387
456
|
config: PulseRateConfig
|
|
388
457
|
The configuration object containing the parameters for the feature extraction
|
|
389
458
|
|
|
@@ -392,21 +461,21 @@ def extract_accelerometer_feature(
|
|
|
392
461
|
pd.DataFrame
|
|
393
462
|
The dataframe with the relative power accelerometer feature.
|
|
394
463
|
"""
|
|
395
|
-
|
|
396
|
-
if config.sensor not in [
|
|
464
|
+
|
|
465
|
+
if config.sensor not in ["imu", "ppg"]:
|
|
397
466
|
raise ValueError("Sensor not recognized.")
|
|
398
|
-
|
|
467
|
+
|
|
399
468
|
d_freq = {}
|
|
400
469
|
d_psd = {}
|
|
401
|
-
for sensor in [
|
|
470
|
+
for sensor in ["imu", "ppg"]:
|
|
402
471
|
config.set_sensor(sensor)
|
|
403
472
|
|
|
404
|
-
if sensor ==
|
|
473
|
+
if sensor == "imu":
|
|
405
474
|
windows = acc_windowed
|
|
406
475
|
else:
|
|
407
476
|
windows = ppg_windowed
|
|
408
477
|
|
|
409
|
-
window_type = hann(config.window_length_welch, sym
|
|
478
|
+
window_type = hann(config.window_length_welch, sym=True)
|
|
410
479
|
d_freq[sensor], d_psd[sensor] = welch(
|
|
411
480
|
windows,
|
|
412
481
|
fs=config.sampling_frequency,
|
|
@@ -414,13 +483,13 @@ def extract_accelerometer_feature(
|
|
|
414
483
|
noverlap=config.overlap_welch_window,
|
|
415
484
|
nfft=config.nfft,
|
|
416
485
|
detrend=False,
|
|
417
|
-
axis=1
|
|
486
|
+
axis=1,
|
|
418
487
|
)
|
|
419
488
|
|
|
420
|
-
d_psd[
|
|
421
|
-
|
|
422
|
-
acc_power_ratio = extract_acc_power_feature(d_freq['imu'], d_psd['imu'], d_freq['ppg'], d_psd['ppg'])
|
|
423
|
-
|
|
424
|
-
return pd.DataFrame(acc_power_ratio, columns=['acc_power_ratio'])
|
|
489
|
+
d_psd["imu"] = np.sum(d_psd["imu"], axis=2) # Sum the PSDs of the three axes
|
|
425
490
|
|
|
491
|
+
acc_power_ratio = extract_acc_power_feature(
|
|
492
|
+
d_freq["imu"], d_psd["imu"], d_freq["ppg"], d_psd["ppg"]
|
|
493
|
+
)
|
|
426
494
|
|
|
495
|
+
return pd.DataFrame(acc_power_ratio, columns=["acc_power_ratio"])
|