├── .flake8 ├── .github └── workflows │ ├── docs.yml │ ├── linting.yml │ ├── pypi.yml │ └── test.yml ├── .gitignore ├── .isort.cfg ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── ECG_logo (1).png ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs └── source │ ├── api.rst │ ├── conf.py │ ├── examples │ ├── Artefacts │ │ ├── README.txt │ │ ├── plot_ArtefactsDetection.py │ │ ├── plot_PeaksCorrection.py │ │ └── plot_RRCorrection.py │ ├── Plots │ │ ├── README.txt │ │ ├── plot_circular.py │ │ ├── plot_ectopic.py │ │ ├── plot_events.py │ │ ├── plot_evoked.py │ │ ├── plot_frequency.py │ │ ├── plot_pointcare.py │ │ ├── plot_raw.py │ │ ├── plot_rr.py │ │ ├── plot_shortlong.py │ │ └── plot_subspace.py │ ├── README.rst │ └── Recording │ │ ├── README.txt │ │ ├── plot_HeartBeatrpeggios.py │ │ ├── plot_InstantaneousHeartRate.py │ │ └── plot_RecordingPPG.py │ ├── getting_started.rst │ ├── images │ ├── ECG_logo (1).png │ ├── LabLogo.png │ ├── au_clinisk_logo.png │ ├── code-solid.png │ ├── code-solid.svg │ ├── create_figures.py │ ├── ecg.png │ ├── ecg.svg │ ├── editor.gif │ ├── forward-fast-solid.png │ ├── forward-fast-solid.svg │ ├── hrv.html │ ├── hrv.png │ ├── logo.png │ ├── logo.svg │ ├── logo_small.svg │ ├── lundbeckfonden_logo.png │ ├── peaks.gif │ ├── pulseOximeter.png │ ├── pulseOximeter.svg │ ├── raw.html │ ├── raw.png │ ├── recording.png │ ├── segments.gif │ ├── subspaces.html │ ├── subspaces.png │ ├── table-cells-large-solid.png │ ├── table-cells-large-solid.svg │ ├── tutorials.png │ └── tutorials.svg │ ├── index.rst │ ├── notebooks │ ├── 1-PhysiologicalSignals.ipynb │ ├── 1-PhysiologicalSignals.md │ ├── 2-DetectingCycles.ipynb │ ├── 2-DetectingCycles.md │ ├── 3-DetectingAndCorrectingArtefacts.ipynb │ ├── 3-DetectingAndCorrectingArtefacts.md │ ├── 4-HeartRateVariability.ipynb │ ├── 4-HeartRateVariability.md │ ├── 5-InstantaneousHeartRate.ipynb │ ├── 5-InstantaneousHeartRate.md │ ├── 6-WorkingWithBIDSFolders.ipynb │ └── 6-WorkingWithBIDSFolders.md │ ├── references.md │ ├── refs.bib │ └── tutorials.md ├── environment.yml ├── make.bat ├── paper ├── paper.bib └── paper.md ├── requirements-docs.txt ├── requirements-tests.txt ├── requirements.txt ├── setup.py ├── systole ├── __init__.py ├── correction.py ├── datasets │ ├── Task1_ECG.npy │ ├── Task1_EDA.npy │ ├── Task1_Respiration.npy │ ├── Task1_Stim.npy │ ├── __init__.py │ ├── ppg.npy │ └── rr.txt ├── detection.py ├── detectors │ ├── __init__.py │ ├── christov.py │ ├── engelse_zeelenberg.py │ ├── hamilton.py │ ├── moving_average.py │ ├── msptd.py │ ├── pan_tompkins.py │ ├── rolling_average_ppg.py │ └── rolling_average_resp.py ├── hrv.py ├── interact │ ├── __init__.py │ └── interact.py ├── io.py ├── plots │ ├── __init__.py │ ├── backends │ │ ├── __init__.py │ │ ├── bokeh │ │ │ ├── __init__.py │ │ │ ├── plot_ectopic.py │ │ │ ├── plot_events.py │ │ │ ├── plot_evoked.py │ │ │ ├── plot_frequency.py │ │ │ ├── plot_poincare.py │ │ │ ├── plot_raw.py │ │ │ ├── plot_rr.py │ │ │ ├── plot_shortlong.py │ │ │ └── plot_subspaces.py │ │ └── matplotlib │ │ │ ├── __init__.py │ │ │ ├── plot_circular.py │ │ │ ├── plot_ectopic.py │ │ │ ├── plot_events.py │ │ │ ├── plot_evoked.py │ │ │ ├── plot_frequency.py │ │ │ ├── plot_poincare.py │ │ │ ├── plot_raw.py │ │ │ ├── plot_rr.py │ │ │ ├── plot_shortlong.py │ │ │ └── plot_subspaces.py │ ├── plot_circular.py │ ├── plot_ectopic.py │ ├── plot_events.py │ ├── plot_evoked.py │ ├── plot_frequency.py │ ├── plot_poincare.py │ ├── plot_raw.py │ ├── plot_rr.py │ ├── plot_shortlong.py │ ├── plot_subspaces.py │ └── utils.py ├── recording.py ├── reports │ ├── __init__.py │ ├── command_line.py │ ├── group_level.html │ ├── group_level.py │ ├── images │ │ └── logo.svg │ ├── subject_level.html │ ├── subject_level.py │ ├── tables.py │ └── utils.py └── utils.py └── tests ├── __init__.py ├── group_level_ses-session1_task-hrd.tsv ├── test_correction.py ├── test_detection.py ├── test_detectors.py ├── test_hrv.py ├── test_plots.py ├── test_recording.py ├── test_reports.py └── test_utils.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203, E722, E501 4 | exclude = 5 | __init__.py -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | build-and-deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 🛎️ 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Python 3.9 21 | uses: actions/setup-python@v1 22 | with: 23 | python-version: 3.9 24 | 25 | - name: Build 26 | run: | 27 | pip install . 28 | pip install -r requirements-docs.txt 29 | sphinx-build -b html docs/source docs/build/html 30 | 31 | - name: Deploy 🚀 32 | uses: JamesIves/github-pages-deploy-action@v4 33 | with: 34 | folder: docs/build/html 35 | BRANCH: gh-pages 36 | -------------------------------------------------------------------------------- /.github/workflows/linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10"] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Run lint 23 | uses: pre-commit/action@v3.0.0 24 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.10" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: >- 32 | Publish Python 🐍 distribution 📦 to PyPI 33 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 34 | needs: 35 | - build 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/systole # Replace with your PyPI project name 40 | permissions: 41 | id-token: write # IMPORTANT: mandatory for trusted publishing 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests and coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10"] 16 | steps: 17 | - uses: actions/checkout@master 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: 'pip' 23 | - name: Install dependencies 24 | run: | 25 | pip install -r requirements-tests.txt 26 | pip install coverage pytest pytest-cov 27 | pip install . 28 | - name: Run tests and coverage 29 | run: | 30 | pytest ./tests/ --cov=./systole/ --cov-report=xml 31 | - name: Upload coverage to Codecov 32 | uses: codecov/codecov-action@v3.1.1 33 | with: 34 | files: coverage.xml 35 | verbose: true 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *-checkpoint.ipynb 3 | *-checkpoint.md 4 | mypyreports/ 5 | build/ 6 | dist/ 7 | systole.egg-info/ 8 | htmlcov/ 9 | .coverage 10 | .vscode/ 11 | docs/source/auto_examples 12 | docs/source/generated 13 | docs/source/api 14 | coverage.xml 15 | venv/ 16 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_third_party = matplotlib,numpy,pandas,pytest,requests,scipy,seaborn,serial,sphinx_bootstrap_theme,tqdm 3 | multi_line_output = 3 4 | include_trailing_comma = True 5 | force_grid_wrap = 0 6 | use_parentheses = True 7 | ensure_newline_before_comments = True 8 | line_length = 88 -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-isort 3 | rev: v5.10.1 4 | hooks: 5 | - id: isort 6 | files: ^systole/ 7 | - repo: https://github.com/ambv/black 8 | rev: 23.1.0 9 | hooks: 10 | - id: black 11 | language_version: python3 12 | files: ^systole/ 13 | - repo: https://github.com/pycqa/flake8 14 | rev: 6.0.0 15 | hooks: 16 | - id: flake8 17 | files: ^systole/ 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: 'v1.1.1' 20 | hooks: 21 | - id: mypy 22 | files: ^systole/ 23 | args: [--ignore-missing-imports] -------------------------------------------------------------------------------- /ECG_logo (1).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/ECG_logo (1).png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Add README, LICENSE and requirements 2 | include README.rst 3 | include LICENSE 4 | include requirements.txt 5 | include systole/reports/subject_level.html 6 | include systole/reports/group_level.html 7 | include systole/reports/images/logo.svg -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | .. _api_ref: 2 | 3 | .. currentmodule:: systole 4 | 5 | Functions 6 | ========= 7 | 8 | .. contents:: Table of Contents 9 | :depth: 2 10 | 11 | Correction 12 | ---------- 13 | 14 | .. currentmodule:: systole.correction 15 | 16 | .. _correction: 17 | 18 | .. autosummary:: 19 | :toctree: generated/correction 20 | 21 | correct_extra_rr 22 | correct_missed_rr 23 | interpolate_rr 24 | correct_rr 25 | correct_peaks 26 | correct_missed_peaks 27 | correct_ectopic_peaks 28 | 29 | Detection 30 | --------- 31 | 32 | .. currentmodule:: systole.detection 33 | 34 | .. _detection: 35 | 36 | .. autosummary:: 37 | :toctree: generated/detection 38 | 39 | ppg_peaks 40 | ecg_peaks 41 | rsp_peaks 42 | rr_artefacts 43 | interpolate_clipping 44 | 45 | Heart Rate Variability 46 | ---------------------- 47 | 48 | .. currentmodule:: systole.hrv 49 | 50 | .. _hrv: 51 | 52 | .. autosummary:: 53 | :toctree: generated/hrv 54 | 55 | nnX 56 | pnnX 57 | rmssd 58 | time_domain 59 | psd 60 | frequency_domain 61 | nonlinear_domain 62 | poincare 63 | recurrence 64 | recurrence_matrix 65 | all_domain 66 | 67 | Plots 68 | ----- 69 | 70 | .. currentmodule:: systole.plots 71 | 72 | .. _plots: 73 | 74 | .. autosummary:: 75 | :toctree: generated/plots 76 | 77 | plot_circular 78 | plot_ectopic 79 | plot_events 80 | plot_evoked 81 | plot_frequency 82 | plot_poincare 83 | plot_raw 84 | plot_rr 85 | plot_shortlong 86 | plot_subspaces 87 | 88 | Recording 89 | --------- 90 | 91 | .. currentmodule:: systole.recording 92 | 93 | .. _recording: 94 | 95 | .. autosummary:: 96 | :toctree: generated/recording 97 | 98 | Oximeter 99 | BrainVisionExG 100 | 101 | Reports 102 | ------- 103 | 104 | .. currentmodule:: systole.reports 105 | 106 | .. _reports: 107 | 108 | .. autosummary:: 109 | :toctree: generated/reports 110 | 111 | time_table 112 | frequency_table 113 | nonlinear_table 114 | 115 | Interactive data labelling and peaks correction 116 | ----------------------------------------------- 117 | 118 | .. currentmodule:: systole.interact 119 | 120 | .. _reports: 121 | 122 | .. autosummary:: 123 | :toctree: generated/interact 124 | 125 | Viewer 126 | Editor 127 | 128 | Utils 129 | ----- 130 | 131 | .. currentmodule:: systole.utils 132 | 133 | .. _utils: 134 | 135 | .. autosummary:: 136 | :toctree: generated/utils 137 | 138 | norm_triggers 139 | time_shift 140 | heart_rate 141 | to_angles 142 | to_epochs 143 | simulate_rr 144 | to_neighbour 145 | input_conversion 146 | nan_cleaning 147 | find_clipping 148 | get_valid_segments 149 | norm_bad_segments -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import time 14 | 15 | import sphinx_bootstrap_theme 16 | import systole 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = "systole" 21 | copyright = u"2020-{}, Micah Allen".format(time.strftime("%Y")) 22 | author = "Micah Allen" 23 | release = systole.__version__ 24 | 25 | 26 | image_scrapers = ("matplotlib",) 27 | 28 | sphinx_gallery_conf = { 29 | "examples_dirs": "./examples/", 30 | "backreferences_dir": "api", 31 | "image_scrapers": image_scrapers, 32 | } 33 | 34 | bibtex_bibfiles = ['refs.bib'] 35 | bibtex_reference_style = "author_year" 36 | bibtex_default_style = "unsrt" 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | "sphinx.ext.mathjax", 45 | "sphinx.ext.doctest", 46 | "sphinx.ext.viewcode", 47 | "sphinx.ext.githubpages", 48 | "sphinx.ext.autosummary", 49 | "sphinx.ext.autodoc", 50 | "sphinx.ext.intersphinx", 51 | "sphinx_gallery.gen_gallery", 52 | "matplotlib.sphinxext.plot_directive", 53 | "numpydoc", 54 | "jupyter_sphinx", 55 | "sphinx_design", 56 | "myst_nb", 57 | "sphinx_gallery.load_style", 58 | "sphinxcontrib.bibtex" 59 | ] 60 | 61 | panels_add_bootstrap_css = False 62 | 63 | # Generate the API documentation when building 64 | autosummary_generate = True 65 | numpydoc_show_class_members = False 66 | 67 | # raise an error if the documentation does not build and exit the process 68 | # this should especially ensure that the notebooks run correctly 69 | nb_execution_raise_on_error = True 70 | 71 | # Include the example source for plots in API docs 72 | plot_include_source = True 73 | plot_formats = [("png", 90)] 74 | plot_html_show_formats = False 75 | plot_html_show_source_link = False 76 | 77 | source_suffix = ['.rst', '.md'] 78 | 79 | # The master toctree document. 80 | master_doc = "index" 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | # This pattern also affects html_static_path and html_extra_path. 85 | exclude_patterns = [] 86 | 87 | # -- Options for HTML output ------------------------------------------------- 88 | 89 | # The theme to use for HTML and HTML Help pages. See the documentation for 90 | # a list of builtin themes. 91 | 92 | html_theme = "pydata_sphinx_theme" 93 | html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() 94 | html_theme_options = { 95 | "icon_links": [ 96 | dict( 97 | name="GitHub", 98 | url="https://github.com/embodied-computation-group/systole", 99 | icon="fa-brands fa-square-github", 100 | ), 101 | dict( 102 | name="Twitter", 103 | url="https://twitter.com/visceral_mind", 104 | icon="fa-brands fa-square-twitter", 105 | ), 106 | dict( 107 | name="Pypi", 108 | url="https://pypi.org/project/systole/", 109 | icon="fa-solid fa-box", 110 | ), 111 | ], 112 | "logo": { 113 | "text": "Systole", 114 | },} 115 | 116 | html_sidebars = {"**": []} 117 | 118 | # -- Options for HTML output ------------------------------------------------- 119 | 120 | html_logo = "images/logo_small.svg" 121 | html_favicon = "images/logo_small.svg" 122 | 123 | # -- Intersphinx ------------------------------------------------ 124 | 125 | intersphinx_mapping = { 126 | "numpy": ("http://docs.scipy.org/doc/numpy/", None), 127 | "scipy": ("http://docs.scipy.org/doc/scipy/reference/", None), 128 | "matplotlib": ("http://matplotlib.org/", None), 129 | "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), 130 | "seaborn": ("https://seaborn.pydata.org/", None), 131 | "sklearn": ("http://scikit-learn.org/stable", None), 132 | "bokeh": ("http://docs.bokeh.org/en/latest/", None), 133 | } 134 | -------------------------------------------------------------------------------- /docs/source/examples/Artefacts/README.txt: -------------------------------------------------------------------------------- 1 | Artefacts 2 | +++++++++ -------------------------------------------------------------------------------- /docs/source/examples/Artefacts/plot_ArtefactsDetection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Outliers and artefacts detection 3 | ================================ 4 | 5 | This example shows how to detect ectopic, missed, extra, slow and long long 6 | from RR or pulse rate interval time series using the method proposed by 7 | Lipponen & Tarvainen (2019) [#]_. 8 | """ 9 | 10 | # Author: Nicolas Legrand 11 | # Licence: GPL v3 12 | 13 | #%% 14 | from systole.detection import rr_artefacts 15 | from systole.plots import plot_subspaces 16 | from systole.utils import simulate_rr 17 | 18 | #%% 19 | # RR artefacts 20 | # ------------ 21 | # The proposed method will detect 4 kinds of artefacts in an RR time series: 22 | # Missed R peaks, when an existing R component was erroneously NOT detected by 23 | # the algorithm. 24 | # * Extra R peaks, when an R peak was detected but does not exist in the 25 | # signal. 26 | # * Long or short interval intervals, when R peaks are correctly detected but 27 | # the resulting interval has extreme value in the overall time-series. 28 | # * Ectopic beats, due to disturbance of the cardiac rhythm when the heart 29 | # either skip or add an extra beat. 30 | # * The category in which the artefact belongs will have an influence on the 31 | # correction procedure (see Artefact correction). 32 | 33 | #%% 34 | # Simulate RR time series 35 | # ----------------------- 36 | # This function will simulate RR time series containing ectopic, extra, missed, 37 | # long and short artefacts. 38 | 39 | rr = simulate_rr() 40 | 41 | #%% 42 | # Artefact detection 43 | # ------------------ 44 | 45 | outliers = rr_artefacts(rr) 46 | 47 | #%% 48 | # Subspaces visualization 49 | # ----------------------- 50 | # You can visualize the two main subspaces and spot outliers. The left pamel 51 | # plot subspaces that are more sensitive to ectopic beats detection. The right 52 | # panel plot subspaces that will be more sensitive to long or short beats, 53 | # comprizing the extra and missed beats. 54 | 55 | plot_subspaces(rr, figsize=(12, 6)) 56 | 57 | #%% 58 | # References 59 | # ---------- 60 | # .. [#] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for 61 | # heart rate variability time series artefact correction using novel 62 | # beat classification. Journal of Medical Engineering & Technology, 63 | # 43(3), 173–181. https://doi.org/10.1080/03091902.2019.1640306 64 | -------------------------------------------------------------------------------- /docs/source/examples/Artefacts/plot_PeaksCorrection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Detecting and correcting artefacts in peaks vector 3 | ================================================== 4 | 5 | This example describes artefacts correction peaks vectors. 6 | 7 | The function `correct_rr()` automatically detect artefacts using the method proposed 8 | by Lipponen & Tarvainen (2019) [#]_. At each iteration, extra and missed 9 | peaks are corrected replacement or removal of peaks. The detection procedure is run 10 | again using cleaned intervals. When using this method, the signal length stays constant, 11 | which makes it more appropriate for event-related designs where the occurrence of 12 | certain events must be controlled. 13 | 14 | """ 15 | 16 | # Author: Nicolas Legrand 17 | # Licence: GPL v3 18 | 19 | #%% 20 | import numpy as np 21 | import pandas as pd 22 | from systole import import_dataset1 23 | from systole.detection import ecg_peaks 24 | from systole.correction import correct_peaks 25 | from systole.plots import plot_rr, plot_evoked 26 | import matplotlib.pyplot as plt 27 | 28 | #%% Import ECG recording and events triggers 29 | ecg_df = import_dataset1(modalities=['ECG', 'Stim']) 30 | 31 | #%% Detecting R peaks in the ECG signal using the Pan-Tompkins method 32 | signal, peaks = ecg_peaks(ecg_df.ecg, method='pan-tompkins', sfreq=1000) 33 | 34 | #%% We can visualize this series using Systole's built in `plot_rr` function. Here we 35 | # are using Matplotlib as plotting backend. 36 | plot_rr(peaks, input_type='peaks', figsize=(13, 5)) 37 | plt.show() 38 | 39 | #%% Creating artefacts 40 | np.random.seed(123) # For result reproductibility 41 | 42 | corrupted_peaks = peaks.copy() # Create a new RR intervals vector 43 | 44 | # Randomly select 50 peaks in the peask vector and set it to 0 (missed peaks) 45 | corrupted_peaks[np.random.choice(np.where(corrupted_peaks)[0], 50)] = 0 46 | 47 | # Randomly add 50 intervals in the peaks vector (extra peaks) 48 | corrupted_peaks[np.random.choice(len(corrupted_peaks), 50)] = 1 49 | 50 | #%% Lets see if the artefact we created are correctly detected. Note that here, we are 51 | # using `show_artefacts=True` so the artefacts detection runs automatically and shows 52 | # in the plot. 53 | plot_rr( 54 | corrupted_peaks, input_type='peaks', 55 | show_artefacts=True, line=False, figsize=(13, 5) 56 | ) 57 | plt.show() 58 | 59 | #%% The artefacts simulation is working fine. We can now apply the peaks 60 | # correction method. This function will automatically detect possible artefacts in the 61 | # peaks vector and reconstruct the most coherent values using time series interpolation. 62 | # The number of iteration is set to `2` by default, we add it here for clarity. Here, 63 | # the `correct_peaks` function only correct for extra and missed peaks. This feature is 64 | # intentional and reflects the notion that only artefacts in R peaks detection should 65 | # be corrected, but "true" intervals that are anomaly shorter or longer should not be 66 | # corrected. 67 | peaks_correction = correct_peaks(corrupted_peaks) 68 | 69 | #%% Plotting corrected peaks vector. 70 | plot_rr(peaks_correction["clean_peaks"], input_type="peaks", 71 | show_artefacts=True, line=False, figsize=(13, 5)) 72 | plt.show() 73 | 74 | #%% As previously mentioned, this method is more appropriate in the context of 75 | # event-related analysis, where the evolution of the instantaneous heart rate is 76 | # assessed after some experimental manipulation (see Tutorial 5). One way to control 77 | # for the quality of the artefacts correction is to compare the evoked responses 78 | # measured under corrupted, corrected and baseline recording. Here, we will use the 79 | # `plot_evoked` function, which simply take the indexes of events as input together 80 | # with the recording (here the peaks vector), and produce the evoked plots. 81 | 82 | # Merge the two conditions together. 83 | # The events of interest are all data points that are not 0. 84 | triggers_idx = [np.where(ecg_df.stim.to_numpy() != 0)[0]] 85 | 86 | #%% Visualization of the correction quality 87 | _, axs = plt.subplots(1, 3, figsize=(18, 6), sharey=True) 88 | plot_evoked(rr=corrupted_peaks, triggers_idx=triggers_idx, ci=68, 89 | input_type="peaks", decim=100, apply_baseline=(-1.0, 0.0), figsize=(8, 8), 90 | labels="Uncorrected", palette=["#c44e52"], ax=axs[0]) 91 | plot_evoked(rr=peaks_correction["clean_peaks"], triggers_idx=triggers_idx, ci=68, 92 | input_type="peaks", decim=100, apply_baseline=(-1.0, 0.0), figsize=(8, 8), 93 | labels="Corrected", ax=axs[1]) 94 | plot_evoked(rr=peaks, triggers_idx=triggers_idx, ci=68, palette=["#55a868"], 95 | input_type="peaks", decim=100, apply_baseline=(-1.0, 0.0), figsize=(8, 8), 96 | labels="Initial recording", ax=axs[2]) 97 | plt.ylim(-20, 20); 98 | 99 | 100 | #%% 101 | # References 102 | # ---------- 103 | # .. [#] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for 104 | # heart rate variability time series artefact correction using novel 105 | # beat classification. Journal of Medical Engineering & Technology, 106 | # 43(3), 173–181. https://doi.org/10.1080/03091902.2019.1640306 107 | -------------------------------------------------------------------------------- /docs/source/examples/Plots/README.txt: -------------------------------------------------------------------------------- 1 | Plotting 2 | ++++++++ -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_circular.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot circular 4 | ============= 5 | The density function can be represented using the area of the bars, the height or 6 | the transparency (alpha). The default behaviour will use the area. Using the heigth 7 | can visually biase the importance of the largest values. Adapted from [#]_. 8 | 9 | The circular mean was adapted from Pingouin's implementation [#]_. 10 | 11 | .. [#] https://jwalton.info/Matplotlib-rose-plots/ 12 | 13 | .. [#] https://pingouin-stats.org/_modules/pingouin/circular.html#circ_mean 14 | 15 | """ 16 | 17 | # Author: Nicolas Legrand 18 | # Licence: GPL v3 19 | 20 | #%% 21 | # Using a numpy array of angular values as input 22 | # ---------------------------------------------- 23 | import numpy as np 24 | from systole.plots import plot_circular 25 | x = np.random.normal(np.pi, 0.5, 100) 26 | plot_circular(data=x) 27 | #%% 28 | # Using a data frame as input 29 | # --------------------------- 30 | import numpy as np 31 | import pandas as pd 32 | from systole.plots import plot_circular 33 | 34 | # Create angular values (two conditions) 35 | x = np.random.normal(np.pi, 0.5, 100) 36 | y = np.random.uniform(0, np.pi*2, 100) 37 | data = pd.DataFrame(data={'x': x, 'y': y}).melt() 38 | 39 | plot_circular(data=data, y='value', hue='variable') -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_ectopic.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot ectopic beats 4 | ================== 5 | 6 | """ 7 | 8 | # Author: Nicolas Legrand 9 | # Licence: GPL v3 10 | 11 | from systole.plots import plot_ectopic 12 | 13 | #%% 14 | # Visualizing ectopic subspace from RR time series 15 | # ------------------------------------------------ 16 | from systole import import_rr 17 | 18 | # Import PPG recording as numpy array 19 | rr = import_rr().rr.to_numpy() 20 | 21 | plot_ectopic(rr, input_type="rr_ms") 22 | #%% 23 | # Visualizing ectopic subspace from the `artefact` dictionary generated by :py:func:`systole.detection.rr_artefacts()` 24 | # -------------------------------------------------------------------------------------------------------------------- 25 | from systole.detection import rr_artefacts 26 | 27 | # Use the rr_artefacts function to find ectopic beats 28 | artefacts = rr_artefacts(rr) 29 | 30 | plot_ectopic(artefacts=artefacts) 31 | #%% 32 | # Using Bokeh as plotting backend 33 | # ------------------------------- 34 | from bokeh.io import output_notebook 35 | from bokeh.plotting import show 36 | from systole.detection import rr_artefacts 37 | 38 | output_notebook() 39 | 40 | # Use the rr_artefacts function to find ectopic beats 41 | artefacts = rr_artefacts(rr) 42 | show( 43 | plot_ectopic(artefacts=artefacts, backend="bokeh") 44 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_events.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot events 4 | =========== 5 | 6 | """ 7 | import numpy as np 8 | import seaborn as sns 9 | from bokeh.io import output_notebook 10 | from bokeh.plotting import show 11 | from systole.detection import ecg_peaks 12 | from systole.plots import plot_events, plot_rr 13 | 14 | from systole import import_dataset1 15 | 16 | # Author: Nicolas Legrand 17 | # Licence: GPL v3 18 | 19 | #%% 20 | # Plot events distributions using Matplotlib as plotting backend 21 | # -------------------------------------------------------------- 22 | 23 | ecg_df = import_dataset1(modalities=['ECG', "Stim"]) 24 | 25 | # Get events triggers 26 | triggers_idx = [ 27 | np.where(ecg_df.stim.to_numpy() == 2)[0], 28 | np.where(ecg_df.stim.to_numpy() == 1)[0] 29 | ] 30 | 31 | plot_events( 32 | triggers_idx=triggers_idx, labels=["Disgust", "Neutral"], 33 | tmin=-0.5, tmax=10.0, figsize=(13, 3), 34 | palette=[sns.xkcd_rgb["denim blue"], sns.xkcd_rgb["pale red"]], 35 | ) 36 | #%% 37 | # Plot events distributions using Bokeh as plotting backend and add the RR time series 38 | # ------------------------------------------------------------------------------------ 39 | 40 | output_notebook() 41 | 42 | # Peak detection in the ECG signal using the Pan-Tompkins method 43 | signal, peaks = ecg_peaks(ecg_df.ecg, method='pan-tompkins', sfreq=1000) 44 | 45 | # First, we create a RR interval plot 46 | rr_plot = plot_rr(peaks, input_type='peaks', backend='bokeh', figsize=250) 47 | 48 | show( 49 | # Then we add events annotations to this plot using the plot_events function 50 | plot_events(triggers_idx=triggers_idx, backend="bokeh", labels=["Disgust", "Neutral"], 51 | tmin=-0.5, tmax=10.0, palette=[sns.xkcd_rgb["denim blue"], sns.xkcd_rgb["pale red"]], 52 | ax=rr_plot.children[0]) 53 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_evoked.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot evoked 4 | =========== 5 | 6 | """ 7 | 8 | import matplotlib.pyplot as plt 9 | import numpy as np 10 | import seaborn as sns 11 | from systole.detection import ecg_peaks 12 | from systole.plots import plot_evoked 13 | from systole.utils import heart_rate, to_epochs 14 | 15 | from systole import import_dataset1 16 | 17 | # Author: Nicolas Legrand 18 | # Licence: GPL v3 19 | 20 | #%% 21 | # Plot evoked heart rate across two conditions using the Matplotlib backend 22 | # Here, for the sake of example, we are going to create the same plot three time using three kind of input data: 23 | # * The raw signal + the triggers timing (or a list of in case of multiple conditions). 24 | # * The peaks detection + the triggers timing (or a list of in case of multiple conditions) 25 | # * The epoched signal as a 2d NumPy array (or a list of in case of multiple conditions) 26 | # -------------------------------------------------------------- 27 | 28 | ecg_df = import_dataset1(modalities=['ECG', "Stim"]) 29 | 30 | # Get events triggers 31 | triggers_idx = [ 32 | np.where(ecg_df.stim.to_numpy() == 2)[0], 33 | np.where(ecg_df.stim.to_numpy() == 1)[0] 34 | ] 35 | 36 | # Peak detection in the ECG signal using the Pan-Tompkins method 37 | signal, peaks = ecg_peaks(ecg_df.ecg, method='sleepecg', sfreq=1000) 38 | 39 | # Convert to instantaneous heart rate 40 | rr, _ = heart_rate(peaks, kind="cubic", unit="bpm", input_type="peaks") 41 | 42 | # Create list epochs arrays for each condition 43 | hr_epochs, _ = to_epochs( 44 | signal=rr, triggers_idx=triggers_idx, tmin=-1.0, tmax=10.0, 45 | apply_baseline=(-1.0, 0.0) 46 | ) 47 | 48 | fig, axs = plt.subplots(ncols=3, figsize=(15, 5), sharey=True) 49 | 50 | # We define a common set of plotting arguments here 51 | plot_args = { 52 | "backend": "matplotlib", "figsize": (400, 400), 53 | "palette": [sns.xkcd_rgb["denim blue"], sns.xkcd_rgb["pale red"]], 54 | "tmin": -1.0, "tmax": 10.0, "apply_baseline": (-1.0, 0.0), "decim": 100 55 | } 56 | 57 | # Using the raw signal and events triggers 58 | plot_evoked( 59 | signal=ecg_df.ecg.to_numpy(), triggers_idx=triggers_idx, modality="ecg", 60 | ax=axs[0], **plot_args 61 | ) 62 | 63 | # Using the detected peaks and events triggers 64 | plot_evoked( 65 | rr=peaks, triggers_idx=triggers_idx, input_type="peaks", ax=axs[1], 66 | **plot_args 67 | ) 68 | 69 | # Using the list of epochs arrays 70 | plot_evoked( 71 | epochs=hr_epochs, ax=axs[2], **plot_args 72 | ) 73 | #%% Plot evoked heart rate across two conditions using Bokeh as plotting backend. 74 | # Here, for the sake of example, we are going to create the same plot three times using three kind of input data: 75 | # * The raw signal + the triggers timing (or a list of in case of multiple conditions). 76 | # * The peaks detection + the triggers timing (or a list of in case of multiple conditions) 77 | # * The epoched signal as a 2d NumPy array (or a list of in case of multiple conditions) 78 | # -------------------------------------------------------------------------------------- 79 | from bokeh.io import output_notebook 80 | from bokeh.layouts import row 81 | from bokeh.plotting import show 82 | 83 | output_notebook() 84 | 85 | # We define a common set of plotting arguments here 86 | plot_args = { 87 | "backend": "bokeh", "figsize": (300, 300), 88 | "palette": [sns.xkcd_rgb["denim blue"], sns.xkcd_rgb["pale red"]], 89 | "tmin": -1.0, "tmax": 10.0, "apply_baseline": (-1.0, 0.0), "decim": 100 90 | } 91 | 92 | # Using the raw signal and events triggers 93 | raw_plot = plot_evoked( 94 | signal=ecg_df.ecg.to_numpy(), triggers_idx=triggers_idx, modality="ecg", 95 | **plot_args 96 | ) 97 | 98 | # Using the detected peaks and events triggers 99 | peaks_plot = plot_evoked( 100 | rr=peaks, triggers_idx=triggers_idx, input_type="peaks", **plot_args 101 | ) 102 | 103 | # Using the list of epochs arrays 104 | epochs_plots = plot_evoked(epochs=hr_epochs, **plot_args) 105 | 106 | # Create a Bokeh layout and plot the figures side by side 107 | show( 108 | row( 109 | raw_plot, peaks_plot, epochs_plots 110 | ) 111 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_frequency.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot frequency 4 | ============== 5 | 6 | """ 7 | 8 | 9 | 10 | # Author: Nicolas Legrand 11 | # Licence: GPL v3 12 | 13 | #%% 14 | # Visualizing HRV frequency domain from RR time series using Matplotlib as plotting backend 15 | # ----------------------------------------------------------------------------------------- 16 | from systole import import_rr 17 | from systole.plots import plot_frequency 18 | 19 | # Import PPG recording as numpy array 20 | rr = import_rr().rr.to_numpy() 21 | plot_frequency(rr, input_type="rr_ms") 22 | 23 | #%% 24 | # Visualizing HRV frequency domain from RR time series using Bokeh as plotting backend 25 | # ------------------------------------------------------------------------------------ 26 | from systole import import_rr 27 | from systole.plots import plot_frequency 28 | from bokeh.io import output_notebook 29 | from bokeh.plotting import show 30 | output_notebook() 31 | 32 | show( 33 | plot_frequency(rr, input_type="rr_ms", backend="bokeh") 34 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_pointcare.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot pointcare 4 | ============== 5 | 6 | """ 7 | 8 | # Author: Nicolas Legrand 9 | # Licence: GPL v3 10 | 11 | #%% 12 | # Visualizing poincare plot from RR time series using Matplotlib as plotting backend 13 | # ---------------------------------------------------------------------------------- 14 | from systole import import_rr 15 | from systole.plots import plot_poincare 16 | 17 | # Import PPG recording as numpy array 18 | rr = import_rr().rr.to_numpy() 19 | 20 | plot_poincare(rr, input_type="rr_ms") 21 | 22 | #%% 23 | # Using Bokeh as plotting backend 24 | # ------------------------------- 25 | from bokeh.io import output_notebook 26 | from bokeh.plotting import show 27 | output_notebook() 28 | 29 | from systole import import_rr 30 | from systole.plots import plot_poincare 31 | 32 | show( 33 | plot_poincare(rr, input_type="rr_ms", backend="bokeh") 34 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_raw.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot raw physiological signal 4 | ============================= 5 | 6 | """ 7 | 8 | # Author: Nicolas Legrand 9 | # Licence: GPL v3 10 | 11 | 12 | from bokeh.io import output_notebook 13 | from bokeh.plotting import show 14 | from systole.plots import plot_raw 15 | 16 | from systole import import_dataset1, import_ppg 17 | 18 | #%% 19 | # Plot raw ECG signal 20 | # -------------------- 21 | 22 | # Import PPG recording as pandas data frame 23 | physio_df = import_dataset1(modalities=['ECG', 'Respiration']) 24 | 25 | # Only use the first 60 seconds for demonstration 26 | ecg = physio_df[physio_df.time.between(60, 90)].ecg 27 | plot_raw(ecg, modality='ecg', sfreq=1000, detector='sleepecg') 28 | #%% 29 | # Plot raw PPG signal 30 | # ------------------- 31 | # Import Respiration recording as pandas data frame 32 | rsp = import_dataset1(modalities=['Respiration']) 33 | 34 | # Only use the first 90 seconds for demonstration 35 | rsp = physio_df[physio_df.time.between(500, 600)].respiration 36 | plot_raw(rsp, sfreq=1000, modality="respiration") 37 | #%% 38 | # Plot raw respiratory signal 39 | # --------------------------- 40 | 41 | # Import PPG recording as pandas data frame 42 | ppg = import_ppg() 43 | 44 | # Only use the first 60 seconds for demonstration 45 | plot_raw(ppg[ppg.time<60], sfreq=75) 46 | 47 | #%% 48 | # Using Bokeh as plotting backend 49 | # ------------------------------- 50 | 51 | output_notebook() 52 | 53 | show( 54 | plot_raw(ppg, backend="bokeh", show_heart_rate=True, show_artefacts=True) 55 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_rr.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot instantaneous heart rate 4 | ============================= 5 | 6 | """ 7 | 8 | # Author: Nicolas Legrand 9 | # Licence: GPL v3 10 | 11 | from bokeh.io import output_notebook 12 | from bokeh.plotting import show 13 | from systole.plots import plot_rr 14 | 15 | from systole import import_rr 16 | 17 | #%% 18 | # Plot instantaneous heart rate from a RR interval time series (in milliseconds). 19 | # ------------------------------------------------------------------------------- 20 | 21 | # Import R-R intervals time series 22 | rr = import_rr().rr.values 23 | 24 | plot_rr(rr=rr, input_type="rr_ms"); 25 | #%% 26 | # Only show the interpolated instantaneous heart rate, add a bad segment and change the default unit to beats per minute (BPM). 27 | # ----------------------------------------------------------------------------------------------------------------------------- 28 | plot_rr(rr=rr, input_type="rr_ms", unit="bpm", points=False); 29 | #%% 30 | # Use Bokeh as a plotting backend, only show the scatterplt and highlight artefacts in the RR intervals 31 | # ----------------------------------------------------------------------------------------------------- 32 | output_notebook() 33 | 34 | show( 35 | plot_rr( 36 | rr=rr, input_type="rr_ms", backend="bokeh", 37 | line=False, show_artefacts=True 38 | ) 39 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_shortlong.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot short and long invertvals 4 | ============================== 5 | 6 | Visualization of short, long, extra and missed intervals detection. 7 | 8 | The artefact detection is based on the method described in [1]_. 9 | 10 | .. [1] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for 11 | heart rate variability time series artefact correction using novel beat 12 | classification. Journal of Medical Engineering & Technology, 43(3), 13 | 173–181. https://doi.org/10.1080/03091902.2019.1640306 14 | 15 | """ 16 | 17 | # Author: Nicolas Legrand 18 | # Licence: GPL v3 19 | 20 | #%% 21 | # Visualizing short/long and missed/extra intervals from a RR time series 22 | # ----------------------------------------------------------------------- 23 | from systole import import_rr 24 | from systole.plots import plot_shortlong 25 | 26 | # Import PPG recording as numpy array 27 | rr = import_rr().rr.to_numpy() 28 | 29 | plot_shortlong(rr) 30 | #%% 31 | # Visualizing ectopic subspace from the `artefact` dictionary 32 | # ----------------------------------------------------------- 33 | from systole.detection import rr_artefacts 34 | 35 | # Use the rr_artefacts function to short/long and extra/missed intervals 36 | artefacts = rr_artefacts(rr) 37 | 38 | plot_shortlong(artefacts=artefacts) 39 | 40 | #%% 41 | # Using Bokeh as plotting backend 42 | # ------------------------------- 43 | from bokeh.io import output_notebook 44 | from bokeh.plotting import show 45 | from systole.detection import rr_artefacts 46 | output_notebook() 47 | 48 | show( 49 | plot_shortlong( 50 | artefacts=artefacts, backend="bokeh" 51 | ) 52 | ) -------------------------------------------------------------------------------- /docs/source/examples/Plots/plot_subspace.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | Plot subspaces to vivualize short/long and ectopic beats 4 | ======================================================== 5 | 6 | The artefact detection is based on the method described in [1]_. 7 | 8 | .. [1] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for 9 | heart rate variability time series artefact correction using novel beat 10 | classification. Journal of Medical Engineering & Technology, 43(3), 11 | 173–181. https://doi.org/10.1080/03091902.2019.1640306 12 | 13 | """ 14 | 15 | # Author: Nicolas Legrand 16 | # Licence: GPL v3 17 | 18 | #%% 19 | # Visualizing artefacts from RR time series 20 | # ----------------------------------------- 21 | from systole import import_rr 22 | from systole.plots import plot_subspaces 23 | import matplotlib.pyplot as plt 24 | 25 | # Import PPG recording as numpy array 26 | rr = import_rr().rr.to_numpy() 27 | 28 | _, axs = plt.subplots(ncols=2, figsize=(12, 6)) 29 | plot_subspaces(rr, ax=axs); 30 | #%% 31 | # Visualizing artefacts from the `artefact` dictionary 32 | # ---------------------------------------------------- 33 | from systole.detection import rr_artefacts 34 | 35 | # Use the rr_artefacts function to short/long and extra/missed intervals 36 | artefacts = rr_artefacts(rr) 37 | 38 | _, axs = plt.subplots(ncols=2, figsize=(12, 6)) 39 | plot_subspaces(artefacts=artefacts, ax=axs) 40 | 41 | #%% 42 | # Using Bokeh as plotting backend 43 | # ------------------------------- 44 | from bokeh.io import output_notebook 45 | from bokeh.plotting import show 46 | from systole.detection import rr_artefacts 47 | output_notebook() 48 | 49 | show( 50 | plot_subspaces( 51 | artefacts=artefacts, backend="bokeh", figsize=400 52 | ) 53 | ) -------------------------------------------------------------------------------- /docs/source/examples/README.rst: -------------------------------------------------------------------------------- 1 | .. _general_examples: 2 | 3 | Example gallery 4 | =============== 5 | 6 | This section demonstrates some of the basic functionality of **systole**. -------------------------------------------------------------------------------- /docs/source/examples/Recording/README.txt: -------------------------------------------------------------------------------- 1 | Recording 2 | +++++++++ -------------------------------------------------------------------------------- /docs/source/examples/Recording/plot_InstantaneousHeartRate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Instantaneous Heart Rate 3 | ======================== 4 | 5 | This example show how to record PPG signals using the `Nonin 3012LP 6 | Xpod USB pulse oximeter `_ and the `Nonin 7 | 8000SM 'soft-clip' fingertip sensors `_. 8 | Peaks are automatically labelled online and the instantaneous heart rate is 9 | plotted. 10 | """ 11 | 12 | # Author: Nicolas Legrand 13 | # Licence: GPL v3 14 | 15 | import matplotlib.pyplot as plt 16 | import numpy as np 17 | import pandas as pd 18 | from systole import serialSim 19 | from systole.detection import ppg_peaks 20 | from systole.plots import plot_raw, plot_rr 21 | from systole.recording import Oximeter 22 | 23 | #%% 24 | # Recording 25 | # --------- 26 | # For the demonstration purpose, here we simulate data acquisition through 27 | # the pulse oximeter using pre-recorded signal. 28 | 29 | ser = serialSim() 30 | 31 | #%% 32 | # If you want to enable online data acquisition, you should uncomment the 33 | # following lines and provide the reference of the COM port where the pulse 34 | # oximeter is plugged in. 35 | 36 | ############################################################################### 37 | # .. code-block:: python 38 | # 39 | # import serial 40 | # ser = serial.Serial('COM4') # Change this value according to your setup 41 | 42 | # Create an Oxymeter instance, initialize recording and record for 30 seconds 43 | oxi = Oximeter(serial=ser, sfreq=75).setup() 44 | oxi.read(30) 45 | 46 | #%% 47 | # Plotting 48 | # -------- 49 | 50 | signal, peaks = ppg_peaks(signal=oxi.recording, sfreq=75) 51 | 52 | fig, ax = plt.subplots(3, 1, figsize=(13, 8), sharex=True) 53 | 54 | plot_raw(signal=signal, show_heart_rate=False, ax=ax[0]) 55 | 56 | times = pd.to_datetime(np.arange(0, len(peaks)), unit="ms", origin="unix") 57 | ax[1].plot(times, peaks, "#55a868") 58 | ax[1].set_title("Peaks vector") 59 | ax[1].set_ylabel("Peak\n detection") 60 | 61 | plot_rr(peaks, input_type="peaks", ax=ax[2]) 62 | plt.tight_layout() -------------------------------------------------------------------------------- /docs/source/examples/Recording/plot_RecordingPPG.py: -------------------------------------------------------------------------------- 1 | """ 2 | Recording PPG signal 3 | ==================== 4 | 5 | The py:class:`systole.recording.Oximeter` class can be used to read incoming PPG signal 6 | from `Nonin 3012LP Xpod USB pulse oximeter 7 | `_ together with the `Nonin 8000SM 'soft-clip' 8 | fingertip sensors `_. This function can easily 9 | be integrated with other stimulus presentation software like `PsychoPy 10 | `_ to record cardiac activity during psychological 11 | experiments, or to synchronize stimulus delivery around cardiac phases (e.g. systole or 12 | diastole). 13 | 14 | """ 15 | 16 | # Author: Nicolas Legrand 17 | # Licence: GPL v3 18 | 19 | 20 | #%% 21 | # Recording PPG singal 22 | # -------------------- 23 | 24 | from systole.recording import Oximeter 25 | from systole import serialSim 26 | #%% 27 | # Recording and plotting your first PPG time-series only require a few lines of code: 28 | # First, open a serial port. Note that here we are using Systole's PPG simulation 29 | # function so the example can run without any device plugged on the computer for the 30 | # demonstration. If you want to connect to an actual Nonin pulse oximeter, you simply 31 | # have to provide the port reference it is plugged in (see commented lines below). 32 | 33 | ser = serialSim() # Simulate a device 34 | 35 | # Use a USB device 36 | #import serial 37 | #ser = serial.Serial("COM4") # Use this line for USB recording 38 | 39 | #%% 40 | # Once the reference of the port created, you can create a recording instance, 41 | # initialize it and read some incoming signal in just one line of code. 42 | 43 | oxi = Oximeter(serial=ser).setup().read(duration=10) 44 | 45 | #%% The recording instance also interface with systole's plotting module so the signal 46 | # can be directly plotted using built-in functions. 47 | oxi.plot_raw(show_heart_rate=True, figsize=(13, 8)) 48 | 49 | #%% 50 | # Interfacing with PsychoPy 51 | # ------------------------- 52 | # One nice feature provided by Systole is that it can run the recording of PPG signal 53 | # together with other Python scripts like Psychopy, which can be used to build 54 | # psychological experiments. There are two ways for interfacing with other scripts, it 55 | # can be done either in a serial or in a (pseudo-) parallel way. 56 | 57 | # - The ``read()`` method will record for a predefined amount of time 58 | # (specified by the ``duration`` parameter, in seconds). This 'serial mode' 59 | # is the easiest and most robust method, but it does not allow the execution 60 | # of other instructions in the meantime. 61 | 62 | # Code 1 {} 63 | oxi.read(duration=10) 64 | # Code 2 {} 65 | 66 | # - The ``readInWaiting()`` method will only read the bytes temporally stored 67 | # in the USB buffer. For the Nonin device, this represents up to 10 seconds of 68 | # recording (this procedure should be executed at least one time every 10 69 | # seconds for a continuous recording). When inserted into a while loop, it can 70 | # record PPG signal almost in parallel with other commands. 71 | 72 | import time 73 | tstart = time.time() 74 | while time.time() - tstart < 10: 75 | oxi.readInWaiting() 76 | # Insert code here {...} 77 | 78 | #%% 79 | # Online detection 80 | # ---------------- 81 | # The recording instance is also detecting heartbeats automatically in the background, 82 | # and this information can be accessed in real-time to deliver stimuli time-locked to 83 | # specific cardiac phases. Note that the delay between the actual heartbeat and the 84 | # execution of computer code (here the `print` command in the example below) can be 85 | # important. Also, it is important to note that the systolic peak detected in the PPG 86 | # signal is delayed relative to the R peaks observed in the ECG signal. 87 | 88 | # Create an Oxymeter instance and initialize recording 89 | oxi = Oximeter(serial=ser, sfreq=75, add_channels=4).setup() 90 | 91 | # Online peak detection - run for 10 seconds 92 | tstart = time.time() 93 | while time.time() - tstart < 10: 94 | while oxi.serial.inWaiting() >= 5: 95 | paquet = list(oxi.serial.read(5)) 96 | oxi.add_paquet(paquet[2]) # Add new data point 97 | if oxi.peaks[-1] == 1: 98 | print("Heartbeat detected") 99 | -------------------------------------------------------------------------------- /docs/source/getting_started.rst: -------------------------------------------------------------------------------- 1 | 2 | Installation 3 | ++++++++++++ 4 | 5 | The last stable version of Systole can be installed using pip: 6 | 7 | .. code-block:: shell 8 | 9 | pip install systole 10 | 11 | If you want to download the `dev` branch instead and try the last features that are currently under development (and probably a bit unstable), use: 12 | 13 | .. code-block:: shell 14 | 15 | pip install “git+https://github.com/embodied-computation-group/systole.git@dev” 16 | 17 | The following packages are required to use Systole: 18 | 19 | * `Numpy `_ (>=1.15) 20 | * `SciPy `_ (>=1.3.0) 21 | * `Pandas `_ (>=0.24) 22 | * `Numba `_ (>=0.51.2) 23 | * `Seaborn `_ (>=0.9.0) 24 | * `Matplotlib `_ (>=3.0.2) 25 | * `Bokeh `_ (>=2.3.3) 26 | * `pyserial `_ (>=3.4) 27 | * `setuptools `_ (>=38.4) 28 | * `requests `_ (>=2.26.0) 29 | * `tabulate `_ (>=0.8.9) 30 | 31 | 32 | The Python version should be 3.7 or higher. 33 | 34 | 35 | Getting started 36 | +++++++++++++++ 37 | 38 | .. code-block:: python 39 | 40 | from systole import import_dataset1 41 | 42 | # Import ECg recording 43 | signal = import_dataset1(modalities=['ECG']).ecg.to_numpy() 44 | 45 | 46 | Signal extraction and interactive plotting 47 | ========================================== 48 | The package integrates a set of functions for interactive or non interactive data visualization based on `Matplotlib `_ and `Bokeh `_. 49 | 50 | .. code-block:: python 51 | 52 | from systole.plots plot_raw 53 | 54 | plot_raw(signal[60000 : 120000], modality="ecg", backend="bokeh", 55 | show_heart_rate=True, show_artefacts=True, figsize=300) 56 | 57 | .. raw:: html 58 | :file: ./images/raw.html 59 | 60 | 61 | Artefacts detection and rejection 62 | ================================= 63 | Artefacts can be detected and corrected in the RR interval time series or the peaks vector using the method proposed by Lipponen & Tarvainen (2019). 64 | 65 | .. code-block:: python 66 | 67 | from systole.detection import ecg_peaks 68 | from systole.plots plot_subspaces 69 | 70 | # R peaks detection 71 | signal, peaks = ecg_peaks(signal, method='pan-tompkins', sfreq=1000) 72 | 73 | plot_subspaces(peaks, input_type="peaks", backend="bokeh") 74 | 75 | .. raw:: html 76 | :file: ./images/subspaces.html 77 | 78 | 79 | Heart rate variability analysis 80 | =============================== 81 | Systole implements time-domain, frequency-domain and non-linear HRV indices, as well as tools for evoked heart rate analysis. 82 | 83 | .. code-block:: python 84 | 85 | from bokeh.layouts import row 86 | from systole.plots plot_frequency, plot_poincare 87 | 88 | row( 89 | plot_frequency(peaks, input_type="peaks", backend="bokeh", figsize=(300, 200)), 90 | plot_poincare(peaks, input_type="peaks", backend="bokeh", figsize=(200, 200)), 91 | ) 92 | 93 | .. raw:: html 94 | :file: ./images/hrv.html 95 | 96 | 97 | Online systolic peak detection, cardiac-stimulus synchrony, and cardiac circular analysis 98 | ========================================================================================= 99 | 100 | The package natively supports recording of physiological signals from the following setups: 101 | - `Nonin 3012LP Xpod USB pulse oximeter `_ together with the `Nonin 8000SM 'soft-clip' fingertip sensors `_ (USB). 102 | - Remote Data Access (RDA) via BrainVision Recorder together with `Brain product ExG amplifier `_ (Ethernet). 103 | -------------------------------------------------------------------------------- /docs/source/images/ECG_logo (1).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/ECG_logo (1).png -------------------------------------------------------------------------------- /docs/source/images/LabLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/LabLogo.png -------------------------------------------------------------------------------- /docs/source/images/au_clinisk_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/au_clinisk_logo.png -------------------------------------------------------------------------------- /docs/source/images/code-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/code-solid.png -------------------------------------------------------------------------------- /docs/source/images/code-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 63 | -------------------------------------------------------------------------------- /docs/source/images/create_figures.py: -------------------------------------------------------------------------------- 1 | 2 | """Create figures presented in the README. 3 | """ 4 | 5 | from systole import import_dataset1 6 | from systole.detection import ecg_peaks 7 | from systole.plots import plot_frequency, plot_poincare, plot_raw, plot_subspaces 8 | from bokeh.io import export_png, save 9 | from bokeh.layouts import row 10 | # Import ECg recording 11 | signal = import_dataset1(modalities=['ECG']).ecg.to_numpy() 12 | 13 | # R peaks detection 14 | signal, peaks = ecg_peaks(signal, method='pan-tompkins', sfreq=1000) 15 | 16 | ########## 17 | # Raw plot 18 | ########## 19 | 20 | #%% As PNG 21 | export_png( 22 | plot_raw(signal[60000 : 120000], modality="ecg", backend="bokeh", 23 | show_heart_rate=True, show_artefacts=True, figsize=300), 24 | filename="raw.png", width=1400 25 | ) 26 | 27 | #%% As HTML 28 | save( 29 | plot_raw(signal[60000 : 120000], modality="ecg", backend="bokeh", 30 | show_heart_rate=True, show_artefacts=True, figsize=300), 31 | filename="raw.html", 32 | ) 33 | 34 | ######################## 35 | # Heart Rate Variability 36 | ######################## 37 | 38 | #%% As PNG 39 | export_png( 40 | row( 41 | plot_frequency(peaks, input_type="peaks", backend="bokeh", figsize=(600, 400)), 42 | plot_poincare(peaks, input_type="peaks", backend="bokeh", figsize=(400, 400)), 43 | ),filename="hrv.png" 44 | ) 45 | 46 | #%% As HTML 47 | save( 48 | row( 49 | plot_frequency(peaks, input_type="peaks", backend="bokeh", figsize=(300, 200)), 50 | plot_poincare(peaks, input_type="peaks", backend="bokeh", figsize=(200, 200)), 51 | ),filename="hrv.html" 52 | ) 53 | 54 | ##################### 55 | # Artefacts detection 56 | ##################### 57 | 58 | #%% As PNG 59 | export_png( 60 | plot_subspaces(peaks, input_type="peaks", backend="bokeh"), 61 | filename="subspaces.png" 62 | ) 63 | 64 | #%% As HTML 65 | save( 66 | plot_subspaces(peaks, input_type="peaks", backend="bokeh"), 67 | filename="subspaces.html" 68 | ) -------------------------------------------------------------------------------- /docs/source/images/ecg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/ecg.png -------------------------------------------------------------------------------- /docs/source/images/editor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/editor.gif -------------------------------------------------------------------------------- /docs/source/images/forward-fast-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/forward-fast-solid.png -------------------------------------------------------------------------------- /docs/source/images/forward-fast-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 64 | -------------------------------------------------------------------------------- /docs/source/images/hrv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/hrv.png -------------------------------------------------------------------------------- /docs/source/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/logo.png -------------------------------------------------------------------------------- /docs/source/images/logo_small.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 47 | 52 | 53 | 55 | 56 | 58 | image/svg+xml 59 | 61 | 62 | 63 | 64 | 65 | 70 | 73 | 76 | 82 | 88 | 94 | 100 | 101 | 107 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /docs/source/images/lundbeckfonden_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/lundbeckfonden_logo.png -------------------------------------------------------------------------------- /docs/source/images/peaks.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/peaks.gif -------------------------------------------------------------------------------- /docs/source/images/pulseOximeter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/pulseOximeter.png -------------------------------------------------------------------------------- /docs/source/images/raw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/raw.png -------------------------------------------------------------------------------- /docs/source/images/recording.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/recording.png -------------------------------------------------------------------------------- /docs/source/images/segments.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/segments.gif -------------------------------------------------------------------------------- /docs/source/images/subspaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/subspaces.png -------------------------------------------------------------------------------- /docs/source/images/table-cells-large-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/table-cells-large-solid.png -------------------------------------------------------------------------------- /docs/source/images/table-cells-large-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 63 | -------------------------------------------------------------------------------- /docs/source/images/tutorials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/docs/source/images/tutorials.png -------------------------------------------------------------------------------- /docs/source/references.md: -------------------------------------------------------------------------------- 1 | # References 2 | 3 | ```{bibliography} 4 | ``` 5 | -------------------------------------------------------------------------------- /docs/source/tutorials.md: -------------------------------------------------------------------------------- 1 | # Tutorials 2 | 3 | Systole is oriented to physiological signal analysis, with the main focus on cardiac activity. The notion that cognition and metacognition are influenced by body states and physiological signals - heart rate, and heart rate variability being the most prominent ones - is extensively investigated by cognitive neuroscience. Systole_ was first developed to help 4 | interfacing psychological tasks (e.g using Psychopy) with physiological recording in the context of psychophysiology experiments. It has progressively grown and integrates more algorithms and metrics, and embeds several static and dynamic plotting utilities that we think make it useful to generate reports, helps to produce clear and transparent research outputs, and participates in creating more robust and open science overall. 5 | 6 | These tutorials introduce both the basic concepts of cardiac signal analysis and their implementation with examples of analysis using Systole. The focus and the illustrations are largely derived from the cognitive science approach and interest, but it can be of interest to anyone willing to use Systole or learn about cardiac signal analysis in general. 7 | 8 | ```{toctree} 9 | --- 10 | hidden: 11 | glob: 12 | --- 13 | 14 | notebooks/* 15 | 16 | ``` 17 | 18 | | Notebook | Colab | 19 | | --- | ---| 20 | | {ref}`physiological_signals` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/1-PhysiologicalSignals.ipynb) 21 | | {ref}`cardiac_cycles` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/2-DetectingCycles.ipynb) 22 | | {ref}`correcting_artefacts` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/3-DetectingAndCorrectingArtefacts.ipynb) 23 | | {ref}`hrv` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/4-HeartRateVariability.ipynb) 24 | | {ref}`instantaneous_heart_rate` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/5-InstantaneousHeartRate.ipynb) 25 | | {ref}`bids_folders` | [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/embodied-computation-group/systole/blob/dev/docs/source/notebooks/6-WorkingWithBIDSFolders.ipynb) 26 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: systole 2 | dependencies: 3 | - python=3.9 4 | - pip 5 | - pip: 6 | - numpy==1.25 7 | - numba==0.58.0 8 | - bokeh>=3.0.0 9 | - matplotlib>=3.0.2 10 | - seaborn>=0.9.0 11 | - setuptools>=38.4 12 | - packaging 13 | - sleepecg>=0.5.1 14 | - joblib>=1.1.0 -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Systole: A python package for cardiac signal synchrony and analysis' 3 | tags: 4 | - python 5 | - heart rate variability 6 | - psychology 7 | - electrocardiography 8 | - photoplethysmography 9 | authors: 10 | - name: Nicolas Legrand 11 | orcid: 0000-0002-2651-4929 12 | affiliation: "1" 13 | - name: Micah Allen 14 | orcid: 0000-0001-9399-4179 15 | affiliation: "1, 2, 3" 16 | affiliations: 17 | - name: Center of Functionally Integrative Neuroscience, Aarhus University Hospital, Denmark 18 | index: 1 19 | - name: Aarhus Institute of Advanced Studies, Aarhus University, Denmark 20 | index: 2 21 | - name: Cambridge Psychiatry, University of Cambridge, United Kingdom 22 | index: 3 23 | date: 5 January 2022 24 | bibliography: paper.bib 25 | --- 26 | 27 | # Summary 28 | 29 | Systole is a package for cardiac signal analysis in Python. It provides an interface 30 | for recording cardiac signals via electrocardiography (ECG) or photoplethysmography 31 | (PPG), as well as both online and offline data analysis methods extracting cardiac 32 | features, synchronizing experimental stimuli with different phases of the heart, 33 | removing artefacts at different levels and generating plots for data quality check and 34 | publication. Systole is built on the top of Numpy [@harris:2020], Pandas [@reback2020pandas; @mckinney-proc-scipy-2010] and Scipy [@SciPy:2020], and can use both 35 | Matplotlib [@hunter:2007] and Bokeh [@bokeh] to generate visualisations. It is designed to build modular 36 | pipelines that can interface easily with other signal processing or heart rate 37 | variability toolboxes, with a focus on data quality checks. Several parts of the 38 | toolbox utilize Numba [@numba] to offer more competitive performances with classic processing 39 | algorithms. 40 | 41 | # Statement of need 42 | 43 | Analysis of cardiac data remains a major component of medical, physiological, 44 | neuroscientific, and psychological research. In psychology, for example, rising 45 | interest in interoception (i.e., sensing of cardiac signals) has led to a 46 | proliferation of studies synchronizing the onset or timing of experimental stimuli to 47 | different phases of the heart. Similarly, there is rising interest in understanding how 48 | heart-rate variability relates to mental illness, cognitive function, and physical 49 | wellbeing. This diverse interest calls for more open-source tools designed to work 50 | with cardiac data in the context of psychology research, but to date, only limited 51 | options exist. To improve the reproducibility and accessibility of advanced forms of 52 | cardiac physiological analysis, such as online peak detection and artefact control, 53 | we here introduce a fully documented Python package, Systole. Systole introduces core 54 | functionalities to interface with pulse oximeters devices, accelerated peaks detection 55 | and artefacts rejection algorithms as well as a complete suite of interactive and 56 | non-interactive plotting functions to improve quality checks and reports creation in 57 | the context of physiological signal analysis. 58 | 59 | 60 | # Overview 61 | The package focuses on 5 core functional elements. The documentation of the package 62 | includes extensive tutorial notebooks and examples vignettes illustrating these points. 63 | It can be found at [https://systole-docs.github.io/](https://systole-docs.github.io/). 64 | The package has already been used in two publications that also describe some example 65 | uses [@legrand:2020; @legrand:2021]. 66 | 67 | Core functionalities: 68 | 69 | 1. Signal extraction and interactive plotting. 70 | Systole uses adapted versions of ECG [@luis:2019] and PPG [@van_gent:2019] 71 | peaks detectors accelerated with Numba [@numba] for increased performances. 72 | It also implements plotting functionalities for RR time series and heart rate 73 | variability visualization both on Matplotlib [@hunter:2007] and Bokeh [@bokeh]. This 74 | API is designed and developed to facilitate the automated generation of interactive 75 | reports and dashboards from large datasets of physiological data. 76 | 77 | 1. Artefact detection and rejection. 78 | The current artefact detection relies on the Python adaptation of the method proposed 79 | by @lipponen:2019. The correcting of the artefacts is modular and can be 80 | adapted to specific uses. Options are provided to control for signal length and events 81 | synchronization while correcting. 82 | 83 | 3. Instantaneous and evoked heart-rate analysis. 84 | Systole includes utilities and plotting functions for instantaneous and evoked heart 85 | rate analysis from raw signal or RR time series data. 86 | 87 | 4. Heart-rate variability analysis. 88 | The package integrates functions for heart rate variability analysis in the time, 89 | frequency and non-linear domain. This includes the most widely used metrics of heart 90 | rate variability in cognitive science. Other metric or feature extractions can be 91 | performed by interfacing with other packages that are more specialized in this domain 92 | [@gomes:2019; @makowski:2021]. 93 | 94 | 5. Online systolic peak detection, cardiac-stimulus synchrony, and cardiac circular analysis. 95 | The package currently supports recording and synchronization with experiment software 96 | like Psychopy [@peirce:2019] from Nonin 3012LP Xpod USB pulse oximeter together 97 | with the Nonin 8000SM ‘soft-clip’ fingertip sensors (USB) as well as Remote Data Access 98 | (RDA) via BrainVision Recorder together with Brain product ExG amplifier (Ethernet). 99 | 100 | # References 101 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | pydata-sphinx-theme>=0.14.1 2 | sphinx_bootstrap_theme>=0.8.1 3 | sphinx-gallery>=0.11.0 4 | jupyter_sphinx>=0.4.0 5 | sphinx_design>=0.5.0 6 | myst-nb>=0.16.0 7 | sphinx>=5.0.0 8 | numpydoc>=1.4.0 9 | ipympl 10 | myst-parser 11 | sphinxcontrib-bibtex>=2.4.2 -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | -e ./ 2 | papermill>=2.2.2 3 | pytest 4 | ipywidgets 5 | ipympl -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy>=1.21,<=1.26 2 | scipy>=1.3.0 3 | pandas>=0.24 4 | matplotlib>=3.0.2 5 | seaborn>=0.9.0 6 | bokeh>=3.0.0 7 | pyserial>=3.4 8 | setuptools>=38.4 9 | packaging 10 | numba>=0.58.0 11 | tqdm 12 | requests>=2.26.0 13 | tabulate>=0.8.9 14 | sleepecg>=0.5.1 15 | joblib>=1.3.2 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import codecs 3 | from setuptools import find_packages, setup 4 | 5 | PROJECT_ROOT = os.path.dirname(os.path.realpath(__file__)) 6 | REQUIREMENTS_FILE = os.path.join(PROJECT_ROOT, "requirements.txt") 7 | 8 | 9 | def read(fname): 10 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 11 | 12 | 13 | def get_requirements(): 14 | with codecs.open(REQUIREMENTS_FILE) as buff: 15 | return buff.read().splitlines() 16 | 17 | def get_version(rel_path): 18 | """Get the package's version number. 19 | We fetch the version number from the `__version__` variable located in the 20 | package root's `__init__.py` file. This way there is only a single source 21 | of truth for the package's version number. 22 | 23 | """ 24 | for line in read(rel_path).splitlines(): 25 | if line.startswith("__version__"): 26 | delim = '"' if '"' in line else "'" 27 | return line.split(delim)[1] 28 | else: 29 | raise RuntimeError("Unable to find version string.") 30 | 31 | DESCRIPTION = """Systole: A python package for cardiac signal synchrony and analysis""" 32 | 33 | DISTNAME = "systole" 34 | MAINTAINER = "Nicolas Legrand" 35 | MAINTAINER_EMAIL = "nicolas.legrand@cfin.au.dk" 36 | VERSION = "0.2.5" 37 | 38 | 39 | if __name__ == "__main__": 40 | 41 | setup( 42 | name=DISTNAME, 43 | author=MAINTAINER, 44 | author_email=MAINTAINER_EMAIL, 45 | maintainer=MAINTAINER, 46 | maintainer_email=MAINTAINER_EMAIL, 47 | description=DESCRIPTION, 48 | long_description=open("README.rst").read(), 49 | long_description_content_type="text/x-rst", 50 | license="GPL-3.0", 51 | version=get_version("systole/__init__.py"), 52 | install_requires=get_requirements(), 53 | include_package_data=True, 54 | packages=find_packages(), 55 | entry_points = { 56 | 'console_scripts': ['systole=systole.reports.command_line:main'], 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /systole/__init__.py: -------------------------------------------------------------------------------- 1 | from .correction import * 2 | from .datasets import * 3 | from .detection import * 4 | from .hrv import * 5 | from .io import * 6 | from .plots import * 7 | from .utils import * 8 | 9 | __version__ = "0.2.5dev0" 10 | -------------------------------------------------------------------------------- /systole/datasets/Task1_ECG.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/datasets/Task1_ECG.npy -------------------------------------------------------------------------------- /systole/datasets/Task1_EDA.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/datasets/Task1_EDA.npy -------------------------------------------------------------------------------- /systole/datasets/Task1_Respiration.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/datasets/Task1_Respiration.npy -------------------------------------------------------------------------------- /systole/datasets/Task1_Stim.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/datasets/Task1_Stim.npy -------------------------------------------------------------------------------- /systole/datasets/__init__.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | import io 4 | import os.path as op 5 | import time 6 | from typing import List 7 | 8 | import numpy as np 9 | import pandas as pd 10 | import requests # type: ignore 11 | from tqdm import tqdm 12 | 13 | ddir = op.dirname(op.realpath(__file__)) 14 | 15 | __all__ = ["import_ppg", "import_rr", "serialSim", "import_dataset1"] 16 | 17 | 18 | # Simulate serial inputs from ppg recording 19 | # ========================================= 20 | class serialSim: 21 | """Simulate online data acquisition using pre recorded signal and realistic 22 | sampling rate (75 Hz). 23 | """ 24 | 25 | def __init__(self): 26 | self.sfreq = 75 27 | self.ppg = import_ppg().ppg.to_numpy() 28 | self.start = time.time() 29 | 30 | def inWaiting(self): 31 | if time.time() - self.start > 1 / self.sfreq: 32 | self.start = time.time() 33 | lenInWating = 5 34 | else: 35 | lenInWating = 0 36 | 37 | return lenInWating 38 | 39 | def read(self, lenght): 40 | if len(self.ppg) == 0: 41 | self.ppg = import_ppg().ppg.to_numpy() 42 | 43 | # Read 1rst item of ppg signal 44 | rec = self.ppg[:1] 45 | self.ppg = self.ppg[1:] 46 | 47 | # Build valid paquet 48 | paquet = [1, 255, rec[0], 127] 49 | paquet.append(sum(paquet) % 256) 50 | 51 | return paquet[0], paquet[1], paquet[2], paquet[3], paquet[4] 52 | 53 | def reset_input_buffer(self): 54 | print("Reset input buffer") 55 | 56 | 57 | def import_ppg() -> pd.DataFrame: 58 | """Import a 5 minutes long PPG recording. 59 | 60 | Returns 61 | ------- 62 | df : :py:class:`pandas.DataFrame` 63 | Dataframe containing the PPG signale. 64 | """ 65 | path = ( 66 | "https://github.com/embodied-computation-group/systole/raw/" 67 | "master/systole/datasets/" 68 | ) 69 | response = requests.get(f"{path}/ppg.npy") 70 | response.raise_for_status() 71 | ppg = np.load(io.BytesIO(response.content), allow_pickle=True) 72 | df = pd.DataFrame({"ppg": ppg}) 73 | df["time"] = np.arange(0, len(df)) / 75 74 | 75 | return df 76 | 77 | 78 | def import_rr() -> pd.DataFrame: 79 | """Import PPG recording. 80 | 81 | Returns 82 | ------- 83 | rr : :py:class:`pandas.DataFrame` 84 | Dataframe containing the RR time-serie. 85 | """ 86 | path = ( 87 | "https://github.com/embodied-computation-group/systole/raw/" 88 | "master/systole/datasets/" 89 | ) 90 | rr = pd.read_csv(op.join(path, "rr.txt")) 91 | 92 | return rr 93 | 94 | 95 | def import_dataset1( 96 | modalities: List[str] = ["ECG", "EDA", "Respiration", "Stim"], disable: bool = False 97 | ) -> pd.DataFrame: 98 | """Import ECG, EDA and respiration recording. 99 | 100 | Parameters 101 | ---------- 102 | modalities : list 103 | The list of modalities that should be downloaded. Can contain `"ECG"`, `"EDA"`, 104 | `"Respiration"` or `"Stim"`. 105 | disable : bool 106 | Whether to disable the progress bar or not. Default is `False` (show progress 107 | bar). 108 | 109 | Returns 110 | ------- 111 | df : :py:class:`pandas.DataFrame` 112 | Dataframe containing the signal. 113 | 114 | Notes 115 | ----- 116 | Load a 20 minutes recording of ECG, EDA and respiration of a young healthy 117 | participant undergoing the emotional task (valence rating of neutral and 118 | disgusting images) described in _[1]. The sampling frequency is 1000 Hz. 119 | 120 | References 121 | ---------- 122 | [1] : Legrand, N., Etard, O., Vandevelde, A., Pierre, M., Viader, F., Clochon, P., 123 | Doidy, F., Peschanski, D., Eustache, F., & Gagnepain, P. (2020). Long-term 124 | modulation of cardiac activity induced by inhibitory control over emotional 125 | memories. Scientific Reports, 10(1). https://doi.org/10.1038/s41598-020-71858-2 126 | 127 | """ 128 | path = "https://github.com/embodied-computation-group/systole/raw/dev/systole/datasets/Task1_" 129 | pbar = tqdm(modalities, position=0, leave=True, disable=disable) 130 | data = {} 131 | for item in pbar: 132 | pbar.set_description(f"Downloading {item} channel") 133 | response = requests.get(f"{path}{item}.npy") 134 | response.raise_for_status() 135 | data[item.lower()] = np.load(io.BytesIO(response.content), allow_pickle=True) 136 | 137 | df = pd.DataFrame(data) 138 | df["time"] = np.arange(0, len(df)) / 1000 139 | 140 | return df 141 | -------------------------------------------------------------------------------- /systole/datasets/ppg.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/datasets/ppg.npy -------------------------------------------------------------------------------- /systole/datasets/rr.txt: -------------------------------------------------------------------------------- 1 | ,rr 2 | 0,992.0 3 | 1,1016.0 4 | 2,985.3333333333333 5 | 3,1001.3333333333334 6 | 4,982.6666666666667 7 | 5,909.3333333333334 8 | 6,905.3333333333334 9 | 7,928.0 10 | 8,866.6666666666667 11 | 9,904.0 12 | 10,917.3333333333334 13 | 11,932.0 14 | 12,904.0 15 | 13,986.6666666666667 16 | 14,1022.6666666666666 17 | 15,934.6666666666666 18 | 16,992.0 19 | 17,1017.3333333333334 20 | 18,902.6666666666666 21 | 19,912.0 22 | 20,946.6666666666666 23 | 21,910.6666666666666 24 | 22,898.6666666666666 25 | 23,950.6666666666666 26 | 24,941.3333333333334 27 | 25,866.6666666666667 28 | 26,929.3333333333334 29 | 27,990.6666666666667 30 | 28,914.6666666666666 31 | 29,962.6666666666666 32 | 30,996.0 33 | 31,866.6666666666667 34 | 32,885.3333333333333 35 | 33,816.0 36 | 34,796.0 37 | 35,698.6666666666666 38 | 36,712.0 39 | 37,725.3333333333334 40 | 38,748.0 41 | 39,777.3333333333334 42 | 40,792.0 43 | 41,805.3333333333334 44 | 42,820.0 45 | 43,801.3333333333334 46 | 44,794.6666666666666 47 | 45,808.0 48 | 46,841.3333333333334 49 | 47,817.3333333333334 50 | 48,842.6666666666666 51 | 49,882.6666666666667 52 | 50,882.6666666666667 53 | 51,850.6666666666666 54 | 52,869.3333333333333 55 | 53,920.0 56 | 54,897.3333333333334 57 | 55,936.0 58 | 56,965.3333333333334 59 | 57,918.6666666666666 60 | 58,932.0 61 | 59,948.0 62 | 60,877.3333333333333 63 | 61,872.0 64 | 62,878.6666666666667 65 | 63,849.3333333333334 66 | 64,860.0 67 | 65,904.0 68 | 66,894.6666666666667 69 | 67,888.0 70 | 68,898.6666666666666 71 | 69,893.3333333333333 72 | 70,866.6666666666667 73 | 71,884.0 74 | 72,870.6666666666667 75 | 73,836.0 76 | 74,860.0 77 | 75,866.6666666666667 78 | 76,858.6666666666667 79 | 77,868.0 80 | 78,900.0 81 | 79,908.0 82 | 80,870.6666666666667 83 | 81,889.3333333333333 84 | 82,888.0 85 | 83,842.6666666666666 86 | 84,856.0 87 | 85,884.0 88 | 86,876.0 89 | 87,852.0 90 | 88,862.6666666666667 91 | 89,873.3333333333333 92 | 90,841.3333333333334 93 | 91,858.6666666666667 94 | 92,860.0 95 | 93,828.0 96 | 94,832.0 97 | 95,862.6666666666667 98 | 96,782.6666666666666 99 | 97,741.3333333333333 100 | 98,713.3333333333334 101 | 99,772.0 102 | 100,726.6666666666666 103 | 101,728.0 104 | 102,760.0 105 | 103,802.6666666666666 106 | 104,818.6666666666666 107 | 105,772.0 108 | 106,781.3333333333334 109 | 107,789.3333333333334 110 | 108,776.0 111 | 109,749.3333333333333 112 | 110,757.3333333333333 113 | 111,764.0 114 | 112,734.6666666666667 115 | 113,744.0 116 | 114,768.0 117 | 115,830.6666666666666 118 | 116,876.0 119 | 117,958.6666666666666 120 | 118,972.0 121 | 119,936.0 122 | 120,1024.0 123 | 121,1092.0 124 | 122,996.0 125 | 123,1045.3333333333333 126 | 124,993.3333333333333 127 | 125,860.0 128 | 126,888.0 129 | 127,881.3333333333333 130 | 128,854.6666666666667 131 | 129,822.6666666666666 132 | 130,932.0 133 | 131,950.6666666666666 134 | 132,913.3333333333334 135 | 133,880.0 136 | 134,917.3333333333334 137 | 135,944.0 138 | 136,893.3333333333333 139 | 137,898.6666666666666 140 | 138,938.6666666666666 141 | 139,886.6666666666667 142 | 140,838.6666666666666 143 | 141,808.0 144 | 142,786.6666666666666 145 | 143,741.3333333333333 146 | 144,761.3333333333333 147 | 145,821.3333333333334 148 | 146,925.3333333333334 149 | 147,874.6666666666667 150 | 148,908.0 151 | 149,966.6666666666666 152 | 150,936.0 153 | 151,948.0 154 | 152,1005.3333333333334 155 | 153,937.3333333333334 156 | 154,880.0 157 | 155,912.0 158 | 156,929.3333333333334 159 | 157,909.3333333333334 160 | 158,942.6666666666666 161 | 159,992.0 162 | 160,982.6666666666667 163 | 161,964.0 164 | 162,992.0 165 | 163,962.6666666666666 166 | 164,902.6666666666666 167 | 165,941.3333333333334 168 | 166,978.6666666666666 169 | 167,898.6666666666666 170 | 168,904.0 171 | 169,864.0 172 | 170,854.6666666666667 173 | 171,792.0 174 | 172,788.0 175 | 173,737.3333333333333 176 | 174,780.0 177 | 175,781.3333333333334 178 | 176,806.6666666666666 179 | 177,724.0 180 | 178,732.0 181 | 179,686.6666666666666 182 | 180,700.0 183 | 181,676.0 184 | 182,682.6666666666666 185 | 183,697.3333333333334 186 | 184,748.0 187 | 185,833.3333333333334 188 | 186,925.3333333333334 189 | 187,972.0 190 | 188,1052.0 191 | 189,1094.6666666666667 192 | 190,1117.3333333333333 193 | 191,1060.0 194 | 192,1052.0 195 | 193,1050.6666666666667 196 | 194,930.6666666666666 197 | 195,888.0 198 | 196,902.6666666666666 199 | 197,822.6666666666666 200 | 198,782.6666666666666 201 | 199,808.0 202 | 200,857.3333333333333 203 | 201,920.0 204 | 202,885.3333333333333 205 | 203,957.3333333333334 206 | 204,928.0 207 | 205,858.6666666666667 208 | 206,864.0 209 | 207,881.3333333333333 210 | 208,922.6666666666666 211 | 209,833.3333333333334 212 | 210,844.0 213 | 211,809.3333333333334 214 | 212,861.3333333333333 215 | 213,942.6666666666666 216 | 214,936.0 217 | 215,860.0 218 | 216,894.6666666666667 219 | 217,904.0 220 | 218,842.6666666666666 221 | 219,829.3333333333334 222 | 220,874.6666666666667 223 | 221,969.3333333333334 224 | 222,980.0 225 | 223,1036.0 226 | 224,1048.0 227 | 225,953.3333333333334 228 | 226,917.3333333333334 229 | 227,945.3333333333334 230 | 228,966.6666666666666 231 | 229,862.6666666666667 232 | 230,910.6666666666666 233 | 231,922.6666666666666 234 | 232,920.0 235 | 233,918.6666666666666 236 | 234,981.3333333333333 237 | 235,1008.0 238 | 236,953.3333333333334 239 | 237,972.0 240 | 238,970.6666666666666 241 | 239,873.3333333333333 242 | 240,886.6666666666667 243 | 241,926.6666666666666 244 | 242,862.6666666666667 245 | 243,853.3333333333334 246 | 244,905.3333333333334 247 | -------------------------------------------------------------------------------- /systole/detectors/__init__.py: -------------------------------------------------------------------------------- 1 | from .christov import christov 2 | from .engelse_zeelenberg import engelse_zeelenberg 3 | from .hamilton import hamilton 4 | from .moving_average import moving_average 5 | from .msptd import msptd 6 | from .pan_tompkins import pan_tompkins 7 | from .rolling_average_ppg import rolling_average_ppg 8 | from .rolling_average_resp import rolling_average_resp 9 | 10 | __all__ = [ 11 | "pan_tompkins", 12 | "hamilton", 13 | "christov", 14 | "moving_average", 15 | "engelse_zeelenberg", 16 | "msptd", 17 | "rolling_average_ppg", 18 | "rolling_average_resp", 19 | ] 20 | -------------------------------------------------------------------------------- /systole/detectors/christov.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Tuple, Union 4 | 5 | import numpy as np 6 | from numba import jit 7 | from scipy.signal import lfilter 8 | 9 | 10 | def christov(signal: np.ndarray, sfreq: int) -> np.ndarray: 11 | """R peaks detection using Christov's method. 12 | 13 | Parameters 14 | ---------- 15 | signal : 16 | The unfiltered ECG signal. 17 | sfreq : 18 | The sampling frequency. 19 | 20 | Returns 21 | ------- 22 | peaks : np.ndarray 23 | The indexs of the ECG peaks. 24 | 25 | References 26 | ---------- 27 | This function is directly adapted from py-ecg-detectors 28 | (https://github.com/berndporr/py-ecg-detectors). This version of the code has been 29 | optimized using Numba for better performances. 30 | 31 | [1].. Ivaylo I. Christov, Real time electrocardiogram QRS detection using 32 | combined adaptive threshold, BioMedical Engineering OnLine 2004, vol. 3:28, 33 | 2004. 34 | """ 35 | b, total_taps = numba_one(sfreq) 36 | 37 | MA1 = lfilter(b, [1], signal) 38 | 39 | b, total_taps = numba_two(sfreq, total_taps) 40 | 41 | MA2 = lfilter(b, [1], MA1) 42 | 43 | b, Y, total_taps = numba_three(sfreq, total_taps, MA2) 44 | 45 | MA3 = lfilter(b, [1], Y) 46 | 47 | peaks = numba_four(MA3, sfreq, total_taps) 48 | 49 | return peaks 50 | 51 | 52 | @jit(nopython=True) 53 | def numba_one(sfreq: int) -> Tuple: 54 | total_taps = 0 55 | 56 | b = np.ones(int(0.02 * sfreq)) 57 | b = b / int(0.02 * sfreq) 58 | total_taps += len(b) 59 | 60 | return b, total_taps 61 | 62 | 63 | @jit(nopython=True) 64 | def numba_two(sfreq: int, total_taps) -> Tuple: 65 | b = np.ones(int(0.028 * sfreq)) 66 | b = b / int(0.028 * sfreq) 67 | total_taps += len(b) 68 | 69 | return b, total_taps 70 | 71 | 72 | @jit(nopython=True) 73 | def numba_three(sfreq: int, total_taps, MA2) -> Tuple: 74 | Y = [] 75 | for i in range(1, len(MA2) - 1): 76 | diff = abs(MA2[i + 1] - MA2[i - 1]) 77 | Y.append(diff) 78 | 79 | b = np.ones(int(0.040 * sfreq)) 80 | b = b / int(0.040 * sfreq) 81 | total_taps += len(b) 82 | 83 | return b, Y, total_taps 84 | 85 | 86 | @jit(nopython=True) 87 | def numba_four(MA3, sfreq: int, total_taps) -> np.ndarray: 88 | MA3[0:total_taps] = 0 89 | 90 | ms50 = int(0.05 * sfreq) 91 | ms200 = int(0.2 * sfreq) 92 | ms1200 = int(1.2 * sfreq) 93 | ms350 = int(0.35 * sfreq) 94 | 95 | M = 0 96 | newM5 = 0.0 97 | M_list = [] 98 | MM: List[Union[int, float]] = [] 99 | M_slope = np.linspace(1.0, 0.6, ms1200 - ms200) 100 | F = 0 101 | F_list = [] 102 | R = 0 103 | RR = [] 104 | Rm = 0 105 | R_list = [] 106 | 107 | MFR = 0 108 | MFR_list = [] 109 | 110 | QRS: List = [] 111 | 112 | for i in range(len(MA3)): 113 | # M 114 | if i < 5 * sfreq: 115 | M = 0.6 * np.max(MA3[: i + 1]) 116 | MM.append(M) 117 | if len(MM) > 5: 118 | MM.pop(0) 119 | 120 | elif QRS and i < QRS[-1] + ms200: 121 | newM5 = 0.6 * np.max(MA3[QRS[-1] : i]) 122 | if newM5 > 1.5 * MM[-1]: 123 | newM5 = 1.1 * MM[-1] 124 | 125 | elif QRS and i == QRS[-1] + ms200: 126 | if newM5 == 0: 127 | newM5 = MM[-1] 128 | MM.append(newM5) 129 | if len(MM) > 5: 130 | MM.pop(0) 131 | M = np.mean(np.asarray(MM)) 132 | 133 | elif QRS and i > QRS[-1] + ms200 and i < QRS[-1] + ms1200: 134 | M = np.mean(np.asarray(MM)) * M_slope[i - (QRS[-1] + ms200)] 135 | 136 | elif QRS and i > QRS[-1] + ms1200: 137 | M = 0.6 * np.mean(np.asarray(MM)) 138 | 139 | # F 140 | if i > ms350: 141 | F_section = MA3[i - ms350 : i] 142 | max_latest = np.max(F_section[-ms50:]) 143 | max_earliest = np.max(F_section[:ms50]) 144 | F = F + ((max_latest - max_earliest) / 150.0) 145 | 146 | # R 147 | if QRS and i < QRS[-1] + int((2.0 / 3.0 * Rm)): 148 | R = 0 149 | 150 | elif QRS and i > QRS[-1] + int((2.0 / 3.0 * Rm)) and i < QRS[-1] + Rm: 151 | dec = (M - np.mean(np.asarray(MM))) / 1.4 152 | R = 0 + dec 153 | 154 | MFR = M + F + R 155 | M_list.append(M) 156 | F_list.append(F) 157 | R_list.append(R) 158 | MFR_list.append(MFR) 159 | 160 | if not QRS and MA3[i] > MFR: 161 | QRS.append(i) 162 | 163 | elif QRS and i > QRS[-1] + ms200 and MA3[i] > MFR: 164 | QRS.append(i) 165 | if len(QRS) > 2: 166 | RR.append(QRS[-1] - QRS[-2]) 167 | if len(RR) > 5: 168 | RR.pop(0) 169 | Rm = int(np.mean(np.asarray(RR))) 170 | 171 | QRS.pop(0) 172 | 173 | return np.asarray(QRS) 174 | -------------------------------------------------------------------------------- /systole/detectors/engelse_zeelenberg.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Union 4 | 5 | import numpy as np 6 | from numba import jit 7 | from scipy.signal import butter, lfilter 8 | 9 | 10 | def engelse_zeelenberg(signal: np.ndarray, sfreq: int) -> np.ndarray: 11 | """R peaks detection using Engelse and Zeelenberg's method. 12 | 13 | Parameters 14 | ---------- 15 | signal : 16 | The unfiltered ECG signal. 17 | sfreq : 18 | The sampling frequency. 19 | 20 | Returns 21 | ------- 22 | peaks : 23 | The indexs of the ECG peaks. 24 | 25 | References 26 | ---------- 27 | This function is directly adapted from py-ecg-detectors 28 | (https://github.com/berndporr/py-ecg-detectors). This version of the code has been 29 | optimized using Numba for better performances. 30 | 31 | [1].. Engelse, W.A.H., Zeelenberg, C. A single scan algorithm for QRS detection and 32 | feature extraction, IEEE Comp. in Cardiology, vol. 6, pp. 37-42, 1979 with 33 | modifications A. Lourenco, H. Silva, P. Leite, R. Lourenco and A. Fred, 34 | “Real Time Electrocardiogram Segmentation for Finger Based ECG Biometrics”, 35 | BIOSIGNALS 2012, pp. 49-54, 2012. 36 | """ 37 | 38 | f1 = 48 / sfreq 39 | f2 = 52 / sfreq 40 | b, a = butter(4, [f1 * 2, f2 * 2], btype="bandstop") 41 | filtered_ecg = lfilter(b, a, signal) 42 | 43 | diff = numba_one(filtered_ecg) 44 | 45 | ci = [1, 4, 6, 4, 1] 46 | low_pass = lfilter(ci, 1, diff) 47 | 48 | peaks = numba_two(sfreq, low_pass, signal) 49 | 50 | return peaks 51 | 52 | 53 | @jit(nopython=True) 54 | def numba_one(filtered_ecg: np.ndarray) -> np.ndarray: 55 | diff = np.zeros(len(filtered_ecg)) 56 | for i in range(4, len(diff)): 57 | diff[i] = filtered_ecg[i] - filtered_ecg[i - 4] 58 | return diff 59 | 60 | 61 | @jit(nopython=True) 62 | def numba_two(sfreq: int, low_pass, signal: np.ndarray) -> np.ndarray: 63 | low_pass[: int(0.2 * sfreq)] = 0 64 | 65 | ms200 = int(0.2 * sfreq) 66 | ms1200 = int(1.2 * sfreq) 67 | ms160 = int(0.16 * sfreq) 68 | neg_threshold = int(0.01 * sfreq) 69 | 70 | M = 0 71 | M_list = [] 72 | neg_m = [] 73 | MM: List[Union[int, float]] = [] 74 | M_slope = np.linspace(1.0, 0.6, ms1200 - ms200) 75 | 76 | QRS: List = [] 77 | r_peaks = [] 78 | 79 | counter = 0 80 | 81 | thi_list = [] 82 | thi = False 83 | thf_list = [] 84 | thf = False 85 | newM5 = 0.0 86 | 87 | for i in range(len(low_pass)): 88 | # M 89 | if i < 5 * sfreq: 90 | M = 0.6 * np.max(low_pass[: i + 1]) 91 | MM.append(M) 92 | if len(MM) > 5: 93 | MM.pop(0) 94 | 95 | elif QRS and i < QRS[-1] + ms200: 96 | newM5 = 0.6 * np.max(low_pass[QRS[-1] : i]) 97 | 98 | if newM5 > 1.5 * MM[-1]: 99 | newM5 = 1.1 * MM[-1] 100 | 101 | elif newM5 and QRS and i == QRS[-1] + ms200: 102 | MM.append(newM5) 103 | if len(MM) > 5: 104 | MM.pop(0) 105 | M = np.mean(np.asarray(MM)) 106 | 107 | elif QRS and i > QRS[-1] + ms200 and i < QRS[-1] + ms1200: 108 | M = np.mean(np.asarray(MM)) * M_slope[i - (QRS[-1] + ms200)] 109 | 110 | elif QRS and i > QRS[-1] + ms1200: 111 | M = 0.6 * np.mean(np.asarray(MM)) 112 | 113 | M_list.append(M) 114 | neg_m.append(-M) 115 | 116 | if not QRS and low_pass[i] > M: 117 | QRS.append(i) 118 | thi_list.append(i) 119 | thi = True 120 | 121 | elif QRS and i > QRS[-1] + ms200 and low_pass[i] > M: 122 | QRS.append(i) 123 | thi_list.append(i) 124 | thi = True 125 | 126 | if thi and i < thi_list[-1] + ms160: 127 | if low_pass[i] < -M and low_pass[i - 1] > -M: 128 | # thf_list.append(i) 129 | thf = True 130 | 131 | if thf and low_pass[i] < -M: 132 | thf_list.append(i) 133 | counter += 1 134 | 135 | elif low_pass[i] > -M and thf: 136 | counter = 0 137 | thi = False 138 | thf = False 139 | 140 | elif thi and i > thi_list[-1] + ms160: 141 | counter = 0 142 | thi = False 143 | thf = False 144 | 145 | if counter > neg_threshold: 146 | unfiltered_section = signal[thi_list[-1] - int(0.01 * sfreq) : i] 147 | r_peaks.append( 148 | np.argmax(unfiltered_section) + thi_list[-1] - int(0.01 * sfreq) 149 | ) 150 | counter = 0 151 | thi = False 152 | thf = False 153 | 154 | # removing the 1st detection as it 1st needs the QRS complex amplitude for the threshold 155 | r_peaks.pop(0) 156 | 157 | return r_peaks 158 | -------------------------------------------------------------------------------- /systole/detectors/hamilton.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List 4 | 5 | import numpy as np 6 | from numba import jit 7 | from scipy.signal import butter, lfilter 8 | 9 | 10 | def hamilton(signal: np.ndarray, sfreq: int) -> np.ndarray: 11 | """R peaks detection using Hamilton's method. 12 | 13 | Parameters 14 | ---------- 15 | signal : 16 | The unfiltered ECG signal. 17 | sfreq : 18 | The sampling frequency. 19 | 20 | Returns 21 | ------- 22 | peaks : 23 | The indexs of the ECG peaks. 24 | 25 | References 26 | ---------- 27 | This function is directly adapted from py-ecg-detectors 28 | (https://github.com/berndporr/py-ecg-detectors). This version of the code has been 29 | optimized using Numba for better performances. 30 | 31 | [1].. P.S. Hamilton, Open Source ECG Analysis Software Documentation, E.P.Limited, 32 | 2002. 33 | """ 34 | frequencies = numba_first(signal, sfreq) 35 | 36 | b, a = butter(1, frequencies, btype="bandpass") 37 | 38 | filtered_ecg = lfilter(b, a, signal) 39 | 40 | diff, a, b = numba_second(filtered_ecg, sfreq) 41 | 42 | ma = lfilter(b, a, diff) 43 | 44 | peaks = numba_third(ma, b, sfreq) 45 | 46 | return peaks 47 | 48 | 49 | @jit(nopython=True) 50 | def numba_first(signal: np.ndarray, sfreq: int) -> List: 51 | signal = np.asarray(signal) 52 | f1 = 8 / sfreq 53 | f2 = 16 / sfreq 54 | return [f1 * 2, f2 * 2] 55 | 56 | 57 | @jit(nopython=True) 58 | def numba_second(filtered_ecg: np.ndarray, sfreq: int): 59 | diff = np.abs(np.diff(filtered_ecg)) 60 | b = np.ones(int(0.08 * sfreq)) 61 | b = b / int(0.08 * sfreq) 62 | a = [1] 63 | return diff, a, b 64 | 65 | 66 | @jit(nopython=True) 67 | def numba_third(ma, b, sfreq): 68 | ma[0 : len(b) * 2] = 0 69 | 70 | n_pks = [] 71 | n_pks_ave = 0.0 72 | s_pks = [] 73 | s_pks_ave = 0.0 74 | QRS = [0] 75 | RR = [] 76 | RR_ave = 0.0 77 | 78 | th = 0.0 79 | 80 | i = 0 81 | idx = [] 82 | peaks = [] 83 | 84 | for i in range(len(ma)): 85 | if i > 0 and i < len(ma) - 1: 86 | if ma[i - 1] < ma[i] and ma[i + 1] < ma[i]: 87 | peak = i 88 | peaks.append(i) 89 | 90 | if ma[peak] > th and (peak - QRS[-1]) > 0.3 * sfreq: 91 | QRS.append(peak) 92 | idx.append(i) 93 | s_pks.append(ma[peak]) 94 | if len(n_pks) > 8: 95 | s_pks.pop(0) 96 | s_pks_ave = np.mean(np.asarray(s_pks)) 97 | 98 | if RR_ave != 0.0: 99 | if QRS[-1] - QRS[-2] > 1.5 * RR_ave: 100 | missed_peaks = peaks[idx[-2] + 1 : idx[-1]] 101 | for missed_peak in missed_peaks: 102 | if ( 103 | missed_peak - peaks[idx[-2]] > int(0.360 * sfreq) 104 | and ma[missed_peak] > 0.5 * th 105 | ): 106 | QRS.append(missed_peak) 107 | QRS.sort() 108 | break 109 | 110 | if len(QRS) > 2: 111 | RR.append(QRS[-1] - QRS[-2]) 112 | if len(RR) > 8: 113 | RR.pop(0) 114 | RR_ave = int(np.mean(np.asarray(RR))) 115 | 116 | else: 117 | n_pks.append(ma[peak]) 118 | if len(n_pks) > 8: 119 | n_pks.pop(0) 120 | n_pks_ave = np.mean(np.asarray(n_pks)) 121 | 122 | th = n_pks_ave + 0.45 * (s_pks_ave - n_pks_ave) 123 | 124 | i += 1 125 | 126 | QRS.pop(0) 127 | 128 | return QRS 129 | -------------------------------------------------------------------------------- /systole/detectors/moving_average.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List 4 | 5 | import numpy as np 6 | from numba import jit 7 | from scipy.signal import butter, lfilter 8 | 9 | from systole.detectors.pan_tompkins import MWA_cumulative 10 | 11 | 12 | def moving_average(signal: np.ndarray, sfreq: int) -> np.ndarray: 13 | """R peaks detection using two moving average. 14 | 15 | Parameters 16 | ---------- 17 | signal : 18 | The unfiltered ECG signal. 19 | sfreq : 20 | The sampling frequency. 21 | 22 | Returns 23 | ------- 24 | peaks : 25 | The indexs of the ECG peaks. 26 | 27 | References 28 | ---------- 29 | This function is directly adapted from py-ecg-detectors 30 | (https://github.com/berndporr/py-ecg-detectors). This version of the code has been 31 | optimized using Numba for better performances. 32 | 33 | [1].. Elgendi, Mohamed & Jonkman, Mirjam & De Boer, Friso. (2010). Frequency Bands 34 | Effects on QRS Detection. The 3rd International Conference on Bio-inspired 35 | Systems and Signal Processing (BIOSIGNALS2010). 428-431. 36 | """ 37 | 38 | f1 = 8 / sfreq 39 | f2 = 20 / sfreq 40 | 41 | b, a = butter(2, [f1 * 2, f2 * 2], btype="bandpass") 42 | 43 | filtered_ecg = lfilter(b, a, signal) 44 | 45 | window1 = int(0.12 * sfreq) 46 | mwa_qrs = MWA_cumulative(np.abs(filtered_ecg), window1) 47 | 48 | window2 = int(0.6 * sfreq) 49 | mwa_beat = MWA_cumulative(np.abs(filtered_ecg), window2) 50 | 51 | peaks = numba_one(signal, mwa_qrs, mwa_beat, sfreq, filtered_ecg) 52 | 53 | return peaks 54 | 55 | 56 | @jit(nopython=True) 57 | def numba_one( 58 | signal: np.ndarray, mwa_qrs, mwa_beat, sfreq: int, filtered_ecg: np.ndarray 59 | ) -> np.ndarray: 60 | blocks = np.zeros(len(signal)) 61 | block_height = np.max(filtered_ecg) 62 | 63 | for i in range(len(mwa_qrs)): 64 | if mwa_qrs[i] > mwa_beat[i]: 65 | blocks[i] = block_height 66 | else: 67 | blocks[i] = 0 68 | 69 | QRS: List = [] 70 | 71 | for i in range(1, len(blocks)): 72 | if blocks[i - 1] == 0 and blocks[i] == block_height: 73 | start = i 74 | 75 | elif blocks[i - 1] == block_height and blocks[i] == 0: 76 | end = i - 1 77 | 78 | if end - start > int(0.08 * sfreq): 79 | detection = np.argmax(filtered_ecg[start : end + 1]) + start 80 | if QRS: 81 | if detection - QRS[-1] > int(0.3 * sfreq): 82 | QRS.append(detection) 83 | else: 84 | QRS.append(detection) 85 | 86 | return np.array(QRS) 87 | -------------------------------------------------------------------------------- /systole/detectors/pan_tompkins.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List 4 | 5 | import numpy as np 6 | from numba import jit 7 | from scipy.signal import butter, lfilter 8 | 9 | 10 | def pan_tompkins( 11 | signal: np.ndarray, sfreq: int, moving_average: str = "cumulative" 12 | ) -> np.ndarray: 13 | """ 14 | Parameters 15 | ---------- 16 | signal : 17 | The unfiltered ECG signal. 18 | sfreq : 19 | The sampling frequency. 20 | moving_average : 21 | The moving average function to use. 22 | 23 | Returns 24 | ------- 25 | peaks_idx : 26 | Indexes of R peaks in the input signal. 27 | 28 | References 29 | ---------- 30 | This function is directly adapted from py-ecg-detectors 31 | (https://github.com/berndporr/py-ecg-detectors). This version of the code has been 32 | optimized using Numba for better performances. 33 | 34 | [1].. Jiapu Pan and Willis J. Tompkins. A Real-Time QRS Detection Algorithm. 35 | In: IEEE Transactions on Biomedical Engineering BME-32.3 (1985), pp. 230–236. 36 | """ 37 | signal = np.asarray(signal, dtype=float) 38 | 39 | f1 = 5 / sfreq 40 | f2 = 15 / sfreq 41 | 42 | b, a = butter(1, [f1 * 2, f2 * 2], btype="bandpass") 43 | 44 | filtered_ecg = lfilter(b, a, signal) 45 | 46 | diff = np.diff(filtered_ecg) 47 | 48 | squared = diff * diff 49 | 50 | N = int(0.12 * sfreq) 51 | ma = {"cumulative": MWA_cumulative} # Dict of moving average methods 52 | mwa = ma[moving_average](squared, N) 53 | mwa[: int(0.2 * sfreq)] = 0 54 | 55 | peaks = panPeakDetect(mwa, sfreq) 56 | 57 | return np.array(peaks, dtype=int) 58 | 59 | 60 | @jit(nopython=True) 61 | def MWA_cumulative(input_array: np.ndarray, window_size: int) -> np.ndarray: 62 | """Cumulative moving average method""" 63 | 64 | ret = np.cumsum(input_array) 65 | ret[window_size:] = ret[window_size:] - ret[:-window_size] 66 | ret[: window_size - 1] /= np.arange(1, window_size) 67 | ret[window_size - 1 :] = ret[window_size - 1 :] / window_size 68 | 69 | return ret 70 | 71 | 72 | @jit(nopython=True) 73 | def panPeakDetect(detection: np.ndarray, sfreq: int) -> List: 74 | """Pan-Tompkins detection algorithm. 75 | 76 | Parameters 77 | ---------- 78 | detection : 79 | Vector of detected peaks. 80 | sfreq : 81 | The sampling frequency. 82 | 83 | Returns 84 | ------- 85 | signal_peaks : 86 | The indexs of the ECG peaks. 87 | 88 | """ 89 | 90 | min_distance = int(0.25 * sfreq) 91 | 92 | signal_peaks = [0] 93 | noise_peaks = [] 94 | 95 | SPKI = 0.0 96 | NPKI = 0.0 97 | 98 | threshold_I1 = 0.0 99 | threshold_I2 = 0.0 100 | 101 | RR_missed = 0 102 | index = 0 103 | indexes = [] 104 | 105 | missed_peaks = [] 106 | peaks = [] 107 | 108 | for i in range(len(detection)): 109 | if i > 0 and i < len(detection) - 1: 110 | if detection[i - 1] < detection[i] and detection[i + 1] < detection[i]: 111 | peak = i 112 | peaks.append(i) 113 | 114 | if ( 115 | detection[peak] > threshold_I1 116 | and (peak - signal_peaks[-1]) > 0.3 * sfreq 117 | ): 118 | signal_peaks.append(peak) 119 | indexes.append(index) 120 | SPKI = 0.125 * detection[signal_peaks[-1]] + 0.875 * SPKI 121 | if RR_missed != 0: 122 | if signal_peaks[-1] - signal_peaks[-2] > RR_missed: 123 | missed_section_peaks = peaks[indexes[-2] + 1 : indexes[-1]] 124 | missed_section_peaks2 = [] 125 | for missed_peak in missed_section_peaks: 126 | if ( 127 | missed_peak - signal_peaks[-2] > min_distance 128 | and signal_peaks[-1] - missed_peak > min_distance 129 | and detection[missed_peak] > threshold_I2 130 | ): 131 | missed_section_peaks2.append(missed_peak) 132 | 133 | if len(missed_section_peaks2) > 0: 134 | missed_peak = missed_section_peaks2[ 135 | np.argmax( 136 | detection[np.array(missed_section_peaks2)] 137 | ) 138 | ] 139 | missed_peaks.append(missed_peak) 140 | signal_peaks.append(signal_peaks[-1]) 141 | signal_peaks[-2] = missed_peak 142 | 143 | else: 144 | noise_peaks.append(peak) 145 | NPKI = 0.125 * detection[noise_peaks[-1]] + 0.875 * NPKI 146 | 147 | threshold_I1 = NPKI + 0.25 * (SPKI - NPKI) 148 | threshold_I2 = 0.5 * threshold_I1 149 | 150 | if len(signal_peaks) > 8: 151 | RR = np.array(signal_peaks[-9:]) 152 | RR = RR[1:] - RR[:-1] 153 | RR_ave = int(np.mean(RR)) 154 | RR_missed = int(1.66 * RR_ave) 155 | 156 | index = index + 1 157 | 158 | signal_peaks.pop(0) 159 | 160 | return signal_peaks 161 | -------------------------------------------------------------------------------- /systole/detectors/rolling_average_ppg.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | from typing import Tuple 3 | 4 | import numpy as np 5 | import pandas as pd 6 | from scipy.signal import find_peaks 7 | 8 | 9 | def rolling_average_ppg( 10 | signal: np.ndarray, 11 | sfreq: int = 1000, 12 | win: float = 0.75, 13 | moving_average: bool = True, 14 | moving_average_length: float = 0.05, 15 | peak_enhancement: bool = True, 16 | distance: float = 0.3, 17 | ) -> Tuple[np.ndarray, np.ndarray]: 18 | """A simple systolic peak finder for PPG signals. 19 | 20 | This method uses a rolling average + standard deviation approach to update a 21 | detection threshold. All the peaks found above this threshold are potential 22 | systolic peaks. 23 | 24 | Parameters 25 | ---------- 26 | signal : 27 | The raw signal recorded from the pulse oximeter time series. 28 | sfreq : 29 | The sampling frequency (Hz). Defaults to `1000`. 30 | win : 31 | Window size (in seconds) used to compute the threshold (i.e. rolling mean + 32 | standard deviation). 33 | moving_average : 34 | Apply mooving average to remove high frequency noise before peaks detection. The 35 | length of the time windows can be controlled with `moving_average_length`. 36 | moving_average_length : 37 | The length of the window used for moveing average (seconds). Default to `0.05`. 38 | peak_enhancement : 39 | If `True` (default), the ppg signal is squared before peaks detection. 40 | distance : 41 | The minimum interval between two peaks (seconds). 42 | verbose : 43 | Control function verbosity. Defaults to `False` (do not print processing steps). 44 | 45 | Returns 46 | ------- 47 | peaks_idx : 48 | Indices of detected systolic peaks. 49 | 50 | Raises 51 | ------ 52 | ValueError 53 | If `clipping_thresholds` is not a tuple, a list or `"auto"`. 54 | 55 | Notes 56 | ----- 57 | This algorithm use a simple rolling average to detect peaks. The signal is 58 | first resampled and a rolling average is applyed to correct high frequency 59 | noise and clipping, using method detailled in [1]_. The signal is then 60 | squared and detection of peaks is performed using threshold corresponding 61 | to the moving averagte + stadard deviation. 62 | 63 | Examples 64 | -------- 65 | >>> from systole import import_ppg 66 | >>> from systole.detection import ppg_peaks 67 | >>> df = import_ppg() # Import PPG recording 68 | >>> signal, peaks = ppg_peaks(signal=df.ppg.to_numpy()) 69 | >>> print(f'{sum(peaks)} peaks detected.') 70 | 378 peaks detected. 71 | 72 | References 73 | ---------- 74 | .. [1] van Gent, P., Farah, H., van Nes, N. and van Arem, B., 2019. 75 | Analysing Noisy Driver Physiology Real-Time Using Off-the-Shelf Sensors: 76 | Heart Rate Analysis Software from the Taking the Fast Lane Project. Journal 77 | of Open Research Software, 7(1), p.32. DOI: http://doi.org/10.5334/jors.241 78 | 79 | """ 80 | if moving_average is True: 81 | # Moving average (high frequency noise) 82 | rolling_noise = max(int(sfreq * moving_average_length), 1) # 0.05 second 83 | signal = ( 84 | pd.DataFrame({"signal": signal}) 85 | .rolling(rolling_noise, center=True) 86 | .mean() 87 | .signal.to_numpy() 88 | ) 89 | if peak_enhancement is True: 90 | # Square signal (peak enhancement) 91 | signal = (np.asarray(signal) ** 2) * np.sign(signal) 92 | 93 | # Compute moving average and standard deviation 94 | signal_df = pd.DataFrame({"signal": signal}) 95 | mean_signal = ( 96 | signal_df.rolling(int(sfreq * win), center=True).mean().signal.to_numpy() 97 | ) 98 | std_signal = ( 99 | signal_df.rolling(int(sfreq * win), center=True).std().signal.to_numpy() 100 | ) 101 | 102 | # Substract moving average + standard deviation 103 | signal -= mean_signal + std_signal 104 | 105 | # Find positive peaks 106 | peaks_idx = find_peaks(signal, height=0, distance=int(sfreq * distance))[0] 107 | 108 | return peaks_idx 109 | -------------------------------------------------------------------------------- /systole/detectors/rolling_average_resp.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Tuple, Union 4 | 5 | import numpy as np 6 | import pandas as pd 7 | from scipy.signal import find_peaks 8 | 9 | 10 | def rolling_average_resp( 11 | signal: np.ndarray, 12 | sfreq: int, 13 | win: float = 0.025, 14 | kind: str = "peaks-onsets", 15 | ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: 16 | """A simple peaks and/or onsets detection algorithm for respiratory signal inspired 17 | by [1]_. 18 | 19 | Parameters 20 | ---------- 21 | signal : 22 | The respiratory signal. Peaks are considered to represent end of inspiration, 23 | trough represent end of expiration. 24 | sfreq : 25 | The sampling frequency. 26 | win : 27 | Window size (in seconds). Default is set to 25ms, following recommandation 28 | from [1]_. 29 | kind : 30 | What kind of detection to perform. Peak detection (`"peaks"`), trough detection 31 | (`"onsets"`) or both (`"peaks-onsets"`, default). 32 | 33 | Returns 34 | ------- 35 | peaks_idx | trough_idx | (peaks_idx, trough_idx) : 36 | Indexes of peaks and / or onsets in the respiratory signal. 37 | 38 | References 39 | ---------- 40 | .. [1] Torben Noto, Guangyu Zhou, Stephan Schuele, Jessica Templer, Christina 41 | Zelano,Automated analysis of breathing waveforms using BreathMetrics: a 42 | respiratory signal processing toolbox, Chemical Senses, Volume 43, Issue 8, 43 | October 2018, Pages 583-597, https://doi.org/10.1093/chemse/bjy045 44 | 45 | """ 46 | # Soothing using rolling mean 47 | signal = ( 48 | pd.DataFrame({"signal": signal}) 49 | .rolling(int(sfreq * win), center=True) 50 | .mean() 51 | .fillna(method="bfill") 52 | .fillna(method="ffill") 53 | .signal.to_numpy() 54 | ) 55 | 56 | # Normalize (z-score) the respiration signal 57 | signal = (signal - signal.mean()) / signal.std() # type: ignore 58 | 59 | # Peak enhancement 60 | signal = signal**3 61 | 62 | # Find peaks and trough in preprocessed signal 63 | if "peaks" in kind: 64 | peaks_idx = find_peaks(signal, height=0, distance=int(2 * sfreq))[0] 65 | 66 | if "onsets" in kind: 67 | onsets_idx = find_peaks(-signal, height=0, distance=int(2 * sfreq))[0] 68 | 69 | if kind == "peaks": 70 | return peaks_idx 71 | elif kind == "trough": 72 | return onsets_idx 73 | else: 74 | return peaks_idx, onsets_idx 75 | -------------------------------------------------------------------------------- /systole/interact/__init__.py: -------------------------------------------------------------------------------- 1 | from .interact import Editor, Viewer 2 | 3 | __all__ = [ 4 | "Viewer", 5 | "Editor", 6 | ] 7 | -------------------------------------------------------------------------------- /systole/plots/__init__.py: -------------------------------------------------------------------------------- 1 | """Plotting functions.""" 2 | from .plot_circular import plot_circular 3 | from .plot_ectopic import plot_ectopic 4 | from .plot_events import plot_events 5 | from .plot_evoked import plot_evoked 6 | from .plot_frequency import plot_frequency 7 | from .plot_poincare import plot_poincare 8 | from .plot_raw import plot_raw 9 | from .plot_rr import plot_rr 10 | from .plot_shortlong import plot_shortlong 11 | from .plot_subspaces import plot_subspaces 12 | 13 | __all__ = [ 14 | "plot_circular", 15 | "plot_rr", 16 | "plot_raw", 17 | "plot_events", 18 | "plot_evoked", 19 | "plot_subspaces", 20 | "plot_ectopic", 21 | "plot_shortlong", 22 | "plot_frequency", 23 | "plot_poincare", 24 | ] 25 | -------------------------------------------------------------------------------- /systole/plots/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/plots/backends/__init__.py -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/plots/backends/bokeh/__init__.py -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_ectopic.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict 4 | 5 | import numpy as np 6 | from bokeh.plotting._figure import figure 7 | 8 | 9 | def plot_ectopic( 10 | artefacts=Dict[str, np.ndarray], figsize: int = 600, ax=None 11 | ) -> figure: 12 | """Plot interactive ectopic subspace. 13 | 14 | Parameters 15 | ---------- 16 | artefacts : dict or None 17 | The artefacts detected using 18 | :py:func:`systole.detection.rr_artefacts()`. 19 | figsize : int 20 | Figure heights. Default is `600`. 21 | ax : None 22 | Only apply when using Matplotlib backend. 23 | 24 | Returns 25 | ------- 26 | ectopic_plot : :class:`bokeh.plotting.figure.Figure` 27 | The boken figure containing the plot. 28 | 29 | """ 30 | c1, c2, xlim, ylim = 0.13, 0.17, 10, 5 31 | 32 | outliers = ( 33 | artefacts["ectopic"] 34 | | artefacts["short"] 35 | | artefacts["long"] 36 | | artefacts["extra"] 37 | | artefacts["missed"] 38 | ) 39 | 40 | # All values fit in the x and y lims 41 | for this_art in [artefacts["subspace1"]]: 42 | this_art[this_art > xlim] = xlim 43 | this_art[this_art < -xlim] = -xlim 44 | for this_art in [artefacts["subspace2"]]: 45 | this_art[this_art > ylim] = ylim 46 | this_art[this_art < -ylim] = -ylim 47 | 48 | ectopic_plot = figure( 49 | title="Ectopic beats", 50 | height=figsize, 51 | width=figsize, 52 | x_axis_label="Subspace 1", 53 | y_axis_label="Subspace 2", 54 | output_backend="webgl", 55 | x_range=[-xlim, xlim], 56 | y_range=[-ylim, ylim], 57 | ) 58 | 59 | # Upper area 60 | def f1(x): 61 | return -c1 * x + c2 62 | 63 | ectopic_plot.patch( 64 | [-10, -10, -1, -1], [f1(-5), 5, 5, f1(-1)], alpha=0.2, color="grey" 65 | ) 66 | 67 | # Lower area 68 | def f2(x): 69 | return -c1 * x - c2 70 | 71 | ectopic_plot.patch([1, 1, 10, 10], [f2(1), -5, -5, f2(5)], alpha=0.2, color="grey") 72 | 73 | # Plot normal intervals 74 | ectopic_plot.circle( 75 | artefacts["subspace1"][~outliers], 76 | artefacts["subspace2"][~outliers], 77 | color="gray", 78 | size=8, 79 | alpha=0.2, 80 | legend_label="Standard IBI", 81 | ) 82 | 83 | # Plot ectopic beats 84 | if artefacts["ectopic"].any(): 85 | ectopic_plot.triangle( 86 | artefacts["subspace1"][artefacts["ectopic"]], 87 | artefacts["subspace2"][artefacts["ectopic"]], 88 | size=8, 89 | alpha=0.8, 90 | legend_label="Ectopic beats", 91 | color="#6c0073", 92 | ) 93 | 94 | # Plot missed beats 95 | if artefacts["missed"].any(): 96 | ectopic_plot.square( 97 | artefacts["subspace1"][artefacts["missed"]], 98 | artefacts["subspace2"][artefacts["missed"]], 99 | size=8, 100 | alpha=0.8, 101 | legend_label="Missed beats", 102 | color="#2f5f91", 103 | ) 104 | 105 | # Plot long beats 106 | if artefacts["long"].any(): 107 | ectopic_plot.circle( 108 | artefacts["subspace1"][artefacts["long"]], 109 | artefacts["subspace2"][artefacts["long"]], 110 | size=8, 111 | alpha=0.8, 112 | legend_label="Long beats", 113 | color="#9ac1d4", 114 | ) 115 | 116 | # Plot extra beats 117 | if artefacts["extra"].any(): 118 | ectopic_plot.square( 119 | artefacts["subspace1"][artefacts["extra"]], 120 | artefacts["subspace2"][artefacts["extra"]], 121 | size=8, 122 | alpha=0.8, 123 | legend_label="Extra beats", 124 | color="#9d2b39", 125 | ) 126 | 127 | # Plot short beats 128 | if artefacts["short"].any(): 129 | ectopic_plot.circle( 130 | artefacts["subspace1"][artefacts["short"]], 131 | artefacts["subspace2"][artefacts["short"]], 132 | size=8, 133 | alpha=0.8, 134 | legend_label="Short beats", 135 | color="#c56c5e", 136 | ) 137 | 138 | return ectopic_plot 139 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_events.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Optional 4 | 5 | import pandas as pd 6 | from bokeh.models import BoxAnnotation, Span 7 | from bokeh.plotting import ColumnDataSource, figure 8 | 9 | 10 | def plot_events( 11 | df: pd.DataFrame, 12 | figsize: int = 400, 13 | ax: Optional[figure] = None, 14 | behavior: Optional[List[pd.DataFrame]] = None, 15 | ) -> figure: 16 | """Plot events to get a visual display of the paradigm (Bokeh). 17 | 18 | Parameters 19 | ---------- 20 | df : 21 | The events data frame (tmin, trigger, tmax, label, color, [behavior]). 22 | figsize : 23 | Figure size. Default is `(13, 5)`. 24 | ax : 25 | Where to draw the plot. Default is `None` (create a new figure). 26 | behavior : 27 | (Optional) Additional information about trials that will appear when hovering 28 | on the area (`bokeh` version only). 29 | 30 | Returns 31 | ------- 32 | event_plot : :class:`bokeh.plotting.figure.Figure` 33 | The bokeh figure containing the plot. 34 | 35 | """ 36 | 37 | if ax is None: 38 | TOOLTIPS = [ 39 | ("(x,y)", "($tmin, $tmax)"), 40 | ] 41 | 42 | event_plot = figure( 43 | title="Events", 44 | sizing_mode="stretch_width", 45 | height=figsize, 46 | x_axis_label="Time", 47 | x_axis_type="datetime", 48 | y_range=(0, 1), 49 | tooltips=TOOLTIPS, 50 | ) 51 | # Plot time course of events 52 | event_source = ColumnDataSource(data=df) 53 | 54 | event_plot.circle( 55 | x="trigger", 56 | y=0.5, 57 | size=10, 58 | line_color="color", 59 | fill_color="white", 60 | legend_field="label", 61 | line_width=3, 62 | source=event_source, 63 | ) 64 | # Hide y axis if no other time series is provided 65 | event_plot.yaxis.visible = False 66 | 67 | else: 68 | event_plot = ax 69 | 70 | # Loop across events df 71 | for _, tmin, trigger, tmax, _, color in df.itertuples(): 72 | # Plot time range 73 | event_range = BoxAnnotation( 74 | left=tmin, right=tmax, fill_alpha=0.2, fill_color=color 75 | ) 76 | event_range.level = "underlay" 77 | event_plot.add_layout(event_range) 78 | 79 | # Plot trigger 80 | event_trigger = Span( 81 | location=trigger, 82 | dimension="height", 83 | line_color="gray", 84 | line_dash="dashed", 85 | line_width=1, 86 | ) 87 | event_trigger.level = "underlay" 88 | event_plot.add_layout(event_trigger) 89 | 90 | return event_plot 91 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_evoked.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Iterable, List, Tuple 4 | 5 | import numpy as np 6 | import pandas as pd 7 | from bokeh.models import Band, ColumnDataSource, Span 8 | from bokeh.plotting import figure 9 | 10 | 11 | def plot_evoked( 12 | epochs: List[np.ndarray], 13 | time: np.ndarray, 14 | palette: Iterable, 15 | figsize: Tuple[float, float], 16 | labels: List[str], 17 | unit: str, 18 | ci: str = "sd", 19 | **kwargs 20 | ) -> figure: 21 | """Plot continuous or discontinuous RR intervals time series. 22 | 23 | Parameters 24 | ---------- 25 | epochs : 26 | A 2d (trial * time) numpy array containing the time series 27 | of the epoched signal. 28 | time : 29 | Start and end time of the epochs in seconds, relative to the 30 | time-locked event. Defaults to -1 and 10, respectively. 31 | palette : 32 | The color palette. 33 | figsize : 34 | The figure size. 35 | labels : 36 | The condition label. 37 | unit : 38 | The heart rate unit. Can be `'rr'` (R-R intervals, in ms) or `'bpm'` (beats 39 | per minutes). Default is `'bpm'`. 40 | ci : 41 | The confidence interval around the point estimates. Only `"sd"` is currently 42 | implemented. 43 | kwargs : 44 | Other keyword arguments are passed down to py:`func:seaborn.lineplot()` (only 45 | relevant if `backend` is `"matplotlib"`). 46 | 47 | Returns 48 | ------- 49 | evoked_plot : 50 | The bokeh figure containing the plot. 51 | 52 | """ 53 | 54 | ylabel = "R-R interval (ms)" if unit == "rr" else "Beats per minute (bpm)" 55 | 56 | evoked_plot = figure( 57 | title="Instantaneous heart rate", 58 | sizing_mode="fixed", 59 | width=figsize[0], 60 | height=figsize[1], 61 | x_axis_label="Time", 62 | y_axis_label=ylabel, 63 | ) 64 | 65 | # Vertical and horizontal lines 66 | vline = Span( 67 | location=0, 68 | dimension="height", 69 | line_color="grey", 70 | line_width=2, 71 | line_dash="dashed", 72 | ) 73 | hline = Span(location=0, dimension="width", line_color="black", line_width=1) 74 | evoked_plot.renderers.extend([vline, hline]) 75 | 76 | # Loop across condition 77 | for ep, lab, col in zip(epochs, labels, palette): 78 | for i in range(ep.shape[0]): 79 | evoked_plot.line( 80 | x=time, 81 | y=ep[i], 82 | alpha=0.2, 83 | line_color=col, 84 | ) 85 | 86 | df_source = pd.DataFrame( 87 | { 88 | "time": time, 89 | "average": ep.mean(0), 90 | "lower": ep.mean(0) - ep.std(0), 91 | "upper": ep.mean(0) + ep.std(0), 92 | } 93 | ) 94 | 95 | source = ColumnDataSource(df_source) 96 | 97 | # Show confidence interval 98 | band = Band( 99 | base="time", 100 | lower="lower", 101 | upper="upper", 102 | source=source, 103 | level="underlay", 104 | fill_alpha=0.2, 105 | line_width=1, 106 | fill_color=col, 107 | ) 108 | evoked_plot.add_layout(band) 109 | 110 | # Show average 111 | evoked_plot.line( 112 | x="time", 113 | y="average", 114 | source=source, 115 | line_width=2, 116 | legend_label=lab, 117 | line_color=col, 118 | ) 119 | 120 | return evoked_plot 121 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_frequency.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from bokeh.plotting import figure 7 | 8 | 9 | def plot_frequency( 10 | freq: np.ndarray, 11 | power: np.ndarray, 12 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 13 | fbands: Optional[Dict[str, Tuple[str, Tuple[float, float], str]]] = None, 14 | ax=None, 15 | ) -> figure: 16 | """Plot the frequency component of the heart rate variability. 17 | 18 | Parameters 19 | ---------- 20 | freq : 21 | Frequencies. 22 | power : 23 | Power spectral density. 24 | fbands : 25 | Dictionary containing the names of the frequency bands of interest 26 | (str), their range (tuples) and their color in the PSD plot. 27 | Default is: 28 | >>> {'vlf': ('Very low frequency', (0.003, 0.04), 'b'), 29 | >>> 'lf': ('Low frequency', (0.04, 0.15), 'g'), 30 | >>> 'hf': ('High frequency', (0.15, 0.4), 'r')} 31 | figsize : 32 | Figure size. Default is `(13, 5)`. 33 | ax : 34 | Where to draw the plot. Default is `None` (create a new figure). 35 | 36 | Returns 37 | ------- 38 | psd_plot : 39 | The boken figure containing the plot. 40 | 41 | """ 42 | if isinstance(figsize, int): 43 | height, width = figsize, figsize 44 | elif figsize is None: 45 | figsize = (13, 5) 46 | elif isinstance(figsize, list) | isinstance(figsize, tuple): 47 | width, height = figsize 48 | 49 | psd_plot = figure( 50 | title="Power spectral density", 51 | height=height, 52 | width=width, 53 | x_axis_label="Frequency (Hz)", 54 | y_axis_label="PSD [s²/Hz]", 55 | output_backend="webgl", 56 | ) 57 | 58 | if fbands is None: 59 | fbands = { 60 | "vlf": ("Very low frequency", (0.003, 0.04), "#4c72b0"), 61 | "lf": ("Low frequency", (0.04, 0.15), "#55a868"), 62 | "hf": ("High frequency", (0.15, 0.4), "#c44e52"), 63 | } 64 | 65 | for f in ["vlf", "lf", "hf"]: 66 | mask = (freq >= fbands[f][1][0]) & (freq <= fbands[f][1][1]) 67 | 68 | # Line 69 | psd_plot.line(freq[mask], power[mask], color="grey") 70 | 71 | # Fill area 72 | psd_plot.varea( 73 | x=freq[mask], y1=0, y2=power[mask], color=fbands[f][2], alpha=0.2 74 | ) 75 | 76 | return psd_plot 77 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_poincare.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from bokeh.models import Arrow, NormalHead 7 | from bokeh.plotting import figure 8 | 9 | from systole.hrv import nonlinear_domain 10 | 11 | 12 | def plot_poincare( 13 | rr: np.ndarray, 14 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 15 | ax=None, 16 | ) -> figure: 17 | """poincare plot. 18 | 19 | Parameters 20 | ---------- 21 | rr : 22 | RR intervals (miliseconds). 23 | figsize : 24 | Figure size. Default is `(13, 5)`. 25 | ax : 26 | Where to draw the plot. Default is `None` (create a new figure). 27 | 28 | Returns 29 | ------- 30 | poincare_plot : 31 | The poincare plot. 32 | 33 | """ 34 | if figsize is None: 35 | height, width = 400, 400 36 | elif isinstance(figsize, int): 37 | height, width = figsize, figsize 38 | else: 39 | width, height = figsize 40 | 41 | if np.any(rr >= 3000) | np.any(rr <= 200): 42 | # Set outliers to reasonable values for plotting 43 | rr[np.where(rr > 3000)[0]] = 3000 44 | rr[np.where(rr < 200)[0]] = 200 45 | 46 | # Create x and y vectors 47 | rr_x, rr_y = rr[:-1], rr[1:] 48 | 49 | # Find outliers idx 50 | outliers = (rr_x == 3000) | (rr_x == 200) | (rr_y == 3000) | (rr_y == 200) 51 | range_min, range_max = rr.min() - 50, rr.max() + 50 52 | 53 | poincare_plot = figure( 54 | title="Poincare plot", 55 | height=height, 56 | width=width, 57 | x_axis_label="RR (n)", 58 | y_axis_label="RR (n+1)", 59 | output_backend="webgl", 60 | x_range=[range_min, range_max], 61 | y_range=[range_min, range_max], 62 | ) 63 | 64 | # Identity line 65 | poincare_plot.line( 66 | [range_min, range_max], [range_min, range_max], color="grey", line_dash="dashed" 67 | ) 68 | 69 | # Compute SD1 and SD2 metrics 70 | df = nonlinear_domain(rr) 71 | sd1 = df[df["Metric"] == "SD1"]["Values"].values[0] 72 | sd2 = df[df["Metric"] == "SD2"]["Values"].values[0] 73 | 74 | # Ellipse 75 | poincare_plot.ellipse( 76 | x=rr_x[~outliers].mean(), 77 | y=rr_y[~outliers].mean(), 78 | height=sd1 * 2, 79 | width=sd2 * 2, 80 | angle=np.pi / 4, 81 | fill_alpha=0.4, 82 | fill_color="#a9373b", 83 | line_alpha=1.0, 84 | line_width=3, 85 | line_color="gray", 86 | line_dash="dashed", 87 | ) 88 | 89 | # Scatter plot - valid intervals only 90 | poincare_plot.circle( 91 | rr_x[~outliers], 92 | rr_y[~outliers], 93 | size=2.5, 94 | fill_color="#4c72b0", 95 | line_color="gray", 96 | alpha=0.2, 97 | ) 98 | 99 | # Scatter plot - outliers 100 | poincare_plot.circle( 101 | rr_x[outliers], rr_y[outliers], size=5, color="#a9373b", alpha=0.8 102 | ) 103 | 104 | # SD1 arrow 105 | poincare_plot.add_layout( 106 | Arrow( 107 | end=NormalHead(fill_color="blue", size=10), 108 | x_start=rr_x[~outliers].mean(), 109 | y_start=rr_y[~outliers].mean(), 110 | x_end=rr_x[~outliers].mean() + (-sd1 * np.cos(np.deg2rad(45))), 111 | y_end=rr_y[~outliers].mean() + sd1 * np.sin(np.deg2rad(45)), 112 | ) 113 | ) 114 | 115 | # SD2 arrow 116 | poincare_plot.add_layout( 117 | Arrow( 118 | end=NormalHead(fill_color="green", size=10), 119 | x_start=rr_x[~outliers].mean(), 120 | y_start=rr_y[~outliers].mean(), 121 | x_end=rr_x[~outliers].mean() + sd2 * np.cos(np.deg2rad(45)), 122 | y_end=rr_y[~outliers].mean() + sd2 * np.sin(np.deg2rad(45)), 123 | ) 124 | ) 125 | 126 | return poincare_plot 127 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_shortlong.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict 4 | 5 | import numpy as np 6 | from bokeh.plotting import figure 7 | 8 | 9 | def plot_shortlong( 10 | artefacts=Dict[str, np.ndarray], figsize: int = 600, **kwargs 11 | ) -> figure: 12 | """Plot interactive short/long subspace. 13 | 14 | Parameters 15 | ---------- 16 | artefacts : 17 | The artefacts detected using 18 | :py:func:`systole.detection.rr_artefacts()`. 19 | figsize : 20 | Figure heights. Default is `600`. 21 | 22 | Returns 23 | ------- 24 | shorLong_plot : 25 | The boken figure containing the plot. 26 | 27 | """ 28 | xlim, ylim = 10, 10 29 | 30 | outliers = ( 31 | artefacts["ectopic"] 32 | | artefacts["short"] 33 | | artefacts["long"] 34 | | artefacts["extra"] 35 | | artefacts["missed"] 36 | ) 37 | 38 | # All values fit in the x and y lims 39 | for this_art in [artefacts["subspace1"], artefacts["subspace3"]]: 40 | this_art[this_art > xlim] = xlim 41 | this_art[this_art < -xlim] = -xlim 42 | this_art[this_art > ylim] = ylim 43 | this_art[this_art < -ylim] = -ylim 44 | 45 | shorLong_plot = figure( 46 | title="Short and long intervals", 47 | height=figsize, 48 | width=figsize, 49 | x_axis_label="Subspace 1", 50 | y_axis_label="Subspace 3", 51 | output_backend="webgl", 52 | x_range=[-xlim, xlim], 53 | y_range=[-ylim, ylim], 54 | ) 55 | 56 | # Upper area 57 | shorLong_plot.patch([-10, -10, -1, -1], [1, 10, 10, 1], alpha=0.2, color="grey") 58 | 59 | # Lower area 60 | shorLong_plot.patch([1, 1, 10, 10], [-1, -10, -10, -1], alpha=0.2, color="grey") 61 | 62 | # Plot normal intervals 63 | shorLong_plot.circle( 64 | artefacts["subspace1"][~outliers], 65 | artefacts["subspace3"][~outliers], 66 | color="gray", 67 | size=8, 68 | alpha=0.2, 69 | legend_label="Standard IBI", 70 | ) 71 | 72 | # Plot ectopic beats 73 | if artefacts["ectopic"].any(): 74 | shorLong_plot.triangle( 75 | artefacts["subspace1"][artefacts["ectopic"]], 76 | artefacts["subspace3"][artefacts["ectopic"]], 77 | size=8, 78 | alpha=0.8, 79 | legend_label="Ectopic beats", 80 | color="#6c0073", 81 | ) 82 | 83 | # Plot missed beats 84 | if artefacts["missed"].any(): 85 | shorLong_plot.square( 86 | artefacts["subspace1"][artefacts["missed"]], 87 | artefacts["subspace3"][artefacts["missed"]], 88 | size=8, 89 | alpha=0.8, 90 | legend_label="Missed beats", 91 | color="#2f5f91", 92 | ) 93 | 94 | # Plot long beats 95 | if artefacts["long"].any(): 96 | shorLong_plot.circle( 97 | artefacts["subspace1"][artefacts["long"]], 98 | artefacts["subspace3"][artefacts["long"]], 99 | size=8, 100 | alpha=0.8, 101 | legend_label="Long beats", 102 | color="#9ac1d4", 103 | ) 104 | 105 | # Plot extra beats 106 | if artefacts["extra"].any(): 107 | shorLong_plot.square( 108 | artefacts["subspace1"][artefacts["extra"]], 109 | artefacts["subspace3"][artefacts["extra"]], 110 | size=8, 111 | alpha=0.8, 112 | legend_label="Extra beats", 113 | color="#9d2b39", 114 | ) 115 | 116 | # Plot short beats 117 | if artefacts["short"].any(): 118 | shorLong_plot.circle( 119 | artefacts["subspace1"][artefacts["short"]], 120 | artefacts["subspace3"][artefacts["short"]], 121 | size=8, 122 | alpha=0.8, 123 | legend_label="Short beats", 124 | color="#c56c5e", 125 | ) 126 | 127 | return shorLong_plot 128 | -------------------------------------------------------------------------------- /systole/plots/backends/bokeh/plot_subspaces.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict 4 | 5 | import numpy as np 6 | from bokeh.layouts import row 7 | from bokeh.models.layouts import Row 8 | 9 | from systole.plots import plot_ectopic, plot_shortlong 10 | 11 | 12 | def plot_subspaces( 13 | artefacts: Dict[str, np.ndarray], figsize: int = 600, **kwargs 14 | ) -> Row: 15 | """Plot hrv subspace as described by Lipponen & Tarvainen (2019) [#]_. 16 | 17 | Parameters 18 | ---------- 19 | artefacts : 20 | The artefacts detected using 21 | :py:func:`systole.detection.rr_artefacts()`. 22 | figsize : 23 | Figure size. Defaults to `600` when using bokeh backend. 24 | 25 | Returns 26 | ------- 27 | fig : 28 | The bokeh figure containing the two plots. 29 | 30 | """ 31 | fig = row( 32 | plot_ectopic( # type: ignore 33 | artefacts=artefacts, figsize=figsize, input_type=None, backend="bokeh" 34 | ), 35 | plot_shortlong( # type: ignore 36 | artefacts=artefacts, figsize=figsize, input_type=None, backend="bokeh" 37 | ), 38 | ) 39 | 40 | return fig 41 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/systole/plots/backends/matplotlib/__init__.py -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_circular.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Union 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | 9 | 10 | def plot_circular( 11 | data: List[Union[float, List[float], np.ndarray]], 12 | palette: List[str], 13 | labels: List[str], 14 | ax: Axes, 15 | units: str = "radians", 16 | bins: int = 32, 17 | density: str = "area", 18 | norm: bool = True, 19 | mean: bool = False, 20 | offset: float = 0.0, 21 | **kwargs 22 | ) -> Axes: 23 | """Plot polar histogram. 24 | 25 | This function is an internal function used by:py:func`systole.plots.plot_circular`. 26 | 27 | Parameters 28 | ---------- 29 | data : 30 | List of numpy arrays. 31 | palette : 32 | Color palette. Default sets to Seaborn `"deep"`. 33 | labels : 34 | The conditions labels. 35 | units : 36 | Unit of the angular values provided. Can be `"degree"` or `"radian"`. 37 | Default sets to `"radians"`. 38 | bins : 39 | Number of slices in the circle. Use even value to have a bin edge at zero. 40 | density : 41 | How to represent the density of the circular distribution. Can be one of the 42 | following: 43 | - `"area"`: use the area of the circular bins. 44 | - `"height"`: use the height of the circular bins. 45 | - `"alpha"`: change the transparency of the circular bins. 46 | Default set to `"area"`. This method should be prefered over `"height"` as 47 | increasing the height of the bars is increasin their visual importance (area) 48 | non linearly. The `"area"` method can control for this bias. 49 | norm : 50 | If `True` (default), normalize the distribution between 0 and 1. 51 | mean : 52 | If `True`, show the mean and 95% CI. Default set to `False`. 53 | offset : 54 | Where 0 will be placed on the circle, in radians. Default set to `0`. 55 | ax : 56 | Where to draw the plot. Default is `None` (create a new figure). 57 | 58 | Returns 59 | ------- 60 | ax : 61 | The matplotlib axes containing the plot. 62 | 63 | """ 64 | 65 | # Loop across conditions 66 | for angles, color, label in zip(data, palette, labels): 67 | angles = np.asarray(angles) 68 | 69 | # Bin data and count 70 | count, bin = np.histogram(angles, bins=bins, range=(0, np.pi * 2)) 71 | 72 | # Compute width 73 | widths = np.diff(bin)[0] 74 | 75 | if density == "area": # Default 76 | # Area to assign each bin 77 | area = count / angles.size 78 | # Calculate corresponding bin radius 79 | radius = (area / np.pi) ** 0.5 80 | alpha = (count * 0) + 1.0 81 | elif density == "height": # Using height (can be misleading) 82 | radius = count / angles.size 83 | alpha = (count * 0) + 1.0 84 | elif density == "alpha": # Using transparency 85 | radius = (count * 0) + 1.0 86 | # Alpha level to each bin 87 | alpha = count / angles.size 88 | alpha = alpha / alpha.max() 89 | else: 90 | raise ValueError("Invalid method") 91 | 92 | if norm is True: 93 | radius = radius / radius.max() 94 | 95 | # Plot data on ax 96 | for b, r, a in zip(bin[:-1], radius, alpha): 97 | ax.bar( 98 | b, 99 | r, 100 | align="edge", 101 | width=widths, 102 | edgecolor="k", 103 | linewidth=1, 104 | color=color, 105 | alpha=a, 106 | ) 107 | 108 | # Plot mean and CI 109 | if mean: 110 | # Use pingouin.circ_mean() method 111 | alpha = np.array(angles) 112 | w = np.ones_like(alpha) 113 | circ_mean = np.angle(np.multiply(w, np.exp(1j * alpha)).sum(axis=0)) 114 | ax.plot(circ_mean, radius.max(), "ko") 115 | 116 | # Set the direction of the zero angle 117 | ax.set_theta_offset(offset) 118 | 119 | # Remove ylabels 120 | ax.set_yticks([]) 121 | 122 | if units == "radians": 123 | circle_label = [ 124 | "$0$", 125 | r"$\pi/4$", 126 | r"$\pi/2$", 127 | r"$3\pi/4$", 128 | r"$\pi$", 129 | r"$5\pi/4$", 130 | r"$3\pi/2$", 131 | r"$7\pi/4$", 132 | ] 133 | ax.set_xticklabels(circle_label) 134 | plt.tight_layout() 135 | 136 | return ax 137 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_ectopic.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, Optional 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | 9 | 10 | def plot_ectopic( 11 | artefacts=Dict[str, np.ndarray], 12 | figsize: int = 600, 13 | ax: Optional[Axes] = None, 14 | **kwargs 15 | ) -> Axes: 16 | """Plot ectopic subspace. 17 | 18 | Parameters 19 | ---------- 20 | artefacts : 21 | The artefacts detected using 22 | :py:func:`systole.detection.rr_artefacts()`. 23 | figsize : 24 | Figure heights. Default is `600`. 25 | ax : 26 | Where to draw the plot. Default is `None` (create a new figure). 27 | 28 | Returns 29 | ------- 30 | ax : 31 | The matplotlib axes containing the plot. 32 | 33 | """ 34 | c1, c2, xlim, ylim = 0.13, 0.17, 10, 5 35 | 36 | outliers = ( 37 | artefacts["ectopic"] 38 | | artefacts["short"] 39 | | artefacts["long"] 40 | | artefacts["extra"] 41 | | artefacts["missed"] 42 | ) 43 | 44 | # All values fit in the x and y lims 45 | for this_art in [artefacts["subspace1"]]: 46 | this_art[this_art > xlim] = xlim 47 | this_art[this_art < -xlim] = -xlim 48 | for this_art in [artefacts["subspace2"]]: 49 | this_art[this_art > ylim] = ylim 50 | this_art[this_art < -ylim] = -ylim 51 | 52 | if ax is None: 53 | _, ax = plt.subplots(figsize=figsize) 54 | 55 | # Plot normal beats 56 | ax.scatter( 57 | artefacts["subspace1"][~outliers], 58 | artefacts["subspace2"][~outliers], 59 | color="gray", 60 | edgecolors="k", 61 | s=15, 62 | alpha=0.2, 63 | zorder=10, 64 | label="Normal", 65 | ) 66 | 67 | # Ectopic beats 68 | if artefacts["ectopic"].any(): 69 | ax.scatter( 70 | artefacts["subspace1"][artefacts["ectopic"]], 71 | artefacts["subspace2"][artefacts["ectopic"]], 72 | color="r", 73 | edgecolors="k", 74 | zorder=10, 75 | label="Ectopic", 76 | ) 77 | 78 | # Short RR intervals 79 | if artefacts["short"].any(): 80 | ax.scatter( 81 | artefacts["subspace1"][artefacts["short"]], 82 | artefacts["subspace2"][artefacts["short"]], 83 | color="b", 84 | edgecolors="k", 85 | zorder=10, 86 | marker="s", 87 | label="Short", 88 | ) 89 | 90 | # Long RR intervals 91 | if artefacts["long"].any(): 92 | ax.scatter( 93 | artefacts["subspace1"][artefacts["long"]], 94 | artefacts["subspace2"][artefacts["long"]], 95 | color="g", 96 | edgecolors="k", 97 | zorder=10, 98 | marker="s", 99 | label="Long", 100 | ) 101 | 102 | # Missed RR intervals 103 | if artefacts["missed"].any(): 104 | ax.scatter( 105 | artefacts["subspace1"][artefacts["missed"]], 106 | artefacts["subspace2"][artefacts["missed"]], 107 | color="g", 108 | edgecolors="k", 109 | zorder=10, 110 | label="Missed", 111 | ) 112 | 113 | # Extra RR intervals 114 | if artefacts["extra"].any(): 115 | ax.scatter( 116 | artefacts["subspace1"][artefacts["extra"]], 117 | artefacts["subspace2"][artefacts["extra"]], 118 | color="b", 119 | edgecolors="k", 120 | zorder=10, 121 | label="Extra", 122 | ) 123 | 124 | # Upper area 125 | def f1(x): 126 | return -c1 * x + c2 127 | 128 | ax.plot([-1, -10], [f1(-1), f1(-10)], "k", linewidth=1, linestyle="--") 129 | ax.plot([-1, -1], [f1(-1), 10], "k", linewidth=1, linestyle="--") 130 | x = [-10, -10, -1, -1] 131 | y = [f1(-10), 10, 10, f1(-1)] 132 | ax.fill(x, y, color="gray", alpha=0.3) 133 | 134 | # Lower area 135 | def f2(x): 136 | return -c1 * x - c2 137 | 138 | ax.plot([1, 10], [f2(1), f2(10)], "k", linewidth=1, linestyle="--") 139 | ax.plot([1, 1], [f2(1), -10], "k", linewidth=1, linestyle="--") 140 | x = [1, 1, 10, 10] 141 | y = [f2(1), -10, -10, f2(10)] 142 | ax.fill(x, y, color="gray", alpha=0.3) 143 | 144 | ax.set_xlabel("Subspace $S_{11}$") 145 | ax.set_ylabel("Subspace $S_{12}$") 146 | ax.set_ylim(-ylim, ylim) 147 | ax.set_xlim(-xlim, xlim) 148 | ax.set_title("Subspace 1 \n (ectopic beats detection)") 149 | ax.legend() 150 | 151 | return ax 152 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_events.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Optional, Tuple 4 | 5 | import matplotlib.pyplot as plt 6 | import pandas as pd 7 | from matplotlib.axes import Axes 8 | 9 | 10 | def plot_events( 11 | df: pd.DataFrame, 12 | figsize: Tuple[float, float] = (13, 3), 13 | ax: Optional[Axes] = None, 14 | behavior=None, 15 | ) -> Axes: 16 | """Plot events to get a visual display of the paradigm (Matplotlib). 17 | 18 | Parameters 19 | ---------- 20 | df : 21 | The events data frame (tmin, trigger, tmax, label, color, [behavior]). 22 | figsize : 23 | Figure size. Default is `(13, 5)`. 24 | ax : 25 | Where to draw the plot. Default is *None* (create a new figure). 26 | behavior : 27 | (Optional) Additional information about trials that will appear when hovering 28 | on the area (only relevant for `bokeh` backend). This parameter will be 29 | ignored. 30 | 31 | Returns 32 | ------- 33 | ax : 34 | The matplotlib axes containing the plot. 35 | 36 | """ 37 | if ax is None: 38 | _, ax = plt.subplots(figsize=figsize) 39 | 40 | # Plot first label only 41 | all_labels = [] 42 | for i in range(len(df)): 43 | if df.loc[i, "label"] in all_labels: 44 | df.loc[i, "label"] = "" 45 | else: 46 | all_labels.append(df.loc[i, "label"]) 47 | 48 | # Loop across events df 49 | for i, tmin, trigger, tmax, label, color in df.itertuples(): 50 | # Plot time range 51 | ax.axvspan(xmin=tmin, xmax=tmax, color=color, alpha=0.2, label=label) 52 | 53 | # Plot trigger 54 | ax.axvline(x=trigger, color="gray", linestyle="--", linewidth=0.5) 55 | 56 | # Add y ticks with channels names 57 | ax.set_xlabel("Time") 58 | ax.legend() 59 | 60 | return ax 61 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_evoked.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Tuple 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | import pandas as pd 8 | import seaborn as sns 9 | from matplotlib.axes import Axes 10 | 11 | 12 | def plot_evoked( 13 | epochs: List[np.ndarray], 14 | time: np.ndarray, 15 | figsize: Tuple[float, float], 16 | labels: List[str], 17 | unit: str, 18 | ax=None, 19 | **kwargs 20 | ) -> Axes: 21 | """Plot events occurence across recording. 22 | 23 | Parameters 24 | ---------- 25 | epochs : 26 | A 2d (trial * time) numpy array containing the time series 27 | of the epoched signal. 28 | time : 29 | Start and end time of the epochs in seconds, relative to the 30 | time-locked event. Defaults to -1 and 10, respectively. 31 | figsize : 32 | The lines color. 33 | labels : 34 | The different condition/participants label/IDs. 35 | unit : 36 | The heart rate unit. Can be `'rr'` (R-R intervals, in ms) or `'bpm'` (beats 37 | per minutes). Default is `'bpm'`. 38 | ax : 39 | Figure size. Default is `(13, 5)`. 40 | kwargs : 41 | Other keyword arguments are passed down to py:`func:seaborn.lineplot()`. 42 | 43 | Returns 44 | ------- 45 | ax : 46 | The matplotlib axes containing the plot. 47 | 48 | """ 49 | 50 | if ax is None: 51 | _, ax = plt.subplots(figsize=figsize) 52 | if isinstance(labels, str): 53 | labels = [labels] 54 | 55 | ax.axvline(x=0, linestyle="--", color="gray") 56 | ax.axhline(y=0, color="black", linewidth=1) 57 | 58 | # Loop across the many condition/participants provided and create a long data frame 59 | # that can be passed to Seaborn's lineplot() with custom args 60 | epoch_df = pd.DataFrame([]) 61 | for ep, lab in zip(epochs, labels): 62 | # Create a dataframe for seaborn 63 | df = pd.DataFrame(ep.T) 64 | df["Time"] = time 65 | df = df.melt(id_vars=["Time"], var_name="subject", value_name="heart_rate") 66 | df["Label"] = lab 67 | epoch_df = pd.concat([epoch_df, df], ignore_index=True) 68 | 69 | # Use Seaborn lineplot with the transformed data 70 | sns.lineplot(data=epoch_df, x="Time", y="heart_rate", hue="Label", ax=ax, **kwargs) 71 | 72 | ax.set_xlabel("Time (s)") 73 | ylabel = "R-R interval change (ms)" if unit == "rr" else "Heart rate change (bpm)" 74 | ax.set_ylabel(ylabel) 75 | 76 | return ax 77 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_frequency.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | 9 | 10 | def plot_frequency( 11 | freq: np.ndarray, 12 | power: np.ndarray, 13 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 14 | fbands: Optional[Dict[str, Tuple[str, Tuple[float, float], str]]] = None, 15 | ax: Optional[Axes] = None, 16 | ) -> Axes: 17 | """Plot the frequency component of the heart rate variability. 18 | 19 | Parameters 20 | ---------- 21 | freq : 22 | Frequencies. 23 | power : 24 | Power spectral density. 25 | figsize : 26 | Figure size. Default is `(8, 5)`. 27 | fbands : 28 | Dictionary containing the names of the frequency bands of interest 29 | (str), their range (tuples) and their color in the PSD plot. 30 | Default is: 31 | >>> {'vlf': ('Very low frequency', (0.003, 0.04), 'b'), 32 | >>> 'lf': ('Low frequency', (0.04, 0.15), 'g'), 33 | >>> 'hf': ('High frequency', (0.15, 0.4), 'r')} 34 | ax : 35 | Where to draw the plot. Default is `None` (create a new figure). 36 | 37 | Returns 38 | ------- 39 | ax : 40 | The matplotlib axes containing the plot. 41 | 42 | """ 43 | if figsize is None: 44 | figsize = (8, 5) 45 | elif isinstance(figsize, int): 46 | figsize = (figsize, figsize) 47 | else: 48 | if len(figsize) == 1: 49 | figsize = (figsize[0], figsize[0]) 50 | 51 | if fbands is None: 52 | fbands = { 53 | "vlf": ("Very low frequency", (0.003, 0.04), "#4c72b0"), 54 | "lf": ("Low frequency", (0.04, 0.15), "#55a868"), 55 | "hf": ("High frequency", (0.15, 0.4), "#c44e52"), 56 | } 57 | 58 | # Plot the PSD 59 | if ax is None: 60 | fig, ax = plt.subplots(figsize=figsize) 61 | ax.plot(freq, power, "k") 62 | for f in ["vlf", "lf", "hf"]: 63 | mask = (freq >= fbands[f][1][0]) & (freq <= fbands[f][1][1]) 64 | ax.fill_between(freq, power, where=mask, color=fbands[f][2], alpha=0.2) 65 | ax.axvline(x=fbands[f][1][0], linestyle="--", color="gray") 66 | ax.set_xlim(0.003, 0.4) 67 | ax.set_xlabel("Frequency [Hz]") 68 | ax.set_ylabel("PSD [$s^2$/Hz]") 69 | ax.set_title("Power Spectral Density", fontweight="bold") 70 | 71 | return ax 72 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_poincare.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Optional, Tuple, Union 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | from matplotlib.patches import Ellipse 9 | 10 | from systole.hrv import nonlinear_domain 11 | 12 | 13 | def plot_poincare( 14 | rr: np.ndarray, 15 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 16 | ax: Optional[Axes] = None, 17 | ) -> Union[Tuple[np.ndarray, np.ndarray], Axes]: 18 | """poincare plot. 19 | 20 | Parameters 21 | ---------- 22 | rr : 23 | RR intervals (miliseconds). 24 | figsize : 25 | Figure size. Default is `(8, 8)`. 26 | ax : 27 | Where to draw the plot. Default is `None` (create a new figure). 28 | 29 | Returns 30 | ------- 31 | ax : 32 | The poincare plot. 33 | 34 | """ 35 | if figsize is None: 36 | figsize = (8, 8) 37 | elif isinstance(figsize, int): 38 | figsize = (figsize, figsize) 39 | else: 40 | if len(figsize) == 1: 41 | figsize = (figsize[0], figsize[0]) 42 | 43 | if ax is None: 44 | _, ax = plt.subplots(figsize=figsize) 45 | 46 | if np.any(rr >= 3000) | np.any(rr <= 200): 47 | # Set outliers to reasonable values for plotting 48 | rr[np.where(rr > 3000)[0]] = 3000 49 | rr[np.where(rr < 200)[0]] = 200 50 | 51 | # Create x and y vectors 52 | rr_x, rr_y = rr[:-1], rr[1:] 53 | 54 | # Find outliers idx 55 | outliers = (rr_x == 3000) | (rr_x == 200) | (rr_y == 3000) | (rr_y == 200) 56 | range_min, range_max = rr.min() - 50, rr.max() + 50 57 | 58 | # Identity line 59 | ax.plot( 60 | [range_min, range_max], [range_min, range_max], color="grey", linestyle="--" 61 | ) 62 | 63 | # Compute SD1 and SD2 metrics 64 | df = nonlinear_domain(rr) 65 | sd1 = df[df["Metric"] == "SD1"]["Values"].values[0] 66 | sd2 = df[df["Metric"] == "SD2"]["Values"].values[0] 67 | 68 | # Ellipse 69 | ellipse_ = Ellipse( 70 | (rr_x[~outliers].mean(), rr_y[~outliers].mean()), 71 | sd1 * 2, 72 | sd2 * 2, 73 | angle=-45, 74 | fc="grey", 75 | zorder=1, 76 | fill=False, 77 | ) 78 | ax.add_artist(ellipse_) 79 | ellipse_ = Ellipse( 80 | (rr_x[~outliers].mean(), rr_y[~outliers].mean()), 81 | sd1 * 2, 82 | sd2 * 2, 83 | angle=-45, 84 | fc="#4c72b0", 85 | alpha=0.4, 86 | zorder=1, 87 | ) 88 | ax.add_artist(ellipse_) 89 | 90 | # Scatter plot - valid intervals 91 | ax.scatter( 92 | rr_x[~outliers], 93 | rr_y[~outliers], 94 | s=5, 95 | color="#4c72b0", 96 | alpha=0.1, 97 | edgecolors="grey", 98 | ) 99 | 100 | # Scatter plot - outliers 101 | ax.scatter( 102 | rr_x[outliers], 103 | rr_y[outliers], 104 | s=5, 105 | color="#a9373b", 106 | alpha=0.8, 107 | edgecolors="grey", 108 | ) 109 | 110 | # SD1 arrow 111 | ax.arrow( 112 | rr_x[~outliers].mean(), 113 | rr_y[~outliers].mean(), 114 | -sd1 * np.cos(np.deg2rad(45)), 115 | sd1 * np.sin(np.deg2rad(45)), 116 | head_width=10, 117 | head_length=10, 118 | fc="b", 119 | ec="b", 120 | zorder=4, 121 | linewidth=1.5, 122 | ) 123 | 124 | # SD2 arrow 125 | ax.arrow( 126 | rr_x[~outliers].mean(), 127 | rr_y[~outliers].mean(), 128 | sd2 * np.cos(np.deg2rad(45)), 129 | sd2 * np.sin(np.deg2rad(45)), 130 | head_width=10, 131 | head_length=10, 132 | fc="b", 133 | ec="g", 134 | zorder=4, 135 | linewidth=1.5, 136 | ) 137 | 138 | ax.set_xlabel("RR (n)") 139 | ax.set_ylabel("RR (n+1)") 140 | ax.set_title("Poincare plot", fontweight="bold") 141 | ax.minorticks_on() 142 | 143 | return ax 144 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_shortlong.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, Optional 4 | 5 | import matplotlib.pyplot as plt 6 | import numpy as np 7 | from matplotlib.axes import Axes 8 | 9 | 10 | def plot_shortlong( 11 | artefacts=Dict[str, np.ndarray], 12 | figsize: int = 600, 13 | ax: Optional[Axes] = None, 14 | **kwargs 15 | ) -> Axes: 16 | """Plot ectopic subspace. 17 | 18 | Parameters 19 | ---------- 20 | artefacts : 21 | The artefacts detected using 22 | :py:func:`systole.detection.rr_artefacts()`. 23 | figsize : 24 | Figure heights. Default is `600`. 25 | ax : 26 | Where to draw the plot. Default is `None` (create a new figure). 27 | 28 | Returns 29 | ------- 30 | ax : 31 | The matplotlib axes containing the plot. 32 | 33 | """ 34 | xlim, ylim = 10, 5 35 | 36 | outliers = ( 37 | artefacts["ectopic"] 38 | | artefacts["short"] 39 | | artefacts["long"] 40 | | artefacts["extra"] 41 | | artefacts["missed"] 42 | ) 43 | 44 | # All values fit in the x and y lims 45 | for this_art in [artefacts["subspace1"]]: 46 | this_art[this_art > xlim] = xlim 47 | this_art[this_art < -xlim] = -xlim 48 | for this_art in [artefacts["subspace2"]]: 49 | this_art[this_art > ylim] = ylim 50 | this_art[this_art < -ylim] = -ylim 51 | 52 | if ax is None: 53 | _, ax = plt.subplots(figsize=figsize) 54 | 55 | # Plot normal beats 56 | ax.scatter( 57 | artefacts["subspace1"][~outliers], 58 | artefacts["subspace3"][~outliers], 59 | color="gray", 60 | edgecolors="k", 61 | alpha=0.2, 62 | zorder=10, 63 | s=15, 64 | label="Normal", 65 | ) 66 | 67 | # Ectopic beats 68 | if artefacts["ectopic"].any(): 69 | ax.scatter( 70 | artefacts["subspace1"][artefacts["ectopic"]], 71 | artefacts["subspace3"][artefacts["ectopic"]], 72 | color="r", 73 | edgecolors="k", 74 | zorder=10, 75 | label="Ectopic", 76 | ) 77 | 78 | # Short RR intervals 79 | if artefacts["short"].any(): 80 | ax.scatter( 81 | artefacts["subspace1"][artefacts["short"]], 82 | artefacts["subspace3"][artefacts["short"]], 83 | color="b", 84 | edgecolors="k", 85 | zorder=10, 86 | marker="s", 87 | label="Short", 88 | ) 89 | 90 | # Long RR intervals 91 | if artefacts["long"].any(): 92 | ax.scatter( 93 | artefacts["subspace1"][artefacts["long"]], 94 | artefacts["subspace3"][artefacts["long"]], 95 | color="g", 96 | edgecolors="k", 97 | zorder=10, 98 | marker="s", 99 | label="Long", 100 | ) 101 | 102 | # Missed RR intervals 103 | if artefacts["missed"].any(): 104 | ax.scatter( 105 | artefacts["subspace1"][artefacts["missed"]], 106 | artefacts["subspace3"][artefacts["missed"]], 107 | color="g", 108 | edgecolors="k", 109 | zorder=10, 110 | label="Missed", 111 | ) 112 | 113 | # Extra RR intervals 114 | if artefacts["extra"].any(): 115 | ax.scatter( 116 | artefacts["subspace1"][artefacts["extra"]], 117 | artefacts["subspace3"][artefacts["extra"]], 118 | color="b", 119 | edgecolors="k", 120 | zorder=10, 121 | label="Extra", 122 | ) 123 | 124 | # Upper area 125 | ax.plot([-1, -10], [1, 1], "k", linewidth=1, linestyle="--") 126 | ax.plot([-1, -1], [1, 10], "k", linewidth=1, linestyle="--") 127 | x = [-10, -10, -1, -1] 128 | y = [1, 10, 10, 1] 129 | ax.fill(x, y, color="gray", alpha=0.3) 130 | 131 | # Lower area 132 | ax.plot([1, 10], [-1, -1], "k", linewidth=1, linestyle="--") 133 | ax.plot([1, 1], [-1, -10], "k", linewidth=1, linestyle="--") 134 | x = [1, 1, 10, 10] 135 | y = [-1, -10, -10, -1] 136 | ax.fill(x, y, color="gray", alpha=0.3) 137 | 138 | ax.set_xlabel("Subspace $S_{21}$") 139 | ax.set_ylabel("Subspace $S_{22}$") 140 | ax.set_ylim(-ylim * 2, ylim * 2) 141 | ax.set_xlim(-xlim, xlim) 142 | ax.set_title("Subspace 2 \n (long and short beats detection)") 143 | ax.legend() 144 | 145 | return ax 146 | -------------------------------------------------------------------------------- /systole/plots/backends/matplotlib/plot_subspaces.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from matplotlib.axes import Axes 7 | 8 | from systole.plots import plot_ectopic, plot_shortlong 9 | 10 | 11 | def plot_subspaces( 12 | artefacts: Dict[str, np.ndarray], 13 | figsize: Tuple[int, int] = (10, 5), 14 | ax: Optional[Union[Tuple, List]] = None, 15 | ) -> Tuple[Axes, Axes]: 16 | """Plot hrv subspace as described by Lipponen & Tarvainen (2019). 17 | 18 | Parameters 19 | ---------- 20 | artefacts : 21 | The artefacts detected using 22 | :py:func:`systole.detection.rr_artefacts()`. 23 | figsize : 24 | Figure size. Defaults to `(10, 5)` when using matplotlib backend. 25 | 26 | Returns 27 | ------- 28 | axs : 29 | The matplotlib axes containing the plot. 30 | 31 | """ 32 | if ax is None: 33 | ectopic_ax, short_long_ax = None, None 34 | else: 35 | ectopic_ax, short_long_ax = ax 36 | 37 | ectopic = plot_ectopic( # type: ignore 38 | artefacts=artefacts, 39 | figsize=figsize, 40 | input_type=None, 41 | backend="matplotlib", 42 | ax=ectopic_ax, 43 | ) 44 | shortLong = plot_shortlong( # type: ignore 45 | artefacts=artefacts, 46 | figsize=figsize, 47 | input_type=None, 48 | backend="matplotlib", 49 | ax=short_long_ax, 50 | ) 51 | 52 | return ectopic, shortLong 53 | -------------------------------------------------------------------------------- /systole/plots/plot_ectopic.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union, overload 4 | 5 | import numpy as np 6 | from bokeh.plotting._figure import figure 7 | from matplotlib.axes import Axes 8 | 9 | from systole.correction import rr_artefacts 10 | from systole.plots.utils import get_plotting_function 11 | from systole.utils import input_conversion 12 | 13 | 14 | @overload 15 | def plot_ectopic( 16 | rr: None, 17 | artefacts: Dict[str, np.ndarray], 18 | input_type: str = "rr_ms", 19 | ) -> Union[figure, Axes]: 20 | ... 21 | 22 | 23 | @overload 24 | def plot_ectopic( 25 | rr: Union[List[float], np.ndarray], 26 | artefacts: None, 27 | input_type: str = "rr_ms", 28 | ) -> Union[figure, Axes]: 29 | ... 30 | 31 | 32 | @overload 33 | def plot_ectopic( 34 | rr: Union[List[float], np.ndarray], 35 | artefacts: Dict[str, np.ndarray], 36 | input_type: str = "rr_ms", 37 | ) -> Union[figure, Axes]: 38 | ... 39 | 40 | 41 | def plot_ectopic( 42 | rr=None, 43 | artefacts=None, 44 | input_type: str = "rr_ms", 45 | ax: Optional[Axes] = None, 46 | backend: str = "matplotlib", 47 | figsize: Optional[Union[Tuple[float, float], int]] = None, 48 | ) -> Union[figure, Axes]: 49 | """Visualization of ectopic beats detection. 50 | 51 | The artefact detection is based on the method described in [1]_. 52 | 53 | Parameters 54 | ---------- 55 | rr : 56 | Interval time-series (R-R, beat-to-beat...), in miliseconds. 57 | artefacts : 58 | The artefacts detected using 59 | :py:func:`systole.detection.rr_artefacts()`. 60 | input_type : 61 | The type of input vector. Default is `"rr_ms"` for vectors of 62 | RR intervals, or interbeat intervals (IBI), expressed in milliseconds. 63 | Can also be `"peaks"` (a boolean vector where `1` represents the 64 | occurrence of R waves or systolic peaks) or `"rr_s"` for IBI expressed 65 | in seconds. 66 | ax : 67 | Where to draw the plot. Default is *None* (create a new figure). Only 68 | applies when `backend="matplotlib"`. 69 | backend : 70 | Select plotting backend (`"matplotlib"` or `"bokeh"`. Defaults to 71 | `"matplotlib"`. 72 | figsize : 73 | Figure size. Default is `(13, 5)` for Matplotlib backend, and the 74 | height is `600` when using Bokeh backend. 75 | 76 | Returns 77 | ------- 78 | plot : 79 | The matplotlib axes, or the boken figure containing the plot. 80 | 81 | See also 82 | -------- 83 | plot_shortlong, plot_subspaces 84 | 85 | References 86 | ---------- 87 | .. [1] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for heart 88 | rate variability time series artefact correction using novel beat classification. 89 | Journal of Medical Engineering & Technology, 43(3), 173–181. 90 | https://doi.org/10.1080/03091902.2019.1640306 91 | 92 | Notes 93 | ----- 94 | If both `rr` and `artefacts` are provided, the function will drop `artefacts` 95 | and re-evaluate given the current RR time-series. 96 | 97 | Examples 98 | -------- 99 | 100 | Visualizing ectopic subspace from RR time series. 101 | 102 | .. jupyter-execute:: 103 | 104 | from systole import import_rr 105 | from systole.plots import plot_ectopic 106 | 107 | # Import PPG recording as numpy array 108 | rr = import_rr().rr.to_numpy() 109 | 110 | plot_ectopic(rr, input_type="rr_ms") 111 | 112 | Visualizing ectopic subspace from the `artefact` dictionary generated by 113 | :py:func:`systole.detection.rr_artefacts()`. 114 | 115 | .. jupyter-execute:: 116 | 117 | from systole.detection import rr_artefacts 118 | 119 | # Use the rr_artefacts function to find ectopic beats 120 | artefacts = rr_artefacts(rr) 121 | 122 | plot_ectopic(artefacts=artefacts) 123 | 124 | Using Bokeh as plotting backend. 125 | 126 | .. jupyter-execute:: 127 | 128 | from bokeh.io import output_notebook 129 | from bokeh.plotting import show 130 | from systole.detection import rr_artefacts 131 | output_notebook() 132 | 133 | # Use the rr_artefacts function to find ectopic beats 134 | artefacts = rr_artefacts(rr) 135 | show( 136 | plot_ectopic(artefacts=artefacts, backend="bokeh") 137 | ) 138 | 139 | """ 140 | 141 | if figsize is None: 142 | if backend == "matplotlib": 143 | figsize = (6, 6) 144 | elif backend == "bokeh": 145 | figsize = 600 146 | 147 | if artefacts is None: 148 | if rr is None: 149 | raise ValueError("rr or artefacts should be provided") 150 | else: 151 | if input_type != "rr_ms": 152 | rr = input_conversion(rr, input_type=input_type, output_type="rr_ms") 153 | artefacts = rr_artefacts(rr) 154 | 155 | plot_ectopic_args = { 156 | "artefacts": artefacts, 157 | "ax": ax, 158 | "figsize": figsize, 159 | } 160 | 161 | plotting_function = get_plotting_function("plot_ectopic", "plot_ectopic", backend) 162 | plot = plotting_function(**plot_ectopic_args) 163 | 164 | return plot 165 | -------------------------------------------------------------------------------- /systole/plots/plot_frequency.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from bokeh.plotting._figure import figure 7 | from matplotlib.axes import Axes 8 | from scipy.interpolate import interp1d 9 | 10 | from systole.hrv import psd 11 | from systole.plots.utils import get_plotting_function 12 | from systole.utils import input_conversion 13 | 14 | 15 | def plot_frequency( 16 | rr: Union[np.ndarray, list], 17 | input_type: str = "peaks", 18 | fbands: Optional[Dict[str, Tuple[str, Tuple[float, float], str]]] = None, 19 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 20 | backend: str = "matplotlib", 21 | ax: Optional[Axes] = None, 22 | **kwargs 23 | ) -> Union[figure, Axes]: 24 | """Plot power spectral densty of RR time series. 25 | 26 | Parameters 27 | ---------- 28 | rr : 29 | Boolean vector of peaks detection or RR intervals. 30 | input_type : 31 | The type of input vector. Default is `"peaks"` (a boolean vector where 32 | `1` represents the occurrence of R waves or systolic peaks). Can also be 33 | `"rr_s"` or `"rr_ms"` for vectors of RR intervals, or interbeat intervals 34 | (IBI), expressed in seconds or milliseconds (respectively). 35 | fbands : 36 | Dictionary containing the names of the frequency bands of interest (str), their 37 | range (tuples) and their color in the PSD plot. Default is:: 38 | 39 | { 40 | 'vlf': ('Very low frequency', (0.003, 0.04), 'b'), 41 | 'lf': ('Low frequency', (0.04, 0.15), 'g'), 42 | 'hf': ('High frequency', (0.15, 0.4), 'r') 43 | } 44 | 45 | figsize : 46 | Figure size. Default is `(13, 5)`. 47 | ax : 48 | Where to draw the plot. Default is `None` (create a new figure). 49 | backend : 50 | Select plotting backend (`"matplotlib"`, `"bokeh"`). Defaults to `"matplotlib"`. 51 | 52 | Returns 53 | ------- 54 | plot : 55 | The matplotlib axes, or the boken figure containing the plot. 56 | 57 | See also 58 | -------- 59 | plot_events, plot_ectopic, plot_shortlong, plot_subspaces, plot_frequency, 60 | plot_timedomain, plot_nonlinear 61 | 62 | Examples 63 | -------- 64 | 65 | Visualizing HRV frequency domain from RR time series using Matplotlib as plotting 66 | backend. 67 | 68 | .. jupyter-execute:: 69 | 70 | from systole import import_rr 71 | from systole.plots import plot_frequency 72 | # Import PPG recording as numpy array 73 | rr = import_rr().rr.to_numpy() 74 | plot_frequency(rr, input_type="rr_ms") 75 | 76 | Visualizing HRV frequency domain from RR time series using Bokeh as plotting 77 | backend. 78 | 79 | .. jupyter-execute:: 80 | 81 | from systole import import_rr 82 | from systole.plots import plot_frequency 83 | from bokeh.io import output_notebook 84 | from bokeh.plotting import show 85 | output_notebook() 86 | 87 | show( 88 | plot_frequency(rr, input_type="rr_ms", backend="bokeh") 89 | ) 90 | 91 | """ 92 | # Define figure size 93 | if figsize is None: 94 | if backend == "matplotlib": 95 | figsize = (8, 6) 96 | elif backend == "bokeh": 97 | figsize = 600 98 | 99 | if input_type != "rr_ms": 100 | rr = input_conversion(rr, input_type=input_type, output_type="rr_ms") 101 | freq, power = psd(rr) 102 | 103 | # Interpolate PSD line for plotting 104 | f = interp1d(freq, power, kind="cubic") 105 | freq = np.arange(0.003, 0.4, 0.001) 106 | power = f(freq) 107 | 108 | # Clip power to avoid values < 0 before plotting 109 | power = np.clip(power, a_min=0, a_max=None) # type: ignore 110 | 111 | plot_frequency_args = { 112 | "freq": freq, 113 | "power": power, 114 | "figsize": figsize, 115 | "fbands": fbands, 116 | "ax": ax, 117 | } 118 | 119 | plotting_function = get_plotting_function( 120 | "plot_frequency", "plot_frequency", backend 121 | ) 122 | plot = plotting_function(**plot_frequency_args) 123 | 124 | return plot 125 | -------------------------------------------------------------------------------- /systole/plots/plot_poincare.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from bokeh.plotting import figure 7 | from matplotlib.axes import Axes 8 | 9 | from systole.plots.utils import get_plotting_function 10 | 11 | 12 | def plot_poincare( 13 | rr: Union[np.ndarray, list], 14 | input_type: str = "peaks", 15 | figsize: Optional[Union[List[int], Tuple[int, int], int]] = None, 16 | backend: str = "matplotlib", 17 | ax: Optional["Axes"] = None, 18 | ) -> Union[figure, Axes]: 19 | """Poincare plot. 20 | 21 | Parameters 22 | ---------- 23 | rr : 24 | Boolean vector of peaks detection or RR intervals. 25 | input_type : 26 | The type of input vector. Default is `"peaks"` (a boolean vector where 27 | `1` represents the occurrence of R waves or systolic peaks). 28 | Can also be `"rr_s"` or `"rr_ms"` for vectors of RR intervals, or 29 | interbeat intervals (IBI), expressed in seconds or milliseconds 30 | (respectively). 31 | figsize : 32 | Figure size. Default is `(13, 5)`. 33 | backend : 34 | Select plotting backend {"matplotlib", "bokeh"}. Defaults to 35 | "matplotlib". 36 | ax : 37 | Where to draw the plot. Default is `None` (create a new figure). 38 | 39 | Returns 40 | ------- 41 | plot : 42 | The matplotlib axes, or the boken figure containing the plot. 43 | 44 | See also 45 | -------- 46 | plot_frequency 47 | 48 | Examples 49 | -------- 50 | 51 | Visualizing poincare plot from RR time series using Matplotlib as plotting backend. 52 | 53 | .. jupyter-execute:: 54 | 55 | from systole import import_rr 56 | from systole.plots import plot_poincare 57 | 58 | # Import PPG recording as numpy array 59 | rr = import_rr().rr.to_numpy() 60 | 61 | plot_poincare(rr, input_type="rr_ms") 62 | 63 | Using Bokeh backend 64 | 65 | .. jupyter-execute:: 66 | 67 | from bokeh.io import output_notebook 68 | from bokeh.plotting import show 69 | output_notebook() 70 | 71 | from systole import import_rr 72 | from systole.plots import plot_poincare 73 | 74 | show( 75 | plot_poincare(rr, input_type="rr_ms", backend="bokeh") 76 | ) 77 | 78 | """ 79 | 80 | # Define figure size 81 | if figsize is None: 82 | if backend == "matplotlib": 83 | figsize = (6, 6) 84 | elif backend == "bokeh": 85 | figsize = 300 86 | 87 | if input_type == "rr_ms": 88 | rr = np.asarray(rr) 89 | elif input_type == "rr_s": 90 | rr = np.asarray(rr) * 1000 91 | elif input_type == "peaks": 92 | rr = np.diff(np.where(rr)[0]) 93 | 94 | plot_poincare_args = { 95 | "rr": rr, 96 | "figsize": figsize, 97 | "ax": ax, 98 | } 99 | 100 | plotting_function = get_plotting_function("plot_poincare", "plot_poincare", backend) 101 | plot = plotting_function(**plot_poincare_args) 102 | 103 | return plot 104 | -------------------------------------------------------------------------------- /systole/plots/plot_shortlong.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union, overload 4 | 5 | import numpy as np 6 | from bokeh.plotting._figure import figure 7 | from matplotlib.axes import Axes 8 | 9 | from systole.correction import rr_artefacts 10 | from systole.plots.utils import get_plotting_function 11 | from systole.utils import input_conversion 12 | 13 | 14 | @overload 15 | def plot_shortlong( 16 | rr: None, artefacts: Dict[str, np.ndarray], input_type: str = "rr_ms" 17 | ) -> Union[figure, Axes]: 18 | ... 19 | 20 | 21 | @overload 22 | def plot_shortlong( 23 | rr: Union[List[float], np.ndarray], artefacts: None, input_type: str = "rr_ms" 24 | ) -> Union[figure, Axes]: 25 | ... 26 | 27 | 28 | @overload 29 | def plot_shortlong( 30 | rr: Union[List[float], np.ndarray], 31 | artefacts: Dict[str, np.ndarray], 32 | input_type: str = "rr_ms", 33 | ) -> Union[figure, Axes]: 34 | ... 35 | 36 | 37 | def plot_shortlong( 38 | rr=None, 39 | artefacts=None, 40 | input_type: str = "rr_ms", 41 | ax: Optional[Axes] = None, 42 | figsize: Optional[Union[Tuple[float, float], int]] = None, 43 | backend: str = "matplotlib", 44 | ) -> Union[figure, Axes]: 45 | """Visualization of short, long, extra and missed intervals detection. 46 | 47 | The artefact detection is based on the method described in [1]_. 48 | 49 | Parameters 50 | ---------- 51 | rr : 52 | Interval time-series (R-R, beat-to-beat...), in seconds or in 53 | miliseconds. 54 | artefacts : 55 | The artefacts detected using 56 | :py:func:`systole.detection.rr_artefacts()`. 57 | input_type : 58 | The type of input vector. Default is `"peaks"` (a boolean vector where 59 | `1` represents the occurrence of R waves or systolic peaks). 60 | Can also be `"rr_s"` or `"rr_ms"` for vectors of RR intervals, or 61 | interbeat intervals (IBI), expressed in seconds or milliseconds 62 | (respectively). 63 | ax : 64 | Where to draw the plot. Default is *None* (create a new figure). Only 65 | applies when `backend="matplotlib"`. 66 | backend : 67 | Select plotting backend {"matplotlib", "bokeh"}. Defaults to 68 | "matplotlib". 69 | figsize : 70 | Figure size. Default is `(6, 6)` for matplotlib backend, and the height 71 | is `600` when using bokeh backend. 72 | 73 | Returns 74 | ------- 75 | plot : 76 | The matplotlib axes, or the boken figure containing the plot. 77 | 78 | See also 79 | -------- 80 | plot_ectopic, plot_subspaces 81 | 82 | References 83 | ---------- 84 | .. [1] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for heart rate 85 | variability time series artefact correction using novel beat classification. 86 | Journal of Medical Engineering & Technology, 43(3), 173–181. 87 | https://doi.org/10.1080/03091902.2019.1640306 88 | 89 | Notes 90 | ----- 91 | If both `rr` and `artefacts` are provided, the function will drop `artefacts` 92 | and re-evaluate given the current RR time-series. 93 | 94 | Examples 95 | -------- 96 | 97 | Visualizing short/long and missed/extra intervals from a RR time series. 98 | 99 | .. jupyter-execute:: 100 | 101 | from systole import import_rr 102 | from systole.plots import plot_shortlong 103 | 104 | # Import PPG recording as numpy array 105 | rr = import_rr().rr.to_numpy() 106 | 107 | plot_shortlong(rr) 108 | 109 | Visualizing ectopic subspace from the `artefact` dictionary. 110 | 111 | .. jupyter-execute:: 112 | 113 | from systole.detection import rr_artefacts 114 | 115 | # Use the rr_artefacts function to short/long and extra/missed intervals 116 | artefacts = rr_artefacts(rr) 117 | 118 | plot_shortlong(artefacts=artefacts) 119 | 120 | Using Bokeh as plotting backend. 121 | 122 | .. jupyter-execute:: 123 | 124 | from bokeh.io import output_notebook 125 | from bokeh.plotting import show 126 | from systole.detection import rr_artefacts 127 | output_notebook() 128 | 129 | show( 130 | plot_shortlong(artefacts=artefacts, backend="bokeh") 131 | ) 132 | 133 | """ 134 | if figsize is None: 135 | if backend == "matplotlib": 136 | figsize = (6, 6) 137 | elif backend == "bokeh": 138 | figsize = 600 139 | 140 | if artefacts is None: 141 | if rr is None: 142 | raise ValueError("rr or artefacts should be provided") 143 | else: 144 | if input_type != "rr_ms": 145 | rr = input_conversion(rr, input_type=input_type, output_type="rr_ms") 146 | artefacts = rr_artefacts(rr) 147 | 148 | plot_shortlong_args = { 149 | "artefacts": artefacts, 150 | "ax": ax, 151 | "figsize": figsize, 152 | } 153 | 154 | plotting_function = get_plotting_function( 155 | "plot_shortlong", "plot_shortlong", backend 156 | ) 157 | plot = plotting_function(**plot_shortlong_args) 158 | 159 | return plot 160 | -------------------------------------------------------------------------------- /systole/plots/plot_subspaces.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | from typing import Dict, List, Optional, Tuple, Union 4 | 5 | import numpy as np 6 | from bokeh.plotting._figure import figure 7 | from matplotlib.axes import Axes 8 | 9 | from systole.correction import rr_artefacts 10 | from systole.plots.utils import get_plotting_function 11 | from systole.utils import input_conversion 12 | 13 | 14 | def plot_subspaces( 15 | rr: Optional[Union[List[float], np.ndarray]] = None, 16 | artefacts: Optional[Dict[str, np.ndarray]] = None, 17 | input_type: str = "rr_s", 18 | figsize: Optional[Union[Tuple[float, float], int]] = None, 19 | ax: Optional[Union[Tuple, List]] = None, 20 | backend: str = "matplotlib", 21 | ) -> Union[figure, Axes]: 22 | """Visualization of short, long, extra, missed and ectopic beats detection. 23 | 24 | The artefact detection is based on the method described in [1]_. 25 | 26 | Parameters 27 | ---------- 28 | rr : 29 | R-R interval time-series, peaks or peaks index vectors. The default expected 30 | vector is R-R intervals in milliseconds. Other data format can be provided by 31 | specifying the `"input_type"` (can be `"rr_s"`, `"peaks"` or `"peaks_idx"`). 32 | artefacts : 33 | A dictionary containing the infos abount the artefacts detected using the 34 | :py:func:`systole.detection.rr_artefacts()` function. This parameter is 35 | optional, but if provided the data provided in `rr` will be ignored. 36 | input_type : 37 | The type of input vector. Default is `"peaks"` (a boolean vector where 38 | `1` represents the occurrence of R waves or systolic peaks). 39 | Can also be `"rr_s"` or `"rr_ms"` for vectors of RR intervals, or 40 | interbeat intervals (IBI), expressed in seconds or milliseconds 41 | (respectively). 42 | figsize : 43 | Figure size. Default is `(12, 6)` for matplotlib backend, and the height is 44 | `600` when using bokeh backend. 45 | ax : 46 | Where to draw the plot. Default is `None` (create a new figure). Otherwise, a 47 | tuple of list of Matplotlib axes should be provided. Only applies if 48 | `backend="matplotlib"`. 49 | backend : 50 | Select plotting backend {"matplotlib", "bokeh"}. Defaults to "matplotlib". 51 | 52 | Returns 53 | ------- 54 | plot : 55 | The matplotlib axes, or the boken figure containing the plot. 56 | 57 | See also 58 | -------- 59 | plot_events, plot_ectopic, plot_shortlong, plot_subspaces, plot_frequency, 60 | plot_timedomain, plot_nonlinear 61 | 62 | References 63 | ---------- 64 | .. [1] Lipponen, J. A., & Tarvainen, M. P. (2019). A robust algorithm for 65 | heart rate variability time series artefact correction using novel beat 66 | classification. Journal of Medical Engineering & Technology, 43(3), 67 | 173–181. https://doi.org/10.1080/03091902.2019.1640306 68 | 69 | Examples 70 | -------- 71 | 72 | Visualizing artefacts from RR time series. 73 | 74 | .. jupyter-execute:: 75 | 76 | from systole import import_rr 77 | from systole.plots import plot_subspaces 78 | import matplotlib.pyplot as plt 79 | 80 | # Import PPG recording as numpy array 81 | rr = import_rr().rr.to_numpy() 82 | 83 | _, axs = plt.subplots(ncols=2, figsize=(12, 6)) 84 | plot_subspaces(rr, ax=axs) 85 | 86 | Visualizing artefacts from the `artefact` dictionary. 87 | 88 | .. jupyter-execute:: 89 | 90 | from systole.detection import rr_artefacts 91 | 92 | # Use the rr_artefacts function to short/long and extra/missed intervals 93 | artefacts = rr_artefacts(rr) 94 | 95 | _, axs = plt.subplots(ncols=2, figsize=(12, 6)) 96 | plot_subspaces(artefacts=artefacts, ax=axs) 97 | 98 | Using Bokeh as plotting backend. 99 | 100 | .. jupyter-execute:: 101 | 102 | from bokeh.io import output_notebook 103 | from bokeh.plotting import show 104 | from systole.detection import rr_artefacts 105 | output_notebook() 106 | 107 | show( 108 | plot_subspaces( 109 | artefacts=artefacts, backend="bokeh", figsize=400 110 | ) 111 | ) 112 | 113 | """ 114 | if figsize is None: 115 | if backend == "matplotlib": 116 | figsize = (12, 6) 117 | elif backend == "bokeh": 118 | figsize = 600 119 | 120 | if (artefacts is not None) & (rr is not None): 121 | raise ValueError("Both `artefacts` and `rr` are provided.") 122 | 123 | if artefacts is None: 124 | if rr is None: 125 | raise ValueError("rr or artefacts should be provided") 126 | else: 127 | if input_type != "rr_ms": 128 | rr = input_conversion(rr, input_type=input_type, output_type="rr_ms") 129 | artefacts = rr_artefacts(rr) 130 | 131 | plot_subspaces_args = {"artefacts": artefacts, "figsize": figsize, "ax": ax} 132 | 133 | plotting_function = get_plotting_function( 134 | "plot_subspaces", "plot_subspaces", backend 135 | ) 136 | plot = plotting_function(**plot_subspaces_args) 137 | 138 | return plot 139 | -------------------------------------------------------------------------------- /systole/plots/utils.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | import importlib 4 | 5 | from packaging.version import parse 6 | 7 | 8 | def get_plotting_function(plot_name, plot_module, backend="matplotlib"): 9 | """Return plotting function for correct backend. 10 | 11 | Inspired by Arviz'backend management. 12 | """ 13 | 14 | _backend = { 15 | "mpl": "matplotlib", 16 | "bokeh": "bokeh", 17 | "matplotlib": "matplotlib", 18 | } 19 | 20 | backend = backend.lower() 21 | 22 | try: 23 | backend = _backend[backend] 24 | except KeyError as err: 25 | raise KeyError( 26 | "Backend {} is not implemented. Try backend in {}".format( 27 | backend, set(_backend.values()) 28 | ) 29 | ) from err 30 | 31 | if backend == "bokeh": 32 | try: 33 | import bokeh 34 | 35 | assert parse(bokeh.__version__) >= parse("1.4.0") 36 | 37 | except (ImportError, AssertionError) as err: 38 | raise ImportError( 39 | "'bokeh' backend needs Bokeh (1.4.0+) installed." 40 | " Please upgrade or install" 41 | ) from err 42 | 43 | # Perform import of plotting method 44 | module = importlib.import_module( 45 | "systole.plots.backends.{backend}.{plot_module}".format( 46 | backend=backend, plot_module=plot_module 47 | ) 48 | ) 49 | 50 | plotting_method = getattr(module, plot_name) 51 | 52 | return plotting_method 53 | -------------------------------------------------------------------------------- /systole/reports/__init__.py: -------------------------------------------------------------------------------- 1 | from .command_line import wrapper 2 | from .group_level import ( 3 | frequency_domain_group_level, 4 | nonlinear_domain_group_level, 5 | time_domain_group_level, 6 | ) 7 | from .subject_level import subject_level_report 8 | from .tables import frequency_table, nonlinear_table, time_table 9 | from .utils import create_reports, import_data 10 | 11 | __all__ = [ 12 | "wrapper", 13 | "import_data", 14 | "create_reports", 15 | "time_domain_group_level", 16 | "frequency_domain_group_level", 17 | "nonlinear_domain_group_level", 18 | "subject_level_report", 19 | "time_table", 20 | "frequency_table", 21 | "nonlinear_table", 22 | ] 23 | -------------------------------------------------------------------------------- /systole/reports/group_level.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ resources }} 7 | {{ script }} 8 | 14 | 15 | 16 | 47 | 48 |
49 |
50 |
51 |

