|
1 | 1 | ^{:kindly/hide-code true |
2 | | - :clay {:title "Exploring Heart Rate Variability" |
3 | | - :external-requirements ["WESAD dataset at /workspace/datasets/WESAD/"] |
4 | | - :quarto {:author :ludgersolbach |
5 | | - :draft true |
6 | | - :type :post |
7 | | - :date "2025-10-17" |
8 | | - :tags [:data-analysis :noj]}}} |
| 2 | + :clay {:title "Exploring Heart Rate Variability" |
| 3 | + :external-requirements ["WESAD dataset at /workspace/datasets/WESAD/"] |
| 4 | + :quarto {:author :ludgersolbach |
| 5 | + :draft true |
| 6 | + :type :post |
| 7 | + :date "2025-10-17" |
| 8 | + :tags [:data-analysis :noj]}}} |
9 | 9 | (ns data-analysis.heart-rate-variability.exploring-heart-rate-variability |
10 | 10 | (:require [tablecloth.api :as tc] |
11 | 11 | [scicloj.tableplot.v1.plotly :as plotly] |
|
27 | 27 | [scicloj.tableplot.v1.plotly :as plotly] |
28 | 28 | [java-time.api :as jt])) |
29 | 29 |
|
30 | | - |
31 | 30 | ;; # Exploring HRV - DRAFT 🛠 |
32 | 31 |
|
33 | 32 | (ns data-analysis.heart-rate-variability.exploring-heart-rate-variability |
|
50 | 49 | (:import [com.github.psambit9791.jdsp.signal CrossCorrelation] |
51 | 50 | [com.github.psambit9791.jdsp.signal.peaks FindPeak] |
52 | 51 | [com.github.psambit9791.jdsp.filter Butterworth] |
53 | | - [com.github.psambit9791.jdsp.transform DiscreteFourier])) |
54 | | - |
| 52 | + [com.github.psambit9791.jdsp.transform DiscreteFourier] |
| 53 | + [com.github.psambit9791.jdsp.windows Hanning])) |
55 | 54 |
|
56 | 55 | ;; ## My pulse-to-pulse intervals |
57 | 56 |
|
58 | | - |
59 | 57 | ;; (extracted from PPG data) |
60 | 58 |
|
61 | | - |
62 | 59 | (def my-ppi |
63 | 60 | (-> (tc/dataset "src/data_analysis/heart_rate_variability/ppi-series.csv" |
64 | 61 | {:key-fn keyword}) |
|
75 | 72 | :=height 300 :=width 700}) |
76 | 73 | (plotly/layer-bar {:=y :ppi}))) |
77 | 74 |
|
78 | | - |
79 | 75 | (def compute-measures |
80 | 76 | (fn [ppi-ds {:keys [sampling-rate |
81 | | - window-size-in-sec ]}] |
| 77 | + window-size-in-sec]}] |
82 | 78 | (let [spline (interp/interpolation |
83 | 79 | :cubic |
84 | 80 | (:t ppi-ds) |
|
92 | 88 | bw (com.github.psambit9791.jdsp.filter.Butterworth. |
93 | 89 | sampling-rate) |
94 | 90 | n (tc/row-count resampled-ppi) |
95 | | - window-size (* window-size-in-sec sampling-rate) |
96 | | - hop-size 8 |
| 91 | + window-size (* window-size-in-sec sampling-rate) |
| 92 | + hop-size (* 5 sampling-rate) ; 5-second hops for reasonable overlap |
97 | 93 | n-windows (int (/ (- n window-size) |
98 | 94 | hop-size)) |
99 | 95 | ranges (-> ppi-ds |
|
126 | 122 | window (-> resampled-ppi |
127 | 123 | :ppi |
128 | 124 | (dtype/sub-buffer start-idx window-size)) |
129 | | - window-standardized (stats/standardize window) |
| 125 | +;; Filter first, then standardize for FFT normalization |
130 | 126 | window-filtered (.bandPassFilter bw |
131 | | - (double-array window-standardized) |
| 127 | + (double-array window) |
132 | 128 | 4 |
133 | | - 0 |
| 129 | + 0.04 ; Lower cutoff at 0.04 Hz to remove baseline drift |
134 | 130 | 0.4) |
135 | | - fft (DiscreteFourier. (double-array window-filtered)) |
| 131 | + window-standardized (stats/standardize window-filtered) |
| 132 | +;; Apply Hann window to reduce spectral leakage |
| 133 | + hann-window (-> (Hanning. window-size) .getWindow) |
| 134 | + window-windowed (map * window-standardized hann-window) |
| 135 | + fft (DiscreteFourier. (double-array window-windowed)) |
136 | 136 | _ (.transform fft) |
137 | 137 | whole-magnitude (.getMagnitude fft true)] |
138 | 138 | {:t (* w hop-size (/ 1.0 sampling-rate)) |
139 | 139 | :whole-magnitude whole-magnitude |
140 | 140 | :magnitude (take 40 whole-magnitude)}))) |
141 | 141 | vec)] |
142 | 142 | {:sampling-rate sampling-rate |
| 143 | + :window-size window-size ;; Added: FFT window size needed for freq resolution |
143 | 144 | :resampled-ppi resampled-ppi |
144 | 145 | :rmssds rmssds |
145 | 146 | :spectrograms spectrograms}))) |
146 | 147 |
|
147 | | - |
148 | 148 | (comment |
149 | 149 | (compute-measures my-ppi |
150 | 150 | {:sampling-rate 10 |
151 | 151 | :window-size-in-sec 60})) |
152 | 152 |
|
153 | | - |
154 | 153 | ;; [An Overview of Heart Rate Variability Metrics and Norms](https://pmc.ncbi.nlm.nih.gov/articles/PMC5624990/) |
155 | 154 | ;; by Fred Shaffer, & J P Ginsberg. |
156 | 155 | ;; doi: [10.3389/fpubh.2017.00258](https://www.frontiersin.org/journals/public-health/articles/10.3389/fpubh.2017.00258/full) |
157 | 156 |
|
158 | 157 | (defn LF-to-HF [freqs spectrogram] |
159 | 158 | (let [ds (tc/dataset {:f freqs |
160 | 159 | :s (:magnitude spectrogram)} |
161 | | - tc/dataset)] |
162 | | - (/ (-> ds |
163 | | - (tc/select-rows #(<= 0.04 (% :f) 0.15)) |
164 | | - :s |
165 | | - tcc/sum) |
166 | | - (-> ds |
167 | | - (tc/select-rows #(<= 0.15 (% :f) 0.4)) |
168 | | - :s |
169 | | - tcc/sum)))) |
170 | | - |
| 160 | + tc/dataset) |
| 161 | + lf-power (-> ds |
| 162 | + (tc/select-rows #(<= 0.04 (% :f) 0.15)) |
| 163 | + :s |
| 164 | + tcc/sum) |
| 165 | + hf-power (-> ds |
| 166 | + (tc/select-rows #(<= 0.15 (% :f) 0.4)) |
| 167 | + :s |
| 168 | + tcc/sum)] |
| 169 | + (if (pos? hf-power) |
| 170 | + (/ lf-power hf-power) |
| 171 | + Double/NaN))) ; Return NaN when HF power is zero or negative |
171 | 172 |
|
172 | 173 | (defn plot-with-measures [{:keys [sampling-rate |
| 174 | + window-size |
173 | 175 | resampled-ppi |
174 | 176 | rmssds |
175 | 177 | spectrograms]}] |
176 | 178 | (when spectrograms |
177 | | - (let [n (-> spectrograms first :magnitude count) |
| 179 | + (let [;; Number of frequency bins we're displaying (truncated spectrum) |
| 180 | + n (-> spectrograms first :magnitude count) |
| 181 | + ;; Correct frequency resolution based on FFT window size |
| 182 | + ;; freq_resolution = sampling_rate / FFT_size |
| 183 | + freq-resolution (/ sampling-rate window-size) |
| 184 | + ;; Nyquist frequency (maximum representable frequency) |
178 | 185 | Nyquist-freq (/ sampling-rate 2.0) |
179 | | - freq-resolution (/ Nyquist-freq n) |
180 | 186 | times (map (comp str :t) spectrograms) |
181 | | - freqs (tcc/* (range n) |
182 | | - freq-resolution)] |
| 187 | + ;; Generate frequency values for the n bins we're displaying |
| 188 | + freqs (tcc/* (range n) freq-resolution)] |
183 | 189 | {:resampled-ppi (-> resampled-ppi |
184 | 190 | (plotly/base {:=height 300 :=width 700}) |
185 | 191 | (plotly/layer-bar (merge {:=x :t |
|
206 | 212 | :width 700 |
207 | 213 | :margin {:t 25} |
208 | 214 | :xaxis {:title {:text "t"}} |
209 | | - :yaxis {:title {:text "freq"}}}}) |
| 215 | + :yaxis {:title {:text "freq (Hz)"}}}}) |
210 | 216 | :mean-power-spectrum (-> {:freq freqs |
211 | 217 | :mean-power (-> spectrograms |
212 | 218 | (->> (map :magnitude)) |
|
227 | 233 | (assoc-in [:layout :yaxis :range] [0 4]) |
228 | 234 | (assoc-in [:layout :yaxis :title] {:text "LF/HF"}))}))) |
229 | 235 |
|
230 | | - |
231 | 236 | (delay |
232 | 237 | (-> my-ppi |
233 | 238 | (compute-measures {:sampling-rate 10 |
234 | 239 | :window-size-in-sec 60}) |
235 | 240 | plot-with-measures)) |
236 | 241 |
|
237 | | - |
238 | 242 | ;; ## Analysing ECG data |
239 | 243 |
|
240 | 244 | ;; ### The [WESAD](https://dl.acm.org/doi/10.1145/3242969.3242985) dataset |
|
265 | 269 | :ECG (-> ld |
266 | 270 | (get-in [:signal :chest :ECG]) |
267 | 271 | (py. flatten)) |
268 | | - :label (-> ld |
269 | | - (get :label))}))))) |
| 272 | + :label (-> ld |
| 273 | + (get :label))}))))) |
270 | 274 |
|
271 | 275 | (delay |
272 | 276 | (labelled-dataset 5)) |
|
275 | 279 |
|
276 | 280 | ;; [Pan-Tompkins Algorithm](https://en.wikipedia.org/wiki/Pan%E2%80%93Tompkins_algorithm) |
277 | 281 |
|
278 | | -;; [Unupervised ECG QRS Detection](https://hooman650.github.io/ECG-QRS.html) by Hooman SedghamizHooman Sedghamiz |
| 282 | +;; [Unupervised ECG QRS Detection](https://hooman650.github.io/ECG-QRS.html) by Hooman Sedghamiz |
279 | 283 |
|
280 | 284 | ;; scipy: `peaks = signal.find_peaks(signal, height=mean, distance=200)` |
281 | 285 | ;; JDSP equivalent: |
|
290 | 294 | final-peaks (.filterByPeakDistance peak-obj height-filtered distance)] |
291 | 295 | final-peaks)) ; Returns int[] of peak row-numbers |
292 | 296 |
|
293 | | - |
294 | 297 | (delay |
295 | 298 | (let [bw (com.github.psambit9791.jdsp.filter.Butterworth. |
296 | 299 | WESAD-sampling-rate) |
|
328 | 331 | (plotly/layer-point {:=y :peak |
329 | 332 | :=name "peak"})))) |
330 | 333 |
|
331 | | - |
332 | 334 | (defn extract-ppi |
333 | 335 | "Extract peak-to-peak intervals from ECG signal. |
334 | 336 | Returns dataset with columns: :t (time in seconds), :ppi (interval in seconds)" |
|
345 | 347 | 4 5 15)) |
346 | 348 | ;; Differentiate and square |
347 | 349 | (tc/add-column :sqdiff |
348 | | - #(tcc/- (:filtered %) |
349 | | - (tcc/shift (:filtered %) 1)))) |
| 350 | + #(tcc/sq |
| 351 | + (tcc/- (:filtered %) |
| 352 | + (tcc/shift (:filtered %) 1))))) |
350 | 353 | ;; Find peaks with distance constraint (200 samples = ~0.29s) |
351 | 354 | peak-indices (find-peaks (:sqdiff pipeline) |
352 | 355 | {:distance 200}) |
|
381 | 384 | extract-ppi |
382 | 385 | (compute-measures measures-params))))) |
383 | 386 |
|
384 | | - |
385 | 387 | (delay |
386 | 388 | (-> {:ppi-params {:subject-id 5 |
387 | 389 | :row-interval [0 1000000]} |
|
413 | 415 | (tc/add-column :offset #(cons 0 (reductions + (:n %)))) |
414 | 416 | (tc/select-columns [:offset :n :label]))))) |
415 | 417 |
|
416 | | - |
417 | 418 | (delay |
418 | 419 | (label-intervals 5)) |
419 | 420 |
|
420 | | - |
421 | 421 | (delay |
422 | 422 | (let [subject 5] |
423 | | - (kind/fragment |
424 | | - (-> (label-intervals subject) |
425 | | - (tc/select-rows #(not= (:label %) :ignore)) |
426 | | - #_(tc/select-rows #(= (:label %) :meditation)) |
427 | | - (tc/rows :as-maps) |
428 | | - (->> (mapcat (fn [{:keys [offset n label]}] |
429 | | - [label |
430 | | - (try (-> {:ppi-params {:subject-id subject |
431 | | - :row-interval [offset (+ offset n)]} |
432 | | - :measures-params {:sampling-rate 10 |
433 | | - :window-size-in-sec 120}} |
434 | | - WESAD-spectrograms |
435 | | - plot-with-measures) |
436 | | - (catch Exception e 'unavailable))]))))))) |
| 423 | + (-> (label-intervals subject) |
| 424 | + (tc/select-rows #(not= (:label %) :ignore)) |
| 425 | + #_(tc/select-rows #(= (:label %) :meditation)) |
| 426 | + (tc/rows :as-maps) |
| 427 | + (->> (mapcat (fn [{:keys [offset n label]}] |
| 428 | + [label |
| 429 | + (try (-> {:ppi-params {:subject-id subject |
| 430 | + :row-interval [offset (+ offset n)]} |
| 431 | + :measures-params {:sampling-rate 10 |
| 432 | + :window-size-in-sec 120}} |
| 433 | + WESAD-spectrograms |
| 434 | + plot-with-measures) |
| 435 | + (catch Exception e 'unavailable))])))))) |
| 436 | + |
| 437 | +;; ## Conclusion |
| 438 | + |
| 439 | +;; - measures are tricky |
| 440 | +;; - domain knowledge matters |
| 441 | +;; - workflow matters |
| 442 | +;; - visualization matters |
| 443 | + |
| 444 | + |
| 445 | + |
| 446 | + |
| 447 | + |
| 448 | + |
| 449 | + |
| 450 | + |
| 451 | + |
| 452 | + |
| 453 | + |
| 454 | + |
0 commit comments