paradigma 0.3.1__py3-none-any.whl → 0.4.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.
Files changed (122) hide show
  1. paradigma/assets/gait_detection_clf_package.pkl +0 -0
  2. paradigma/assets/gait_filtering_clf_package.pkl +0 -0
  3. paradigma/assets/ppg_quality_clf_package.pkl +0 -0
  4. paradigma/assets/tremor_detection_clf_package.pkl +0 -0
  5. paradigma/classification.py +115 -0
  6. paradigma/config.py +314 -0
  7. paradigma/constants.py +48 -7
  8. paradigma/feature_extraction.py +811 -547
  9. paradigma/pipelines/__init__.py +0 -0
  10. paradigma/pipelines/gait_pipeline.py +727 -0
  11. paradigma/pipelines/heart_rate_pipeline.py +426 -0
  12. paradigma/pipelines/heart_rate_utils.py +780 -0
  13. paradigma/pipelines/tremor_pipeline.py +299 -0
  14. paradigma/preprocessing.py +363 -0
  15. paradigma/segmenting.py +396 -0
  16. paradigma/testing.py +416 -0
  17. paradigma/util.py +393 -16
  18. {paradigma-0.3.1.dist-info → paradigma-0.4.0.dist-info}/METADATA +58 -14
  19. paradigma-0.4.0.dist-info/RECORD +22 -0
  20. {paradigma-0.3.1.dist-info → paradigma-0.4.0.dist-info}/WHEEL +1 -1
  21. paradigma/gait_analysis.py +0 -415
  22. paradigma/gait_analysis_config.py +0 -266
  23. paradigma/heart_rate_analysis.py +0 -127
  24. paradigma/heart_rate_analysis_config.py +0 -9
  25. paradigma/heart_rate_util.py +0 -173
  26. paradigma/imu_preprocessing.py +0 -232
  27. paradigma/ppg/classifier/LR_PPG_quality.pkl +0 -0
  28. paradigma/ppg/classifier/LR_model.mat +0 -0
  29. paradigma/ppg/feat_extraction/acc_feature.m +0 -20
  30. paradigma/ppg/feat_extraction/peakdet.m +0 -64
  31. paradigma/ppg/feat_extraction/ppg_features.m +0 -53
  32. paradigma/ppg/glob_functions/extract_hr_segments.m +0 -37
  33. paradigma/ppg/glob_functions/extract_overlapping_segments.m +0 -23
  34. paradigma/ppg/glob_functions/jsonlab/AUTHORS.txt +0 -41
  35. paradigma/ppg/glob_functions/jsonlab/ChangeLog.txt +0 -74
  36. paradigma/ppg/glob_functions/jsonlab/LICENSE_BSD.txt +0 -25
  37. paradigma/ppg/glob_functions/jsonlab/LICENSE_GPLv3.txt +0 -699
  38. paradigma/ppg/glob_functions/jsonlab/README.txt +0 -394
  39. paradigma/ppg/glob_functions/jsonlab/examples/.svn/entries +0 -368
  40. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/demo_jsonlab_basic.m.svn-base +0 -180
  41. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/demo_ubjson_basic.m.svn-base +0 -180
  42. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/example1.json.svn-base +0 -23
  43. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/example2.json.svn-base +0 -22
  44. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/example3.json.svn-base +0 -11
  45. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/example4.json.svn-base +0 -34
  46. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/jsonlab_basictest.matlab.svn-base +0 -662
  47. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/jsonlab_selftest.m.svn-base +0 -27
  48. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/jsonlab_selftest.matlab.svn-base +0 -144
  49. paradigma/ppg/glob_functions/jsonlab/examples/.svn/text-base/jsonlab_speedtest.m.svn-base +0 -21
  50. paradigma/ppg/glob_functions/jsonlab/examples/demo_jsonlab_basic.m +0 -180
  51. paradigma/ppg/glob_functions/jsonlab/examples/demo_ubjson_basic.m +0 -180
  52. paradigma/ppg/glob_functions/jsonlab/examples/example1.json +0 -23
  53. paradigma/ppg/glob_functions/jsonlab/examples/example2.json +0 -22
  54. paradigma/ppg/glob_functions/jsonlab/examples/example3.json +0 -11
  55. paradigma/ppg/glob_functions/jsonlab/examples/example4.json +0 -34
  56. paradigma/ppg/glob_functions/jsonlab/examples/jsonlab_basictest.matlab +0 -662
  57. paradigma/ppg/glob_functions/jsonlab/examples/jsonlab_selftest.m +0 -27
  58. paradigma/ppg/glob_functions/jsonlab/examples/jsonlab_selftest.matlab +0 -144
  59. paradigma/ppg/glob_functions/jsonlab/examples/jsonlab_speedtest.m +0 -21
  60. paradigma/ppg/glob_functions/jsonlab/jsonopt.m +0 -32
  61. paradigma/ppg/glob_functions/jsonlab/loadjson.m +0 -566
  62. paradigma/ppg/glob_functions/jsonlab/loadubjson.m +0 -528
  63. paradigma/ppg/glob_functions/jsonlab/mergestruct.m +0 -33
  64. paradigma/ppg/glob_functions/jsonlab/savejson.m +0 -475
  65. paradigma/ppg/glob_functions/jsonlab/saveubjson.m +0 -504
  66. paradigma/ppg/glob_functions/jsonlab/varargin2struct.m +0 -40
  67. paradigma/ppg/glob_functions/sample_prob_final.m +0 -49
  68. paradigma/ppg/glob_functions/synchronization.m +0 -76
  69. paradigma/ppg/glob_functions/tsdf_scan_meta.m +0 -22
  70. paradigma/ppg/hr_functions/Long_TFD_JOT.m +0 -37
  71. paradigma/ppg/hr_functions/PPG_TFD_HR.m +0 -59
  72. paradigma/ppg/hr_functions/TFD toolbox JOT/.gitignore +0 -4
  73. paradigma/ppg/hr_functions/TFD toolbox JOT/CHANGELOG.md +0 -23
  74. paradigma/ppg/hr_functions/TFD toolbox JOT/LICENCE.md +0 -27
  75. paradigma/ppg/hr_functions/TFD toolbox JOT/README.md +0 -251
  76. paradigma/ppg/hr_functions/TFD toolbox JOT/README.pdf +0 -0
  77. paradigma/ppg/hr_functions/TFD toolbox JOT/common/gen_Doppler_kern.m +0 -142
  78. paradigma/ppg/hr_functions/TFD toolbox JOT/common/gen_Doppler_lag_kern.m +0 -314
  79. paradigma/ppg/hr_functions/TFD toolbox JOT/common/gen_lag_kern.m +0 -123
  80. paradigma/ppg/hr_functions/TFD toolbox JOT/dec_tfd.m +0 -154
  81. paradigma/ppg/hr_functions/TFD toolbox JOT/decimated_TFDs/dec_di_gdtfd.m +0 -194
  82. paradigma/ppg/hr_functions/TFD toolbox JOT/decimated_TFDs/dec_li_gdtfd.m +0 -200
  83. paradigma/ppg/hr_functions/TFD toolbox JOT/decimated_TFDs/dec_nonsep_gdtfd.m +0 -229
  84. paradigma/ppg/hr_functions/TFD toolbox JOT/decimated_TFDs/dec_sep_gdtfd.m +0 -241
  85. paradigma/ppg/hr_functions/TFD toolbox JOT/full_TFDs/di_gdtfd.m +0 -157
  86. paradigma/ppg/hr_functions/TFD toolbox JOT/full_TFDs/li_gdtfd.m +0 -190
  87. paradigma/ppg/hr_functions/TFD toolbox JOT/full_TFDs/nonsep_gdtfd.m +0 -196
  88. paradigma/ppg/hr_functions/TFD toolbox JOT/full_TFDs/sep_gdtfd.m +0 -199
  89. paradigma/ppg/hr_functions/TFD toolbox JOT/full_tfd.m +0 -144
  90. paradigma/ppg/hr_functions/TFD toolbox JOT/load_curdir.m +0 -13
  91. paradigma/ppg/hr_functions/TFD toolbox JOT/pics/decimated_TFDs_examples.png +0 -0
  92. paradigma/ppg/hr_functions/TFD toolbox JOT/pics/full_TFDs_examples.png +0 -0
  93. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/check_dec_params_seq.m +0 -79
  94. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/dispEE.m +0 -9
  95. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/dispVars.m +0 -26
  96. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/disp_bytes.m +0 -25
  97. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/fold_vector_full.m +0 -40
  98. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/fold_vector_half.m +0 -34
  99. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/gen_LFM.m +0 -29
  100. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/get_analytic_signal.m +0 -76
  101. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/get_window.m +0 -176
  102. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/isreal_fn.m +0 -11
  103. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/padWin.m +0 -97
  104. paradigma/ppg/hr_functions/TFD toolbox JOT/utils/vtfd.m +0 -149
  105. paradigma/ppg/preprocessing/preprocessing_imu.m +0 -15
  106. paradigma/ppg/preprocessing/preprocessing_ppg.m +0 -13
  107. paradigma/ppg_preprocessing.py +0 -313
  108. paradigma/preprocessing_config.py +0 -69
  109. paradigma/quantification.py +0 -58
  110. paradigma/tremor/TremorFeaturesAndClassification.m +0 -345
  111. paradigma/tremor/feat_extraction/DerivativesExtract.m +0 -22
  112. paradigma/tremor/feat_extraction/ExtractBandSignalsRMS.m +0 -72
  113. paradigma/tremor/feat_extraction/MFCCExtract.m +0 -100
  114. paradigma/tremor/feat_extraction/PSDBandPower.m +0 -52
  115. paradigma/tremor/feat_extraction/PSDEst.m +0 -63
  116. paradigma/tremor/feat_extraction/PSDExtrAxis.m +0 -88
  117. paradigma/tremor/feat_extraction/PSDExtrOpt.m +0 -95
  118. paradigma/tremor/preprocessing/InterpData.m +0 -32
  119. paradigma/tremor/weekly_aggregates/WeeklyAggregates.m +0 -295
  120. paradigma/windowing.py +0 -219
  121. paradigma-0.3.1.dist-info/RECORD +0 -108
  122. {paradigma-0.3.1.dist-info → paradigma-0.4.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,780 @@
1
+ import numpy as np
2
+ from scipy import signal
3
+ from typing import Tuple
4
+
5
+ from paradigma.config import HeartRateConfig
6
+
7
+
8
+ def assign_sqa_label(
9
+ ppg_prob: np.ndarray,
10
+ config: HeartRateConfig,
11
+ acc_label=None
12
+ ) -> np.ndarray:
13
+ """
14
+ Assigns a signal quality label to every individual data point.
15
+
16
+ Parameters
17
+ ----------
18
+ ppg_prob : np.ndarray
19
+ The probabilities for PPG.
20
+ config : HeartRateConfig
21
+ The configuration parameters.
22
+ acc_label : np.ndarray, optional
23
+ The labels for the accelerometer.
24
+
25
+ Returns
26
+ -------
27
+ np.ndarray
28
+ The signal quality assessment labels.
29
+ """
30
+ # Default _label to ones if not provided
31
+ if acc_label is None:
32
+ acc_label = np.ones(len(ppg_prob))
33
+
34
+ # Number of samples in an epoch
35
+ fs = config.sampling_frequency
36
+ samples_per_epoch = config.window_length_s * fs
37
+
38
+ # Calculate number of samples to shift for each epoch
39
+ samples_shift = config.window_step_length_s * fs
40
+ n_samples = int(np.round(len(ppg_prob) + config.window_overlap_s) * fs)
41
+ data_prob = np.zeros(n_samples)
42
+ data_label_imu = np.zeros(n_samples, dtype=np.int8)
43
+
44
+ for i in range(n_samples):
45
+ # Start and end indices for current epoch
46
+ start_idx = max(0, int((i - (samples_per_epoch - samples_shift)) // fs)) # max to handle first epochs
47
+ end_idx = min(int(i // fs), len(ppg_prob)) # min to handle last epochs
48
+
49
+ # Extract probabilities and labels for the current epoch
50
+ prob = ppg_prob[start_idx:end_idx+1]
51
+ label_imu = acc_label[start_idx:end_idx+1]
52
+
53
+ # Calculate mean probability and majority voting for labels
54
+ data_prob[i] = np.mean(prob)
55
+ data_label_imu[i] = int(np.mean(label_imu) >= 0.5)
56
+
57
+ # Set probability to zero if majority IMU label is 0
58
+ data_prob[data_label_imu == 0] = 0
59
+ sqa_label = data_prob >= config.threshold_sqa
60
+
61
+ return sqa_label
62
+
63
+
64
+ def extract_hr_segments(sqa_label: np.ndarray, min_hr_samples: int) -> Tuple[np.ndarray, np.ndarray]:
65
+ """
66
+ Extracts heart rate segments based on the SQA label.
67
+
68
+ Parameters
69
+ ----------
70
+ sqa_label : np.ndarray
71
+ The signal quality assessment label.
72
+ min_hr_samples : int
73
+ The minimum number of samples required for a heart rate segment.
74
+
75
+ Returns
76
+ -------
77
+ Tuple[v_start_idx_long, v_end_idx_long]
78
+ The start and end indices of the heart rate segments.
79
+ """
80
+ # Find the start and end indices of the heart rate segments
81
+ v_start_idx = np.where(np.diff(sqa_label.astype(int)) == 1)[0] + 1
82
+ v_end_idx = np.where(np.diff(sqa_label.astype(int)) == -1)[0] + 1
83
+
84
+ # Check if the first segment is a start or end
85
+ if sqa_label[0] == 1:
86
+ v_start_idx = np.insert(v_start_idx, 0, 0)
87
+ if sqa_label[-1] == 1:
88
+ v_end_idx = np.append(v_end_idx, len(sqa_label))
89
+
90
+ # Check if the segments are long enough
91
+ v_start_idx_long = v_start_idx[(v_end_idx - v_start_idx) >= min_hr_samples]
92
+ v_end_idx_long = v_end_idx[(v_end_idx - v_start_idx) >= min_hr_samples]
93
+
94
+ return v_start_idx_long, v_end_idx_long
95
+
96
+
97
+ def extract_hr_from_segment(
98
+ ppg: np.ndarray,
99
+ tfd_length: int,
100
+ fs: int,
101
+ kern_type: str,
102
+ kern_params: dict
103
+ ) -> np.ndarray:
104
+ """
105
+ Extracts heart rate from the time-frequency distribution of the PPG signal.
106
+
107
+ Parameters
108
+ ----------
109
+ ppg : np.ndarray
110
+ The preprocessed PPG segment with 2 seconds of padding on both sides to reduce boundary effects.
111
+ tfd_length : int
112
+ Length of each segment (in seconds) to calculate the time-frequency distribution.
113
+ fs : int
114
+ The sampling frequency of the PPG signal.
115
+ kern_type : str
116
+ Type of TFD kernel to use (e.g., 'wvd' for Wigner-Ville distribution).
117
+ kern_params : dict
118
+ Parameters for the specified kernel. Not required for 'wvd', but relevant for other
119
+ kernels like 'spwvd' or 'swvd'. Default is None.
120
+
121
+ Returns
122
+ -------
123
+ np.ndarray
124
+ The estimated heart rate.
125
+ """
126
+
127
+ # Constants to handle boundary effects
128
+ edge_padding = 4 * fs # Additional 4 seconds (2 seconds on both sides)
129
+ tfd_length = tfd_length * fs # Convert tfd_length to samples
130
+
131
+ # Calculate the actual segment length without padding
132
+ original_segment_length = len(ppg) - edge_padding
133
+
134
+ # Determine the number of tfd_length-second segments
135
+ num_segments = max(1, int(original_segment_length // tfd_length))
136
+
137
+ # Split the PPG signal into segments
138
+ ppg_segments = []
139
+ for i in range(num_segments):
140
+ if i != num_segments - 1: # For all segments except the last
141
+ start_idx = int(i * tfd_length)
142
+ end_idx = int((i + 1) * tfd_length + edge_padding)
143
+ else: # For the last segment
144
+ start_idx = int(i * tfd_length)
145
+ end_idx = len(ppg)
146
+ ppg_segments.append(ppg[start_idx:end_idx])
147
+
148
+ hr_est_from_ppg = np.array([])
149
+ for segment in ppg_segments:
150
+ # Calculate the time-frequency distribution
151
+ hr_tfd = extract_hr_with_tfd(segment, fs, kern_type, kern_params)
152
+ hr_est_from_ppg = np.concatenate((hr_est_from_ppg, hr_tfd))
153
+
154
+ return hr_est_from_ppg
155
+
156
+
157
+ def extract_hr_with_tfd(
158
+ ppg: np.ndarray,
159
+ fs: int,
160
+ kern_type: str,
161
+ kern_params: dict
162
+ ) -> np.ndarray:
163
+ """
164
+ Estimate heart rate (HR) from a PPG segment using a TFD method with optional
165
+ moving average filtering.
166
+
167
+ Parameters
168
+ ----------
169
+ ppg_segment : array-like
170
+ Segment of the PPG signal to process.
171
+ fs : int
172
+ Sampling frequency in Hz.
173
+ kern_type : str
174
+ Type of TFD kernel to use.
175
+ kern_params : dict
176
+ Parameters for the specified kernel.
177
+
178
+ Returns
179
+ -------
180
+ hr_smooth_tfd : np.ndarray
181
+ Estimated HR values (in beats per minute) for each 2-second segment of the PPG signal.
182
+ """
183
+ # Generate the TFD matrix using the specified kernel
184
+ tfd_obj = TimeFreqDistr()
185
+ tfd = tfd_obj.nonsep_gdtfd(ppg, kern_type, kern_params) # Returns an NxN matrix
186
+
187
+ # Get time and frequency axes for the TFD
188
+ num_time_samples, num_freq_bins = tfd.shape
189
+ time_axis = np.arange(num_time_samples) / fs
190
+ freq_axis = np.linspace(0, 0.5, num_freq_bins) * fs
191
+
192
+ # Estimate HR by identifying the max frequency in the TFD
193
+ max_freq_indices = np.argmax(tfd, axis=0)
194
+
195
+ hr_smooth_tfd = np.array([])
196
+ for i in range(2, int(len(ppg) / fs) - 4 + 1, 2): # Skip the first and last 2 seconds, add 1 to include the last segment
197
+ relevant_indices = (time_axis >= i) & (time_axis < i + 2)
198
+ avg_frequency = np.mean(freq_axis[max_freq_indices[relevant_indices]])
199
+ hr_smooth_tfd = np.concatenate((hr_smooth_tfd, [60 * avg_frequency])) # Convert frequency to BPM
200
+
201
+ return hr_smooth_tfd
202
+
203
+
204
+ class TimeFreqDistr:
205
+ def __init__(self):
206
+ """
207
+ This module contains the implementation of the Generalized Time-Frequency Distribution (TFD) computation using non-separable kernels.
208
+ This is a Python implementation of the MATLAB code provided by John O Toole in the following repository: https://github.com/otoolej/memeff_TFDs
209
+
210
+ The following functions are implemented for the computation of the TFD:
211
+ - nonsep_gdtfd: Computes the generalized time-frequency distribution using a non-separable kernel.
212
+ - get_analytic_signal: Generates the analytic signal of the input signal.
213
+ - gen_analytic: Generates the analytic signal by zero-padding and performing FFT.
214
+ - gen_time_lag: Generates the time-lag distribution of the analytic signal.
215
+ - multiply_kernel_signal: Multiplies the TFD by the Doppler-lag kernel.
216
+ - gen_doppler_lag_kern: Generates the Doppler-lag kernel based on kernel type and parameters.
217
+ - get_kern: Gets the kernel based on the provided kernel type.
218
+ - get_window: General function to calculate a window function.
219
+ - get_win: Helper function to create the specified window type.
220
+ - shift_window: Shifts the window so that positive indices appear first.
221
+ - pad_window: Zero-pads the window to a specified length.
222
+ - compute_tfd: Finalizes the time-frequency distribution computation.
223
+ """
224
+ pass
225
+
226
+ def nonsep_gdtfd(
227
+ self,
228
+ x: np.ndarray,
229
+ kern_type: None | str = None,
230
+ kern_params: None | dict = None
231
+ ):
232
+ """
233
+ Computes the generalized time-frequency distribution (TFD) using a non-separable kernel.
234
+
235
+ Parameters:
236
+ -----------
237
+ x : ndarray
238
+ Input signal to be analyzed.
239
+ kern_type : str, optional
240
+ Type of kernel to be used for TFD computation. Default is None.
241
+ Currently supported kernels are:
242
+ wvd - kernel for Wigner-Ville distribution
243
+ swvd - kernel for smoothed Wigner-Ville distribution
244
+ (lag-independent kernel)
245
+ pwvd - kernel for pseudo Wigner-Ville distribution
246
+ (Doppler-independent kernel)
247
+ sep - kernel for separable kernel (combintation of SWVD and PWVD)
248
+
249
+ kern_params : dict, optional
250
+ Dictionary of parameters specific to the kernel type. Default is None.
251
+ The structure of the dictionary depends on the selected kernel type:
252
+ - wvd:
253
+ An empty dictionary, as no additional parameters are required.
254
+ - swvd:
255
+ Dictionary with the following keys:
256
+ 'win_length': Length of the smoothing window.
257
+ 'win_type': Type of window function (e.g., 'hamm', 'hann').
258
+ 'win_param' (optional): Additional parameters for the window.
259
+ 'win_param2' (optional): 0 for time-domain window or 1 for Doppler-domain window.
260
+
261
+ Example:
262
+ ```python
263
+ kern_params = {
264
+ 'win_length': 11,
265
+ 'win_type': 'hamm',
266
+ 'win_param': 0,
267
+ 'domain': 1
268
+ }
269
+ ```
270
+ - pwvd:
271
+ Dictionary with the following keys:
272
+ 'win_length': Length of the smoothing window.
273
+ 'win_type': Type of window function (e.g., 'cosh').
274
+ 'win_param' (optional): Additional parameters for the window.
275
+ 'win_param2' (optional): 0 for time-domain window or 1 for Doppler-domain window.
276
+ Example:
277
+ ```python
278
+ kern_params = {
279
+ 'win_length': 200,
280
+ 'win_type': 'cosh',
281
+ 'win_param': 0.1
282
+ }
283
+ ```
284
+ - sep:
285
+ Dictionary containing two nested dictionaries, one for the Doppler window and one for the lag window:
286
+ 'doppler': {
287
+ 'win_length': Length of the Doppler-domain window.
288
+ 'win_type': Type of Doppler-domain window function.
289
+ 'win_param' (optional): Additional parameters for the Doppler window.
290
+ 'win_param2' (optional): 0 for time-domain window or 1 for Doppler-domain window.
291
+ }
292
+ 'lag': {
293
+ 'win_length': Length of the lag-domain window.
294
+ 'win_type': Type of lag-domain window function.
295
+ 'win_param' (optional): Additional parameters for the lag window.
296
+ 'win_param2' (optional): 0 for time-domain window or 1 for Doppler-domain window.
297
+ }
298
+ Example:
299
+ ```python
300
+ kern_params = {
301
+ 'doppler': {
302
+ 'win_length': doppler_samples,
303
+ 'win_type': win_type_doppler,
304
+ },
305
+ 'lag': {
306
+ 'win_length': lag_samples,
307
+ 'win_type': win_type_lag,
308
+ }
309
+ }
310
+ ```
311
+
312
+ Returns:
313
+ --------
314
+ tfd : ndarray
315
+ The computed time-frequency distribution.
316
+ """
317
+ z = self.get_analytic_signal(x)
318
+ N = len(z) // 2 # Since z is a signal of length 2N
319
+ Nh = int(np.ceil(N / 2))
320
+
321
+ # Generate the time-lag distribution of the analytic signal
322
+ tfd = self.gen_time_lag(z)
323
+
324
+ # Multiply the TFD by the Doppler-lag kernel
325
+ tfd = self.multiply_kernel_signal(tfd, kern_type, kern_params, N, Nh)
326
+
327
+ # Finalize the TFD computation
328
+ tfd = self.compute_tfd(N, Nh, tfd)
329
+
330
+ return tfd
331
+
332
+ def get_analytic_signal(self, x: np.ndarray) -> np.ndarray:
333
+ """
334
+ Generates the signals analytic version.
335
+
336
+ Parameters:
337
+ -----------
338
+ z : ndarray
339
+ Input real-valued signal.
340
+
341
+ Returns:
342
+ --------
343
+ z : ndarray
344
+ Analytic signal with zero-padded imaginary part.
345
+ """
346
+ N = len(x)
347
+
348
+ # Ensure the signal length is even by trimming one sample if odd, since the gen_time_lag function requires an even-length signal
349
+ if N % 2 != 0:
350
+ x = x[:-1]
351
+
352
+ # Make the analytical signal of the real-valued signal z (preprocessed PPG signal)
353
+ # doesn't work for input of complex numbers
354
+ z = self.gen_analytic(x)
355
+
356
+ return z
357
+
358
+ def gen_analytic(self, x: np.ndarray) -> np.ndarray:
359
+ """
360
+ Generates an analytic signal by zero-padding and performing FFT.
361
+
362
+ Parameters:
363
+ -----------
364
+ x : ndarray
365
+ Input real-valued signal.
366
+
367
+ Returns:
368
+ --------
369
+ z : ndarray
370
+ Analytic signal in the time domain with zeroed second half.
371
+ """
372
+ N = len(x)
373
+
374
+ # Zero-pad the signal to double its length
375
+ x = np.concatenate((np.real(x), np.zeros(N)))
376
+ x_fft = np.fft.fft(x)
377
+
378
+ # Generate the analytic signal in the frequency domain
379
+ H = np.empty(2 * N) # Preallocate an array of size 2*N
380
+ H[0] = 1 # First element
381
+ H[1:N] = 2 # Next N-1 elements
382
+ H[N] = 1 # Middle element
383
+ H[N+1:] = 0 # Last N-1 elements
384
+ z_cb = np.fft.ifft(x_fft * H)
385
+
386
+ # Force the second half of the time-domain signal to zero
387
+ z = np.concatenate((z_cb[:N], np.zeros(N)))
388
+
389
+ return z
390
+
391
+ def gen_time_lag(self, z: np.ndarray) -> np.ndarray:
392
+ """
393
+ Generate the time-lag distribution of the analytic signal z.
394
+
395
+ Parameters:
396
+ -----------
397
+ z : ndarray
398
+ Analytic signal of the input signal x.
399
+
400
+ Returns:
401
+ --------
402
+ tfd : ndarray
403
+ Time-lag distribution of the analytic signal z.
404
+
405
+ """
406
+ N = len(z) // 2 # Assuming z is a signal of length 2N
407
+ Nh = int(np.ceil(N / 2))
408
+
409
+ # Initialize the time-frequency distribution (TFD) matrix
410
+ tfd = np.zeros((N, N), dtype=complex)
411
+
412
+ m = np.arange(Nh)
413
+
414
+ # Loop over time indices
415
+ for n in range(N):
416
+ inp = np.mod(n + m, 2 * N)
417
+ inn = np.mod(n - m, 2 * N)
418
+
419
+ # Extract the time slice from the analytic signal
420
+ K_time_slice = z[inp] * np.conj(z[inn])
421
+
422
+ # Store real and imaginary parts
423
+ tfd[n, :Nh] = np.real(K_time_slice)
424
+ tfd[n, Nh:] = np.imag(K_time_slice)
425
+
426
+ return tfd
427
+
428
+ def multiply_kernel_signal(
429
+ self,
430
+ tfd: np.ndarray,
431
+ kern_type: str,
432
+ kern_params: dict,
433
+ N: int,
434
+ Nh: int
435
+ ) -> np.ndarray:
436
+ """
437
+ Multiplies the TFD by the Doppler-lag kernel.
438
+
439
+ Parameters:
440
+ -----------
441
+ tfd : ndarray
442
+ Time-frequency distribution.
443
+ kern_type : str
444
+ Kernel type to be applied.
445
+ kern_params : dict
446
+ Kernel parameters specific to the kernel type.
447
+ N : int
448
+ Length of the signal.
449
+ Nh : int
450
+ Half length of the signal.
451
+
452
+ Returns:
453
+ --------
454
+ tfd : ndarray
455
+ Modified TFD after kernel multiplication.
456
+ """
457
+ # Loop over lag indices
458
+ for m in range(Nh):
459
+ # Generate the Doppler-lag kernel for each lag index
460
+ g_lag_slice = self.gen_doppler_lag_kern(kern_type, kern_params, N, m)
461
+
462
+ # Extract and transform the TFD slice for this lag
463
+ tfd_slice = np.fft.fft(tfd[:, m]) + 1j * np.fft.fft(tfd[:, Nh + m])
464
+
465
+ # Multiply by the kernel and perform inverse FFT
466
+ R_lag_slice = np.fft.ifft(tfd_slice * g_lag_slice)
467
+
468
+ # Store real and imaginary parts back into the TFD
469
+ tfd[:, m] = np.real(R_lag_slice)
470
+ tfd[:, Nh + m] = np.imag(R_lag_slice)
471
+
472
+ return tfd
473
+
474
+ def gen_doppler_lag_kern(
475
+ self,
476
+ kern_type: str,
477
+ kern_params: dict,
478
+ N: int,
479
+ lag_index: int
480
+ ):
481
+ """
482
+ Generate the Doppler-lag kernel based on kernel type and parameters.
483
+
484
+ Parameters:
485
+ -----------
486
+ kern_type : str
487
+ Type of kernel (e.g., 'wvd', 'swvd', 'pwvd', etc.).
488
+ kern_params : dict
489
+ Parameters for the kernel.
490
+ N : int
491
+ Signal length.
492
+ lag_index : int
493
+ Current lag index.
494
+
495
+ Returns:
496
+ --------
497
+ g : ndarray
498
+ Doppler-lag kernel for the given lag.
499
+ """
500
+ g = np.zeros(N, dtype=complex) # Initialize the kernel
501
+
502
+ # Get kernel based on the type
503
+ g = self.get_kern(g, lag_index, kern_type, kern_params, N)
504
+
505
+ return np.real(g) # All kernels are real valued
506
+
507
+ def get_kern(
508
+ self,
509
+ g: np.ndarray,
510
+ lag_index: int,
511
+ kern_type: str,
512
+ kern_params: dict,
513
+ N: int
514
+ ) -> np.ndarray:
515
+ """
516
+ Get the kernel based on the provided kernel type.
517
+
518
+ Parameters:
519
+ -----------
520
+ g : ndarray
521
+ Kernel to be filled.
522
+ lag_index : int
523
+ Lag index for the kernel.
524
+ kern_type : str
525
+ Type of kernel to use (now included: 'wvd', 'swvd', 'pwvd', 'sep').
526
+ kern_params : dict
527
+ Parameters for the specified kernel.
528
+ N : int
529
+ Signal length.
530
+
531
+ Returns:
532
+ --------
533
+ g : ndarray
534
+ Kernel function at the current lag.
535
+ """
536
+ # Validate kern_type
537
+ valid_kern_types = ['wvd', 'sep', 'swvd', 'pwvd'] # List of valid kernel types which are currently supported
538
+ if kern_type not in valid_kern_types:
539
+ raise ValueError(f"Unknown kernel type: {kern_type}. Expected one of {valid_kern_types}")
540
+
541
+ num_params = len(kern_params)
542
+
543
+ if kern_type == 'wvd':
544
+ g[:] = 1 # WVD kernel is the equal to 1 for all lags
545
+
546
+ elif kern_type == 'sep':
547
+ # Separable Kernel
548
+ g1 = np.copy(g) # Create a new array for g1
549
+ g2 = np.copy(g) # Create a new array for g2
550
+
551
+ # Call recursively to obtain g1 and g2 kernels (no in-place modification of g)
552
+ g1 = self.get_kern(g1, lag_index, 'swvd', kern_params['lag'], N) # Generate the first kernel
553
+ g2 = self.get_kern(g2, lag_index, 'pwvd', kern_params['doppler'], N) # Generate the second kernel
554
+ g = g1 * g2 # Multiply the two kernels to obtain the separable kernel
555
+
556
+ else:
557
+ if num_params < 2:
558
+ raise ValueError("Missing required kernel parameters: 'win_length' and 'win_type'")
559
+
560
+ win_length = kern_params['win_length']
561
+ win_type = kern_params['win_type']
562
+ win_param = kern_params['win_param'] if 'win_param' in kern_params else 0
563
+ win_param2 = kern_params['win_param2'] if 'win_param2' in kern_params else 1
564
+
565
+ G = self.get_window(win_length, win_type, win_param)
566
+ G = self.pad_window(G, N)
567
+
568
+ if kern_type == 'swvd' and win_param2 == 0:
569
+ G = np.fft.fft(G)
570
+ if G[0] != 0: # add this check to avoid division by zero
571
+ G /= G[0]
572
+ G = G[lag_index]
573
+
574
+ g[:] = G
575
+
576
+ return g
577
+
578
+ def get_window(
579
+ self,
580
+ win_length: int,
581
+ win_type: str,
582
+ win_param: float | None = None,
583
+ dft_window: bool = False,
584
+ Npad: int = 0
585
+ ) -> np.ndarray:
586
+ """
587
+ General function to calculate a window function.
588
+
589
+ Parameters:
590
+ -----------
591
+ win_length : int
592
+ Length of the window.
593
+ win_type : str
594
+ Type of window. Options are:
595
+ {'delta', 'rect', 'hamm'|'hamming', 'hann'|'hanning', 'gauss', 'cosh'}.
596
+ win_param : float, optional
597
+ Window parameter (e.g., alpha for Gaussian window). Default is None.
598
+ dft_window : bool, optional
599
+ If True, returns the DFT of the window. Default is False.
600
+ Npad : int, optional
601
+ If greater than 0, zero-pads the window to length Npad. Default is 0.
602
+
603
+ Returns:
604
+ --------
605
+ win : ndarray
606
+ The calculated window (or its DFT if dft_window is True).
607
+ """
608
+
609
+ # Get the window
610
+ win = self.get_win(win_length, win_type, win_param, dft_window)
611
+
612
+ # Shift the window so that positive indices are first
613
+ win = self.shift_window(win)
614
+
615
+ # Zero-pad the window to length Npad if necessary
616
+ if Npad > 0:
617
+ win = self.pad_window(win, Npad)
618
+
619
+ return win
620
+
621
+ def get_win(
622
+ self,
623
+ win_length: int,
624
+ win_type: str,
625
+ win_param: float | None = None,
626
+ dft_window: bool = False
627
+ ) -> np.ndarray:
628
+ """
629
+ Helper function to create the specified window type.
630
+
631
+ Parameters:
632
+ -----------
633
+ win_length : int
634
+ Length of the window.
635
+ win_type : str
636
+ Type of window.
637
+ win_param : float, optional
638
+ Additional parameter for certain window types (e.g., Gaussian alpha). Default is None.
639
+ dft_window : bool, optional
640
+ If True, returns the DFT of the window. Default is False.
641
+
642
+ Returns:
643
+ --------
644
+ win : ndarray
645
+ The created window (or its DFT if dft_window is True).
646
+ """
647
+ if win_type == 'delta':
648
+ win = np.zeros(win_length)
649
+ win[win_length // 2] = 1
650
+ elif win_type == 'rect':
651
+ win = np.ones(win_length)
652
+ elif win_type in ['hamm', 'hamming']:
653
+ win = signal.windows.hamming(win_length)
654
+ elif win_type in ['hann', 'hanning']:
655
+ win = signal.windows.hann(win_length)
656
+ elif win_type == 'gauss':
657
+ win = signal.windows.gaussian(win_length, std=win_param if win_param else 0.4)
658
+ elif win_type == 'cosh':
659
+ win_hlf = win_length // 2
660
+ if not win_param:
661
+ win_param = 0.01
662
+ win = np.array([np.cosh(m) ** (-2 * win_param) for m in range(-win_hlf, win_hlf+1)])
663
+ win = np.fft.fftshift(win)
664
+ else:
665
+ raise ValueError(f"Unknown window type {win_type}")
666
+
667
+ # If dft_window is True, return the DFT of the window
668
+ if dft_window:
669
+ win = np.fft.fft(np.roll(win, win_length // 2))
670
+ win = np.roll(win, -win_length // 2)
671
+
672
+ return win
673
+
674
+ def shift_window(self, w: np.ndarray) -> np.ndarray:
675
+ """
676
+ Shift the window so that positive indices appear first.
677
+
678
+ Parameters:
679
+ -----------
680
+ w : ndarray
681
+ Window to be shifted.
682
+
683
+ Returns:
684
+ --------
685
+ w_shifted : ndarray
686
+ Shifted window with positive indices first.
687
+ """
688
+ N = len(w)
689
+ return np.roll(w, N // 2)
690
+
691
+ def pad_window(self, w: np.ndarray, Npad: int) -> np.ndarray:
692
+ """
693
+ Zero-pad the window to a specified length.
694
+
695
+ Parameters:
696
+ -----------
697
+ w : ndarray
698
+ The original window.
699
+ Npad : int
700
+ Length to zero-pad the window to.
701
+
702
+ Returns:
703
+ --------
704
+ w_pad : ndarray
705
+ Zero-padded window of length Npad.
706
+
707
+ Raises:
708
+ -------
709
+ ValueError:
710
+ If Npad is less than the original window length.
711
+ """
712
+ N = len(w)
713
+ w_pad = np.zeros(Npad)
714
+ Nh = N // 2
715
+
716
+ if Npad < N:
717
+ raise ValueError("Npad must be greater than or equal to the window length")
718
+
719
+ if N == Npad:
720
+ return w
721
+
722
+ if N % 2 == 1: # For odd N
723
+ w_pad[:Nh+1] = w[:Nh+1]
724
+ w_pad[-Nh:] = w[-Nh:]
725
+ else: # For even N
726
+ w_pad[:Nh] = w[:Nh]
727
+ w_pad[Nh] = w[Nh] / 2
728
+ w_pad[-Nh:] = w[-Nh:]
729
+ w_pad[-Nh] = w[Nh] / 2
730
+
731
+ return w_pad
732
+
733
+ def compute_tfd(
734
+ self,
735
+ N: int,
736
+ Nh: int,
737
+ tfd: np.ndarray
738
+ ):
739
+ """
740
+ Finalizes the time-frequency distribution computation.
741
+
742
+ Parameters:
743
+ -----------
744
+ N : int
745
+ Size of the TFD.
746
+ Nh : int
747
+ Half-length parameter.
748
+ tfd : np.ndarray
749
+ Time-frequency distribution to be finalized.
750
+
751
+ Returns:
752
+ --------
753
+ tfd : np.ndarray
754
+ Final computed TFD (N,N).
755
+ """
756
+ m = np.arange(0, Nh) # m = 0:(Nh-1)
757
+ mb = np.arange(1, Nh) # mb = 1:(Nh-1)
758
+
759
+ for n in range(0, N-1, 2): # for n=0:2:N-2
760
+ R_even_half = np.complex128(tfd[n, :Nh]) + 1j * np.complex128(tfd[n, Nh:])
761
+ R_odd_half = np.complex128(tfd[n+1, :Nh]) + 1j * np.complex128(tfd[n+1, Nh:])
762
+
763
+ R_tslice_even = np.zeros(N, dtype=np.complex128)
764
+ R_tslice_odd = np.zeros(N, dtype=np.complex128)
765
+
766
+ R_tslice_even[m] = R_even_half
767
+ R_tslice_odd[m] = R_odd_half
768
+
769
+ R_tslice_even[N-mb] = np.conj(R_even_half[mb])
770
+ R_tslice_odd[N-mb] = np.conj(R_odd_half[mb])
771
+
772
+ # Perform FFT to compute time slices
773
+ tfd_time_slice = np.fft.fft(R_tslice_even + 1j * R_tslice_odd)
774
+
775
+ tfd[n, :] = np.real(tfd_time_slice)
776
+ tfd[n+1, :] = np.imag(tfd_time_slice)
777
+
778
+ tfd = tfd / N # Normalize the TFD
779
+ tfd = tfd.transpose() # Transpose the TFD to have the time on the x-axis and frequency on the y-axis
780
+ return tfd