Group-level quality reports

52 |
This report was generated using Systole v.{{systole_version}}. If you use this package for research, please cite as:
53 | Legrand, N., & Allen, M. (2022). Systole: A python package for cardiac signal synchrony and analysis. In Journal of Open Source Software (Vol. 7, Issue 69, p. 3832). The Open Journal. https://doi.org/10.21105/joss.03832 54 |
55 |
56 | 57 | {% if show_ecg %} 58 | 59 | 60 |
61 |
62 |

Electrocardiography (ECG)

63 |

Time-domain metrics

64 |
65 | {{ div["time_domain"] }} 66 |
67 |

Frequency-domain metrics

68 |
69 | {{ div["frequency_domain"] }} 70 |
71 |

Nonlinear-domain metrics

72 |
73 | {{ div["nonlinear_domain"] }} 74 |
75 |

Artefacts

76 |
77 | {{ div["artefacts"] }} 78 |
79 |
80 |
81 | 82 | {% endif %} 83 | 84 | 85 |
86 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/embodied-computation-group/systole/f5d7fb7369505ae059cd1f0584930e2f864e2bc8/tests/__init__.py -------------------------------------------------------------------------------- /tests/group_level_ses-session1_task-hrd.tsv: -------------------------------------------------------------------------------- 1 | Values Metric participant_id task modality hrv_domain 2 | 4288 n_beats sub-0001 hrd ecg artefacts 3 | 0 n_missed sub-0001 hrd ecg artefacts 4 | 0 per_miseed sub-0001 hrd ecg artefacts 5 | 58 n_long sub-0001 hrd ecg artefacts 6 | 1.35261194 per_long sub-0001 hrd ecg artefacts 7 | 0 n_extra sub-0001 hrd ecg artefacts 8 | 0 per_extra sub-0001 hrd ecg artefacts 9 | 12 n_short sub-0001 hrd ecg artefacts 10 | 0.279850746 per_short sub-0001 hrd ecg artefacts 11 | 7 n_ectopics sub-0001 hrd ecg artefacts 12 | 0.163246269 per_ectopics sub-0001 hrd ecg artefacts 13 | 77 n_artefacts sub-0001 hrd ecg artefacts 14 | 1.795708955 per_artefacts sub-0001 hrd ecg artefacts 15 | 943.042687 MeanRR sub-0001 hrd ecg time_domain 16 | 64.129378 MeanBPM sub-0001 hrd ecg time_domain 17 | 944 MedianRR sub-0001 hrd ecg time_domain 18 | 63.559322 MedianBPM sub-0001 hrd ecg time_domain 19 | 623 MinRR sub-0001 hrd ecg time_domain 20 | 46.082949 MinBPM sub-0001 hrd ecg time_domain 21 | 1302 MaxRR sub-0001 hrd ecg time_domain 22 | 96.308186 MaxBPM sub-0001 hrd ecg time_domain 23 | 82.892637 SDNN sub-0001 hrd ecg time_domain 24 | 62.411542 SDSD sub-0001 hrd ecg time_domain 25 | 62.404279 RMSSD sub-0001 hrd ecg time_domain 26 | 1250 nn50 sub-0001 hrd ecg time_domain 27 | 29.164722 pnn50 sub-0001 hrd ecg time_domain 28 | 0.007812 vlf_peak sub-0001 hrd ecg frequency_domain 29 | 2064.385836 vlf_power sub-0001 hrd ecg frequency_domain 30 | 0.050781 lf_peak sub-0001 hrd ecg frequency_domain 31 | 2415.216319 lf_power sub-0001 hrd ecg frequency_domain 32 | 0.152344 hf_peak sub-0001 hrd ecg frequency_domain 33 | 1091.865514 hf_power sub-0001 hrd ecg frequency_domain 34 | 37.05281909 vlf_power_per sub-0001 hrd ecg frequency_domain 35 | 43.34973229 lf_power_per sub-0001 hrd ecg frequency_domain 36 | 19.59744862 hf_power_per sub-0001 hrd ecg frequency_domain 37 | 68.86683671 lf_power_nu sub-0001 hrd ecg frequency_domain 38 | 31.13316329 hf_power_nu sub-0001 hrd ecg frequency_domain 39 | 5571.467669 total_power sub-0001 hrd ecg frequency_domain 40 | 2.212008978 lf_hf_ratio sub-0001 hrd ecg frequency_domain 41 | 44.1264757 SD1 sub-0001 hrd ecg nonlinear_domain 42 | 108.5911004 SD2 sub-0001 hrd ecg nonlinear_domain 43 | 38.42782803 recurrence_rate sub-0001 hrd ecg nonlinear_domain 44 | 635 l_max sub-0001 hrd ecg nonlinear_domain 45 | 17.73612857 l_mean sub-0001 hrd ecg nonlinear_domain 46 | 80.36609306 determinism_rate sub-0001 hrd ecg nonlinear_domain 47 | 3.656676272 shannon_entropy sub-0001 hrd ecg nonlinear_domain 48 | 5307 n_beats sub-0002 hrd ecg artefacts 49 | 1 n_missed sub-0002 hrd ecg artefacts 50 | 0.018843037 per_miseed sub-0002 hrd ecg artefacts 51 | 107 n_long sub-0002 hrd ecg artefacts 52 | 2.016205012 per_long sub-0002 hrd ecg artefacts 53 | 1 n_extra sub-0002 hrd ecg artefacts 54 | 0.018843037 per_extra sub-0002 hrd ecg artefacts 55 | 8 n_short sub-0002 hrd ecg artefacts 56 | 0.1507443 per_short sub-0002 hrd ecg artefacts 57 | 22 n_ectopics sub-0002 hrd ecg artefacts 58 | 0.414546825 per_ectopics sub-0002 hrd ecg artefacts 59 | 139 n_artefacts sub-0002 hrd ecg artefacts 60 | 2.619182212 per_artefacts sub-0002 hrd ecg artefacts 61 | 694.225028 MeanRR sub-0002 hrd ecg time_domain 62 | 86.947581 MeanBPM sub-0002 hrd ecg time_domain 63 | 689 MedianRR sub-0002 hrd ecg time_domain 64 | 87.082729 MedianBPM sub-0002 hrd ecg time_domain 65 | 205 MinRR sub-0002 hrd ecg time_domain 66 | 46.332046 MinBPM sub-0002 hrd ecg time_domain 67 | 1295 MaxRR sub-0002 hrd ecg time_domain 68 | 292.682927 MaxBPM sub-0002 hrd ecg time_domain 69 | 55.236005 SDNN sub-0002 hrd ecg time_domain 70 | 36.554464 SDSD sub-0002 hrd ecg time_domain 71 | 36.551019 RMSSD sub-0002 hrd ecg time_domain 72 | 378 nn50 sub-0002 hrd ecg time_domain 73 | 7.125353 pnn50 sub-0002 hrd ecg time_domain 74 | 0.007812 vlf_peak sub-0002 hrd ecg frequency_domain 75 | 746.2596336 vlf_power sub-0002 hrd ecg frequency_domain 76 | 0.050781 lf_peak sub-0002 hrd ecg frequency_domain 77 | 850.7009118 lf_power sub-0002 hrd ecg frequency_domain 78 | 0.210938 hf_peak sub-0002 hrd ecg frequency_domain 79 | 530.6167755 hf_power sub-0002 hrd ecg frequency_domain 80 | 35.07555877 vlf_power_per sub-0002 hrd ecg frequency_domain 81 | 39.98448862 lf_power_per sub-0002 hrd ecg frequency_domain 82 | 24.93995261 hf_power_per sub-0002 hrd ecg frequency_domain 83 | 61.58618829 lf_power_nu sub-0002 hrd ecg frequency_domain 84 | 38.41381171 hf_power_nu sub-0002 hrd ecg frequency_domain 85 | 2127.577321 total_power sub-0002 hrd ecg frequency_domain 86 | 1.603230337 lf_hf_ratio sub-0002 hrd ecg frequency_domain 87 | 25.84547329 SD1 sub-0002 hrd ecg nonlinear_domain 88 | 73.70816798 SD2 sub-0002 hrd ecg nonlinear_domain 89 | 49.67275412 recurrence_rate sub-0002 hrd ecg nonlinear_domain 90 | 920 l_max sub-0002 hrd ecg nonlinear_domain 91 | 23.57782381 l_mean sub-0002 hrd ecg nonlinear_domain 92 | 84.68337539 determinism_rate sub-0002 hrd ecg nonlinear_domain 93 | 4.019278841 shannon_entropy sub-0002 hrd ecg nonlinear_domain 94 | -------------------------------------------------------------------------------- /tests/test_detection.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | import unittest 4 | from unittest import TestCase 5 | 6 | import numpy as np 7 | 8 | from systole import import_dataset1, import_ppg 9 | from systole.detection import ppg_peaks, ecg_peaks, rsp_peaks, interpolate_clipping, rr_artefacts 10 | 11 | 12 | class TestDetection(TestCase): 13 | 14 | def test_ppg_peaks(self): 15 | """Test ppg_peaks function""" 16 | ppg = import_ppg().ppg.to_numpy() # Import PPG recording 17 | rolling_average_signal, rolling_average_peaks = ppg_peaks(ppg, sfreq=75, method="rolling_average") 18 | msptd_signal, msptd_peaks = ppg_peaks(ppg, sfreq=75, method="msptd") 19 | 20 | assert np.all(rolling_average_signal == msptd_signal) 21 | 22 | # mean RR intervals 23 | assert np.isclose(np.diff(np.where(rolling_average_peaks)[0]).mean(), 874.2068965517242) 24 | assert np.isclose(np.diff(np.where(msptd_peaks)[0]).mean(), 867.3105263157895) 25 | 26 | # with nan removal and clipping correction 27 | rolling_average_signal2, rolling_average_peaks2 = ppg_peaks( 28 | ppg, clipping_thresholds=(0, 255), clean_nan=True, sfreq=75 29 | ) 30 | assert (rolling_average_signal == rolling_average_signal2).all() 31 | assert np.isclose(np.diff(np.where(rolling_average_peaks2)[0]).mean(), 874.2068965517242) 32 | 33 | def test_ecg_peaks(self): 34 | signal_df = import_dataset1(modalities=["ECG"])[: 20 * 2000] 35 | for method in [ 36 | "sleepecg", 37 | "christov", 38 | "engelse-zeelenberg", 39 | "hamilton", 40 | "pan-tompkins", 41 | "moving-average", 42 | ]: 43 | _, peaks = ecg_peaks( 44 | signal_df.ecg, method=method, sfreq=2000, find_local=True 45 | ) 46 | assert not np.any(peaks > 1) 47 | 48 | with self.assertRaises(ValueError): 49 | _, peaks = ecg_peaks( 50 | signal_df.ecg.to_numpy(), method="error", sfreq=2000, find_local=True 51 | ) 52 | 53 | def test_resp_peaks(self): 54 | """Test resp_peaks function""" 55 | # Import respiration recording 56 | resp = import_dataset1(modalities=["Respiration"])[: 200 * 2000].respiration.to_numpy() 57 | 58 | rolling_average_signal, _ = rsp_peaks( 59 | resp, sfreq=2000, kind="peaks", method="rolling_average" 60 | ) 61 | msptd_signal, _ = rsp_peaks( 62 | resp, sfreq=2000, kind="peaks", method="msptd" 63 | ) 64 | 65 | assert np.all(rolling_average_signal == msptd_signal) 66 | 67 | # with nan removal 68 | rolling_average_signal2, _ = rsp_peaks( 69 | resp, clean_nan=True, sfreq=2000, kind="peaks", method="rolling_average" 70 | ) 71 | assert (rolling_average_signal == rolling_average_signal2).all() 72 | 73 | def test_rr_artefacts(self): 74 | ppg = import_ppg().ppg.to_numpy() 75 | _, peaks = ppg_peaks(ppg, sfreq=75) 76 | rr_ms = np.diff(np.where(peaks)[0]) 77 | artefacts_ms = rr_artefacts(rr_ms, input_type="rr_ms") 78 | artefacts_peaks = rr_artefacts(peaks, input_type="peaks") 79 | assert all( 80 | 377 == x for x in [len(artefacts_ms[k]) for k in artefacts_ms.keys()] 81 | ) 82 | assert all( 83 | 377 == x for x in [len(artefacts_peaks[k]) for k in artefacts_peaks.keys()] 84 | ) 85 | 86 | def test_interpolate_clipping(self): 87 | df = import_ppg() 88 | clean_signal = interpolate_clipping(df.ppg.to_numpy()) 89 | assert clean_signal.mean().round() == 100 90 | clean_signal = interpolate_clipping(list(df.ppg.to_numpy())) 91 | assert clean_signal.mean().round() == 100 92 | clean_signal = interpolate_clipping(df.ppg.to_numpy()) 93 | 94 | # Test with out of bound values as first and last 95 | df.ppg.iloc[0], df.ppg.iloc[-1] = 255, 255 96 | clean_signal = interpolate_clipping(df.ppg.to_numpy()) 97 | df.ppg.iloc[0], df.ppg.iloc[-1] = 0, 0 98 | clean_signal = interpolate_clipping(df.ppg.to_numpy()) 99 | 100 | 101 | if __name__ == "__main__": 102 | unittest.main(argv=["first-arg-is-ignored"], exit=False) 103 | -------------------------------------------------------------------------------- /tests/test_detectors.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | import unittest 4 | from unittest import TestCase 5 | 6 | from systole import import_ppg 7 | from systole.detectors import msptd 8 | 9 | 10 | class TestDetectors(TestCase): 11 | def test_msptd(self): 12 | """Test msptd function""" 13 | ppg = import_ppg().ppg.to_numpy() 14 | peaks = msptd(signal=ppg, sfreq=75, kind="peaks") 15 | onsets = msptd(signal=ppg, sfreq=75, kind="onsets") 16 | peaks_onsets = msptd(signal=ppg, sfreq=75, kind="peaks-onsets") 17 | assert (peaks_onsets[0] == peaks).all() 18 | assert (peaks_onsets[1] == onsets).all() 19 | 20 | 21 | if __name__ == "__main__": 22 | unittest.main(argv=["first-arg-is-ignored"], exit=False) 23 | -------------------------------------------------------------------------------- /tests/test_reports.py: -------------------------------------------------------------------------------- 1 | # Author: Nicolas Legrand 2 | 3 | import os 4 | import shutil 5 | import unittest 6 | from unittest import TestCase 7 | 8 | import matplotlib.pyplot as plt 9 | import pandas as pd 10 | from bokeh.models import Column 11 | 12 | from systole import import_dataset1, import_rr 13 | from systole.hrv import frequency_domain, nonlinear_domain, time_domain 14 | from systole.reports import frequency_table, nonlinear_table, time_table 15 | from systole.reports.group_level import ( 16 | artefacts_group_level, 17 | frequency_domain_group_level, 18 | nonlinear_domain_group_level, 19 | time_domain_group_level, 20 | ) 21 | from systole.reports.subject_level import subject_level_report 22 | 23 | 24 | class TestReports(TestCase): 25 | def test_subject_level(self): 26 | """Test the subject-level reports""" 27 | 28 | ####### 29 | # ECG # 30 | ####### 31 | ecg = import_dataset1(modalities=["ECG"]).ecg.to_numpy() 32 | 33 | subject_level_report( 34 | participant_id="participant_test", 35 | pattern="task_test", 36 | modality="beh", 37 | result_folder="./", 38 | session="session_test", 39 | ecg=ecg, 40 | ecg_sfreq=1000, 41 | ) 42 | 43 | shutil.rmtree("./participant_test") 44 | 45 | def test_group_level(self): 46 | """Test the group-level reports""" 47 | 48 | summary_df = pd.read_csv( 49 | os.path.dirname(__file__) + "/group_level_ses-session1_task-hrd.tsv", 50 | sep="\t", 51 | ) 52 | 53 | time_domain_group_level(summary_df) 54 | frequency_domain_group_level(summary_df) 55 | nonlinear_domain_group_level(summary_df) 56 | artefacts_group_level(summary_df) 57 | 58 | def test_time_table(self): 59 | """Test the time_table function""" 60 | rr = import_rr().rr 61 | time_df = time_domain(rr, input_type="rr_ms") 62 | 63 | # With a df as input 64 | table_df = time_table(time_df=time_df, backend="tabulate") 65 | assert isinstance(table_df, str) 66 | 67 | table = time_table(time_df=time_df, backend="bokeh") 68 | assert isinstance(table, Column) 69 | 70 | # With RR intervals as inputs 71 | table_rr = time_table(rr=rr, backend="tabulate") 72 | assert isinstance(table_rr, str) 73 | 74 | table = time_table(rr=rr, backend="bokeh") 75 | assert isinstance(table, Column) 76 | 77 | # Check for consistency between methods 78 | assert table_rr == table_df 79 | 80 | plt.close("all") 81 | 82 | def test_frequency_table(self): 83 | """Test frequency_table function""" 84 | rr = import_rr().rr 85 | frequency_df = frequency_domain(rr, input_type="rr_ms") 86 | 87 | # With a df as input 88 | table_df = frequency_table(frequency_df=frequency_df, backend="tabulate") 89 | assert isinstance(table_df, str) 90 | 91 | table = frequency_table(frequency_df=frequency_df, backend="bokeh") 92 | assert isinstance(table, Column) 93 | 94 | # With RR intervals as inputs 95 | table_rr = frequency_table(rr=rr, backend="tabulate") 96 | assert isinstance(table_rr, str) 97 | 98 | table = frequency_table(rr=rr, backend="bokeh") 99 | assert isinstance(table, Column) 100 | 101 | # Check for consistency between methods 102 | assert table_rr == table_df 103 | 104 | plt.close("all") 105 | 106 | def test_nonlinear_table(self): 107 | """Test nonlinear_table function""" 108 | rr = import_rr().rr 109 | nonlinear_df = nonlinear_domain(rr, input_type="rr_ms") 110 | 111 | # With a df as input 112 | table_df = nonlinear_table(nonlinear_df=nonlinear_df, backend="tabulate") 113 | assert isinstance(table_df, str) 114 | 115 | table = nonlinear_table(nonlinear_df=nonlinear_df, backend="bokeh") 116 | assert isinstance(table, Column) 117 | 118 | # With RR intervals as inputs 119 | table_rr = nonlinear_table(rr=rr, backend="tabulate") 120 | assert isinstance(table_rr, str) 121 | 122 | table = nonlinear_table(rr=rr, backend="bokeh") 123 | assert isinstance(table, Column) 124 | 125 | # Check for consistency between methods 126 | assert table_rr == table_df 127 | 128 | plt.close("all") 129 | 130 | 131 | if __name__ == "__main__": 132 | unittest.main(argv=["first-arg-is-ignored"], exit=False) 133 | --------------------------------------------------------------------------------