rapidtide 2.9.6__py3-none-any.whl → 3.1.3__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 (405) hide show
  1. cloud/gmscalc-HCPYA +1 -1
  2. cloud/mount-and-run +2 -0
  3. cloud/rapidtide-HCPYA +3 -3
  4. rapidtide/Colortables.py +538 -38
  5. rapidtide/OrthoImageItem.py +1094 -51
  6. rapidtide/RapidtideDataset.py +1709 -114
  7. rapidtide/__init__.py +0 -8
  8. rapidtide/_version.py +4 -4
  9. rapidtide/calccoherence.py +242 -97
  10. rapidtide/calcnullsimfunc.py +240 -140
  11. rapidtide/calcsimfunc.py +314 -129
  12. rapidtide/correlate.py +1211 -389
  13. rapidtide/data/examples/src/testLD +56 -0
  14. rapidtide/data/examples/src/test_findmaxlag.py +2 -2
  15. rapidtide/data/examples/src/test_mlregressallt.py +32 -17
  16. rapidtide/data/examples/src/testalign +1 -1
  17. rapidtide/data/examples/src/testatlasaverage +35 -7
  18. rapidtide/data/examples/src/testboth +21 -0
  19. rapidtide/data/examples/src/testcifti +11 -0
  20. rapidtide/data/examples/src/testdelayvar +13 -0
  21. rapidtide/data/examples/src/testdlfilt +25 -0
  22. rapidtide/data/examples/src/testfft +35 -0
  23. rapidtide/data/examples/src/testfileorfloat +37 -0
  24. rapidtide/data/examples/src/testfmri +92 -42
  25. rapidtide/data/examples/src/testfuncs +3 -3
  26. rapidtide/data/examples/src/testglmfilt +8 -6
  27. rapidtide/data/examples/src/testhappy +84 -51
  28. rapidtide/data/examples/src/testinitdelay +19 -0
  29. rapidtide/data/examples/src/testmodels +33 -0
  30. rapidtide/data/examples/src/testnewrefine +26 -0
  31. rapidtide/data/examples/src/testnoiseamp +2 -2
  32. rapidtide/data/examples/src/testppgproc +17 -0
  33. rapidtide/data/examples/src/testrefineonly +22 -0
  34. rapidtide/data/examples/src/testretro +26 -13
  35. rapidtide/data/examples/src/testretrolagtcs +16 -0
  36. rapidtide/data/examples/src/testrolloff +11 -0
  37. rapidtide/data/examples/src/testsimdata +45 -28
  38. rapidtide/data/models/model_cnn_pytorch/loss.png +0 -0
  39. rapidtide/data/models/model_cnn_pytorch/loss.txt +1 -0
  40. rapidtide/data/models/model_cnn_pytorch/model.pth +0 -0
  41. rapidtide/data/models/model_cnn_pytorch/model_meta.json +68 -0
  42. rapidtide/data/models/model_cnn_pytorch_fulldata/loss.png +0 -0
  43. rapidtide/data/models/model_cnn_pytorch_fulldata/loss.txt +1 -0
  44. rapidtide/data/models/model_cnn_pytorch_fulldata/model.pth +0 -0
  45. rapidtide/data/models/model_cnn_pytorch_fulldata/model_meta.json +80 -0
  46. rapidtide/data/models/model_cnnbp_pytorch_fullldata/loss.png +0 -0
  47. rapidtide/data/models/model_cnnbp_pytorch_fullldata/loss.txt +1 -0
  48. rapidtide/data/models/model_cnnbp_pytorch_fullldata/model.pth +0 -0
  49. rapidtide/data/models/model_cnnbp_pytorch_fullldata/model_meta.json +138 -0
  50. rapidtide/data/models/model_cnnfft_pytorch_fulldata/loss.png +0 -0
  51. rapidtide/data/models/model_cnnfft_pytorch_fulldata/loss.txt +1 -0
  52. rapidtide/data/models/model_cnnfft_pytorch_fulldata/model.pth +0 -0
  53. rapidtide/data/models/model_cnnfft_pytorch_fulldata/model_meta.json +128 -0
  54. rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/loss.png +0 -0
  55. rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/loss.txt +1 -0
  56. rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/model.pth +0 -0
  57. rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/model_meta.json +49 -0
  58. rapidtide/data/models/model_revised_tf2/model.keras +0 -0
  59. rapidtide/data/models/{model_serdar → model_revised_tf2}/model_meta.json +1 -1
  60. rapidtide/data/models/model_serdar2_tf2/model.keras +0 -0
  61. rapidtide/data/models/{model_serdar2 → model_serdar2_tf2}/model_meta.json +1 -1
  62. rapidtide/data/models/model_serdar_tf2/model.keras +0 -0
  63. rapidtide/data/models/{model_revised → model_serdar_tf2}/model_meta.json +1 -1
  64. rapidtide/data/reference/HCP1200v2_MTT_2mm.nii.gz +0 -0
  65. rapidtide/data/reference/HCP1200v2_binmask_2mm.nii.gz +0 -0
  66. rapidtide/data/reference/HCP1200v2_csf_2mm.nii.gz +0 -0
  67. rapidtide/data/reference/HCP1200v2_gray_2mm.nii.gz +0 -0
  68. rapidtide/data/reference/HCP1200v2_graylaghist.json +7 -0
  69. rapidtide/data/reference/HCP1200v2_graylaghist.tsv.gz +0 -0
  70. rapidtide/data/reference/HCP1200v2_laghist.json +7 -0
  71. rapidtide/data/reference/HCP1200v2_laghist.tsv.gz +0 -0
  72. rapidtide/data/reference/HCP1200v2_mask_2mm.nii.gz +0 -0
  73. rapidtide/data/reference/HCP1200v2_maxcorr_2mm.nii.gz +0 -0
  74. rapidtide/data/reference/HCP1200v2_maxtime_2mm.nii.gz +0 -0
  75. rapidtide/data/reference/HCP1200v2_maxwidth_2mm.nii.gz +0 -0
  76. rapidtide/data/reference/HCP1200v2_negmask_2mm.nii.gz +0 -0
  77. rapidtide/data/reference/HCP1200v2_timepercentile_2mm.nii.gz +0 -0
  78. rapidtide/data/reference/HCP1200v2_white_2mm.nii.gz +0 -0
  79. rapidtide/data/reference/HCP1200v2_whitelaghist.json +7 -0
  80. rapidtide/data/reference/HCP1200v2_whitelaghist.tsv.gz +0 -0
  81. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2.xml +131 -0
  82. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2_regions.txt +60 -0
  83. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2_space-MNI152NLin6Asym_2mm.nii.gz +0 -0
  84. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin2009cAsym_2mm.nii.gz +0 -0
  85. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin2009cAsym_2mm_mask.nii.gz +0 -0
  86. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin6Asym_2mm_mask.nii.gz +0 -0
  87. rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL2_space-MNI152NLin6Asym_2mm_mask.nii.gz +0 -0
  88. rapidtide/data/reference/MNI152_T1_1mm_Brain_FAST_seg.nii.gz +0 -0
  89. rapidtide/data/reference/MNI152_T1_1mm_Brain_Mask.nii.gz +0 -0
  90. rapidtide/data/reference/MNI152_T1_2mm_Brain_FAST_seg.nii.gz +0 -0
  91. rapidtide/data/reference/MNI152_T1_2mm_Brain_Mask.nii.gz +0 -0
  92. rapidtide/decorators.py +91 -0
  93. rapidtide/dlfilter.py +2553 -414
  94. rapidtide/dlfiltertorch.py +5201 -0
  95. rapidtide/externaltools.py +328 -13
  96. rapidtide/fMRIData_class.py +108 -92
  97. rapidtide/ffttools.py +168 -0
  98. rapidtide/filter.py +2704 -1462
  99. rapidtide/fit.py +2361 -579
  100. rapidtide/genericmultiproc.py +197 -0
  101. rapidtide/happy_supportfuncs.py +3255 -548
  102. rapidtide/helper_classes.py +587 -1116
  103. rapidtide/io.py +2569 -468
  104. rapidtide/linfitfiltpass.py +784 -0
  105. rapidtide/makelaggedtcs.py +267 -97
  106. rapidtide/maskutil.py +555 -25
  107. rapidtide/miscmath.py +835 -144
  108. rapidtide/multiproc.py +217 -44
  109. rapidtide/patchmatch.py +752 -0
  110. rapidtide/peakeval.py +32 -32
  111. rapidtide/ppgproc.py +2205 -0
  112. rapidtide/qualitycheck.py +353 -40
  113. rapidtide/refinedelay.py +854 -0
  114. rapidtide/refineregressor.py +939 -0
  115. rapidtide/resample.py +725 -204
  116. rapidtide/scripts/__init__.py +1 -0
  117. rapidtide/scripts/{adjustoffset → adjustoffset.py} +7 -2
  118. rapidtide/scripts/{aligntcs → aligntcs.py} +7 -2
  119. rapidtide/scripts/{applydlfilter → applydlfilter.py} +7 -2
  120. rapidtide/scripts/applyppgproc.py +28 -0
  121. rapidtide/scripts/{atlasaverage → atlasaverage.py} +7 -2
  122. rapidtide/scripts/{atlastool → atlastool.py} +7 -2
  123. rapidtide/scripts/{calcicc → calcicc.py} +7 -2
  124. rapidtide/scripts/{calctexticc → calctexticc.py} +7 -2
  125. rapidtide/scripts/{calcttest → calcttest.py} +7 -2
  126. rapidtide/scripts/{ccorrica → ccorrica.py} +7 -2
  127. rapidtide/scripts/delayvar.py +28 -0
  128. rapidtide/scripts/{diffrois → diffrois.py} +7 -2
  129. rapidtide/scripts/{endtidalproc → endtidalproc.py} +7 -2
  130. rapidtide/scripts/{fdica → fdica.py} +7 -2
  131. rapidtide/scripts/{filtnifti → filtnifti.py} +7 -2
  132. rapidtide/scripts/{filttc → filttc.py} +7 -2
  133. rapidtide/scripts/{fingerprint → fingerprint.py} +20 -16
  134. rapidtide/scripts/{fixtr → fixtr.py} +7 -2
  135. rapidtide/scripts/{gmscalc → gmscalc.py} +7 -2
  136. rapidtide/scripts/{happy → happy.py} +7 -2
  137. rapidtide/scripts/{happy2std → happy2std.py} +7 -2
  138. rapidtide/scripts/{happywarp → happywarp.py} +8 -4
  139. rapidtide/scripts/{histnifti → histnifti.py} +7 -2
  140. rapidtide/scripts/{histtc → histtc.py} +7 -2
  141. rapidtide/scripts/{glmfilt → linfitfilt.py} +7 -4
  142. rapidtide/scripts/{localflow → localflow.py} +7 -2
  143. rapidtide/scripts/{mergequality → mergequality.py} +7 -2
  144. rapidtide/scripts/{pairproc → pairproc.py} +7 -2
  145. rapidtide/scripts/{pairwisemergenifti → pairwisemergenifti.py} +7 -2
  146. rapidtide/scripts/{physiofreq → physiofreq.py} +7 -2
  147. rapidtide/scripts/{pixelcomp → pixelcomp.py} +7 -2
  148. rapidtide/scripts/{plethquality → plethquality.py} +7 -2
  149. rapidtide/scripts/{polyfitim → polyfitim.py} +7 -2
  150. rapidtide/scripts/{proj2flow → proj2flow.py} +7 -2
  151. rapidtide/scripts/{rankimage → rankimage.py} +7 -2
  152. rapidtide/scripts/{rapidtide → rapidtide.py} +7 -2
  153. rapidtide/scripts/{rapidtide2std → rapidtide2std.py} +7 -2
  154. rapidtide/scripts/{resamplenifti → resamplenifti.py} +7 -2
  155. rapidtide/scripts/{resampletc → resampletc.py} +7 -2
  156. rapidtide/scripts/retrolagtcs.py +28 -0
  157. rapidtide/scripts/retroregress.py +28 -0
  158. rapidtide/scripts/{roisummarize → roisummarize.py} +7 -2
  159. rapidtide/scripts/{runqualitycheck → runqualitycheck.py} +7 -2
  160. rapidtide/scripts/{showarbcorr → showarbcorr.py} +7 -2
  161. rapidtide/scripts/{showhist → showhist.py} +7 -2
  162. rapidtide/scripts/{showstxcorr → showstxcorr.py} +7 -2
  163. rapidtide/scripts/{showtc → showtc.py} +7 -2
  164. rapidtide/scripts/{showxcorr_legacy → showxcorr_legacy.py} +8 -8
  165. rapidtide/scripts/{showxcorrx → showxcorrx.py} +7 -2
  166. rapidtide/scripts/{showxy → showxy.py} +7 -2
  167. rapidtide/scripts/{simdata → simdata.py} +7 -2
  168. rapidtide/scripts/{spatialdecomp → spatialdecomp.py} +7 -2
  169. rapidtide/scripts/{spatialfit → spatialfit.py} +7 -2
  170. rapidtide/scripts/{spatialmi → spatialmi.py} +7 -2
  171. rapidtide/scripts/{spectrogram → spectrogram.py} +7 -2
  172. rapidtide/scripts/stupidramtricks.py +238 -0
  173. rapidtide/scripts/{synthASL → synthASL.py} +7 -2
  174. rapidtide/scripts/{tcfrom2col → tcfrom2col.py} +7 -2
  175. rapidtide/scripts/{tcfrom3col → tcfrom3col.py} +7 -2
  176. rapidtide/scripts/{temporaldecomp → temporaldecomp.py} +7 -2
  177. rapidtide/scripts/{testhrv → testhrv.py} +1 -1
  178. rapidtide/scripts/{threeD → threeD.py} +7 -2
  179. rapidtide/scripts/{tidepool → tidepool.py} +7 -2
  180. rapidtide/scripts/{variabilityizer → variabilityizer.py} +7 -2
  181. rapidtide/simFuncClasses.py +2113 -0
  182. rapidtide/simfuncfit.py +312 -108
  183. rapidtide/stats.py +579 -247
  184. rapidtide/tests/.coveragerc +27 -6
  185. rapidtide-2.9.6.data/scripts/fdica → rapidtide/tests/cleanposttest +4 -6
  186. rapidtide/tests/happycomp +9 -0
  187. rapidtide/tests/resethappytargets +1 -1
  188. rapidtide/tests/resetrapidtidetargets +1 -1
  189. rapidtide/tests/resettargets +1 -1
  190. rapidtide/tests/runlocaltest +3 -3
  191. rapidtide/tests/showkernels +1 -1
  192. rapidtide/tests/test_aliasedcorrelate.py +4 -4
  193. rapidtide/tests/test_aligntcs.py +1 -1
  194. rapidtide/tests/test_calcicc.py +1 -1
  195. rapidtide/tests/test_cleanregressor.py +184 -0
  196. rapidtide/tests/test_congrid.py +70 -81
  197. rapidtide/tests/test_correlate.py +1 -1
  198. rapidtide/tests/test_corrpass.py +4 -4
  199. rapidtide/tests/test_delayestimation.py +54 -59
  200. rapidtide/tests/test_dlfiltertorch.py +437 -0
  201. rapidtide/tests/test_doresample.py +2 -2
  202. rapidtide/tests/test_externaltools.py +69 -0
  203. rapidtide/tests/test_fastresampler.py +9 -5
  204. rapidtide/tests/test_filter.py +96 -57
  205. rapidtide/tests/test_findmaxlag.py +50 -19
  206. rapidtide/tests/test_fullrunhappy_v1.py +15 -10
  207. rapidtide/tests/test_fullrunhappy_v2.py +19 -13
  208. rapidtide/tests/test_fullrunhappy_v3.py +28 -13
  209. rapidtide/tests/test_fullrunhappy_v4.py +30 -11
  210. rapidtide/tests/test_fullrunhappy_v5.py +62 -0
  211. rapidtide/tests/test_fullrunrapidtide_v1.py +61 -7
  212. rapidtide/tests/test_fullrunrapidtide_v2.py +26 -14
  213. rapidtide/tests/test_fullrunrapidtide_v3.py +28 -8
  214. rapidtide/tests/test_fullrunrapidtide_v4.py +16 -8
  215. rapidtide/tests/test_fullrunrapidtide_v5.py +15 -6
  216. rapidtide/tests/test_fullrunrapidtide_v6.py +142 -0
  217. rapidtide/tests/test_fullrunrapidtide_v7.py +114 -0
  218. rapidtide/tests/test_fullrunrapidtide_v8.py +66 -0
  219. rapidtide/tests/test_getparsers.py +158 -0
  220. rapidtide/tests/test_io.py +59 -18
  221. rapidtide/tests/{test_glmpass.py → test_linfitfiltpass.py} +10 -10
  222. rapidtide/tests/test_mi.py +1 -1
  223. rapidtide/tests/test_miscmath.py +1 -1
  224. rapidtide/tests/test_motionregress.py +5 -5
  225. rapidtide/tests/test_nullcorr.py +6 -9
  226. rapidtide/tests/test_padvec.py +216 -0
  227. rapidtide/tests/test_parserfuncs.py +101 -0
  228. rapidtide/tests/test_phaseanalysis.py +1 -1
  229. rapidtide/tests/test_rapidtideparser.py +59 -53
  230. rapidtide/tests/test_refinedelay.py +296 -0
  231. rapidtide/tests/test_runmisc.py +5 -5
  232. rapidtide/tests/test_sharedmem.py +60 -0
  233. rapidtide/tests/test_simroundtrip.py +132 -0
  234. rapidtide/tests/test_simulate.py +1 -1
  235. rapidtide/tests/test_stcorrelate.py +4 -2
  236. rapidtide/tests/test_timeshift.py +2 -2
  237. rapidtide/tests/test_valtoindex.py +1 -1
  238. rapidtide/tests/test_zRapidtideDataset.py +5 -3
  239. rapidtide/tests/utils.py +10 -9
  240. rapidtide/tidepoolTemplate.py +88 -70
  241. rapidtide/tidepoolTemplate.ui +60 -46
  242. rapidtide/tidepoolTemplate_alt.py +88 -53
  243. rapidtide/tidepoolTemplate_alt.ui +62 -52
  244. rapidtide/tidepoolTemplate_alt_qt6.py +921 -0
  245. rapidtide/tidepoolTemplate_big.py +1125 -0
  246. rapidtide/tidepoolTemplate_big.ui +2386 -0
  247. rapidtide/tidepoolTemplate_big_qt6.py +1129 -0
  248. rapidtide/tidepoolTemplate_qt6.py +793 -0
  249. rapidtide/util.py +1389 -148
  250. rapidtide/voxelData.py +1048 -0
  251. rapidtide/wiener.py +138 -25
  252. rapidtide/wiener2.py +114 -8
  253. rapidtide/workflows/adjustoffset.py +107 -5
  254. rapidtide/workflows/aligntcs.py +86 -3
  255. rapidtide/workflows/applydlfilter.py +231 -89
  256. rapidtide/workflows/applyppgproc.py +540 -0
  257. rapidtide/workflows/atlasaverage.py +309 -48
  258. rapidtide/workflows/atlastool.py +130 -9
  259. rapidtide/workflows/calcSimFuncMap.py +490 -0
  260. rapidtide/workflows/calctexticc.py +202 -10
  261. rapidtide/workflows/ccorrica.py +123 -15
  262. rapidtide/workflows/cleanregressor.py +415 -0
  263. rapidtide/workflows/delayvar.py +1268 -0
  264. rapidtide/workflows/diffrois.py +84 -6
  265. rapidtide/workflows/endtidalproc.py +149 -9
  266. rapidtide/workflows/fdica.py +197 -17
  267. rapidtide/workflows/filtnifti.py +71 -4
  268. rapidtide/workflows/filttc.py +76 -5
  269. rapidtide/workflows/fitSimFuncMap.py +578 -0
  270. rapidtide/workflows/fixtr.py +74 -4
  271. rapidtide/workflows/gmscalc.py +116 -6
  272. rapidtide/workflows/happy.py +1242 -480
  273. rapidtide/workflows/happy2std.py +145 -13
  274. rapidtide/workflows/happy_parser.py +277 -59
  275. rapidtide/workflows/histnifti.py +120 -4
  276. rapidtide/workflows/histtc.py +85 -4
  277. rapidtide/workflows/{glmfilt.py → linfitfilt.py} +128 -14
  278. rapidtide/workflows/localflow.py +329 -29
  279. rapidtide/workflows/mergequality.py +80 -4
  280. rapidtide/workflows/niftidecomp.py +323 -19
  281. rapidtide/workflows/niftistats.py +178 -8
  282. rapidtide/workflows/pairproc.py +99 -5
  283. rapidtide/workflows/pairwisemergenifti.py +86 -3
  284. rapidtide/workflows/parser_funcs.py +1488 -56
  285. rapidtide/workflows/physiofreq.py +139 -12
  286. rapidtide/workflows/pixelcomp.py +211 -9
  287. rapidtide/workflows/plethquality.py +105 -23
  288. rapidtide/workflows/polyfitim.py +159 -19
  289. rapidtide/workflows/proj2flow.py +76 -3
  290. rapidtide/workflows/rankimage.py +115 -8
  291. rapidtide/workflows/rapidtide.py +1785 -1858
  292. rapidtide/workflows/rapidtide2std.py +101 -3
  293. rapidtide/workflows/rapidtide_parser.py +590 -389
  294. rapidtide/workflows/refineDelayMap.py +249 -0
  295. rapidtide/workflows/refineRegressor.py +1215 -0
  296. rapidtide/workflows/regressfrommaps.py +308 -0
  297. rapidtide/workflows/resamplenifti.py +86 -4
  298. rapidtide/workflows/resampletc.py +92 -4
  299. rapidtide/workflows/retrolagtcs.py +442 -0
  300. rapidtide/workflows/retroregress.py +1501 -0
  301. rapidtide/workflows/roisummarize.py +176 -7
  302. rapidtide/workflows/runqualitycheck.py +72 -7
  303. rapidtide/workflows/showarbcorr.py +172 -16
  304. rapidtide/workflows/showhist.py +87 -3
  305. rapidtide/workflows/showstxcorr.py +161 -4
  306. rapidtide/workflows/showtc.py +172 -10
  307. rapidtide/workflows/showxcorrx.py +250 -62
  308. rapidtide/workflows/showxy.py +186 -16
  309. rapidtide/workflows/simdata.py +418 -112
  310. rapidtide/workflows/spatialfit.py +83 -8
  311. rapidtide/workflows/spatialmi.py +252 -29
  312. rapidtide/workflows/spectrogram.py +306 -33
  313. rapidtide/workflows/synthASL.py +157 -6
  314. rapidtide/workflows/tcfrom2col.py +77 -3
  315. rapidtide/workflows/tcfrom3col.py +75 -3
  316. rapidtide/workflows/tidepool.py +3829 -666
  317. rapidtide/workflows/utils.py +45 -19
  318. rapidtide/workflows/utils_doc.py +293 -0
  319. rapidtide/workflows/variabilityizer.py +118 -5
  320. {rapidtide-2.9.6.dist-info → rapidtide-3.1.3.dist-info}/METADATA +30 -223
  321. rapidtide-3.1.3.dist-info/RECORD +393 -0
  322. {rapidtide-2.9.6.dist-info → rapidtide-3.1.3.dist-info}/WHEEL +1 -1
  323. rapidtide-3.1.3.dist-info/entry_points.txt +65 -0
  324. rapidtide-3.1.3.dist-info/top_level.txt +2 -0
  325. rapidtide/calcandfitcorrpairs.py +0 -262
  326. rapidtide/data/examples/src/testoutputsize +0 -45
  327. rapidtide/data/models/model_revised/model.h5 +0 -0
  328. rapidtide/data/models/model_serdar/model.h5 +0 -0
  329. rapidtide/data/models/model_serdar2/model.h5 +0 -0
  330. rapidtide/data/reference/ASPECTS_nlin_asym_09c_2mm.nii.gz +0 -0
  331. rapidtide/data/reference/ASPECTS_nlin_asym_09c_2mm_mask.nii.gz +0 -0
  332. rapidtide/data/reference/ATTbasedFlowTerritories_split_nlin_asym_09c_2mm.nii.gz +0 -0
  333. rapidtide/data/reference/ATTbasedFlowTerritories_split_nlin_asym_09c_2mm_mask.nii.gz +0 -0
  334. rapidtide/data/reference/HCP1200_binmask_2mm_2009c_asym.nii.gz +0 -0
  335. rapidtide/data/reference/HCP1200_lag_2mm_2009c_asym.nii.gz +0 -0
  336. rapidtide/data/reference/HCP1200_mask_2mm_2009c_asym.nii.gz +0 -0
  337. rapidtide/data/reference/HCP1200_negmask_2mm_2009c_asym.nii.gz +0 -0
  338. rapidtide/data/reference/HCP1200_sigma_2mm_2009c_asym.nii.gz +0 -0
  339. rapidtide/data/reference/HCP1200_strength_2mm_2009c_asym.nii.gz +0 -0
  340. rapidtide/glmpass.py +0 -434
  341. rapidtide/refine_factored.py +0 -641
  342. rapidtide/scripts/retroglm +0 -23
  343. rapidtide/workflows/glmfrommaps.py +0 -202
  344. rapidtide/workflows/retroglm.py +0 -643
  345. rapidtide-2.9.6.data/scripts/adjustoffset +0 -23
  346. rapidtide-2.9.6.data/scripts/aligntcs +0 -23
  347. rapidtide-2.9.6.data/scripts/applydlfilter +0 -23
  348. rapidtide-2.9.6.data/scripts/atlasaverage +0 -23
  349. rapidtide-2.9.6.data/scripts/atlastool +0 -23
  350. rapidtide-2.9.6.data/scripts/calcicc +0 -22
  351. rapidtide-2.9.6.data/scripts/calctexticc +0 -23
  352. rapidtide-2.9.6.data/scripts/calcttest +0 -22
  353. rapidtide-2.9.6.data/scripts/ccorrica +0 -23
  354. rapidtide-2.9.6.data/scripts/diffrois +0 -23
  355. rapidtide-2.9.6.data/scripts/endtidalproc +0 -23
  356. rapidtide-2.9.6.data/scripts/filtnifti +0 -23
  357. rapidtide-2.9.6.data/scripts/filttc +0 -23
  358. rapidtide-2.9.6.data/scripts/fingerprint +0 -593
  359. rapidtide-2.9.6.data/scripts/fixtr +0 -23
  360. rapidtide-2.9.6.data/scripts/glmfilt +0 -24
  361. rapidtide-2.9.6.data/scripts/gmscalc +0 -22
  362. rapidtide-2.9.6.data/scripts/happy +0 -25
  363. rapidtide-2.9.6.data/scripts/happy2std +0 -23
  364. rapidtide-2.9.6.data/scripts/happywarp +0 -350
  365. rapidtide-2.9.6.data/scripts/histnifti +0 -23
  366. rapidtide-2.9.6.data/scripts/histtc +0 -23
  367. rapidtide-2.9.6.data/scripts/localflow +0 -23
  368. rapidtide-2.9.6.data/scripts/mergequality +0 -23
  369. rapidtide-2.9.6.data/scripts/pairproc +0 -23
  370. rapidtide-2.9.6.data/scripts/pairwisemergenifti +0 -23
  371. rapidtide-2.9.6.data/scripts/physiofreq +0 -23
  372. rapidtide-2.9.6.data/scripts/pixelcomp +0 -23
  373. rapidtide-2.9.6.data/scripts/plethquality +0 -23
  374. rapidtide-2.9.6.data/scripts/polyfitim +0 -23
  375. rapidtide-2.9.6.data/scripts/proj2flow +0 -23
  376. rapidtide-2.9.6.data/scripts/rankimage +0 -23
  377. rapidtide-2.9.6.data/scripts/rapidtide +0 -23
  378. rapidtide-2.9.6.data/scripts/rapidtide2std +0 -23
  379. rapidtide-2.9.6.data/scripts/resamplenifti +0 -23
  380. rapidtide-2.9.6.data/scripts/resampletc +0 -23
  381. rapidtide-2.9.6.data/scripts/retroglm +0 -23
  382. rapidtide-2.9.6.data/scripts/roisummarize +0 -23
  383. rapidtide-2.9.6.data/scripts/runqualitycheck +0 -23
  384. rapidtide-2.9.6.data/scripts/showarbcorr +0 -23
  385. rapidtide-2.9.6.data/scripts/showhist +0 -23
  386. rapidtide-2.9.6.data/scripts/showstxcorr +0 -23
  387. rapidtide-2.9.6.data/scripts/showtc +0 -23
  388. rapidtide-2.9.6.data/scripts/showxcorr_legacy +0 -536
  389. rapidtide-2.9.6.data/scripts/showxcorrx +0 -23
  390. rapidtide-2.9.6.data/scripts/showxy +0 -23
  391. rapidtide-2.9.6.data/scripts/simdata +0 -23
  392. rapidtide-2.9.6.data/scripts/spatialdecomp +0 -23
  393. rapidtide-2.9.6.data/scripts/spatialfit +0 -23
  394. rapidtide-2.9.6.data/scripts/spatialmi +0 -23
  395. rapidtide-2.9.6.data/scripts/spectrogram +0 -23
  396. rapidtide-2.9.6.data/scripts/synthASL +0 -23
  397. rapidtide-2.9.6.data/scripts/tcfrom2col +0 -23
  398. rapidtide-2.9.6.data/scripts/tcfrom3col +0 -23
  399. rapidtide-2.9.6.data/scripts/temporaldecomp +0 -23
  400. rapidtide-2.9.6.data/scripts/threeD +0 -236
  401. rapidtide-2.9.6.data/scripts/tidepool +0 -23
  402. rapidtide-2.9.6.data/scripts/variabilityizer +0 -23
  403. rapidtide-2.9.6.dist-info/RECORD +0 -359
  404. rapidtide-2.9.6.dist-info/top_level.txt +0 -86
  405. {rapidtide-2.9.6.dist-info → rapidtide-3.1.3.dist-info/licenses}/LICENSE +0 -0
