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.
- cloud/gmscalc-HCPYA +1 -1
- cloud/mount-and-run +2 -0
- cloud/rapidtide-HCPYA +3 -3
- rapidtide/Colortables.py +538 -38
- rapidtide/OrthoImageItem.py +1094 -51
- rapidtide/RapidtideDataset.py +1709 -114
- rapidtide/__init__.py +0 -8
- rapidtide/_version.py +4 -4
- rapidtide/calccoherence.py +242 -97
- rapidtide/calcnullsimfunc.py +240 -140
- rapidtide/calcsimfunc.py +314 -129
- rapidtide/correlate.py +1211 -389
- rapidtide/data/examples/src/testLD +56 -0
- rapidtide/data/examples/src/test_findmaxlag.py +2 -2
- rapidtide/data/examples/src/test_mlregressallt.py +32 -17
- rapidtide/data/examples/src/testalign +1 -1
- rapidtide/data/examples/src/testatlasaverage +35 -7
- rapidtide/data/examples/src/testboth +21 -0
- rapidtide/data/examples/src/testcifti +11 -0
- rapidtide/data/examples/src/testdelayvar +13 -0
- rapidtide/data/examples/src/testdlfilt +25 -0
- rapidtide/data/examples/src/testfft +35 -0
- rapidtide/data/examples/src/testfileorfloat +37 -0
- rapidtide/data/examples/src/testfmri +92 -42
- rapidtide/data/examples/src/testfuncs +3 -3
- rapidtide/data/examples/src/testglmfilt +8 -6
- rapidtide/data/examples/src/testhappy +84 -51
- rapidtide/data/examples/src/testinitdelay +19 -0
- rapidtide/data/examples/src/testmodels +33 -0
- rapidtide/data/examples/src/testnewrefine +26 -0
- rapidtide/data/examples/src/testnoiseamp +2 -2
- rapidtide/data/examples/src/testppgproc +17 -0
- rapidtide/data/examples/src/testrefineonly +22 -0
- rapidtide/data/examples/src/testretro +26 -13
- rapidtide/data/examples/src/testretrolagtcs +16 -0
- rapidtide/data/examples/src/testrolloff +11 -0
- rapidtide/data/examples/src/testsimdata +45 -28
- rapidtide/data/models/model_cnn_pytorch/loss.png +0 -0
- rapidtide/data/models/model_cnn_pytorch/loss.txt +1 -0
- rapidtide/data/models/model_cnn_pytorch/model.pth +0 -0
- rapidtide/data/models/model_cnn_pytorch/model_meta.json +68 -0
- rapidtide/data/models/model_cnn_pytorch_fulldata/loss.png +0 -0
- rapidtide/data/models/model_cnn_pytorch_fulldata/loss.txt +1 -0
- rapidtide/data/models/model_cnn_pytorch_fulldata/model.pth +0 -0
- rapidtide/data/models/model_cnn_pytorch_fulldata/model_meta.json +80 -0
- rapidtide/data/models/model_cnnbp_pytorch_fullldata/loss.png +0 -0
- rapidtide/data/models/model_cnnbp_pytorch_fullldata/loss.txt +1 -0
- rapidtide/data/models/model_cnnbp_pytorch_fullldata/model.pth +0 -0
- rapidtide/data/models/model_cnnbp_pytorch_fullldata/model_meta.json +138 -0
- rapidtide/data/models/model_cnnfft_pytorch_fulldata/loss.png +0 -0
- rapidtide/data/models/model_cnnfft_pytorch_fulldata/loss.txt +1 -0
- rapidtide/data/models/model_cnnfft_pytorch_fulldata/model.pth +0 -0
- rapidtide/data/models/model_cnnfft_pytorch_fulldata/model_meta.json +128 -0
- rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/loss.png +0 -0
- rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/loss.txt +1 -0
- rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/model.pth +0 -0
- rapidtide/data/models/model_ppgattention_pytorch_w128_fulldata/model_meta.json +49 -0
- rapidtide/data/models/model_revised_tf2/model.keras +0 -0
- rapidtide/data/models/{model_serdar → model_revised_tf2}/model_meta.json +1 -1
- rapidtide/data/models/model_serdar2_tf2/model.keras +0 -0
- rapidtide/data/models/{model_serdar2 → model_serdar2_tf2}/model_meta.json +1 -1
- rapidtide/data/models/model_serdar_tf2/model.keras +0 -0
- rapidtide/data/models/{model_revised → model_serdar_tf2}/model_meta.json +1 -1
- rapidtide/data/reference/HCP1200v2_MTT_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_binmask_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_csf_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_gray_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_graylaghist.json +7 -0
- rapidtide/data/reference/HCP1200v2_graylaghist.tsv.gz +0 -0
- rapidtide/data/reference/HCP1200v2_laghist.json +7 -0
- rapidtide/data/reference/HCP1200v2_laghist.tsv.gz +0 -0
- rapidtide/data/reference/HCP1200v2_mask_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_maxcorr_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_maxtime_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_maxwidth_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_negmask_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_timepercentile_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_white_2mm.nii.gz +0 -0
- rapidtide/data/reference/HCP1200v2_whitelaghist.json +7 -0
- rapidtide/data/reference/HCP1200v2_whitelaghist.tsv.gz +0 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2.xml +131 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2_regions.txt +60 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1-seg2_space-MNI152NLin6Asym_2mm.nii.gz +0 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin2009cAsym_2mm.nii.gz +0 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin2009cAsym_2mm_mask.nii.gz +0 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL1_space-MNI152NLin6Asym_2mm_mask.nii.gz +0 -0
- rapidtide/data/reference/JHU-ArterialTerritoriesNoVent-LVL2_space-MNI152NLin6Asym_2mm_mask.nii.gz +0 -0
- rapidtide/data/reference/MNI152_T1_1mm_Brain_FAST_seg.nii.gz +0 -0
- rapidtide/data/reference/MNI152_T1_1mm_Brain_Mask.nii.gz +0 -0
- rapidtide/data/reference/MNI152_T1_2mm_Brain_FAST_seg.nii.gz +0 -0
- rapidtide/data/reference/MNI152_T1_2mm_Brain_Mask.nii.gz +0 -0
- rapidtide/decorators.py +91 -0
- rapidtide/dlfilter.py +2553 -414
- rapidtide/dlfiltertorch.py +5201 -0
- rapidtide/externaltools.py +328 -13
- rapidtide/fMRIData_class.py +108 -92
- rapidtide/ffttools.py +168 -0
- rapidtide/filter.py +2704 -1462
- rapidtide/fit.py +2361 -579
- rapidtide/genericmultiproc.py +197 -0
- rapidtide/happy_supportfuncs.py +3255 -548
- rapidtide/helper_classes.py +587 -1116
- rapidtide/io.py +2569 -468
- rapidtide/linfitfiltpass.py +784 -0
- rapidtide/makelaggedtcs.py +267 -97
- rapidtide/maskutil.py +555 -25
- rapidtide/miscmath.py +835 -144
- rapidtide/multiproc.py +217 -44
- rapidtide/patchmatch.py +752 -0
- rapidtide/peakeval.py +32 -32
- rapidtide/ppgproc.py +2205 -0
- rapidtide/qualitycheck.py +353 -40
- rapidtide/refinedelay.py +854 -0
- rapidtide/refineregressor.py +939 -0
- rapidtide/resample.py +725 -204
- rapidtide/scripts/__init__.py +1 -0
- rapidtide/scripts/{adjustoffset → adjustoffset.py} +7 -2
- rapidtide/scripts/{aligntcs → aligntcs.py} +7 -2
- rapidtide/scripts/{applydlfilter → applydlfilter.py} +7 -2
- rapidtide/scripts/applyppgproc.py +28 -0
- rapidtide/scripts/{atlasaverage → atlasaverage.py} +7 -2
- rapidtide/scripts/{atlastool → atlastool.py} +7 -2
- rapidtide/scripts/{calcicc → calcicc.py} +7 -2
- rapidtide/scripts/{calctexticc → calctexticc.py} +7 -2
- rapidtide/scripts/{calcttest → calcttest.py} +7 -2
- rapidtide/scripts/{ccorrica → ccorrica.py} +7 -2
- rapidtide/scripts/delayvar.py +28 -0
- rapidtide/scripts/{diffrois → diffrois.py} +7 -2
- rapidtide/scripts/{endtidalproc → endtidalproc.py} +7 -2
- rapidtide/scripts/{fdica → fdica.py} +7 -2
- rapidtide/scripts/{filtnifti → filtnifti.py} +7 -2
- rapidtide/scripts/{filttc → filttc.py} +7 -2
- rapidtide/scripts/{fingerprint → fingerprint.py} +20 -16
- rapidtide/scripts/{fixtr → fixtr.py} +7 -2
- rapidtide/scripts/{gmscalc → gmscalc.py} +7 -2
- rapidtide/scripts/{happy → happy.py} +7 -2
- rapidtide/scripts/{happy2std → happy2std.py} +7 -2
- rapidtide/scripts/{happywarp → happywarp.py} +8 -4
- rapidtide/scripts/{histnifti → histnifti.py} +7 -2
- rapidtide/scripts/{histtc → histtc.py} +7 -2
- rapidtide/scripts/{glmfilt → linfitfilt.py} +7 -4
- rapidtide/scripts/{localflow → localflow.py} +7 -2
- rapidtide/scripts/{mergequality → mergequality.py} +7 -2
- rapidtide/scripts/{pairproc → pairproc.py} +7 -2
- rapidtide/scripts/{pairwisemergenifti → pairwisemergenifti.py} +7 -2
- rapidtide/scripts/{physiofreq → physiofreq.py} +7 -2
- rapidtide/scripts/{pixelcomp → pixelcomp.py} +7 -2
- rapidtide/scripts/{plethquality → plethquality.py} +7 -2
- rapidtide/scripts/{polyfitim → polyfitim.py} +7 -2
- rapidtide/scripts/{proj2flow → proj2flow.py} +7 -2
- rapidtide/scripts/{rankimage → rankimage.py} +7 -2
- rapidtide/scripts/{rapidtide → rapidtide.py} +7 -2
- rapidtide/scripts/{rapidtide2std → rapidtide2std.py} +7 -2
- rapidtide/scripts/{resamplenifti → resamplenifti.py} +7 -2
- rapidtide/scripts/{resampletc → resampletc.py} +7 -2
- rapidtide/scripts/retrolagtcs.py +28 -0
- rapidtide/scripts/retroregress.py +28 -0
- rapidtide/scripts/{roisummarize → roisummarize.py} +7 -2
- rapidtide/scripts/{runqualitycheck → runqualitycheck.py} +7 -2
- rapidtide/scripts/{showarbcorr → showarbcorr.py} +7 -2
- rapidtide/scripts/{showhist → showhist.py} +7 -2
- rapidtide/scripts/{showstxcorr → showstxcorr.py} +7 -2
- rapidtide/scripts/{showtc → showtc.py} +7 -2
- rapidtide/scripts/{showxcorr_legacy → showxcorr_legacy.py} +8 -8
- rapidtide/scripts/{showxcorrx → showxcorrx.py} +7 -2
- rapidtide/scripts/{showxy → showxy.py} +7 -2
- rapidtide/scripts/{simdata → simdata.py} +7 -2
- rapidtide/scripts/{spatialdecomp → spatialdecomp.py} +7 -2
- rapidtide/scripts/{spatialfit → spatialfit.py} +7 -2
- rapidtide/scripts/{spatialmi → spatialmi.py} +7 -2
- rapidtide/scripts/{spectrogram → spectrogram.py} +7 -2
- rapidtide/scripts/stupidramtricks.py +238 -0
- rapidtide/scripts/{synthASL → synthASL.py} +7 -2
- rapidtide/scripts/{tcfrom2col → tcfrom2col.py} +7 -2
- rapidtide/scripts/{tcfrom3col → tcfrom3col.py} +7 -2
- rapidtide/scripts/{temporaldecomp → temporaldecomp.py} +7 -2
- rapidtide/scripts/{testhrv → testhrv.py} +1 -1
- rapidtide/scripts/{threeD → threeD.py} +7 -2
- rapidtide/scripts/{tidepool → tidepool.py} +7 -2
- rapidtide/scripts/{variabilityizer → variabilityizer.py} +7 -2
- rapidtide/simFuncClasses.py +2113 -0
- rapidtide/simfuncfit.py +312 -108
- rapidtide/stats.py +579 -247
- rapidtide/tests/.coveragerc +27 -6
- rapidtide-2.9.6.data/scripts/fdica → rapidtide/tests/cleanposttest +4 -6
- rapidtide/tests/happycomp +9 -0
- rapidtide/tests/resethappytargets +1 -1
- rapidtide/tests/resetrapidtidetargets +1 -1
- rapidtide/tests/resettargets +1 -1
- rapidtide/tests/runlocaltest +3 -3
- rapidtide/tests/showkernels +1 -1
- rapidtide/tests/test_aliasedcorrelate.py +4 -4
- rapidtide/tests/test_aligntcs.py +1 -1
- rapidtide/tests/test_calcicc.py +1 -1
- rapidtide/tests/test_cleanregressor.py +184 -0
- rapidtide/tests/test_congrid.py +70 -81
- rapidtide/tests/test_correlate.py +1 -1
- rapidtide/tests/test_corrpass.py +4 -4
- rapidtide/tests/test_delayestimation.py +54 -59
- rapidtide/tests/test_dlfiltertorch.py +437 -0
- rapidtide/tests/test_doresample.py +2 -2
- rapidtide/tests/test_externaltools.py +69 -0
- rapidtide/tests/test_fastresampler.py +9 -5
- rapidtide/tests/test_filter.py +96 -57
- rapidtide/tests/test_findmaxlag.py +50 -19
- rapidtide/tests/test_fullrunhappy_v1.py +15 -10
- rapidtide/tests/test_fullrunhappy_v2.py +19 -13
- rapidtide/tests/test_fullrunhappy_v3.py +28 -13
- rapidtide/tests/test_fullrunhappy_v4.py +30 -11
- rapidtide/tests/test_fullrunhappy_v5.py +62 -0
- rapidtide/tests/test_fullrunrapidtide_v1.py +61 -7
- rapidtide/tests/test_fullrunrapidtide_v2.py +26 -14
- rapidtide/tests/test_fullrunrapidtide_v3.py +28 -8
- rapidtide/tests/test_fullrunrapidtide_v4.py +16 -8
- rapidtide/tests/test_fullrunrapidtide_v5.py +15 -6
- rapidtide/tests/test_fullrunrapidtide_v6.py +142 -0
- rapidtide/tests/test_fullrunrapidtide_v7.py +114 -0
- rapidtide/tests/test_fullrunrapidtide_v8.py +66 -0
- rapidtide/tests/test_getparsers.py +158 -0
- rapidtide/tests/test_io.py +59 -18
- rapidtide/tests/{test_glmpass.py → test_linfitfiltpass.py} +10 -10
- rapidtide/tests/test_mi.py +1 -1
- rapidtide/tests/test_miscmath.py +1 -1
- rapidtide/tests/test_motionregress.py +5 -5
- rapidtide/tests/test_nullcorr.py +6 -9
- rapidtide/tests/test_padvec.py +216 -0
- rapidtide/tests/test_parserfuncs.py +101 -0
- rapidtide/tests/test_phaseanalysis.py +1 -1
- rapidtide/tests/test_rapidtideparser.py +59 -53
- rapidtide/tests/test_refinedelay.py +296 -0
- rapidtide/tests/test_runmisc.py +5 -5
- rapidtide/tests/test_sharedmem.py +60 -0
- rapidtide/tests/test_simroundtrip.py +132 -0
- rapidtide/tests/test_simulate.py +1 -1
- rapidtide/tests/test_stcorrelate.py +4 -2
- rapidtide/tests/test_timeshift.py +2 -2
- rapidtide/tests/test_valtoindex.py +1 -1
- rapidtide/tests/test_zRapidtideDataset.py +5 -3
- rapidtide/tests/utils.py +10 -9
- rapidtide/tidepoolTemplate.py +88 -70
- rapidtide/tidepoolTemplate.ui +60 -46
- rapidtide/tidepoolTemplate_alt.py +88 -53
- rapidtide/tidepoolTemplate_alt.ui +62 -52
- rapidtide/tidepoolTemplate_alt_qt6.py +921 -0
- rapidtide/tidepoolTemplate_big.py +1125 -0
- rapidtide/tidepoolTemplate_big.ui +2386 -0
- rapidtide/tidepoolTemplate_big_qt6.py +1129 -0
- rapidtide/tidepoolTemplate_qt6.py +793 -0
- rapidtide/util.py +1389 -148
- rapidtide/voxelData.py +1048 -0
- rapidtide/wiener.py +138 -25
- rapidtide/wiener2.py +114 -8
- rapidtide/workflows/adjustoffset.py +107 -5
- rapidtide/workflows/aligntcs.py +86 -3
- rapidtide/workflows/applydlfilter.py +231 -89
- rapidtide/workflows/applyppgproc.py +540 -0
- rapidtide/workflows/atlasaverage.py +309 -48
- rapidtide/workflows/atlastool.py +130 -9
- rapidtide/workflows/calcSimFuncMap.py +490 -0
- rapidtide/workflows/calctexticc.py +202 -10
- rapidtide/workflows/ccorrica.py +123 -15
- rapidtide/workflows/cleanregressor.py +415 -0
- rapidtide/workflows/delayvar.py +1268 -0
- rapidtide/workflows/diffrois.py +84 -6
- rapidtide/workflows/endtidalproc.py +149 -9
- rapidtide/workflows/fdica.py +197 -17
- rapidtide/workflows/filtnifti.py +71 -4
- rapidtide/workflows/filttc.py +76 -5
- rapidtide/workflows/fitSimFuncMap.py +578 -0
- rapidtide/workflows/fixtr.py +74 -4
- rapidtide/workflows/gmscalc.py +116 -6
- rapidtide/workflows/happy.py +1242 -480
- rapidtide/workflows/happy2std.py +145 -13
- rapidtide/workflows/happy_parser.py +277 -59
- rapidtide/workflows/histnifti.py +120 -4
- rapidtide/workflows/histtc.py +85 -4
- rapidtide/workflows/{glmfilt.py → linfitfilt.py} +128 -14
- rapidtide/workflows/localflow.py +329 -29
- rapidtide/workflows/mergequality.py +80 -4
- rapidtide/workflows/niftidecomp.py +323 -19
- rapidtide/workflows/niftistats.py +178 -8
- rapidtide/workflows/pairproc.py +99 -5
- rapidtide/workflows/pairwisemergenifti.py +86 -3
- rapidtide/workflows/parser_funcs.py +1488 -56
- rapidtide/workflows/physiofreq.py +139 -12
- rapidtide/workflows/pixelcomp.py +211 -9
- rapidtide/workflows/plethquality.py +105 -23
- rapidtide/workflows/polyfitim.py +159 -19
- rapidtide/workflows/proj2flow.py +76 -3
- rapidtide/workflows/rankimage.py +115 -8
- rapidtide/workflows/rapidtide.py +1785 -1858
- rapidtide/workflows/rapidtide2std.py +101 -3
- rapidtide/workflows/rapidtide_parser.py +590 -389
- rapidtide/workflows/refineDelayMap.py +249 -0
- rapidtide/workflows/refineRegressor.py +1215 -0
- rapidtide/workflows/regressfrommaps.py +308 -0
- rapidtide/workflows/resamplenifti.py +86 -4
- rapidtide/workflows/resampletc.py +92 -4
- rapidtide/workflows/retrolagtcs.py +442 -0
- rapidtide/workflows/retroregress.py +1501 -0
- rapidtide/workflows/roisummarize.py +176 -7
- rapidtide/workflows/runqualitycheck.py +72 -7
- rapidtide/workflows/showarbcorr.py +172 -16
- rapidtide/workflows/showhist.py +87 -3
- rapidtide/workflows/showstxcorr.py +161 -4
- rapidtide/workflows/showtc.py +172 -10
- rapidtide/workflows/showxcorrx.py +250 -62
- rapidtide/workflows/showxy.py +186 -16
- rapidtide/workflows/simdata.py +418 -112
- rapidtide/workflows/spatialfit.py +83 -8
- rapidtide/workflows/spatialmi.py +252 -29
- rapidtide/workflows/spectrogram.py +306 -33
- rapidtide/workflows/synthASL.py +157 -6
- rapidtide/workflows/tcfrom2col.py +77 -3
- rapidtide/workflows/tcfrom3col.py +75 -3
- rapidtide/workflows/tidepool.py +3829 -666
- rapidtide/workflows/utils.py +45 -19
- rapidtide/workflows/utils_doc.py +293 -0
- rapidtide/workflows/variabilityizer.py +118 -5
- {rapidtide-2.9.6.dist-info → rapidtide-3.1.3.dist-info}/METADATA +30 -223
- rapidtide-3.1.3.dist-info/RECORD +393 -0
- {rapidtide-2.9.6.dist-info → rapidtide-3.1.3.dist-info}/WHEEL +1 -1
- rapidtide-3.1.3.dist-info/entry_points.txt +65 -0
- rapidtide-3.1.3.dist-info/top_level.txt +2 -0
- rapidtide/calcandfitcorrpairs.py +0 -262
- rapidtide/data/examples/src/testoutputsize +0 -45
- rapidtide/data/models/model_revised/model.h5 +0 -0
- rapidtide/data/models/model_serdar/model.h5 +0 -0
- rapidtide/data/models/model_serdar2/model.h5 +0 -0
- rapidtide/data/reference/ASPECTS_nlin_asym_09c_2mm.nii.gz +0 -0
- rapidtide/data/reference/ASPECTS_nlin_asym_09c_2mm_mask.nii.gz +0 -0
- rapidtide/data/reference/ATTbasedFlowTerritories_split_nlin_asym_09c_2mm.nii.gz +0 -0
- rapidtide/data/reference/ATTbasedFlowTerritories_split_nlin_asym_09c_2mm_mask.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_binmask_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_lag_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_mask_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_negmask_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_sigma_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/data/reference/HCP1200_strength_2mm_2009c_asym.nii.gz +0 -0
- rapidtide/glmpass.py +0 -434
- rapidtide/refine_factored.py +0 -641
- rapidtide/scripts/retroglm +0 -23
- rapidtide/workflows/glmfrommaps.py +0 -202
- rapidtide/workflows/retroglm.py +0 -643
- rapidtide-2.9.6.data/scripts/adjustoffset +0 -23
- rapidtide-2.9.6.data/scripts/aligntcs +0 -23
- rapidtide-2.9.6.data/scripts/applydlfilter +0 -23
- rapidtide-2.9.6.data/scripts/atlasaverage +0 -23
- rapidtide-2.9.6.data/scripts/atlastool +0 -23
- rapidtide-2.9.6.data/scripts/calcicc +0 -22
- rapidtide-2.9.6.data/scripts/calctexticc +0 -23
- rapidtide-2.9.6.data/scripts/calcttest +0 -22
- rapidtide-2.9.6.data/scripts/ccorrica +0 -23
- rapidtide-2.9.6.data/scripts/diffrois +0 -23
- rapidtide-2.9.6.data/scripts/endtidalproc +0 -23
- rapidtide-2.9.6.data/scripts/filtnifti +0 -23
- rapidtide-2.9.6.data/scripts/filttc +0 -23
- rapidtide-2.9.6.data/scripts/fingerprint +0 -593
- rapidtide-2.9.6.data/scripts/fixtr +0 -23
- rapidtide-2.9.6.data/scripts/glmfilt +0 -24
- rapidtide-2.9.6.data/scripts/gmscalc +0 -22
- rapidtide-2.9.6.data/scripts/happy +0 -25
- rapidtide-2.9.6.data/scripts/happy2std +0 -23
- rapidtide-2.9.6.data/scripts/happywarp +0 -350
- rapidtide-2.9.6.data/scripts/histnifti +0 -23
- rapidtide-2.9.6.data/scripts/histtc +0 -23
- rapidtide-2.9.6.data/scripts/localflow +0 -23
- rapidtide-2.9.6.data/scripts/mergequality +0 -23
- rapidtide-2.9.6.data/scripts/pairproc +0 -23
- rapidtide-2.9.6.data/scripts/pairwisemergenifti +0 -23
- rapidtide-2.9.6.data/scripts/physiofreq +0 -23
- rapidtide-2.9.6.data/scripts/pixelcomp +0 -23
- rapidtide-2.9.6.data/scripts/plethquality +0 -23
- rapidtide-2.9.6.data/scripts/polyfitim +0 -23
- rapidtide-2.9.6.data/scripts/proj2flow +0 -23
- rapidtide-2.9.6.data/scripts/rankimage +0 -23
- rapidtide-2.9.6.data/scripts/rapidtide +0 -23
- rapidtide-2.9.6.data/scripts/rapidtide2std +0 -23
- rapidtide-2.9.6.data/scripts/resamplenifti +0 -23
- rapidtide-2.9.6.data/scripts/resampletc +0 -23
- rapidtide-2.9.6.data/scripts/retroglm +0 -23
- rapidtide-2.9.6.data/scripts/roisummarize +0 -23
- rapidtide-2.9.6.data/scripts/runqualitycheck +0 -23
- rapidtide-2.9.6.data/scripts/showarbcorr +0 -23
- rapidtide-2.9.6.data/scripts/showhist +0 -23
- rapidtide-2.9.6.data/scripts/showstxcorr +0 -23
- rapidtide-2.9.6.data/scripts/showtc +0 -23
- rapidtide-2.9.6.data/scripts/showxcorr_legacy +0 -536
- rapidtide-2.9.6.data/scripts/showxcorrx +0 -23
- rapidtide-2.9.6.data/scripts/showxy +0 -23
- rapidtide-2.9.6.data/scripts/simdata +0 -23
- rapidtide-2.9.6.data/scripts/spatialdecomp +0 -23
- rapidtide-2.9.6.data/scripts/spatialfit +0 -23
- rapidtide-2.9.6.data/scripts/spatialmi +0 -23
- rapidtide-2.9.6.data/scripts/spectrogram +0 -23
- rapidtide-2.9.6.data/scripts/synthASL +0 -23
- rapidtide-2.9.6.data/scripts/tcfrom2col +0 -23
- rapidtide-2.9.6.data/scripts/tcfrom3col +0 -23
- rapidtide-2.9.6.data/scripts/temporaldecomp +0 -23
- rapidtide-2.9.6.data/scripts/threeD +0 -236
- rapidtide-2.9.6.data/scripts/tidepool +0 -23
- rapidtide-2.9.6.data/scripts/variabilityizer +0 -23
- rapidtide-2.9.6.dist-info/RECORD +0 -359
- rapidtide-2.9.6.dist-info/top_level.txt +0 -86
- {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
|