rapidtide/ppgproc.py ADDED
@@ -0,0 +1,2205 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # Copyright 2016-2025 Blaise Frederick
5
+ #
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+ #
18
+ #
19
+ from typing import Any, Callable
20
+
21
+ import matplotlib.pyplot as plt
22
+ import numpy as np
23
+ from numpy.typing import NDArray
24
+ from scipy import signal
25
+ from scipy.interpolate import interp1d
26
+
27
+ import rapidtide.filter as tide_filt
28
+ import rapidtide.io as tide_io
29
+
30
+
31
+ class PPGKalmanFilter:
32
+ """
33
+ Kalman filter optimized for PPG (photoplethysmogram) signals.
34
+ PPG signals are lower frequency, more sinusoidal, and have different
35
+ noise characteristics compared to ECG.
36
+ """
37
+
38
+ def __init__(
39
+ self, dt: float = 0.01, process_noise: float = 0.001, measurement_noise: float = 0.05
40
+ ) -> None:
41
+ """
42
+ Initialize Kalman filter for PPG signals.
43
+
44
+ Initialize Kalman filter with default parameters suitable for photoplethysmography (PPG)
45
+ signal processing. Uses a constant velocity model with position and velocity states.
46
+
47
+ Parameters
48
+ ----------
49
+ dt : float, optional
50
+ Sampling interval in seconds, default is 0.01 (100Hz sampling rate typical for PPG)
51
+ process_noise : float, optional
52
+ Process noise covariance (Q) controlling state uncertainty, default is 0.001.
53
+ Lower values (0.0001-0.01) appropriate for smoother PPG signals
54
+ measurement_noise : float, optional
55
+ Measurement noise covariance (R) representing sensor/motion artifact noise,
56
+ default is 0.05
57
+
58
+ Returns
59
+ -------
60
+ None
61
+ Initializes internal filter parameters and state variables
62
+
63
+ Notes
64
+ -----
65
+ The filter uses a constant velocity model with state vector [position, velocity].
66
+ PPG signals are inherently smoother than other physiological signals, so lower
67
+ process noise values are typically appropriate.
68
+
69
+ Examples
70
+ --------
71
+ >>> filter = KalmanFilter(dt=0.02, process_noise=0.005, measurement_noise=0.1)
72
+ >>> filter = KalmanFilter() # Uses default parameters
73
+ """
74
+ # State vector: [position, velocity]
75
+ self.x = np.array([[0.0], [0.0]])
76
+
77
+ # State transition matrix (constant velocity model)
78
+ self.F = np.array([[1, dt], [0, 1]])
79
+
80
+ # Measurement matrix (we only measure position)
81
+ self.H = np.array([[1, 0]])
82
+
83
+ # Process noise covariance (lower for smoother PPG signals)
84
+ self.Q = np.array([[dt**4 / 4, dt**3 / 2], [dt**3 / 2, dt**2]]) * process_noise
85
+
86
+ # Measurement noise covariance
87
+ self.R = np.array([[measurement_noise]])
88
+
89
+ # Estimation error covariance
90
+ self.P = np.eye(2)
91
+
92
+ def predict(self) -> None:
93
+ """Prediction step"""
94
+ self.x = self.F @ self.x
95
+ self.P = self.F @ self.P @ self.F.T + self.Q
96
+
97
+ def update(self, measurement: NDArray) -> None:
98
+ """
99
+ Update step with measurement.
100
+
101
+ Parameters
102
+ ----------
103
+ measurement : NDArray
104
+ The measurement vector used to update the state estimate.
105
+
106
+ Returns
107
+ -------
108
+ None
109
+ This method modifies the instance attributes in-place and does not return anything.
110
+
111
+ Notes
112
+ -----
113
+ This function performs the update step of a Kalman filter. It computes the innovation,
114
+ innovation covariance, Kalman gain, and then updates the state estimate and covariance
115
+ matrix based on the measurement.
116
+
117
+ Examples
118
+ --------
119
+ >>> kf = KalmanFilter()
120
+ >>> measurement = np.array([1.0, 2.0])
121
+ >>> kf.update(measurement)
122
+ >>> print(kf.x)
123
+ [0.95 1.98]
124
+ """
125
+ # Innovation
126
+ y = measurement - self.H @ self.x
127
+
128
+ # Innovation covariance
129
+ S = self.H @ self.P @ self.H.T + self.R
130
+
131
+ # Kalman gain
132
+ K = self.P @ self.H.T @ np.linalg.inv(S)
133
+
134
+ # Update state
135
+ self.x = self.x + K @ y
136
+
137
+ # Update covariance
138
+ I = np.eye(self.P.shape[0])
139
+ self.P = (I - K @ self.H) @ self.P
140
+
141
+ def filter_signal(self, signal_data: NDArray, missing_indices: list | None = None) -> NDArray:
142
+ """
143
+ Filter entire signal and interpolate missing data using a Kalman filter approach.
144
+
145
+ This function applies a filtering process to signal data, handling missing values
146
+ by either prediction-only steps or full update steps depending on the presence
147
+ of missing data points. Missing values are represented as np.nan in the input
148
+ signal_data.
149
+
150
+ Parameters
151
+ ----------
152
+ signal_data : array-like
153
+ Input signal data where missing values are represented as np.nan
154
+ missing_indices : array-like, optional
155
+ Indices of missing data points in the signal. If None, no indices are
156
+ considered missing. Default is None.
157
+
158
+ Returns
159
+ -------
160
+ filtered_signal : ndarray
161
+ Filtered and interpolated signal with the same shape as input signal_data
162
+
163
+ Notes
164
+ -----
165
+ The function uses a Kalman filter framework where:
166
+ - For missing data points or NaN values, only prediction steps are performed
167
+ - For valid measurements, both prediction and update steps are performed
168
+ - The filtered result is stored in the state vector x[0, 0] after each step
169
+
170
+ Examples
171
+ --------
172
+ >>> # Basic usage with missing data
173
+ >>> signal = np.array([1.0, np.nan, 3.0, 4.0, np.nan, 6.0])
174
+ >>> missing_indices = [1, 4]
175
+ >>> filtered = filter_signal(signal, missing_indices)
176
+
177
+ >>> # Usage without missing data
178
+ >>> signal = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
179
+ >>> filtered = filter_signal(signal)
180
+ """
181
+ filtered = np.zeros(len(signal_data))
182
+
183
+ for i, measurement in enumerate(signal_data):
184
+ self.predict()
185
+
186
+ # If data is missing or NaN, skip update step (prediction only)
187
+ if missing_indices is not None and i in missing_indices:
188
+ filtered[i] = self.x[0, 0]
189
+ elif np.isnan(measurement):
190
+ filtered[i] = self.x[0, 0]
191
+ else:
192
+ self.update(np.array([[measurement]]))
193
+ filtered[i] = self.x[0, 0]
194
+
195
+ return filtered
196
+
197
+
198
+ class AdaptivePPGKalmanFilter:
199
+ """
200
+ Adaptive Kalman filter for PPG signals with motion artifact detection.
201
+ Adjusts parameters based on signal characteristics and detects motion artifacts.
202
+ """
203
+
204
+ def __init__(
205
+ self,
206
+ dt: float = 0.01,
207
+ initial_process_noise: float = 0.001,
208
+ initial_measurement_noise: float = 0.05,
209
+ ) -> None:
210
+ """
211
+ Initialize the Kalman filter with default parameters.
212
+
213
+ Parameters
214
+ ----------
215
+ dt : float, optional
216
+ Time step for the Kalman filter, by default 0.01
217
+ initial_process_noise : float, optional
218
+ Initial process noise covariance, by default 0.001
219
+ initial_measurement_noise : float, optional
220
+ Initial measurement noise covariance, by default 0.05
221
+
222
+ Returns
223
+ -------
224
+ None
225
+ This method initializes the instance variables and does not return anything.
226
+
227
+ Notes
228
+ -----
229
+ This constructor sets up a 2D Kalman filter for position and velocity estimation.
230
+ The filter uses a constant velocity model with adaptive noise scaling.
231
+
232
+ Examples
233
+ --------
234
+ >>> filter = KalmanFilter()
235
+ >>> filter = KalmanFilter(dt=0.02, initial_process_noise=0.005)
236
+ """
237
+ self.dt = dt
238
+ self.x = np.array([[0.0], [0.0]])
239
+ self.F = np.array([[1, dt], [0, 1]])
240
+ self.H = np.array([[1, 0]])
241
+ self.P = np.eye(2)
242
+
243
+ # Adaptive noise parameters
244
+ self.Q_scale = initial_process_noise
245
+ self.R_base = initial_measurement_noise
246
+ self.R = np.array([[initial_measurement_noise]])
247
+
248
+ # Innovation history for adaptation and motion detection
249
+ self.innovation_history = []
250
+ self.window_size = 30 # Smaller window for faster adaptation
251
+
252
+ # Motion artifact detection
253
+ self.motion_threshold = 3.0 # Standard deviations
254
+
255
+ def detect_motion_artifact(self, innovation: NDArray) -> bool:
256
+ """
257
+ Detect potential motion artifacts based on innovation magnitude.
258
+
259
+ This function analyzes the innovation signal to identify potential motion artifacts
260
+ by computing a z-score against the recent history of innovation values.
261
+
262
+ Parameters
263
+ ----------
264
+ innovation : NDArray
265
+ The innovation signal to be analyzed, typically representing the difference
266
+ between predicted and actual measurements. Expected to be a 2D array with
267
+ shape (1, 1) for single sample analysis.
268
+
269
+ Returns
270
+ -------
271
+ bool
272
+ True if a motion artifact is detected (z-score exceeds threshold),
273
+ False otherwise.
274
+
275
+ Notes
276
+ -----
277
+ The function requires at least 10 samples in the innovation history to make
278
+ a detection decision. If the recent standard deviation is zero, the function
279
+ returns False to avoid division by zero errors.
280
+
281
+ Examples
282
+ --------
283
+ >>> detector = MotionDetector()
284
+ >>> detector.innovation_history = [0.1, 0.2, 0.15, 0.18, 0.22, 0.19, 0.21, 0.17, 0.23, 0.20]
285
+ >>> detector.motion_threshold = 3.0
286
+ >>> result = detector.detect_motion_artifact(np.array([[5.0]]))
287
+ >>> print(result)
288
+ True
289
+ """
290
+ if len(self.innovation_history) < 10:
291
+ return False
292
+
293
+ recent_std = np.std(self.innovation_history[-10:])
294
+ if recent_std > 0:
295
+ z_score = abs(innovation[0, 0]) / recent_std
296
+ return z_score > self.motion_threshold
297
+ return False
298
+
299
+ def adapt_noise(self, innovation: NDArray, is_motion_artifact: bool) -> None:
300
+ """
301
+ Adapt noise parameters based on signal characteristics.
302
+
303
+ This method adjusts the measurement and process noise covariance matrices
304
+ based on the current innovation signal and motion artifact detection.
305
+
306
+ Parameters
307
+ ----------
308
+ innovation : NDArray
309
+ The innovation signal, typically the difference between predicted and
310
+ actual measurements. Expected to be a 2D array with shape (1, 1) for
311
+ single-dimensional measurements.
312
+ is_motion_artifact : bool
313
+ Flag indicating whether a motion artifact has been detected in the signal.
314
+ When True, measurement noise is increased to handle the artifact.
315
+
316
+ Returns
317
+ -------
318
+ None
319
+ This method modifies the instance attributes in-place and does not return
320
+ any value.
321
+
322
+ Notes
323
+ -----
324
+ The method maintains a sliding window history of innovation values to compute
325
+ statistical measures for process noise adaptation. When motion artifacts are
326
+ detected, the measurement noise covariance matrix R is increased by a factor
327
+ of 10 to reduce the filter's trust in current measurements.
328
+
329
+ Examples
330
+ --------
331
+ >>> # Assuming self is a Kalman filter instance
332
+ >>> innovation = np.array([[0.5]])
333
+ >>> adapt_noise(innovation, is_motion_artifact=True)
334
+ >>> # Measurement noise R is increased, process noise Q_scale is adjusted
335
+ """
336
+ self.innovation_history.append(abs(innovation[0, 0]))
337
+
338
+ if len(self.innovation_history) > self.window_size:
339
+ self.innovation_history.pop(0)
340
+
341
+ # Adjust measurement noise if motion artifact detected
342
+ if is_motion_artifact:
343
+ self.R = np.array([[self.R_base * 10]]) # Increase measurement noise
344
+ else:
345
+ self.R = np.array([[self.R_base]])
346
+
347
+ # Adjust process noise based on signal variability
348
+ if len(self.innovation_history) >= self.window_size:
349
+ innovation_std = np.std(self.innovation_history)
350
+ # PPG needs lower process noise due to smoother signal
351
+ self.Q_scale = max(0.0001, min(0.01, innovation_std * 0.05))
352
+
353
+ def predict(self) -> None:
354
+ Q = (
355
+ np.array([[self.dt**4 / 4, self.dt**3 / 2], [self.dt**3 / 2, self.dt**2]])
356
+ * self.Q_scale
357
+ )
358
+ self.x = self.F @ self.x
359
+ self.P = self.F @ self.P @ self.F.T + Q
360
+
361
+ def update(self, measurement: NDArray) -> bool:
362
+ """
363
+ Update the state estimate using the Kalman filter update step.
364
+
365
+ This method performs the measurement update step of the Kalman filter, incorporating
366
+ new measurements into the state estimate while accounting for measurement noise
367
+ and potential motion artifacts.
368
+
369
+ Parameters
370
+ ----------
371
+ measurement : NDArray
372
+ The new measurement vector used to update the state estimate.
373
+
374
+ Returns
375
+ -------
376
+ bool
377
+ True if motion artifact was detected, False otherwise.
378
+
379
+ Notes
380
+ -----
381
+ The update process includes:
382
+ 1. Computing the innovation (measurement residual)
383
+ 2. Detecting motion artifacts using the detect_motion_artifact method
384
+ 3. Computing the Kalman gain
385
+ 4. Updating the state estimate and covariance matrix
386
+ 5. Adapting noise parameters based on the measurement residual and motion detection
387
+
388
+ Examples
389
+ --------
390
+ >>> kf = KalmanFilter()
391
+ >>> measurement = np.array([1.0, 2.0])
392
+ >>> motion_detected = kf.update(measurement)
393
+ >>> print(f"Motion artifact detected: {motion_detected}")
394
+ """
395
+ y = measurement - self.H @ self.x
396
+
397
+ # Detect motion artifact before updating
398
+ is_motion = self.detect_motion_artifact(y)
399
+
400
+ S = self.H @ self.P @ self.H.T + self.R
401
+ K = self.P @ self.H.T @ np.linalg.inv(S)
402
+
403
+ self.x = self.x + K @ y
404
+ I = np.eye(self.P.shape[0])
405
+ self.P = (I - K @ self.H) @ self.P
406
+
407
+ self.adapt_noise(y, is_motion)
408
+
409
+ return is_motion
410
+
411
+ def filter_signal(self, signal_data: NDArray, missing_indices: list | None = None) -> NDArray:
412
+ """
413
+ Apply filtering to signal data using a Kalman filter approach.
414
+
415
+ This function processes signal measurements using a Kalman filter framework,
416
+ handling missing data and NaN values appropriately while tracking motion detection
417
+ flags for each measurement.
418
+
419
+ Parameters
420
+ ----------
421
+ signal_data : NDArray
422
+ Array containing the signal measurements to be filtered.
423
+ missing_indices : list of int, optional
424
+ List of indices where measurements are missing. If None, no special
425
+ handling is performed for missing data. Default is None.
426
+
427
+ Returns
428
+ -------
429
+ tuple of (NDArray, NDArray)
430
+ A tuple containing:
431
+ - filtered : NDArray
432
+ Array of filtered signal values
433
+ - motion_flags : NDArray of bool
434
+ Boolean array indicating motion detection for each measurement
435
+
436
+ Notes
437
+ -----
438
+ The function uses a prediction-update cycle for each measurement:
439
+ 1. Prediction step is always performed
440
+ 2. For missing measurements or NaN values, the current state estimate is returned
441
+ 3. For valid measurements, the update step is performed and motion is detected
442
+
443
+ Examples
444
+ --------
445
+ >>> # Basic usage
446
+ >>> filtered_data, motion_flags = filter_signal(signal, missing_indices=[2, 5])
447
+ >>>
448
+ >>> # With no missing indices
449
+ >>> filtered_data, motion_flags = filter_signal(signal)
450
+ """
451
+ filtered = np.zeros(len(signal_data))
452
+ motion_flags = np.zeros(len(signal_data), dtype=bool)
453
+
454
+ for i, measurement in enumerate(signal_data):
455
+ self.predict()
456
+
457
+ if missing_indices is not None and i in missing_indices:
458
+ filtered[i] = self.x[0, 0]
459
+ elif np.isnan(measurement):
460
+ filtered[i] = self.x[0, 0]
461
+ else:
462
+ is_motion = self.update(np.array([[measurement]]))
463
+ filtered[i] = self.x[0, 0]
464
+ motion_flags[i] = is_motion
465
+
466
+ return filtered, motion_flags
467
+
468
+
469
+ class ExtendedPPGKalmanFilter:
470
+ """
471
+ Extended Kalman Filter for PPG with sinusoidal model.
472
+ Models the PPG waveform as a sinusoid with varying amplitude and baseline.
473
+ Better for capturing the periodic nature of PPG signals.
474
+ """
475
+
476
+ def __init__(
477
+ self,
478
+ dt: float = 0.01,
479
+ hr_estimate: float = 75,
480
+ process_noise: float = 0.001,
481
+ measurement_noise: float = 0.05,
482
+ ) -> None:
483
+ """
484
+ Initialize the Kalman filter for heart rate estimation from PPG signals.
485
+
486
+ This constructor initializes the state vector and covariance matrices for a
487
+ Kalman filter designed to estimate heart rate from photoplethysmography (PPG)
488
+ signals. The filter models the PPG signal as a sinusoidal waveform with
489
+ time-varying parameters.
490
+
491
+ Parameters
492
+ ----------
493
+ dt : float, optional
494
+ Sampling interval in seconds (default 0.01 for 100Hz sampling, typical for PPG)
495
+ hr_estimate : float, optional
496
+ Initial heart rate estimate in beats per minute (BPM) (default 75)
497
+ process_noise : float, optional
498
+ Process noise covariance (Q). PPG is smoother, so use lower values (0.0001-0.01)
499
+ (default 0.001)
500
+ measurement_noise : float, optional
501
+ Measurement noise covariance (R). Represents sensor/motion artifact noise
502
+ (default 0.05)
503
+
504
+ Returns
505
+ -------
506
+ None
507
+ This method initializes the object's attributes in-place and does not return a value.
508
+
509
+ Notes
510
+ -----
511
+ The state vector x contains [DC offset, amplitude, phase, frequency] representing
512
+ the sinusoidal model of the PPG signal. The filter uses a constant velocity model
513
+ for the frequency component to track heart rate variations.
514
+
515
+ Examples
516
+ --------
517
+ >>> filter = KalmanFilter(dt=0.02, hr_estimate=80, process_noise=0.005)
518
+ >>> print(filter.x)
519
+ [[0.]
520
+ [1.]
521
+ [0.]
522
+ [2.0943951023931953]]
523
+ """
524
+ # State: [DC offset, amplitude, phase, frequency]
525
+ self.x = np.array([[0.0], [1.0], [0.0], [2 * np.pi * hr_estimate / 60]])
526
+ self.dt = dt
527
+
528
+ self.H = np.array([[1, 0, 0, 0]]) # We measure the overall signal
529
+
530
+ self.Q = np.eye(4) * process_noise
531
+ self.R = np.array([[measurement_noise]])
532
+ self.P = np.eye(4) * 0.1
533
+
534
+ # For heart rate extraction
535
+ self.hr_history = []
536
+
537
+ def get_heart_rate(self) -> float:
538
+ """
539
+ Extract current heart rate estimate from state.
540
+
541
+ This function converts the angular frequency stored in the state vector
542
+ to beats per minute (BPM), which represents the heart rate.
543
+
544
+ Parameters
545
+ ----------
546
+ self : object
547
+ The object instance containing the state vector. The state vector
548
+ is expected to have at least 4 elements, with the third element
549
+ (index 3) containing the angular frequency in radians/second.
550
+
551
+ Returns
552
+ -------
553
+ float
554
+ The estimated heart rate in beats per minute (BPM).
555
+
556
+ Notes
557
+ -----
558
+ The conversion from angular frequency (radians/second) to heart rate (BPM)
559
+ is calculated as: HR = ω × 60 / (2π), where ω is the angular frequency.
560
+
561
+ Examples
562
+ --------
563
+ >>> heart_rate = obj.get_heart_rate()
564
+ >>> print(f"Heart rate: {heart_rate:.1f} BPM")
565
+ Heart rate: 72.0 BPM
566
+ """
567
+ frequency = self.x[3, 0] # radians/second
568
+ hr = frequency * 60 / (2 * np.pi) # Convert to BPM
569
+ return hr
570
+
571
+ def state_transition(self, x: NDArray) -> NDArray:
572
+ """
573
+ Nonlinear state transition for sinusoidal model.
574
+
575
+ This function performs the state transition for a sinusoidal model, updating
576
+ the phase based on the frequency and time step, while keeping other state
577
+ variables unchanged.
578
+
579
+ Parameters
580
+ ----------
581
+ x : ndarray
582
+ Input state vector of shape (4,) containing [dc_offset, amplitude, phase, frequency]
583
+
584
+ Returns
585
+ -------
586
+ ndarray
587
+ Transited state vector of shape (4, 1) containing [dc_offset, amplitude, new_phase, frequency]
588
+ where new_phase = (phase + frequency * dt) % (2 * pi)
589
+
590
+ Notes
591
+ -----
592
+ The phase is updated using the formula: new_phase = (phase + freq * dt) % (2 * pi)
593
+ This ensures the phase remains within the range [0, 2π).
594
+
595
+ Examples
596
+ --------
597
+ >>> import numpy as np
598
+ >>> # Example usage
599
+ >>> x = np.array([[1.0], [2.0], [0.5], [0.1]])
600
+ >>> # Assuming self.dt = 0.01
601
+ >>> result = state_transition(x)
602
+ >>> print(result)
603
+ [[1.0]
604
+ [2.0]
605
+ [0.501]
606
+ [0.1]]
607
+ """
608
+ dc, amp, phase, freq = x.flatten()
609
+
610
+ # Update phase based on frequency
611
+ new_phase = (phase + freq * self.dt) % (2 * np.pi)
612
+
613
+ return np.array([[dc], [amp], [new_phase], [freq]])
614
+
615
+ def measurement_function(self, x: NDArray) -> NDArray:
616
+ """
617
+ Measurement model: DC + amplitude * sin(phase)
618
+
619
+ This function implements a measurement model that combines a DC component with a sinusoidal
620
+ signal. The model is defined as: y = dc + amp * sin(phase), where dc is the DC offset,
621
+ amp is the amplitude, and phase is the phase angle.
622
+
623
+ Parameters
624
+ ----------
625
+ x : ndarray
626
+ Input array of shape (4,) containing the model parameters in order:
627
+ [dc, amp, phase, freq]
628
+ - dc : float
629
+ DC offset component
630
+ - amp : float
631
+ Amplitude of the sinusoidal signal
632
+ - phase : float
633
+ Phase angle of the sinusoidal signal (in radians)
634
+ - freq : float
635
+ Frequency of the sinusoidal signal (not used in current implementation)
636
+
637
+ Returns
638
+ -------
639
+ ndarray
640
+ Measurement output array of shape (1, 1) containing the computed measurement:
641
+ [[dc + amp * sin(phase)]]
642
+
643
+ Notes
644
+ -----
645
+ The frequency parameter is included in the input array but not used in the current
646
+ implementation of the measurement model.
647
+
648
+ Examples
649
+ --------
650
+ >>> import numpy as np
651
+ >>> x = np.array([1.0, 2.0, np.pi/4, 1.0])
652
+ >>> result = measurement_function(None, x)
653
+ >>> print(result)
654
+ [[2.41421356]]
655
+ """
656
+ dc, amp, phase, freq = x.flatten()
657
+ return np.array([[dc + amp * np.sin(phase)]])
658
+
659
+ def predict(self) -> None:
660
+ """
661
+ EKF prediction step
662
+
663
+ Performs the prediction step of the Extended Kalman Filter (EKF) algorithm.
664
+ This step propagates the state estimate and covariance matrix forward in time
665
+ using the state transition model and process noise.
666
+
667
+ Parameters
668
+ ----------
669
+ self : object
670
+ The EKF instance containing the following attributes:
671
+ - x : array-like
672
+ Current state vector
673
+ - P : array-like
674
+ Current covariance matrix
675
+ - dt : float
676
+ Time step
677
+ - Q : array-like
678
+ Process noise covariance matrix
679
+ - state_transition : callable
680
+ Function that computes the state transition
681
+
682
+ Returns
683
+ -------
684
+ None
685
+ This method modifies the instance attributes in-place and does not return anything.
686
+
687
+ Notes
688
+ -----
689
+ The prediction step follows the standard EKF equations:
690
+ - State prediction: x = f(x)
691
+ - Covariance prediction: P = F * P * F^T + Q
692
+
693
+ The state transition matrix F is hardcoded for a 4-dimensional state vector
694
+ with position and velocity components, where the third component (typically
695
+ acceleration) is integrated over time.
696
+
697
+ Examples
698
+ --------
699
+ >>> ekf = EKF()
700
+ >>> ekf.predict()
701
+ >>> # State and covariance matrices are updated in-place
702
+ """
703
+ # Propagate state
704
+ self.x = self.state_transition(self.x)
705
+
706
+ # Jacobian of state transition
707
+ F = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, self.dt], [0, 0, 0, 1]])
708
+
709
+ self.P = F @ self.P @ F.T + self.Q
710
+
711
+ def update(self, measurement: NDArray) -> None:
712
+ """
713
+ EKF update step.
714
+
715
+ Perform the measurement update step of the Extended Kalman Filter (EKF).
716
+
717
+ Parameters
718
+ ----------
719
+ measurement : NDArray
720
+ The actual measurement vector used to update the state estimate.
721
+
722
+ Returns
723
+ -------
724
+ None
725
+ This method modifies the state and covariance matrices in-place.
726
+
727
+ Notes
728
+ -----
729
+ This implementation assumes a specific measurement function structure where the state vector
730
+ contains [dc, amp, phase, freq] components. The phase is constrained to remain within [0, 2π]
731
+ after each update.
732
+
733
+ Examples
734
+ --------
735
+ >>> ekf = ExtendedKalmanFilter()
736
+ >>> measurement = np.array([[1.0], [0.5], [0.2]])
737
+ >>> ekf.update(measurement)
738
+ >>> print(ekf.x)
739
+ [[1.0]
740
+ [0.5]
741
+ [0.2]
742
+ [0.0]]
743
+ """
744
+ # Predicted measurement
745
+ z_pred = self.measurement_function(self.x)
746
+
747
+ # Innovation
748
+ y = measurement - z_pred
749
+
750
+ # Jacobian of measurement function
751
+ dc, amp, phase, freq = self.x.flatten()
752
+ H = np.array([[1, np.sin(phase), amp * np.cos(phase), 0]])
753
+
754
+ # Innovation covariance
755
+ S = H @ self.P @ H.T + self.R
756
+
757
+ # Kalman gain
758
+ K = self.P @ H.T / S
759
+
760
+ # Update state
761
+ self.x = self.x + K * y
762
+
763
+ # Ensure phase stays in [0, 2π]
764
+ self.x[2, 0] = self.x[2, 0] % (2 * np.pi)
765
+
766
+ # Update covariance
767
+ I = np.eye(4)
768
+ self.P = (I - K @ H) @ self.P
769
+
770
+ def filter_signal(self, signal_data: NDArray, missing_indices: list | None = None) -> NDArray:
771
+ """
772
+ Apply filtering to signal data using a Kalman filter approach.
773
+
774
+ This function processes signal data through a Kalman filter, handling missing measurements
775
+ and NaN values appropriately. It returns both the filtered signal and corresponding heart rates.
776
+
777
+ Parameters
778
+ ----------
779
+ signal_data : NDArray
780
+ Array containing the raw signal measurements to be filtered.
781
+ missing_indices : list of int, optional
782
+ List of indices where measurements are missing. If None, no special handling
783
+ is applied for missing data. Default is None.
784
+
785
+ Returns
786
+ -------
787
+ tuple of (NDArray, NDArray)
788
+ A tuple containing:
789
+ - filtered : NDArray
790
+ Array of filtered signal values corresponding to the input measurements
791
+ - heart_rates : NDArray
792
+ Array of heart rate values computed at each time step
793
+
794
+ Notes
795
+ -----
796
+ The function uses the following logic for each measurement:
797
+ - For missing indices: Uses current state prediction
798
+ - For NaN values: Uses current state prediction
799
+ - For valid measurements: Updates the filter with the measurement and returns the prediction
800
+
801
+ Examples
802
+ --------
803
+ >>> filtered_signal, hr_values = filter_signal(signal_data, missing_indices=[2, 5])
804
+ >>> print(f"Filtered signal shape: {filtered_signal.shape}")
805
+ >>> print(f"Heart rates shape: {hr_values.shape}")
806
+ """
807
+ filtered = np.zeros(len(signal_data))
808
+ heart_rates = np.zeros(len(signal_data))
809
+
810
+ for i, measurement in enumerate(signal_data):
811
+ self.predict()
812
+
813
+ if missing_indices is not None and i in missing_indices:
814
+ filtered[i] = self.measurement_function(self.x)[0, 0]
815
+ elif np.isnan(measurement):
816
+ filtered[i] = self.measurement_function(self.x)[0, 0]
817
+ else:
818
+ self.update(np.array([[measurement]]))
819
+ filtered[i] = self.measurement_function(self.x)[0, 0]
820
+
821
+ # Track heart rate
822
+ hr = self.get_heart_rate()
823
+ heart_rates[i] = hr
824
+ self.hr_history.append(hr)
825
+
826
+ return filtered, heart_rates
827
+
828
+
829
+ class HarmonicPPGKalmanFilter:
830
+ """
831
+ Extended Kalman Filter for PPG with harmonic sinusoidal model.
832
+ Models the PPG waveform as a fundamental sinusoid plus its first two harmonics.
833
+ This provides a more sophisticated representation of the PPG signal, capturing
834
+ the dicrotic notch and other morphological features.
835
+
836
+ State vector: [DC offset, A1, A2, A3, phase, frequency]
837
+ where:
838
+ DC offset: baseline
839
+ A1: amplitude of fundamental frequency
840
+ A2: amplitude of second harmonic (2f)
841
+ A3: amplitude of third harmonic (3f)
842
+ phase: phase of fundamental
843
+ frequency: angular frequency (rad/s)
844
+
845
+ Measurement model: y = DC + A1*sin(phase) + A2*sin(2*phase) + A3*sin(3*phase)
846
+ """
847
+
848
+ def __init__(
849
+ self,
850
+ dt: float = 0.01,
851
+ hr_estimate: float = 75,
852
+ process_noise: float = 0.001,
853
+ measurement_noise: float = 0.05,
854
+ ) -> None:
855
+ """
856
+ Initialize the Kalman filter for heart rate estimation from PPG signals.
857
+
858
+ This constructor sets up the state vector, covariance matrices, and noise parameters
859
+ for a Kalman filter designed to track the fundamental frequency and harmonics of
860
+ a photoplethysmography (PPG) signal to extract heart rate information.
861
+
862
+ Parameters
863
+ ----------
864
+ dt : float, optional
865
+ Sampling interval in seconds (default is 0.01, corresponding to 100 Hz).
866
+ hr_estimate : float, optional
867
+ Initial heart rate estimate in beats per minute (BPM) (default is 75).
868
+ process_noise : float, optional
869
+ Process noise covariance (Q). Controls how much the state can change over time
870
+ (default is 0.001).
871
+ measurement_noise : float, optional
872
+ Measurement noise covariance (R). Represents sensor noise level (default is 0.05).
873
+
874
+ Returns
875
+ -------
876
+ None
877
+ This method initializes instance attributes and does not return any value.
878
+
879
+ Notes
880
+ -----
881
+ The state vector contains six elements:
882
+ [DC offset, A1, A2, A3, phase, frequency]
883
+
884
+ The Kalman filter uses a linear model to track the time-varying components of the
885
+ PPG signal, with the frequency component directly related to heart rate.
886
+
887
+ Examples
888
+ --------
889
+ >>> kf = KalmanFilter(dt=0.02, hr_estimate=80, process_noise=0.005)
890
+ >>> print(kf.x)
891
+ [[0. ]
892
+ [1. ]
893
+ [0.2]
894
+ [0.1]
895
+ [0. ]
896
+ [2.0943951023931953]]
897
+ """
898
+ # State: [DC offset, A1, A2, A3, phase, frequency]
899
+ # Initialize with reasonable defaults for PPG signals
900
+ self.x = np.array(
901
+ [
902
+ [0.0], # DC offset (will be adjusted from data)
903
+ [1.0], # A1 - fundamental amplitude
904
+ [0.2], # A2 - second harmonic amplitude (typically ~20% of fundamental)
905
+ [0.1], # A3 - third harmonic amplitude (typically ~10% of fundamental)
906
+ [0.0], # phase
907
+ [2 * np.pi * hr_estimate / 60], # frequency in rad/s
908
+ ]
909
+ )
910
+ self.dt = dt
911
+
912
+ # Measurement matrix - we measure the overall signal
913
+ self.H = np.array([[1, 0, 0, 0, 0, 0]])
914
+
915
+ # Process noise - allow more flexibility in amplitudes and phase
916
+ Q_diag = np.array(
917
+ [
918
+ process_noise * 0.1, # DC changes slowly
919
+ process_noise, # A1 fundamental amplitude
920
+ process_noise * 0.5, # A2 second harmonic
921
+ process_noise * 0.5, # A3 third harmonic
922
+ process_noise * 2.0, # phase (changes fastest)
923
+ process_noise * 0.1, # frequency (changes slowly)
924
+ ]
925
+ )
926
+ self.Q = np.diag(Q_diag)
927
+
928
+ self.R = np.array([[measurement_noise]])
929
+ self.P = np.eye(6) * 0.1
930
+
931
+ # For heart rate extraction
932
+ self.hr_history = []
933
+
934
+ def get_heart_rate(self) -> float:
935
+ """
936
+ Extract current heart rate estimate from state.
937
+
938
+ This function converts the angular frequency stored in the state vector
939
+ to beats per minute (BPM), which represents the current heart rate estimate.
940
+
941
+ Parameters
942
+ ----------
943
+ self : object
944
+ The instance containing the state vector with heart rate information.
945
+ The state vector is expected to have at least 6 elements, with the 6th
946
+ element (index 5) containing the angular frequency in radians/second.
947
+
948
+ Returns
949
+ -------
950
+ float
951
+ Current heart rate estimate in beats per minute (BPM).
952
+
953
+ Notes
954
+ -----
955
+ The conversion from angular frequency (radians/second) to BPM is calculated as:
956
+ BPM = frequency × 60 / (2 × π)
957
+
958
+ Examples
959
+ --------
960
+ >>> hr = obj.get_heart_rate()
961
+ >>> print(f"Current heart rate: {hr:.1f} BPM")
962
+ Current heart rate: 72.0 BPM
963
+ """
964
+ frequency = self.x[5, 0] # radians/second
965
+ hr = frequency * 60 / (2 * np.pi) # Convert to BPM
966
+ return hr
967
+
968
+ def state_transition(self, x: NDArray) -> NDArray:
969
+ """
970
+ Nonlinear state transition for harmonic sinusoidal model.
971
+
972
+ This function performs the state transition for a harmonic sinusoidal model
973
+ where the phase evolves according to the frequency and time step, while other
974
+ state variables remain constant.
975
+
976
+ Parameters
977
+ ----------
978
+ x : ndarray
979
+ State vector of shape (6,) containing:
980
+ - dc: DC offset component
981
+ - a1: First harmonic amplitude
982
+ - a2: Second harmonic amplitude
983
+ - a3: Third harmonic amplitude
984
+ - phase: Current phase value
985
+ - freq: Frequency value
986
+
987
+ Returns
988
+ -------
989
+ ndarray
990
+ Transited state vector of shape (6, 1) with updated phase and unchanged
991
+ other state components.
992
+
993
+ Notes
994
+ -----
995
+ The phase is updated using the formula: new_phase = (phase + freq * dt) % (2 * π)
996
+ where dt is the time step stored in self.dt. This ensures the phase remains
997
+ within the [0, 2π) range.
998
+
999
+ Examples
1000
+ --------
1001
+ >>> import numpy as np
1002
+ >>> model = HarmonicModel(dt=0.1)
1003
+ >>> x = np.array([[1.0], [0.5], [0.3], [0.2], [0.1], [0.05]])
1004
+ >>> x_new = model.state_transition(x)
1005
+ >>> print(x_new)
1006
+ [[1.0]
1007
+ [0.5]
1008
+ [0.3]
1009
+ [0.2]
1010
+ [0.105]
1011
+ [0.05]]
1012
+ """
1013
+ dc, a1, a2, a3, phase, freq = x.flatten()
1014
+
1015
+ # Update phase based on frequency
1016
+ new_phase = (phase + freq * self.dt) % (2 * np.pi)
1017
+
1018
+ # Other states remain constant in the model
1019
+ return np.array([[dc], [a1], [a2], [a3], [new_phase], [freq]])
1020
+
1021
+ def measurement_function(self, x: NDArray) -> NDArray:
1022
+ """
1023
+ Measurement model: DC + A1*sin(phase) + A2*sin(2*phase) + A3*sin(3*phase)
1024
+ This models the fundamental frequency and first two harmonics.
1025
+
1026
+ Parameters
1027
+ ----------
1028
+ x : ndarray
1029
+ Input array of shape (6,) containing parameters in order:
1030
+ [dc, a1, a2, a3, phase, freq]
1031
+ - dc: DC offset component
1032
+ - a1: Amplitude of fundamental frequency
1033
+ - a2: Amplitude of second harmonic
1034
+ - a3: Amplitude of third harmonic
1035
+ - phase: Phase angle in radians
1036
+ - freq: Frequency component (not directly used in calculation)
1037
+
1038
+ Returns
1039
+ -------
1040
+ ndarray
1041
+ Measurement output array of shape (1, 1) containing the computed signal value.
1042
+ The output represents a sum of sinusoidal components with different frequencies
1043
+ and amplitudes, modeling a signal with fundamental and harmonic components.
1044
+
1045
+ Notes
1046
+ -----
1047
+ This function implements a harmonic model that combines:
1048
+ - A DC component (dc)
1049
+ - Fundamental frequency component (a1 * sin(phase))
1050
+ - Second harmonic component (a2 * sin(2 * phase))
1051
+ - Third harmonic component (a3 * sin(3 * phase))
1052
+
1053
+ The frequency parameter is included in the input array but not used in the computation,
1054
+ suggesting this might be part of a larger system where frequency is handled elsewhere.
1055
+
1056
+ Examples
1057
+ --------
1058
+ >>> import numpy as np
1059
+ >>> x = np.array([1.0, 0.5, 0.3, 0.2, np.pi/4, 1.0])
1060
+ >>> result = measurement_function(x)
1061
+ >>> print(result)
1062
+ [[1.82940168]]
1063
+ """
1064
+ dc, a1, a2, a3, phase, freq = x.flatten()
1065
+ y = dc + a1 * np.sin(phase) + a2 * np.sin(2 * phase) + a3 * np.sin(3 * phase)
1066
+ return np.array([[y]])
1067
+
1068
+ def predict(self) -> None:
1069
+ """
1070
+ EKF prediction step.
1071
+
1072
+ Performs the prediction step of the Extended Kalman Filter (EKF) algorithm,
1073
+ propagating the state estimate and error covariance forward in time.
1074
+
1075
+ Parameters
1076
+ ----------
1077
+ self : object
1078
+ The EKF instance containing the following attributes:
1079
+ - x : array-like
1080
+ Current state vector
1081
+ - P : array-like
1082
+ Current error covariance matrix
1083
+ - Q : array-like
1084
+ Process noise covariance matrix
1085
+ - dt : float
1086
+ Time step
1087
+
1088
+ Returns
1089
+ -------
1090
+ None
1091
+ This method modifies the instance attributes in-place and does not return anything.
1092
+
1093
+ Notes
1094
+ -----
1095
+ The prediction step involves:
1096
+ 1. State propagation using the state transition function
1097
+ 2. Jacobian matrix computation for the state transition
1098
+ 3. Error covariance prediction using the matrix equation: P = F*P*F^T + Q
1099
+
1100
+ The state transition matrix F is structured as:
1101
+ - First three rows represent constant state variables (dc, a1, a2)
1102
+ - Fourth row represents constant state variable (a3)
1103
+ - Fifth row represents phase that depends on frequency
1104
+ - Sixth row represents frequency with constant rate of change
1105
+
1106
+ Examples
1107
+ --------
1108
+ >>> ekf = EKF()
1109
+ >>> ekf.predict()
1110
+ >>> # State and covariance matrices are updated in-place
1111
+ """
1112
+ # Propagate state
1113
+ self.x = self.state_transition(self.x)
1114
+
1115
+ # Jacobian of state transition
1116
+ # dx_new/dx_old for each state variable
1117
+ F = np.array(
1118
+ [
1119
+ [1, 0, 0, 0, 0, 0], # dc
1120
+ [0, 1, 0, 0, 0, 0], # a1
1121
+ [0, 0, 1, 0, 0, 0], # a2
1122
+ [0, 0, 0, 1, 0, 0], # a3
1123
+ [0, 0, 0, 0, 1, self.dt], # phase depends on frequency
1124
+ [0, 0, 0, 0, 0, 1], # freq
1125
+ ]
1126
+ )
1127
+
1128
+ self.P = F @ self.P @ F.T + self.Q
1129
+
1130
+ def update(self, measurement: NDArray) -> None:
1131
+ """
1132
+ EKF update step.
1133
+
1134
+ Perform the Kalman filter update step using the provided measurement.
1135
+
1136
+ Parameters
1137
+ ----------
1138
+ measurement : NDArray
1139
+ The measured values used to update the state estimate. Shape should be
1140
+ compatible with the measurement function output.
1141
+
1142
+ Returns
1143
+ -------
1144
+ None
1145
+ This method modifies the instance's state variables `self.x` and `self.P`
1146
+ in place.
1147
+
1148
+ Notes
1149
+ -----
1150
+ The update step uses the measurement function to predict the measurement
1151
+ based on the current state, computes the innovation, and updates the state
1152
+ and covariance using the Kalman gain.
1153
+
1154
+ The phase component of the state vector is constrained to the range [0, 2π]
1155
+ after the update.
1156
+
1157
+ Examples
1158
+ --------
1159
+ >>> ekf = ExtendedKalmanFilter()
1160
+ >>> z = np.array([[1.0]])
1161
+ >>> ekf.update(z)
1162
+ """
1163
+ # Predicted measurement
1164
+ z_pred = self.measurement_function(self.x)
1165
+
1166
+ # Innovation
1167
+ y = measurement - z_pred
1168
+
1169
+ # Jacobian of measurement function h(x) = dc + a1*sin(φ) + a2*sin(2φ) + a3*sin(3φ)
1170
+ dc, a1, a2, a3, phase, freq = self.x.flatten()
1171
+
1172
+ # ∂h/∂dc = 1
1173
+ # ∂h/∂a1 = sin(φ)
1174
+ # ∂h/∂a2 = sin(2φ)
1175
+ # ∂h/∂a3 = sin(3φ)
1176
+ # ∂h/∂φ = a1*cos(φ) + 2*a2*cos(2φ) + 3*a3*cos(3φ)
1177
+ # ∂h/∂freq = 0
1178
+ H = np.array(
1179
+ [
1180
+ [
1181
+ 1, # ∂h/∂dc
1182
+ np.sin(phase), # ∂h/∂a1
1183
+ np.sin(2 * phase), # ∂h/∂a2
1184
+ np.sin(3 * phase), # ∂h/∂a3
1185
+ a1 * np.cos(phase)
1186
+ + 2 * a2 * np.cos(2 * phase)
1187
+ + 3 * a3 * np.cos(3 * phase), # ∂h/∂φ
1188
+ 0, # ∂h/∂freq
1189
+ ]
1190
+ ]
1191
+ )
1192
+
1193
+ # Innovation covariance
1194
+ S = H @ self.P @ H.T + self.R
1195
+
1196
+ # Kalman gain
1197
+ K = self.P @ H.T / S
1198
+
1199
+ # Update state
1200
+ self.x = self.x + K * y
1201
+
1202
+ # Ensure phase stays in [0, 2π]
1203
+ self.x[4, 0] = self.x[4, 0] % (2 * np.pi)
1204
+
1205
+ # Update covariance
1206
+ I = np.eye(6)
1207
+ self.P = (I - K @ H) @ self.P
1208
+
1209
+ def filter_signal(self, signal_data: NDArray, missing_indices: list | None = None) -> NDArray:
1210
+ """
1211
+ Filter entire signal and track heart rate.
1212
+
1213
+ This function applies a filtering process to the input signal data, handling missing values
1214
+ and tracking heart rate and harmonic amplitudes at each time point. It uses a Kalman filter
1215
+ framework for prediction and update steps, with special handling for missing data points.
1216
+
1217
+ Parameters
1218
+ ----------
1219
+ signal_data : array-like
1220
+ Input signal data. Use `np.nan` to indicate missing values.
1221
+ missing_indices : array-like, optional
1222
+ Indices of missing data points in `signal_data`. If not provided, all data points
1223
+ are assumed to be valid.
1224
+
1225
+ Returns
1226
+ -------
1227
+ filtered : ndarray
1228
+ Filtered and interpolated signal values.
1229
+ heart_rates : ndarray
1230
+ Estimated heart rate at each time point.
1231
+ harmonic_amplitudes : ndarray
1232
+ Array of shape (n_samples, 3) containing the amplitudes [A1, A2, A3] of the first
1233
+ three harmonics at each time point.
1234
+
1235
+ Notes
1236
+ -----
1237
+ - The function assumes the existence of a Kalman filter class with methods:
1238
+ `predict()`, `update()`, `measurement_function()`, and `get_heart_rate()`.
1239
+ - Heart rate is tracked and stored in `self.hr_history` during processing.
1240
+ - Missing data points are handled by using the current state estimate from the filter
1241
+ without updating the filter with new measurements.
1242
+
1243
+ Examples
1244
+ --------
1245
+ >>> filtered_signal, hr_estimates, harmonics = filter_signal(signal_data, missing_indices)
1246
+ >>> print(f"Filtered signal shape: {filtered_signal.shape}")
1247
+ >>> print(f"Heart rate estimates: {hr_estimates}")
1248
+ >>> print(f"Harmonic amplitudes: {harmonics}")
1249
+ """
1250
+ filtered = np.zeros(len(signal_data))
1251
+ heart_rates = np.zeros(len(signal_data))
1252
+ harmonic_amplitudes = np.zeros((len(signal_data), 3))
1253
+
1254
+ for i, measurement in enumerate(signal_data):
1255
+ self.predict()
1256
+
1257
+ if missing_indices is not None and i in missing_indices:
1258
+ filtered[i] = self.measurement_function(self.x)[0, 0]
1259
+ elif np.isnan(measurement):
1260
+ filtered[i] = self.measurement_function(self.x)[0, 0]
1261
+ else:
1262
+ self.update(np.array([[measurement]]))
1263
+ filtered[i] = self.measurement_function(self.x)[0, 0]
1264
+
1265
+ # Track heart rate
1266
+ hr = self.get_heart_rate()
1267
+ heart_rates[i] = hr
1268
+ self.hr_history.append(hr)
1269
+
1270
+ # Track harmonic amplitudes
1271
+ harmonic_amplitudes[i] = [self.x[1, 0], self.x[2, 0], self.x[3, 0]]
1272
+
1273
+ return filtered, heart_rates, harmonic_amplitudes
1274
+
1275
+
1276
+ class SignalQualityAssessor:
1277
+ """
1278
+ Assesses PPG signal quality based on multiple metrics.
1279
+ Provides a quality score from 0 (poor) to 1 (excellent).
1280
+ """
1281
+
1282
+ def __init__(self, fs: float = 100.0, window_size: float = 5.0) -> None:
1283
+ """
1284
+ Initialize the quality assessment parameters.
1285
+
1286
+ Parameters
1287
+ ----------
1288
+ fs : float, default=100.0
1289
+ Sampling frequency in Hz
1290
+ window_size : float, default=5.0
1291
+ Window size in seconds for quality assessment
1292
+
1293
+ Returns
1294
+ -------
1295
+ None
1296
+ This method initializes instance attributes but does not return any value
1297
+
1298
+ Notes
1299
+ -----
1300
+ The window size is converted to the number of samples based on the sampling frequency.
1301
+ The resulting number of samples is stored in ``self.window_samples``.
1302
+
1303
+ Examples
1304
+ --------
1305
+ >>> qa = QualityAssessor(fs=200.0, window_size=2.5)
1306
+ >>> qa.window_samples
1307
+ 500
1308
+ """
1309
+ self.fs = fs
1310
+ self.window_samples = int(window_size * fs)
1311
+
1312
+ def assess_quality(
1313
+ self, signal_segment: NDArray, filtered_segment: NDArray | None = None
1314
+ ) -> tuple[float, dict]:
1315
+ """
1316
+ Assess the quality of a signal segment based on multiple physiological and signal-processing metrics.
1317
+
1318
+ Parameters
1319
+ ----------
1320
+ signal_segment : ndarray
1321
+ The raw signal segment to be assessed.
1322
+ filtered_segment : ndarray, optional
1323
+ The filtered version of the signal segment. If provided, used to compute SNR.
1324
+ If not provided, SNR is set to a default value of 0.5.
1325
+
1326
+ Returns
1327
+ -------
1328
+ quality_score : float
1329
+ Overall quality score normalized between 0 and 1, where 1 indicates the best quality.
1330
+ metrics : dict
1331
+ Dictionary containing individual quality metrics:
1332
+ - 'snr': Signal-to-noise ratio normalized to 0-1.
1333
+ - 'perfusion': Relative pulse amplitude normalized to 0-1.
1334
+ - 'spectral_purity': Proportion of power in the physiological heart rate band (0.5-3.0 Hz).
1335
+ - 'kurtosis': Measure of outliers/artifacts, normalized to 0-1 (lower is better).
1336
+ - 'zero_crossing': Regularity of zero crossings, normalized to 0-1.
1337
+
1338
+ Notes
1339
+ -----
1340
+ This function computes a weighted average of several quality metrics to produce an overall score.
1341
+ The weights are chosen to reflect the relative importance of each metric in assessing PPG signal quality.
1342
+
1343
+ Examples
1344
+ --------
1345
+ >>> quality_score, metrics = assess_quality(signal_segment, filtered_segment)
1346
+ >>> print(f"Quality Score: {quality_score:.2f}")
1347
+ >>> print(f"SNR: {metrics['snr']:.2f}")
1348
+ """
1349
+ metrics = {}
1350
+
1351
+ # 1. SNR estimate (signal-to-noise ratio)
1352
+ if filtered_segment is not None:
1353
+ noise = signal_segment - filtered_segment
1354
+ signal_power = np.var(filtered_segment)
1355
+ noise_power = np.var(noise)
1356
+ snr = 10 * np.log10(signal_power / (noise_power + 1e-10))
1357
+ metrics["snr"] = max(0, min(snr / 20, 1)) # Normalize to 0-1
1358
+ else:
1359
+ metrics["snr"] = 0.5 # Unknown
1360
+
1361
+ # 2. Perfusion (relative pulse amplitude)
1362
+ dc_component = np.mean(signal_segment)
1363
+ ac_component = np.std(signal_segment)
1364
+ perfusion = ac_component / (dc_component + 1e-10)
1365
+ metrics["perfusion"] = min(perfusion / 0.1, 1) # Normalize, typical range 0.01-0.1
1366
+
1367
+ # 3. Spectral purity (concentration of power in physiological range)
1368
+ freqs, psd = signal.welch(
1369
+ signal_segment, fs=self.fs, nperseg=min(256, len(signal_segment))
1370
+ )
1371
+
1372
+ # Physiological range for heart rate: 0.5-3.0 Hz (30-180 BPM)
1373
+ hr_band = (freqs >= 0.5) & (freqs <= 3.0)
1374
+ total_power = np.sum(psd)
1375
+ hr_power = np.sum(psd[hr_band])
1376
+ metrics["spectral_purity"] = hr_power / (total_power + 1e-10)
1377
+
1378
+ # 4. Kurtosis (measure of outliers/artifacts)
1379
+ from scipy.stats import kurtosis
1380
+
1381
+ kurt = abs(kurtosis(signal_segment))
1382
+ metrics["kurtosis"] = max(0, 1 - kurt / 10) # Lower kurtosis is better
1383
+
1384
+ # 5. Zero crossing rate (should be regular for good PPG)
1385
+ zero_crossings = np.sum(np.diff(np.sign(signal_segment - np.mean(signal_segment))) != 0)
1386
+ expected_crossings = len(signal_segment) / self.fs * 2 * 1.5 # ~2 per beat at 75 BPM
1387
+ zcr_score = 1 - min(abs(zero_crossings - expected_crossings) / expected_crossings, 1)
1388
+ metrics["zero_crossing"] = zcr_score
1389
+
1390
+ # Overall quality score (weighted average)
1391
+ weights = {
1392
+ "snr": 0.3,
1393
+ "perfusion": 0.2,
1394
+ "spectral_purity": 0.3,
1395
+ "kurtosis": 0.1,
1396
+ "zero_crossing": 0.1,
1397
+ }
1398
+
1399
+ quality_score = sum(metrics[key] * weights[key] for key in weights.keys())
1400
+
1401
+ return quality_score, metrics
1402
+
1403
+ def assess_continuous(
1404
+ self, signal: NDArray, filtered: NDArray | None = None, stride: float = 1.0
1405
+ ) -> tuple[NDArray, NDArray]:
1406
+ """
1407
+ Assess quality continuously along the signal.
1408
+
1409
+ This function evaluates signal quality at multiple time points by sliding a window
1410
+ across the input signal. For each window, a quality score is computed using the
1411
+ internal `assess_quality` method. The stride parameter controls the overlap between
1412
+ consecutive windows.
1413
+
1414
+ Parameters
1415
+ ----------
1416
+ signal : array-like
1417
+ Input signal to be assessed for quality.
1418
+ filtered : array-like, optional
1419
+ Filtered version of the signal used for SNR calculation. If None, no filtering
1420
+ is applied in the quality assessment.
1421
+ stride : float, default=1.0
1422
+ Stride between windows in seconds. Controls the overlap between consecutive
1423
+ windows. A stride of 1.0 means no overlap, while smaller values create overlap.
1424
+
1425
+ Returns
1426
+ -------
1427
+ times : ndarray
1428
+ Time points corresponding to quality scores, in seconds.
1429
+ quality_scores : ndarray
1430
+ Quality scores at each time point. The scores are computed using the internal
1431
+ `assess_quality` method for each signal segment.
1432
+
1433
+ Notes
1434
+ -----
1435
+ The function uses a sliding window approach where the window size is defined by
1436
+ `self.window_samples` and the sampling frequency by `self.fs`. The quality assessment
1437
+ is performed on non-overlapping segments of the signal, with the stride determining
1438
+ the step size between consecutive segments.
1439
+
1440
+ Examples
1441
+ --------
1442
+ >>> times, scores = obj.assess_continuous(signal, filtered, stride=0.5)
1443
+ >>> print(f"Quality scores: {scores}")
1444
+ >>> print(f"Time points: {times}")
1445
+ """
1446
+ stride_samples = int(stride * self.fs)
1447
+ n_windows = (len(signal) - self.window_samples) // stride_samples + 1
1448
+
1449
+ times = np.zeros(n_windows)
1450
+ quality_scores = np.zeros(n_windows)
1451
+
1452
+ for i in range(n_windows):
1453
+ start = i * stride_samples
1454
+ end = start + self.window_samples
1455
+
1456
+ if end > len(signal):
1457
+ break
1458
+
1459
+ signal_segment = signal[start:end]
1460
+ filtered_segment = filtered[start:end] if filtered is not None else None
1461
+
1462
+ quality_score, _ = self.assess_quality(signal_segment, filtered_segment)
1463
+
1464
+ times[i] = (start + end) / 2 / self.fs
1465
+ quality_scores[i] = quality_score
1466
+
1467
+ return times[: i + 1], quality_scores[: i + 1]
1468
+
1469
+
1470
+ class HeartRateExtractor:
1471
+ """
1472
+ Extracts heart rate from PPG signals using multiple methods.
1473
+ """
1474
+
1475
+ def __init__(self, fs: float = 100.0) -> None:
1476
+ """
1477
+ Initialize the object with a sampling frequency.
1478
+
1479
+ Parameters
1480
+ ----------
1481
+ fs : float
1482
+ Sampling frequency in Hz. Default is 100.0 Hz.
1483
+
1484
+ Returns
1485
+ -------
1486
+ None
1487
+
1488
+ Notes
1489
+ -----
1490
+ This constructor sets the sampling frequency attribute for the object.
1491
+ The sampling frequency determines the rate at which signals are sampled
1492
+ and is crucial for digital signal processing applications.
1493
+
1494
+ Examples
1495
+ --------
1496
+ >>> obj = MyClass()
1497
+ >>> obj.fs
1498
+ 100.0
1499
+
1500
+ >>> obj = MyClass(fs=200.0)
1501
+ >>> obj.fs
1502
+ 200.0
1503
+ """
1504
+ self.fs = fs
1505
+
1506
+ def extract_from_peaks(
1507
+ self, ppg_signal: NDArray, min_distance: float = 0.4
1508
+ ) -> tuple[float | None, NDArray, NDArray | None, NDArray | None]:
1509
+ """
1510
+ Extract heart rate from a PPG signal using peak detection.
1511
+
1512
+ This function detects peaks in the PPG signal, computes inter-beat intervals (IBIs),
1513
+ removes outliers, and calculates the corresponding heart rate in beats per minute (BPM).
1514
+ It also generates a heart rate waveform (RRI) based on the detected peaks.
1515
+
1516
+ Parameters
1517
+ ----------
1518
+ ppg_signal : array_like
1519
+ Input photoplethysmography (PPG) signal.
1520
+ min_distance : float, optional
1521
+ Minimum time between peaks in seconds. Default is 0.4 seconds.
1522
+
1523
+ Returns
1524
+ -------
1525
+ tuple
1526
+ A tuple containing:
1527
+ - hr : float or None
1528
+ Heart rate in beats per minute (BPM). Returns None if not enough peaks are found.
1529
+ - peak_indices : ndarray
1530
+ Indices of detected peaks in the PPG signal.
1531
+ - rri : ndarray or None
1532
+ Inter-beat interval waveform (RRI) in seconds. Returns None if processing fails.
1533
+ - hr_waveform : ndarray or None
1534
+ Heart rate waveform derived from RRI. Returns None if processing fails.
1535
+
1536
+ Notes
1537
+ -----
1538
+ - The function uses `scipy.signal.find_peaks` for peak detection.
1539
+ - Outliers in inter-beat intervals are filtered using a median-based approach.
1540
+ - The RRI waveform is constructed by interpolating valid IBIs between peaks.
1541
+ - If the signal has insufficient peaks or all IBIs are outliers, the function returns None for heart rate.
1542
+
1543
+ Examples
1544
+ --------
1545
+ >>> hr, peaks, rri, hr_waveform = extractor.extract_from_peaks(ppg_signal, min_distance=0.4)
1546
+ >>> print(f"Heart Rate: {hr} BPM")
1547
+ Heart Rate: 72.5 BPM
1548
+ """
1549
+ # Find peaks
1550
+ min_samples = int(min_distance * self.fs)
1551
+ peaks, properties = signal.find_peaks(ppg_signal, distance=min_samples, prominence=0.1)
1552
+
1553
+ if len(peaks) < 2:
1554
+ return None, peaks
1555
+
1556
+ # Calculate inter-beat intervals
1557
+ ibi = np.diff(peaks) / self.fs # In seconds
1558
+
1559
+ # Remove outliers (use median for robustness)
1560
+ median_ibi = np.median(ibi)
1561
+ valid_ibi = ibi[(ibi > median_ibi * 0.7) & (ibi < median_ibi * 1.3)]
1562
+
1563
+ if len(valid_ibi) == 0:
1564
+ return None, peaks
1565
+
1566
+ # make an RRI waveform
1567
+ rri = np.zeros(len(ppg_signal))
1568
+ for peakidx in range(len(peaks) - 1):
1569
+ if (median_ibi * 0.7) <= ibi[peakidx] <= (median_ibi * 1.3):
1570
+ rri[peaks[peakidx] : peaks[peakidx + 1]] = ibi[peakidx]
1571
+ else:
1572
+ rri[peaks[peakidx] : peaks[peakidx + 1]] = 0.0
1573
+ rri[0 : peaks[0]] = rri[peaks[0]]
1574
+ rri[peaks[-1] :] = rri[peaks[-1]]
1575
+
1576
+ # deal with the zeros
1577
+ badranges = []
1578
+ inarun = False
1579
+ first = -1
1580
+ for i in range(len(rri)):
1581
+ if rri[i] == 0.0:
1582
+ if not inarun:
1583
+ first = i
1584
+ inarun = True
1585
+ else:
1586
+ if inarun:
1587
+ badranges.append((first, i - 1))
1588
+ inarun = False
1589
+ if inarun:
1590
+ if first > 0:
1591
+ badranges.append((first, len(rri) - 1))
1592
+ else:
1593
+ rri = None
1594
+ print(f"badranges = {badranges}")
1595
+
1596
+ if badranges is not None:
1597
+ for first, last in badranges:
1598
+ if first == 0:
1599
+ rri[first : last + 1] = rri[last + 1]
1600
+ elif last == (len(rri) - 1):
1601
+ rri[first : last + 1] = rri[first - 1]
1602
+ else:
1603
+ rri[first : last + 1] = (rri[first - 1] + rri[last + 1]) / 2.0
1604
+
1605
+ # Convert to heart rate
1606
+ hr = 60.0 / np.mean(valid_ibi)
1607
+ if rri is not None:
1608
+ hr_waveform = 60.0 / rri
1609
+ else:
1610
+ hr_waveform = None
1611
+
1612
+ return hr, peaks, rri, hr_waveform
1613
+
1614
+ def extract_from_fft(
1615
+ self, ppg_signal: NDArray, hr_range: tuple[float, float] = (40.0, 180.0)
1616
+ ) -> tuple[float | None, float | None, NDArray, NDArray]:
1617
+ """
1618
+ Extract heart rate using FFT (frequency domain).
1619
+
1620
+ This function computes the power spectral density (PSD) of the input PPG signal
1621
+ using Welch's method and identifies the dominant frequency within a specified
1622
+ heart rate range. The dominant frequency is then converted to heart rate in BPM.
1623
+
1624
+ Parameters
1625
+ ----------
1626
+ ppg_signal : NDArray
1627
+ Input photoplethysmography (PPG) signal.
1628
+ hr_range : tuple of float, optional
1629
+ Expected heart rate range in beats per minute (BPM). Default is (40.0, 180.0).
1630
+
1631
+ Returns
1632
+ -------
1633
+ hr : float or None
1634
+ Dominant heart rate in BPM. Returns None if no valid peak is found in the
1635
+ specified range.
1636
+ frequency : float or None
1637
+ Dominant frequency in Hz. Returns None if no valid peak is found in the
1638
+ specified range.
1639
+ psd : NDArray
1640
+ Power spectral density values corresponding to the frequency array.
1641
+ freqs : NDArray
1642
+ Frequency values corresponding to the power spectral density.
1643
+
1644
+ Notes
1645
+ -----
1646
+ The function uses Welch's method for PSD estimation, which is robust for
1647
+ noisy signals. The heart rate is derived from the peak frequency in the
1648
+ physiological range (default: 40-180 BPM).
1649
+
1650
+ Examples
1651
+ --------
1652
+ >>> hr, freq, psd, freqs = extract_from_fft(ppg_signal, hr_range=(50.0, 120.0))
1653
+ >>> print(f"Heart rate: {hr} BPM")
1654
+ Heart rate: 72.5 BPM
1655
+ """
1656
+ # Compute power spectral density
1657
+ freqs, psd = signal.welch(ppg_signal, fs=self.fs, nperseg=min(256, len(ppg_signal)))
1658
+
1659
+ # Convert HR range to frequency range
1660
+ freq_range = (hr_range[0] / 60.0, hr_range[1] / 60.0)
1661
+
1662
+ # Find peak in physiological range
1663
+ valid_indices = (freqs >= freq_range[0]) & (freqs <= freq_range[1])
1664
+ valid_freqs = freqs[valid_indices]
1665
+ valid_psd = psd[valid_indices]
1666
+
1667
+ if len(valid_psd) == 0:
1668
+ return None, None, psd, freqs
1669
+
1670
+ peak_idx = np.argmax(valid_psd)
1671
+ dominant_freq = valid_freqs[peak_idx]
1672
+ hr = dominant_freq * 60
1673
+
1674
+ return hr, dominant_freq, psd, freqs
1675
+
1676
+ def extract_continuous(
1677
+ self,
1678
+ ppg_signal: NDArray,
1679
+ window_size: float = 10.0,
1680
+ stride: float = 2.0,
1681
+ method: str = "fft",
1682
+ ) -> tuple[NDArray, NDArray]:
1683
+ """
1684
+ Extract heart rate continuously along the PPG signal.
1685
+
1686
+ This function computes heart rate estimates over time by sliding a window
1687
+ across the PPG signal and applying either FFT-based or peak-based methods
1688
+ to each segment. The heart rate is estimated at the center of each window.
1689
+
1690
+ Parameters
1691
+ ----------
1692
+ ppg_signal : array_like
1693
+ Input PPG signal as a 1D array of samples.
1694
+ window_size : float, optional
1695
+ Size of the sliding window in seconds. Default is 10.0 seconds.
1696
+ stride : float, optional
1697
+ Stride between consecutive windows in seconds. Default is 2.0 seconds.
1698
+ method : str, optional
1699
+ Estimation method to use. Either 'fft' for FFT-based heart rate estimation
1700
+ or 'peaks' for peak-based estimation. Default is 'fft'.
1701
+
1702
+ Returns
1703
+ -------
1704
+ times : ndarray
1705
+ Time points (in seconds) corresponding to the heart rate estimates.
1706
+ heart_rates : ndarray
1707
+ Heart rate estimates in beats per minute (BPM) at each time point.
1708
+
1709
+ Notes
1710
+ -----
1711
+ - The function uses the sampling frequency (`self.fs`) to convert time values
1712
+ into sample indices.
1713
+ - If the `method` is 'fft', the function calls `self.extract_from_fft()`.
1714
+ - If the `method` is 'peaks', the function calls `self.extract_from_peaks()`.
1715
+ - Heart rate estimates are only included if they are not `None`.
1716
+
1717
+ Examples
1718
+ --------
1719
+ >>> times, hrs = extract_continuous(ppg_signal, window_size=5.0, stride=1.0)
1720
+ >>> print(times[:3]) # First three time points
1721
+ >>> print(hrs[:3]) # First three heart rate estimates
1722
+ """
1723
+ window_samples = int(window_size * self.fs)
1724
+ stride_samples = int(stride * self.fs)
1725
+ n_windows = (len(ppg_signal) - window_samples) // stride_samples + 1
1726
+
1727
+ times = []
1728
+ heart_rates = []
1729
+
1730
+ for i in range(n_windows):
1731
+ start = i * stride_samples
1732
+ end = start + window_samples
1733
+
1734
+ if end > len(ppg_signal):
1735
+ break
1736
+
1737
+ segment = ppg_signal[start:end]
1738
+
1739
+ if method == "fft":
1740
+ hr, _, _, _ = self.extract_from_fft(segment)
1741
+ else: # peaks
1742
+ hr, _ = self.extract_from_peaks(segment)
1743
+
1744
+ if hr is not None:
1745
+ times.append((start + end) / 2 / self.fs)
1746
+ heart_rates.append(hr)
1747
+
1748
+ return np.array(times), np.array(heart_rates)
1749
+
1750
+
1751
+ class RobustPPGProcessor:
1752
+ """
1753
+ Complete PPG processing pipeline combining filtering, quality assessment,
1754
+ and heart rate extraction with intelligent segment handling.
1755
+ """
1756
+
1757
+ def __init__(
1758
+ self,
1759
+ fs: float = 100.0,
1760
+ method: str = "adaptive",
1761
+ hr_estimate: float = 75.0,
1762
+ process_noise: float = 0.0001,
1763
+ ) -> None:
1764
+ """
1765
+ Initialize the PPG signal processing pipeline with specified parameters.
1766
+
1767
+ Parameters
1768
+ ----------
1769
+ fs : float, optional
1770
+ Sampling frequency in Hz, default is 100.0
1771
+ method : str, optional
1772
+ Filter method to use, must be one of 'standard', 'adaptive', or 'ekf',
1773
+ default is 'adaptive'
1774
+ hr_estimate : float, optional
1775
+ Initial heart rate estimate in BPM, default is 75.0
1776
+ process_noise : float, optional
1777
+ Process noise covariance value, default is 0.0001
1778
+
1779
+ Returns
1780
+ -------
1781
+ None
1782
+ This method initializes the instance attributes and sets up the
1783
+ appropriate filter and processing components based on the specified method.
1784
+
1785
+ Notes
1786
+ -----
1787
+ The initialization creates different filter types based on the method parameter:
1788
+ - 'standard': Uses PPGKalmanFilter with fixed measurement noise
1789
+ - 'adaptive': Uses AdaptivePPGKalmanFilter with adaptive noise estimation
1790
+ - 'ekf': Uses ExtendedPPGKalmanFilter with extended Kalman filter approach
1791
+
1792
+ Examples
1793
+ --------
1794
+ >>> processor = PPGProcessor(fs=125.0, method='ekf', hr_estimate=80.0)
1795
+ >>> processor = PPGProcessor() # Uses default parameters
1796
+ """
1797
+ self.fs = fs
1798
+ self.dt = 1.0 / fs
1799
+ self.method = method
1800
+ self.process_noise = process_noise
1801
+ self.hr_estimate = hr_estimate
1802
+
1803
+ # Initialize components
1804
+ if method == "standard":
1805
+ self.filter = PPGKalmanFilter(
1806
+ dt=self.dt, process_noise=self.process_noise, measurement_noise=0.05
1807
+ )
1808
+ elif method == "adaptive":
1809
+ self.filter = AdaptivePPGKalmanFilter(
1810
+ dt=self.dt,
1811
+ initial_process_noise=self.process_noise,
1812
+ initial_measurement_noise=0.05,
1813
+ )
1814
+ else: # ekf
1815
+ self.filter = ExtendedPPGKalmanFilter(
1816
+ dt=self.dt,
1817
+ hr_estimate=self.hr_estimate,
1818
+ process_noise=self.process_noise,
1819
+ measurement_noise=0.05,
1820
+ )
1821
+
1822
+ self.quality_assessor = SignalQualityAssessor(fs=fs, window_size=5.0)
1823
+ self.hr_extractor = HeartRateExtractor(fs=fs)
1824
+
1825
+ def process(
1826
+ self,
1827
+ signal_data: NDArray,
1828
+ missing_indices: list | None = None,
1829
+ quality_threshold: float = 0.5,
1830
+ ) -> dict:
1831
+ """
1832
+ Complete processing pipeline.
1833
+
1834
+ Parameters:
1835
+ -----------
1836
+ signal_data : array
1837
+ Raw PPG signal
1838
+ missing_indices : array, optional
1839
+ Indices of missing data
1840
+ quality_threshold : float
1841
+ Minimum quality score (0-1) for accepting segments
1842
+
1843
+ Returns:
1844
+ --------
1845
+ results : dict
1846
+ Dictionary containing all processing results
1847
+ """
1848
+ results = {}
1849
+
1850
+ # Step 1: Filter signal
1851
+ if self.method == "adaptive":
1852
+ filtered, motion_flags = self.filter.filter_signal(signal_data, missing_indices)
1853
+ results["motion_flags"] = motion_flags
1854
+ elif self.method == "ekf":
1855
+ filtered, hr_continuous = self.filter.filter_signal(signal_data, missing_indices)
1856
+ results["ekf_heart_rate"] = hr_continuous
1857
+ elif self.method == "raw":
1858
+ filtered = signal_data
1859
+ else:
1860
+ filtered = self.filter.filter_signal(signal_data, missing_indices)
1861
+
1862
+ results["filtered_signal"] = filtered
1863
+
1864
+ # Step 2: Assess quality
1865
+ qual_times, qual_scores = self.quality_assessor.assess_continuous(
1866
+ signal_data, filtered, stride=1.0
1867
+ )
1868
+ results["quality_times"] = qual_times
1869
+ results["quality_scores"] = qual_scores
1870
+
1871
+ # Step 3: Extract heart rate (only from good quality segments)
1872
+ good_quality_mask = qual_scores > quality_threshold
1873
+
1874
+ if np.any(good_quality_mask):
1875
+ hr_times, hr_values = self.hr_extractor.extract_continuous(
1876
+ filtered, window_size=10.0, stride=2.0, method="fft"
1877
+ )
1878
+ results["hr_times"] = hr_times
1879
+ results["hr_values"] = hr_values
1880
+
1881
+ # Overall heart rate from peaks
1882
+ hr_from_peaks, peak_indices, rri, hr_waveform_from_peaks = (
1883
+ self.hr_extractor.extract_from_peaks(filtered)
1884
+ )
1885
+ results["hr_overall"] = hr_from_peaks
1886
+ results["peak_indices"] = peak_indices
1887
+ results["rri"] = rri
1888
+ results["hr_waveform"] = hr_waveform_from_peaks
1889
+ else:
1890
+ results["hr_times"] = np.array([])
1891
+ results["hr_values"] = np.array([])
1892
+ results["hr_overall"] = None
1893
+ results["peak_indices"] = np.array([])
1894
+
1895
+ # Step 4: Compute statistics
1896
+ results["mean_quality"] = np.mean(qual_scores)
1897
+ results["good_quality_percentage"] = (
1898
+ np.sum(good_quality_mask) / len(good_quality_mask) * 100
1899
+ )
1900
+
1901
+ return results
1902
+
1903
+
1904
+ class PPGFeatureExtractor:
1905
+ """
1906
+ Extract additional features from PPG signals useful for health monitoring.
1907
+ """
1908
+
1909
+ def __init__(self, fs: float = 100.0) -> None:
1910
+ """
1911
+ Initialize the object with sampling frequency.
1912
+
1913
+ Parameters
1914
+ ----------
1915
+ fs : float, default=100.0
1916
+ Sampling frequency in Hz. This parameter determines the rate at which
1917
+ signals are sampled and is crucial for proper signal processing.
1918
+
1919
+ Returns
1920
+ -------
1921
+ None
1922
+ This method does not return any value.
1923
+
1924
+ Notes
1925
+ -----
1926
+ The sampling frequency is stored as an instance attribute and is used
1927
+ throughout the class for time-domain and frequency-domain calculations.
1928
+
1929
+ Examples
1930
+ --------
1931
+ >>> obj = MyClass()
1932
+ >>> obj.fs
1933
+ 100.0
1934
+
1935
+ >>> obj = MyClass(fs=200.0)
1936
+ >>> obj.fs
1937
+ 200.0
1938
+ """
1939
+ self.fs = fs
1940
+
1941
+ def extract_hrv_features(self, peak_indices: NDArray) -> dict | None:
1942
+ """
1943
+ Extract Heart Rate Variability (HRV) features.
1944
+
1945
+ Parameters:
1946
+ -----------
1947
+ peak_indices : array
1948
+ Indices of detected peaks
1949
+
1950
+ Returns:
1951
+ --------
1952
+ hrv_features : dict
1953
+ Dictionary of HRV metrics
1954
+ """
1955
+ if len(peak_indices) < 3:
1956
+ return None
1957
+
1958
+ # Inter-beat intervals in milliseconds
1959
+ ibi = np.diff(peak_indices) / self.fs * 1000
1960
+
1961
+ # Remove outliers
1962
+ median_ibi = np.median(ibi)
1963
+ valid_ibi = ibi[(ibi > median_ibi * 0.7) & (ibi < median_ibi * 1.3)]
1964
+
1965
+ if len(valid_ibi) < 2:
1966
+ return None
1967
+
1968
+ features = {}
1969
+
1970
+ # Time domain features
1971
+ features["mean_ibi"] = np.mean(valid_ibi)
1972
+ features["sdnn"] = np.std(valid_ibi) # Standard deviation of NN intervals
1973
+ features["rmssd"] = np.sqrt(
1974
+ np.mean(np.diff(valid_ibi) ** 2)
1975
+ ) # Root mean square of successive differences
1976
+ features["pnn50"] = (
1977
+ np.sum(np.abs(np.diff(valid_ibi)) > 50) / len(valid_ibi) * 100
1978
+ ) # % of intervals > 50ms different
1979
+
1980
+ # Frequency domain features (requires longer recordings)
1981
+ if len(valid_ibi) > 30:
1982
+ # Resample to uniform time series
1983
+ time_points = np.cumsum(np.concatenate([[0], valid_ibi])) / 1000 # seconds
1984
+ f_interp = interp1d(
1985
+ time_points[:-1], valid_ibi, kind="cubic", fill_value="extrapolate"
1986
+ )
1987
+
1988
+ # Create uniform time base at 4 Hz
1989
+ uniform_time = np.arange(0, time_points[-1], 0.25)
1990
+ uniform_ibi = f_interp(uniform_time)
1991
+
1992
+ # Compute PSD
1993
+ freqs, psd = signal.welch(uniform_ibi, fs=4, nperseg=min(256, len(uniform_ibi)))
1994
+
1995
+ # HRV frequency bands
1996
+ vlf_band = (freqs >= 0.003) & (freqs < 0.04) # Very low frequency
1997
+ lf_band = (freqs >= 0.04) & (freqs < 0.15) # Low frequency
1998
+ hf_band = (freqs >= 0.15) & (freqs < 0.4) # High frequency
1999
+
2000
+ features["vlf_power"] = np.trapz(psd[vlf_band], freqs[vlf_band])
2001
+ features["lf_power"] = np.trapz(psd[lf_band], freqs[lf_band])
2002
+ features["hf_power"] = np.trapz(psd[hf_band], freqs[hf_band])
2003
+ features["lf_hf_ratio"] = features["lf_power"] / (features["hf_power"] + 1e-10)
2004
+
2005
+ return features
2006
+
2007
+ def extract_morphology_features(self, signal_segment: NDArray, peak_idx: int) -> dict:
2008
+ """
2009
+ Extract morphological features from a single PPG pulse.
2010
+
2011
+ Parameters:
2012
+ -----------
2013
+ signal_segment : array
2014
+ PPG signal segment containing one pulse
2015
+ peak_idx : int
2016
+ Index of the systolic peak within the segment
2017
+
2018
+ Returns:
2019
+ --------
2020
+ features : dict
2021
+ Morphological features
2022
+ """
2023
+ features = {}
2024
+
2025
+ # Pulse amplitude
2026
+ baseline = np.min(signal_segment)
2027
+ features["pulse_amplitude"] = signal_segment[peak_idx] - baseline
2028
+
2029
+ # Rising time (foot to peak)
2030
+ features["rising_time"] = peak_idx / self.fs
2031
+
2032
+ # Find dicrotic notch (local minimum after peak)
2033
+ if peak_idx < len(signal_segment) - 10:
2034
+ search_window = signal_segment[
2035
+ peak_idx : min(peak_idx + int(0.3 * self.fs), len(signal_segment))
2036
+ ]
2037
+ if len(search_window) > 0:
2038
+ notch_idx = peak_idx + np.argmin(search_window)
2039
+ features["dicrotic_notch_amplitude"] = signal_segment[notch_idx] - baseline
2040
+ features["augmentation_index"] = (signal_segment[notch_idx] - baseline) / features[
2041
+ "pulse_amplitude"
2042
+ ]
2043
+
2044
+ # Pulse width at half maximum
2045
+ half_max = baseline + features["pulse_amplitude"] / 2
2046
+ above_half = signal_segment > half_max
2047
+ if np.any(above_half):
2048
+ transitions = np.diff(above_half.astype(int))
2049
+ rise_points = np.where(transitions == 1)[0]
2050
+ fall_points = np.where(transitions == -1)[0]
2051
+ if len(rise_points) > 0 and len(fall_points) > 0:
2052
+ features["pulse_width"] = (fall_points[0] - rise_points[0]) / self.fs
2053
+
2054
+ return features
2055
+
2056
+ def compute_spo2_proxy(self, filtered_signal: NDArray) -> float:
2057
+ """
2058
+ Compute a proxy for SpO2 (oxygen saturation) based on AC/DC ratio.
2059
+ Note: This is a simplified proxy and not a real SpO2 measurement.
2060
+ Real SpO2 requires red and infrared PPG signals.
2061
+
2062
+ Parameters:
2063
+ -----------
2064
+ filtered_signal : array
2065
+ Filtered PPG signal
2066
+
2067
+ Returns:
2068
+ --------
2069
+ spo2_proxy : float
2070
+ Proxy value (not actual SpO2)
2071
+ """
2072
+ # AC component (pulsatile)
2073
+ ac = np.std(filtered_signal)
2074
+
2075
+ # DC component (baseline)
2076
+ dc = np.mean(filtered_signal)
2077
+
2078
+ # Compute ratio
2079
+ ratio = ac / (dc + 1e-10)
2080
+
2081
+ # Empirical mapping (this is just a proxy!)
2082
+ # Real SpO2 uses calibration curves specific to the sensor
2083
+ spo2_proxy = 110 - 25 * ratio
2084
+
2085
+ return np.clip(spo2_proxy, 70, 100)
2086
+
2087
+
2088
+ def read_happy_ppg(
2089
+ filenameroot: str, debug: bool = False
2090
+ ) -> tuple[NDArray, float, NDArray, NDArray, NDArray | None, list]:
2091
+ Fs, instarttime, incolumns, indata, incompressed, incolsource, inextrainfo = (
2092
+ tide_io.readbidstsv(
2093
+ f"{filenameroot}.json",
2094
+ neednotexist=True,
2095
+ debug=debug,
2096
+ )
2097
+ )
2098
+ if debug:
2099
+ print(f"{indata.shape=}")
2100
+
2101
+ t = np.linspace(0, (indata.shape[1] / Fs), num=indata.shape[1], endpoint=False)
2102
+
2103
+ # set raw file
2104
+ try:
2105
+ rawindex = incolumns.index("cardiacfromfmri")
2106
+ except ValueError:
2107
+ try:
2108
+ rawindex = incolumns.index("cardiacfromfmri_25.0Hz")
2109
+ except ValueError:
2110
+ raise (ValueError("cardiacfromfmri column not found"))
2111
+ raw_ppg = indata[rawindex, :]
2112
+
2113
+ # set badpts file
2114
+ try:
2115
+ badptsindex = incolumns.index("badpts")
2116
+ badpts = indata[badptsindex, :]
2117
+ print(badpts)
2118
+ missing_indices = np.where(badpts > 0)[0]
2119
+ except ValueError:
2120
+ missing_indices = []
2121
+
2122
+ # use pleth, or dlfiltered if pleth is not available
2123
+ try:
2124
+ cleanindex = incolumns.index("pleth")
2125
+ except ValueError:
2126
+ try:
2127
+ cleanindex = incolumns.index("cardiacfromfmri_dlfiltered")
2128
+ except ValueError:
2129
+ try:
2130
+ cleanindex = incolumns.index("cardiacfromfmri_dlfiltered_25.0Hz")
2131
+ except ValueError:
2132
+ raise (ValueError("no clean ppg column found"))
2133
+ clean_ppg = indata[cleanindex, :]
2134
+
2135
+ pleth_ppg = None
2136
+
2137
+ return t, Fs, clean_ppg, raw_ppg, pleth_ppg, missing_indices
2138
+
2139
+
2140
+ def generate_synthetic_ppg(
2141
+ duration: int = 10,
2142
+ fs: float = 100.0,
2143
+ hr: int = 75,
2144
+ noise_level: float = 0.05,
2145
+ missing_percent: int = 5,
2146
+ motion_artifacts: bool = True,
2147
+ ) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]:
2148
+ """
2149
+ Generate synthetic PPG signal for testing.
2150
+
2151
+ Parameters:
2152
+ -----------
2153
+ duration : float
2154
+ Duration in seconds
2155
+ fs : float
2156
+ Sampling frequency in Hz (typically 50-250 Hz for PPG)
2157
+ hr : int
2158
+ Heart rate in beats per minute
2159
+ noise_level : float
2160
+ Standard deviation of additive noise
2161
+ missing_percent : float
2162
+ Percentage of data points to randomly remove
2163
+ motion_artifacts : bool
2164
+ Whether to add motion artifacts
2165
+ """
2166
+ t = np.arange(0, duration, 1 / fs)
2167
+
2168
+ # PPG signal: slower, more sinusoidal than ECG
2169
+ beat_period = 60 / hr
2170
+ ppg = np.zeros_like(t)
2171
+
2172
+ # Main pulsatile component (smoother, more sinusoidal)
2173
+ fundamental_freq = hr / 60 # Hz
2174
+ ppg = 1.0 + 0.8 * np.sin(2 * np.pi * fundamental_freq * t)
2175
+
2176
+ # Add harmonic for dicrotic notch (characteristic of PPG)
2177
+ ppg += 0.15 * np.sin(2 * np.pi * 2 * fundamental_freq * t + np.pi / 3)
2178
+
2179
+ # Add slight baseline drift (common in PPG due to respiration)
2180
+ ppg += 0.2 * np.sin(2 * np.pi * 0.25 * t) # ~15 breaths/min
2181
+
2182
+ # Add Gaussian noise
2183
+ noisy_ppg = ppg + np.random.normal(0, noise_level, len(ppg))
2184
+
2185
+ # Add motion artifacts if requested
2186
+ if motion_artifacts:
2187
+ n_artifacts = 3
2188
+ for _ in range(n_artifacts):
2189
+ artifact_start = np.random.randint(0, len(noisy_ppg) - int(fs))
2190
+ artifact_length = int(fs * np.random.uniform(0.5, 2)) # 0.5-2 second artifacts
2191
+ artifact_end = min(artifact_start + artifact_length, len(noisy_ppg))
2192
+
2193
+ # Large amplitude motion artifact
2194
+ artifact = np.random.uniform(0.5, 2.0) * np.random.randn(artifact_end - artifact_start)
2195
+ noisy_ppg[artifact_start:artifact_end] += artifact
2196
+
2197
+ # Randomly remove data points
2198
+ n_missing = int(len(noisy_ppg) * missing_percent / 100)
2199
+ missing_indices = np.random.choice(len(noisy_ppg), n_missing, replace=False)
2200
+ missing_indices = np.sort(missing_indices)
2201
+
2202
+ corrupted_ppg = noisy_ppg.copy()
2203
+ corrupted_ppg[missing_indices] = np.nan
2204
+
2205
+ return t, ppg, noisy_ppg, corrupted_ppg, missing_indices