diff --git a/.all-contributorsrc b/.all-contributorsrc index 46f9a58..17ea5fe 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -15,6 +15,15 @@ "ideas" ] }, + { + "login": "ineschh", + "name": "Inés Chavarría", + "avatar_url": "https://avatars.githubusercontent.com/u/72545702?v=4", + "profile": "https://github.com/ineschh", + "contributions": [ + "infra" + ] + }, { "login": "eril892", "name": "Raphaël Fournier", @@ -26,6 +35,20 @@ "doc" ] }, + { + "login": "maestroque", + "name": "George Kikas", + "avatar_url": "https://avatars.githubusercontent.com/u/74024609?v=4", + "profile": "https://github.com/maestroque", + "contributions": [ + "code", + "ideas", + "infra", + "bug", + "test", + "review" + ] + }, { "login": "TomasLenc", "name": "Tomas Lenc", @@ -56,6 +79,16 @@ "mentoring" ] }, + { + "login": "m-miedema", + "name": "m-miedema", + "avatar_url": "https://avatars.githubusercontent.com/u/39968233?v=4", + "profile": "https://github.com/m-miedema", + "contributions": [ + "review", + "mentoring" + ] + }, { "login": "smoia", "name": "Stefano Moia", @@ -80,6 +113,16 @@ "code" ] }, + { + "login": "me-pic", + "name": "Marie-Eve Picard", + "avatar_url": "https://avatars.githubusercontent.com/u/77584086?v=4", + "profile": "https://github.com/me-pic", + "contributions": [ + "review", + "infra" + ] + }, { "login": "tsalo", "name": "Taylor Salo", @@ -92,12 +135,13 @@ ] }, { - "login": "ineschh", - "name": "Inés Chavarría", - "avatar_url": "https://avatars.githubusercontent.com/u/72545702?v=4", - "profile": "https://github.com/ineschh", + "login": "RayStick", + "name": "Rachael Stickland", + "avatar_url": "https://avatars.githubusercontent.com/u/50215726?v=4", + "profile": "https://github.com/RayStick", "contributions": [ - "infra" + "infra", + "doc" ] }, { diff --git a/.circleci/config.yml b/.circleci/config.yml index fc36d30..dd45175 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -15,14 +15,14 @@ orbs: # Define a job to be invoked later in a workflow. # See: https://circleci.com/docs/2.0/configuration-reference/#jobs jobs: - test37: # This is the name of the job, feel free to change it to better match what you're trying to do! + test39: # This is the name of the job, feel free to change it to better match what you're trying to do! # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/ # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python # The executor is the environment in which the steps below will be executed - below will use a python 3.6.14 container # Change the version below to your required version of python docker: - - image: cimg/python:3.7 + - image: cimg/python:3.9 working_directory: /tmp/src/phys2denoise resource_class: medium # Checkout the code as the first step. This is a dedicated CircleCI step. @@ -46,7 +46,7 @@ jobs: command: | pytest --cov=./phys2denoise mkdir /tmp/src/coverage - mv ./.coverage /tmp/src/coverage/.coverage.py37 + mv ./.coverage /tmp/src/coverage/.coverage.py39 - store_artifacts: path: /tmp/src/coverage # Persist the specified paths (workspace/echo-output) into the workspace for use in downstream job. @@ -56,11 +56,11 @@ jobs: root: /tmp # Must be relative path from root paths: - - src/coverage/.coverage.py37 + - src/coverage/.coverage.py39 - test310: + test311: docker: - - image: cimg/python:3.10 + - image: cimg/python:3.11 working_directory: /tmp/src/phys2denoise resource_class: medium steps: @@ -75,17 +75,17 @@ jobs: command: | pytest --cov=./phys2denoise mkdir /tmp/src/coverage - mv ./.coverage /tmp/src/coverage/.coverage.py310 + mv ./.coverage /tmp/src/coverage/.coverage.py311 - store_artifacts: path: /tmp/src/coverage - persist_to_workspace: root: /tmp paths: - - src/coverage/.coverage.py310 + - src/coverage/.coverage.py311 style_check: docker: - - image: cimg/python:3.7 + - image: cimg/python:3.9 working_directory: /tmp/src/phys2denoise resource_class: small steps: @@ -105,7 +105,7 @@ jobs: merge_coverage: working_directory: /tmp/src/phys2denoise docker: - - image: cimg/python:3.10 + - image: cimg/python:3.11 resource_class: small steps: - attach_workspace: @@ -133,13 +133,13 @@ workflows: # Inside the workflow, you define the jobs you want to run. jobs: - style_check - - test37: + - test39: requires: - style_check - - test310: + - test311: requires: - style_check - merge_coverage: requires: - - test37 - - test310 + - test39 + - test311 diff --git a/.readthedocs.yml b/.readthedocs.yml index bc99c2a..86e7464 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -20,4 +20,3 @@ python: path: . extra_requirements: - doc - system_packages: true diff --git a/.zenodo.json b/.zenodo.json index 54425df..99bdb75 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -5,17 +5,255 @@ "access_right":"open", "creators": [ { - "affiliation": "BCBL - Basque Center on Cognition, Brain and Language", - "name": "C\u00e9sar Caballero Gaudes" + "affiliations": [ + { + "name": "University of Southern California" + } + ], + "person_or_org": { + "family_name": "Bottenhorn", + "given_name": "Katherine", + "identifiers": [ + { + "identifier": "0000-0002-7796-8795", + "scheme": "orcid" + } + ], + "name": "Bottenhorn, Katherine", + "type": "personal" + } }, { - "affiliation": "McGill University", - "name": "Ross Markello" + "affiliations": [ + { + "name": "BCBL - Basque Center on Cognition, Brain and Language" + } + ], + "person_or_org": { + "family_name": "Caballero Gaudes", + "given_name": "C\u00e9sar", + "identifiers": [ + { + "identifier": "0000-0002-9068-5810", + "scheme": "orcid" + } + ], + "name": "Caballero Gaudes, C\u00e9sar", + "type": "personal" + } }, { - "orcid": "0000-0002-2553-3327", - "affiliation": "BCBL - Basque Center on Cognition, Brain and Language", - "name": "Stefano Moia" + "affiliations": [ + { + "id": "01a28zg77", + "name": "Basque Center on Cognition, Brain and Language" + } + ], + "person_or_org": { + "family_name": "Chavarria", + "given_name": "In\u00e9s", + "name": "Chavarria, In\u00e9s", + "type": "personal" + } + }, + { + "affiliations": [ + { + "id": "05a28rw58", + "name": "ETH Zurich" + } + ], + "person_or_org": { + "family_name": "Fournier", + "given_name": "Rapha\u00ebl", + "name": "Fournier, Rapha\u00ebl", + "type": "personal" + } + }, + { + "affiliations": [ + { + "id": "02j61yw88", + "name": "Aristotle University of Thessaloniki" + } + ], + "person_or_org": { + "family_name": "Kikas", + "given_name": "Georgios", + "name": "Kikas, Georgios", + "type": "personal" + } + }, + { + "affiliations": [ + { + "name": "Universit\u00e9 catholique de Louvain" + } + ], + "person_or_org": { + "family_name": "Lenc", + "given_name": "Tomas", + "identifiers": [ + { + "identifier": "0000-0001-5796-1388", + "scheme": "orcid" + } + ], + "name": "Lenc, Tomas", + "type": "personal" + } + }, + { + "person_or_org": { + "family_name": "Lim", + "given_name": "Ryan", + "name": "Lim, Ryan", + "type": "personal" + } + }, + { + "affiliations": [ + { + "name": "McGill University" + } + ], + "person_or_org": { + "family_name": "Markello", + "given_name": "Ross", + "identifiers": [ + { + "identifier": "0000-0003-1057-1336", + "scheme": "orcid" + } + ], + "name": "Markello, Ross", + "type": "personal" + } + }, + { + "affiliations": [ + { + "id": "01pxwe438", + "name": "McGill University" + } + ], + "person_or_org": { + "family_name": "Miedema", + "given_name": "Mary", + "identifiers": [ + { + "identifier": "0009-0008-3263-731X", + "scheme": "orcid" + } + ], + "name": "Miedema, Mary", + "type": "personal" + } + }, + { + "affiliations": [ + { + "id": "02jz4aj89", + "name": "Maastricht University" + } + ], + "person_or_org": { + "family_name": "Moia", + "given_name": "Stefano", + "identifiers": [ + { + "identifier": "0000-0002-2553-3327", + "scheme": "orcid" + } + ], + "name": "Moia, Stefano", + "type": "personal" + } + }, + { + "person_or_org": { + "family_name": "Patin", + "given_name": "Alice", + "name": "Patin, Alice", + "type": "personal" + } + }, + { + "affiliations": [ + { + "name": "Universit\u00e9 de Montr\u00e9al" + } + ], + "person_or_org": { + "family_name": "Picard", + "given_name": "Marie-Eve", + "identifiers": [ + { + "identifier": "0009-0001-2412-7829", + "scheme": "orcid" + } + ], + "name": "Picard, Marie-Eve", + "type": "personal" + } + }, + { + "affiliations": [ + { + "name": "University of Pennsylvania" + } + ], + "person_or_org": { + "family_name": "Salo", + "given_name": "Taylor", + "identifiers": [ + { + "identifier": "0000-0001-9813-3167", + "scheme": "orcid" + } + ], + "name": "Salo, Taylor", + "type": "personal" + } + }, + { + "affiliations": [ + { + "name": "Northwestern University" + } + ], + "person_or_org": { + "family_name": "Stickland", + "given_name": "Rachael", + "identifiers": [ + { + "identifier": "0000-0003-3398-4272", + "scheme": "orcid" + } + ], + "name": "Stickland, Rachael", + "type": "personal" + } + }, + { + "affiliations": [ + { + "id": "00m6w7z96", + "name": "Northwestern University" + } + ], + "person_or_org": { + "family_name": "Zvolanek", + "given_name": "Kristina", + "identifiers": [ + { + "identifier": "0000-0003-2239-7902", + "scheme": "orcid" + } + ], + "name": "Zvolanek, Kristina", + "type": "personal" + } } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c3a8d..9819170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +# 0.5.0 (Sun Aug 25 2024) + +:tada: This release contains work from new contributors! :tada: + +Thanks for all your work! + +:heart: George Kikas ([@maestroque](https://github.com/maestroque)) + +:heart: Marie-Eve Picard ([@me-pic](https://github.com/me-pic)) + +#### 💥 Breaking Change during development + +- Release Fix [#63](https://github.com/physiopy/phys2denoise/pull/63) ([@maestroque](https://github.com/maestroque)) +- Integrate physutils - Physio object usage [#54](https://github.com/physiopy/phys2denoise/pull/54) ([@maestroque](https://github.com/maestroque)) + +#### 📝 Documentation + +- Fix readthedocs config [#61](https://github.com/physiopy/phys2denoise/pull/61) ([@me-pic](https://github.com/me-pic)) +- Generate docs [#56](https://github.com/physiopy/phys2denoise/pull/56) ([@maestroque](https://github.com/maestroque) [@me-pic](https://github.com/me-pic) [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot])) + +#### 🏠 Internal + +- Update description in setup [#59](https://github.com/physiopy/phys2denoise/pull/59) ([@me-pic](https://github.com/me-pic)) +- Add auto-author and auto-label to github workflow [#52](https://github.com/physiopy/phys2denoise/pull/52) ([@RayStick](https://github.com/RayStick)) + +#### Authors: 4 + +- [@pre-commit-ci[bot]](https://github.com/pre-commit-ci[bot]) +- George Kikas ([@maestroque](https://github.com/maestroque)) +- Marie-Eve Picard ([@me-pic](https://github.com/me-pic)) +- Rachael Stickland ([@RayStick](https://github.com/RayStick)) + +--- + # 0.4.0 (Mon Jul 15 2024) :tada: This release contains work from a new contributor! :tada: diff --git a/README.md b/README.md index 88ba380..dcdb208 100644 --- a/README.md +++ b/README.md @@ -26,20 +26,26 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + +

Cesar Caballero Gaudes

💻 🤔

Raphaël Fournier

💻 📓 📖

Tomas Lenc

🐛 ⚠️

rzlim08

💻 ⚠️

Ross Markello

🚇 🧑‍🏫

Stefano Moia

🔣 🚇 📆 💻 🤔 📖 🧑‍🏫

Alice Patin

💻

Taylor Salo

💻 🤔 👀

Inés Chavarría

🚇

Kristina Zvolanek

🚇 👀 📓
Cesar Caballero Gaudes
Cesar Caballero Gaudes

💻 🤔
Inés Chavarría
Inés Chavarría

🚇
Raphaël Fournier
Raphaël Fournier

💻 📓 📖
George Kikas
George Kikas

💻 🤔 🚇 🐛 ⚠️ 👀
Tomas Lenc
Tomas Lenc

🐛 ⚠️
rzlim08
rzlim08

💻 ⚠️
Ross Markello
Ross Markello

🚇 🧑‍🏫
m-miedema
m-miedema

👀 🧑‍🏫
Stefano Moia
Stefano Moia

🔣 🚇 📆 💻 🤔 📖 🧑‍🏫
Alice Patin
Alice Patin

💻
Marie-Eve Picard
Marie-Eve Picard

👀 🚇
Taylor Salo
Taylor Salo

💻 🤔 👀
Rachael Stickland
Rachael Stickland

🚇 📖
Kristina Zvolanek
Kristina Zvolanek

🚇 👀 📓
diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..4316096 --- /dev/null +++ b/README.rst @@ -0,0 +1,22 @@ +phys2denoise: A toolbox for physiological metrics calculation +============================================================= + +This package is designed for calculating physiological metrics, derived specifically cardiac and respiratory signals, to use in fMRI denoising. + + +.. image:: https://readthedocs.org/projects/phys2denoise/badge/?version=latest + :target: http://phys2denoise.readthedocs.io/en/latest +.. image:: https://img.shields.io/badge/license-Apache%202-blue.svg + :target: http://www.apache.org/licenses/LICENSE-2.0 +.. image:: https://img.shields.io/badge/python-3.6+-blue.svg + :target: https://www.python.org/downloads/ + + +.. _licensing: + +License Information +------------------- + +This codebase is licensed under the Apache License, Version 2.0. The full +license can be found in the `LICENSE `_ file in the ``phys2denoise`` distribution. You may also +obtain a copy of the license at: http://www.apache.org/licenses/LICENSE-2.0. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..f610428 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SPHINXPROJ = phys2denoise +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..b419f38 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,34 @@ +.. _api_ref: +API +=== + +.. py:module:: phys2denoise + +Cardiac data +------------ + +.. automodule:: phys2denoise.metrics.cardiac + :members: heart_rate, heart_rate_variability, heart_beat_interval, cardiac_phase + +Respiratory data +---------------- + +.. automodule:: phys2denoise.metrics.chest_belt + :members: respiratory_cariance_time, respiratory_pattern_variability, env, respiratory_variance, respiratory_phase + +Multimodal data +--------------- + +.. autofunction:: phys2denoise.multimodal.retroicor + +Response functions +------------------ + +.. automodule:: phys2denoise.metrics.responses + :members: crf, icrf, rrf + +Utilities +--------- + +.. automodule:: phys2denoise.metrics.utils + :members: print_metric_call, mirrorpad_1d, rms_envelope_1d, apply_lags, apply_function_in_sliding_window, convolve_and_rescale, export_metric diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e42ac87 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +import os +import sys + +import matplotlib as mpl + +mpl.use("Agg") + +# -- Project information ----------------------------------------------------- + +# Add project name, copyright holder, and author(s) +project = "phys2denoise" +copyright = "2024, physiopy" +author = "physiopy" + +# Import project to get version info +sys.path.insert(0, os.path.abspath(os.path.pardir)) +import phys2denoise # noqa + +# The short X.Y version +version = phys2denoise.__version__ +# The full version, including alpha/beta/rc tags +release = phys2denoise.__version__ + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "matplotlib.sphinxext.plot_directive", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", + "sphinx.ext.mathjax", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", +] + +# Generate the API documentation when building +autosummary_generate = True +numpydoc_show_class_members = False +autoclass_content = "class" + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix(es) of source filenames. +source_suffix = ".rst" + +# The master toctree document. +master_doc = "index" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path . +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +import sphinx_rtd_theme # noqa + +html_theme = "sphinx_rtd_theme" +html_show_sourcelink = False + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = "peakdetdoc" + +# -- Extension configuration ------------------------------------------------- +intersphinx_mapping = { + "matplotlib": ("https://matplotlib.org", None), + "numpy": ("https://docs.scipy.org/doc/numpy", None), + "scipy": ("https://docs.scipy.org/doc/scipy/reference", None), +} + +plot_include_source = True +plot_formats = [("png", 90)] +plot_html_show_formats = False +plot_html_show_source_link = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..590f394 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,11 @@ +.. include:: ../README.rst + +Contents +-------- + +.. toctree:: + :maxdepth: 1 + + installation + usage + api diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..fc61add --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,35 @@ +.. _installation_setup: + +Installation and setup +====================== + +.. _basic_installation: + +Basic installation +------------------ + +The easiest way to install ``phys2denoise`` is to use ``pip``. Assuming you have +Python >= 3.6 installed, you can install ``phys2denoise`` by opening a terminal +and running the following: + +.. code-block:: bash + + pip install phys2denoise + +.. warning:: + + If you encounter an ImportError related to numpy.core.multiarray, please try to update + your matplotlib version to 3.9. + +Developer installation +---------------------- + +This package requires Python >= 3.6. Assuming you have the correct version of +Python installed, you can install ``phys2denoise`` by opening a terminal and running +the following: + +.. code-block:: bash + + git clone https://github.com/physiopy/phys2denoise.git + cd phys2denoise + pip install -e .[dev] diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..ed30bc9 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +-r ../requirements.txt +sphinx>=1.2 +sphinx_rtd_theme diff --git a/docs/usage.rst b/docs/usage.rst new file mode 100644 index 0000000..58b7c89 --- /dev/null +++ b/docs/usage.rst @@ -0,0 +1,10 @@ +.. _usage: + +User guide +========== + +.. toctree:: + :numbered: + + user_guide/metrics.rst + user_guide/exporting.rst diff --git a/docs/user_guide/exporting.rst b/docs/user_guide/exporting.rst new file mode 100644 index 0000000..5674a34 --- /dev/null +++ b/docs/user_guide/exporting.rst @@ -0,0 +1,33 @@ +.. _usage_exporting: + +Exporting physiological data metrics +------------------------------------ +Another feature of the :py:mod:`phys2denoise` package is the ability to export the computed physiological data metrics to a file, with various parameters. +This can be done using the :py:func:`export_metric` function, which provides the following capabilities: + +- Exporting the computed metrics, resampled at the TR of the fMRI data, along with the original data. +- Flagging if the exported data is the convolved version or if the metric contains lags of itself, resulting in appropriate file naming. +- Defining the output file extension and file prefix. +- Defining the number of timepoints to be considered. + +The following example shows how to export the computed respiratory variance time using a Physio object. In the following example, +we consider that the Physio object is ``resp``, containing the respiratory data, peaks and troughs. + +.. code-block:: python + + from phys2denoise.metrics.chest_belt import respiratory_variance_time + from phys2denoise.metrics.utils import export_metric + + RVT = respiratory_variance_time( + resp.data, resp.peaks, resp.troughs, resp.fs, lags=(0, 4, 8, 12) + ) + + export_metric( + RVT, + resp.fs, + tr=1.5, + fileprefix="data/sub-002_ses-01_task-rest_run-01_RVT", + ntp=400, + is_convolved=False, + has_lags=True, + ) diff --git a/docs/user_guide/metrics.rst b/docs/user_guide/metrics.rst new file mode 100644 index 0000000..779f31b --- /dev/null +++ b/docs/user_guide/metrics.rst @@ -0,0 +1,80 @@ +.. _usage_metrics: + +Computing physiological data metrics +------------------------------------- +The :py:mod:`phys2denoise` package provides a set of functions to compute physiological data metrics. The set of supported metrics +includes: + +- Cardiac metrics + - Cardiac phase + - Heart rate + - Heart rate variability + - Heart beat interval +- Respiratory metrics + - Respiratory phase + - Respiratory variance + - Respiratory pattern variability + - Envelope +- Multimodal metrics + - RETROICOR + +All of the metrics computation function definitions, descriptions and references can be found in :ref:`api_ref`. + + +Using a Physio object +##################### + +Physiological data metrics can be easily computed using Physio objects, from the :py:mod:`physutils` module, +on which the physiological data will be loaded. + +The following example shows how to compute the respiratory variance time using a Physio object. + +.. code-block:: python + + from physutils import io + from phys2denoise.metrics.chest_belt import respiratory_variance_time + # peakdet is an example package that provides peak/trough detection for the respiratory signal + from peakdet import operations + + # Load the physiological data + sample_rate = 1000 + physio = io.load_physio('path/to/physiological/data', fs=sample_rate) + + # Peak/trough detection for the respiratory signal, using the peakdet package + physio = operations.peakfind_physio(physio) + + # Compute RVT + physio, rvt = respiratory_variance_time(physio) + +:py:func:`respiratory_variance_time` returns a tuple with the updated Physio object and the computed respiratory variance time. + +:py:mod:`peakdet` is used in this example as it is also compatible with the Physio object. However, any other peak/trough detection +package can be used. In this case, the peak and trough values should be stored in the Physio object manually as follows: + +.. code-block:: python + + # Store the peak and trough values in the Physio object + physio._metadata["peaks"] = peaks + physio._metadata["troughs"] = troughs + +The benefit of using a Physio object other than the encapsulation of all the desired parameters in a single object is the fact that +the object retains a history of all the operations performed on it. This allows for easy debugging and reproducibility of the results. +For further information refer to the :py:mod:`physutils` documentation. + +Without using a Physio object +############################# + +However, if the use of the ``Physio`` object from the :py:mod:`physutils` module is not preferred, the metrics can be also computed without it. The following +example shows how to compute the heart rate and the heart rate variability using the :py:mod:`phys2denoise` package. + +.. code-block:: python + + from phys2denoise.metrics.chest_belt import respiratory_variance_time + + # Given that the respiratory signal is stored in `data` (which is not a physio.Physio instance), the peaks in `peaks`, the troughs in `troughs` + # and the sample rate in `sample_rate` + rvt = respiratory_variance_time(data, peaks, troughs, sample_rate) + +The computed respiratory variance time is stored in the variable ``rvt``. An internal check is performed to verify if the input data is a Physio object or not +determining the appropriate output format. If the input is not a ``Physio`` object, the output will be a numpy array only containing the computed metric. Otherwise, +the output will be a tuple with the updated Physio object and the computed metric. diff --git a/phys2denoise/metrics/cardiac.py b/phys2denoise/metrics/cardiac.py index 2ebf179..7c87dc1 100644 --- a/phys2denoise/metrics/cardiac.py +++ b/phys2denoise/metrics/cardiac.py @@ -1,15 +1,25 @@ """Denoising metrics for cardio recordings.""" import numpy as np +from loguru import logger +from physutils import io, physio from .. import references from ..due import due from .responses import crf from .utils import apply_function_in_sliding_window as afsw -from .utils import convolve_and_rescale - - -def _cardiac_metrics(card, peaks, samplerate, metric, window=6, central_measure="mean"): +from .utils import convolve_and_rescale, return_physio_or_metric + + +def _cardiac_metrics( + data, + metric, + peaks=None, + fs=None, + window=6, + central_measure="mean", + **kwargs, +): """ Compute cardiac metrics. @@ -22,14 +32,14 @@ def _cardiac_metrics(card, peaks, samplerate, metric, window=6, central_measure= Parameters ---------- - card : list or 1D numpy.ndarray - Timeseries of recorded cardiac signal - peaks : list or 1D numpy.ndarray - array of peak indexes for card. - samplerate : float - Sampling rate for card, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded cardiac signal metrics : "hbi", "hr", "hrv", string Cardiac metric(s) to calculate. + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` window : float, optional Size of the sliding window, in seconds. Default is 6. @@ -72,8 +82,32 @@ def _cardiac_metrics(card, peaks, samplerate, metric, window=6, central_measure= Annual International Conference of the IEEE Engineering in Medicine and Biology Society (EMBC), doi: 10.1109/EMBC.2016.7591347. """ + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None and peaks is not None: + data = io.load_physio(data, fs=fs) + data._metadata["peaks"] = peaks + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with existing peaks metadata (e.g. using the peakdet module), or + by providing the physiological data timeseries, the sampling frequency, + and the peak indices separately. + """ + ) + if data.peaks.size == 0: + raise ValueError( + """ + Peaks must be a non-empty list. + Make sure to run peak detection on your physiological data first, + using the peakdet module, or other software of your choice. + """ + ) + # Convert window to samples, but halves it. - halfwindow_samples = int(round(window * samplerate / 2)) + halfwindow_samples = int(round(window * data.fs / 2)) if central_measure in ["mean", "average", "avg"]: central_measure_operator = np.mean @@ -86,14 +120,17 @@ def _cardiac_metrics(card, peaks, samplerate, metric, window=6, central_measure= f" {central_measure} is not a supported metric of centrality." ) - idx_arr = np.arange(len(card)) + idx_arr = np.arange(len(data)) idx_min = afsw(idx_arr, np.min, halfwindow_samples) idx_max = afsw(idx_arr, np.max, halfwindow_samples) - card_met = np.empty_like(card) + card_met = np.empty_like(data) for n, i in enumerate(idx_min): diff = ( - np.diff(peaks[np.logical_and(peaks >= i, peaks <= idx_max[n])]) / samplerate + np.diff( + data.peaks[np.logical_and(data.peaks >= i, data.peaks <= idx_max[n])] + ) + / data.fs ) if metric == "hbi": card_met[n] = central_measure_operator(diff) if diff.size > 0 else 0 @@ -110,13 +147,15 @@ def _cardiac_metrics(card, peaks, samplerate, metric, window=6, central_measure= card_met[np.isnan(card_met)] = 0.0 # Convolve with crf and rescale - card_met = convolve_and_rescale(card_met, crf(samplerate), rescale="rescale") + card_met = convolve_and_rescale(card_met, crf(data.fs), rescale="rescale") - return card_met + return data, card_met @due.dcite(references.CHANG_CUNNINGHAM_GLOVER_2009) -def heart_rate(card, peaks, samplerate, window=6, central_measure="mean"): +@return_physio_or_metric() +@physio.make_operation() +def heart_rate(data, peaks=None, fs=None, window=6, central_measure="mean", **kwargs): """ Compute average heart rate (HR) in a sliding window. @@ -126,12 +165,12 @@ def heart_rate(card, peaks, samplerate, window=6, central_measure="mean"): Parameters ---------- - card : list or 1D numpy.ndarray - Timeseries of recorded cardiac signal - peaks : list or 1D numpy.ndarray - array of peak indexes for card. - samplerate : float - Sampling rate for card, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded cardiac signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` window : float, optional Size of the sliding window, in seconds. Default is 6. @@ -168,13 +207,25 @@ def heart_rate(card, peaks, samplerate, window=6, central_measure="mean"): Annual International Conference of the IEEE Engineering in Medicine and Biology Society (EMBC), doi: 10.1109/EMBC.2016.7591347. """ - return _cardiac_metrics( - card, peaks, samplerate, metric="hrv", window=6, central_measure="mean" + data, hr = _cardiac_metrics( + data, + metric="hr", + peaks=peaks, + fs=fs, + window=window, + central_measure=central_measure, + **kwargs, ) + return data, hr + @due.dcite(references.PINHERO_ET_AL_2016) -def heart_rate_variability(card, peaks, samplerate, window=6, central_measure="mean"): +@return_physio_or_metric() +@physio.make_operation() +def heart_rate_variability( + data, peaks=None, fs=None, window=6, central_measure="mean", **kwargs +): """ Compute average heart rate variability (HRV) in a sliding window. @@ -184,12 +235,12 @@ def heart_rate_variability(card, peaks, samplerate, window=6, central_measure="m Parameters ---------- - card : list or 1D numpy.ndarray - Timeseries of recorded cardiac signal - peaks : list or 1D numpy.ndarray - array of peak indexes for card. - samplerate : float - Sampling rate for card, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded cardiac signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` window : float, optional Size of the sliding window, in seconds. Default is 6. @@ -224,24 +275,36 @@ def heart_rate_variability(card, peaks, samplerate, window=6, central_measure="m Annual International Conference of the IEEE Engineering in Medicine and Biology Society (EMBC), doi: 10.1109/EMBC.2016.7591347. """ - return _cardiac_metrics( - card, peaks, samplerate, metric="hrv", window=6, central_measure="std" + data, hrv = _cardiac_metrics( + data, + metric="hrv", + peaks=peaks, + fs=fs, + window=window, + central_measure=central_measure, + **kwargs, ) + return data, hrv + @due.dcite(references.CHEN_2020) -def heart_beat_interval(card, peaks, samplerate, window=6, central_measure="mean"): +@return_physio_or_metric() +@physio.make_operation() +def heart_beat_interval( + data, peaks=None, fs=None, window=6, central_measure="mean", **kwargs +): """ Compute average heart beat interval (HBI) in a sliding window. Parameters ---------- - card : list or 1D numpy.ndarray - Timeseries of recorded cardiac signal - peaks : list or 1D numpy.ndarray - array of peak indexes for card. - samplerate : float - Sampling rate for card, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded cardiac signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` window : float, optional Size of the sliding window, in seconds. Default is 6. @@ -273,12 +336,22 @@ def heart_beat_interval(card, peaks, samplerate, window=6, central_measure="mean .. [1] J. E. Chen et al., "Resting-state "physiological networks"", Neuroimage, vol. 213, pp. 116707, 2020. """ - return _cardiac_metrics( - card, peaks, samplerate, metric="hbi", window=6, central_measure="mean" + data, hbi = _cardiac_metrics( + data, + metric="hbi", + peaks=peaks, + fs=fs, + window=window, + central_measure=central_measure, + **kwargs, ) + return data, hbi -def cardiac_phase(peaks, sample_rate, slice_timings, n_scans, t_r): + +@return_physio_or_metric() +@physio.make_operation() +def cardiac_phase(data, slice_timings, n_scans, t_r, peaks=None, fs=None, **kwargs): """Calculate cardiac phase from cardiac peaks. Assumes that timing of cardiac events are given in same units @@ -286,27 +359,53 @@ def cardiac_phase(peaks, sample_rate, slice_timings, n_scans, t_r): Parameters ---------- - peaks : 1D array_like - Cardiac peak times, in seconds. - sample_rate : float - Sample rate of physio, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded cardiac signal slice_timings : 1D array_like Slice times, in seconds. n_scans : int Number of volumes in the imaging run. t_r : float Sampling rate of the imaging run, in seconds. + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` Returns ------- phase_card : array_like Cardiac phase signal, of shape (n_scans,) """ + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None and peaks is not None: + data = io.load_physio(data, fs=fs) + data._metadata["peaks"] = peaks + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with existing peaks metadata (e.g. using the peakdet module), or + by providing the physiological data timeseries, the sampling frequency, + and the peak indices separately. + """ + ) + if data.peaks.size == 0: + raise ValueError( + """ + Peaks must be a non-empty list. + Make sure to run peak detection on your physiological data first, + using the peakdet module, or other software of your choice. + """ + ) + assert slice_timings.ndim == 1, "Slice times must be a 1D array" n_slices = np.size(slice_timings) phase_card = np.zeros((n_scans, n_slices)) - card_peaks_sec = peaks / sample_rate + card_peaks_sec = data.peaks / data.fs for i_slice in range(n_slices): # generate slice acquisition timings across all scans times_crSlice = t_r * np.arange(n_scans) + slice_timings[i_slice] @@ -333,4 +432,4 @@ def cardiac_phase(peaks, sample_rate, slice_timings, n_scans, t_r): ) / (t2 - t1) phase_card[:, i_slice] = phase_card_crSlice - return phase_card + return data, phase_card diff --git a/phys2denoise/metrics/chest_belt.py b/phys2denoise/metrics/chest_belt.py index 17ec712..6551c57 100644 --- a/phys2denoise/metrics/chest_belt.py +++ b/phys2denoise/metrics/chest_belt.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +from physutils import io, physio from scipy.interpolate import interp1d from scipy.stats import zscore @@ -9,11 +10,15 @@ from ..due import due from .responses import rrf from .utils import apply_function_in_sliding_window as afsw -from .utils import convolve_and_rescale, rms_envelope_1d +from .utils import convolve_and_rescale, return_physio_or_metric, rms_envelope_1d @due.dcite(references.BIRN_2006) -def respiratory_variance_time(resp, peaks, troughs, samplerate, lags=(0, 4, 8, 12)): +@return_physio_or_metric() +@physio.make_operation() +def respiratory_variance_time( + data, peaks=None, troughs=None, fs=None, lags=(0, 4, 8, 12), **kwargs +): """ Implement the Respiratory Variance over Time (Birn et al. 2006). @@ -21,14 +26,14 @@ def respiratory_variance_time(resp, peaks, troughs, samplerate, lags=(0, 4, 8, 1 Parameters ---------- - resp: array_like - respiratory belt data - samples x 1 - peaks: array_like - peaks found by peakdet algorithm - troughs: array_like - troughs found by peakdet algorithm - samplerate: float - sample rate in hz of respiratory belt + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz + peaks : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of peaks in `data` + troughs : :obj:`numpy.ndarray`, optional if data is a physutils.Physio + Indices of troughs in `data` lags: tuple lags in seconds of the RVT output. Default is 0, 4, 8, 12. @@ -43,13 +48,38 @@ def respiratory_variance_time(resp, peaks, troughs, samplerate, lags=(0, 4, 8, 1 respiratory-variation-related fluctuations from neuronal-activity-related fluctuations in fMRI”, NeuroImage, vol.31, pp. 1536-1548, 2006. """ - timestep = 1 / samplerate + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None and peaks is not None and troughs is not None: + data = io.load_physio(data, fs=fs) + data._metadata["peaks"] = peaks + data._metadata["troughs"] = troughs + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with existing peaks and troughs metadata (e.g. using the peakdet module), or + by providing the physiological data timeseries, the sampling frequency, + and the peak and trough indices separately. + """ + ) + if data.peaks.size == 0 or data.troughs.size == 0: + raise ValueError( + """ + Peaks and troughs must be non-empty lists. + Make sure to run peak/trough detection on your physiological data first, + using the peakdet module, or other software of your choice. + """ + ) + + timestep = 1 / data.fs # respiration belt timing - time = np.arange(0, len(resp) * timestep, timestep) - peak_vals = resp[peaks] - trough_vals = resp[troughs] - peak_time = time[peaks] - trough_time = time[troughs] + time = np.arange(0, len(data) * timestep, timestep) + peak_vals = data[data.peaks] + trough_vals = data[data.troughs] + peak_time = time[data.peaks] + trough_time = time[data.troughs] mid_peak_time = (peak_time[:-1] + peak_time[1:]) / 2 period = np.diff(peak_time) # interpolate peak values over all timepoints @@ -78,17 +108,19 @@ def respiratory_variance_time(resp, peaks, troughs, samplerate, lags=(0, 4, 8, 1 ) rvt_lags[:, ind] = temp_rvt - return rvt_lags + return data, rvt_lags @due.dcite(references.POWER_2018) -def respiratory_pattern_variability(resp, window): +@return_physio_or_metric() +@physio.make_operation() +def respiratory_pattern_variability(data, window, **kwargs): """Calculate respiratory pattern variability. Parameters ---------- - resp : str or 1D numpy.ndarray - Tiemseries representing respiration activity. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory signal window : int Window length in samples. @@ -112,27 +144,33 @@ def respiratory_pattern_variability(resp, window): data," Proceedings of the National Academy of Sciences, issue 9, vol. 115, pp. 2105-2114, 2018. """ + # Initialize physio object + data = physio.check_physio(data, ensure_fs=False, copy=True) + # First, z-score respiratory traces - resp_z = zscore(resp) + resp_z = zscore(data.data) # Collect upper envelope rpv_upper_env = rms_envelope_1d(resp_z, window) # Calculate standard deviation rpv_val = np.std(rpv_upper_env) - return rpv_val + + return data, rpv_val @due.dcite(references.POWER_2020) -def env(resp, samplerate, window=10): +@return_physio_or_metric() +@physio.make_operation() +def env(data, fs=None, window=10, **kwargs): """Calculate respiratory pattern variability across a sliding window. Parameters ---------- - resp : (X,) :obj:`numpy.ndarray` - A 1D array with the respiratory belt time series. - samplerate : :obj:`float` - Sampling rate for resp, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz window : :obj:`int`, optional Size of the sliding window, in seconds. Default is 10. @@ -156,30 +194,67 @@ def env(resp, samplerate, window=10): young adults scanned at rest, including systematic changes and 'missed' deep breaths," Neuroimage, vol. 204, 2020. """ + + def _respiratory_pattern_variability(data, window): + """ + Respiratory pattern variability function without utilizing + the physutils.Physio object history, only to be used within + the context of this function. This is done to only store the + chest_belt.env operation call to the history and not all the + subsequent sub-operations + """ + # First, z-score respiratory traces + resp_z = zscore(data) + + # Collect upper envelope + rpv_upper_env = rms_envelope_1d(resp_z, window) + + # Calculate standard deviation + rpv_val = np.std(rpv_upper_env) + return rpv_val + + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None: + data = io.load_physio(data, fs=fs) + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with the sampling frequency encapsulated, or + by providing the physiological data timeseries and the sampling + frequency separately. + """ + ) + # Convert window to Hertz - window = int(window * samplerate) + window = int(window * data.fs) # Calculate RPV across a rolling window env_arr = ( - pd.Series(resp) + pd.Series(data.data) .rolling(window=window, center=True) - .apply(respiratory_pattern_variability, args=(window,)) + .apply(_respiratory_pattern_variability, args=(window,)) ) env_arr[np.isnan(env_arr)] = 0.0 - return env_arr + + return data, env_arr @due.dcite(references.CHANG_GLOVER_2009) -def respiratory_variance(resp, samplerate, window=6): +@return_physio_or_metric() +@physio.make_operation() +def respiratory_variance(data, fs=None, window=6, **kwargs): """Calculate respiratory variance. Parameters ---------- - resp : (X,) :obj:`numpy.ndarray` - A 1D array with the respiratory belt time series. - samplerate : :obj:`float` - Sampling rate for resp, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory signal + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz window : :obj:`int`, optional Size of the sliding window, in seconds. Default is 6. @@ -207,49 +282,81 @@ def respiratory_variance(resp, samplerate, window=6): end-tidal CO2, and BOLD signals in resting-state fMRI," Neuroimage, issue 4, vol. 47, pp. 1381-1393, 2009. """ + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None: + data = io.load_physio(data, fs=fs) + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with the sampling frequency encapsulated, or + by providing the physiological data timeseries and the sampling + frequency separately. + """ + ) + # Convert window to Hertz - halfwindow_samples = int(round(window * samplerate / 2)) + halfwindow_samples = int(round(window * data.fs / 2)) # Raw respiratory variance - rv_arr = afsw(resp, np.std, halfwindow_samples) + rv_arr = afsw(data, np.std, halfwindow_samples) # Convolve with rrf - rv_out = convolve_and_rescale(rv_arr, rrf(samplerate), rescale="zscore") + rv_out = convolve_and_rescale(rv_arr, rrf(data.fs), rescale="zscore") - return rv_out + return data, rv_out -def respiratory_phase(resp, sample_rate, n_scans, slice_timings, t_r): +@return_physio_or_metric() +@physio.make_operation() +def respiratory_phase(data, n_scans, slice_timings, t_r, fs=None, **kwargs): """Calculate respiratory phase from respiratory signal. Parameters ---------- - resp : 1D array_like - Respiratory signal. - sample_rate : float - Sample rate of physio, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory signal n_scans Number of volumes in the imaging run. slice_timings Slice times, in seconds. t_r Sample rate of the imaging run, in seconds. + fs : float, optional if data is a physutils.Physio + Sampling rate of `data` in Hz Returns ------- phase_resp : array_like Respiratory phase signal, of shape (n_scans, n_slices). """ + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + elif fs is not None: + data = io.load_physio(data, fs=fs) + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with the sampling frequency encapsulated, or + by providing the physiological data timeseries and the sampling + frequency separately. + """ + ) + assert slice_timings.ndim == 1, "Slice times must be a 1D array" n_slices = np.size(slice_timings) phase_resp = np.zeros((n_scans, n_slices)) # generate histogram from respiratory signal # TODO: Replace with numpy.histogram - resp_hist, resp_hist_bins = np.histogram(resp, bins=100) + resp_hist, resp_hist_bins = np.histogram(data, bins=100) # first compute derivative of respiration signal - resp_diff = np.diff(resp, n=1) + resp_diff = np.diff(data, n=1) for i_slice in range(n_slices): # generate slice acquisition timings across all scans @@ -257,15 +364,15 @@ def respiratory_phase(resp, sample_rate, n_scans, slice_timings, t_r): phase_resp_crSlice = np.zeros(n_scans) for j_scan in range(n_scans): iphys = int( - max([1, round(times_crSlice[j_scan] * sample_rate)]) + max([1, round(times_crSlice[j_scan] * data.fs)]) ) # closest idx in resp waveform iphys = min([iphys, len(resp_diff)]) # cannot be longer than resp_diff - thisBin = np.argmin(abs(resp[iphys] - resp_hist_bins)) + thisBin = np.argmin(abs(data[iphys] - resp_hist_bins)) numerator = np.sum(resp_hist[0:thisBin]) phase_resp_crSlice[j_scan] = ( - np.math.pi * np.sign(resp_diff[iphys]) * (numerator / len(resp)) + np.math.pi * np.sign(resp_diff[iphys]) * (numerator / len(data)) ) phase_resp[:, i_slice] = phase_resp_crSlice - return phase_resp + return data, phase_resp diff --git a/phys2denoise/metrics/multimodal.py b/phys2denoise/metrics/multimodal.py index 7a298b2..fa28bae 100644 --- a/phys2denoise/metrics/multimodal.py +++ b/phys2denoise/metrics/multimodal.py @@ -1,34 +1,35 @@ """These functions compute RETROICOR regressors (Glover et al. 2000).""" import numpy as np +from physutils import io, physio from .. import references from ..due import due from .cardiac import cardiac_phase from .chest_belt import respiratory_phase +from .utils import return_physio_or_metric @due.dcite(references.GLOVER_2000) +@return_physio_or_metric() +@physio.make_operation() def retroicor( - physio, - sample_rate, + data, t_r, n_scans, slice_timings, n_harmonics, - card=False, - resp=False, + physio_type=None, + fs=None, + cardiac_peaks=None, + **kwargs, ): """Compute RETROICOR regressors. Parameters ---------- - physio : array_like - 1D array, whether cardiac or respiratory signal. - If cardiac, the array is a set of peaks in seconds. - If respiratory, the array is the actual respiratory signal. - sample_rate : float - Physio sample rate, in Hertz. + data : physutils.Physio, np.ndarray, or array-like object + Object containing the timeseries of the recorded respiratory or cardiac signal t_r : float Imaging sample rate, in seconds. n_scans : int @@ -59,6 +60,44 @@ def retroicor( correction of physiological motion effects in fMRI: RETROICOR“, Magn. Reson. Med., issue 1, vol. 44, pp. 162-167, 2000. """ + if isinstance(data, physio.Physio): + # Initialize physio object + data = physio.check_physio(data, ensure_fs=True, copy=True) + if data.physio_type is None and physio_type is not None: + data._physio_type = physio_type + elif data.physio_type is None and physio_type is None: + raise ValueError( + """ + Since the provided Physio object does not specify a `physio_type`, + this function's `physio_type` parameter must be specified as a + value from {'cardiac', 'respiratory'} + """ + ) + + elif fs is not None and physio_type is not None: + data = io.load_physio(data, fs=fs) + data._physio_type = physio_type + if data.physio_type == "cardiac": + data._metadata["peaks"] = cardiac_peaks + else: + raise ValueError( + """ + To use this function you should either provide a Physio object + with existing peaks metadata if it describes a cardiac signal + (e.g. using the peakdet module), or + by providing the physiological data timeseries, the sampling frequency, + the physio_type and the peak indices separately. + """ + ) + if not data.peaks and data.physio_type == "cardiac": + raise ValueError( + """ + Peaks must be a non-empty list for cardiac data. + Make sure to run peak detection on your cardiac data first, + using the peakdet module, or other software of your choice. + """ + ) + n_slices = np.shape(slice_timings) # number of slices # initialize output variables @@ -73,18 +112,17 @@ def retroicor( # Compute physiological phases using the timings of physio events (e.g. peaks) # slice sampling times - if card: + if data.physio_type == "cardiac": phase[:, i_slice] = cardiac_phase( - physio, + data, crslice_timings, n_scans, t_r, ) - if resp: + if data.physio_type == "respiratory": phase[:, i_slice] = respiratory_phase( - physio, - sample_rate, + data, n_scans, slice_timings, t_r, @@ -99,4 +137,9 @@ def retroicor( (j_harm + 1) * phase[i_slice] ) - return retroicor_regressors, phase + data._computed_metrics["retroicor_regressors"] = dict( + metrics=retroicor_regressors, phase=phase + ) + retroicor_regressors = dict(metrics=retroicor_regressors, phase=phase) + + return data, retroicor_regressors diff --git a/phys2denoise/metrics/responses.py b/phys2denoise/metrics/responses.py index e4abdaa..ad14e8c 100644 --- a/phys2denoise/metrics/responses.py +++ b/phys2denoise/metrics/responses.py @@ -3,6 +3,7 @@ import logging import numpy as np +from loguru import logger from .. import references from ..due import due @@ -57,10 +58,12 @@ def _crf(t): ) * np.exp(-0.5 * (((t - 12) ** 2) / 9)) return rf - time_stamps = np.arange(0, time_length, 1 / samplerate) + time_stamps = np.arange(0, time_length, samplerate) time_stamps -= onset + logger.debug(f"Time stamps: {time_stamps}") crf_arr = _crf(time_stamps) crf_arr = crf_arr / max(abs(crf_arr)) + logger.debug(f"CRF: {crf_arr}") if inverse: return -crf_arr @@ -136,7 +139,7 @@ def _rrf(t): rf = 0.6 * t**2.1 * np.exp(-t / 1.6) - 0.0023 * t**3.54 * np.exp(-t / 4.25) return rf - time_stamps = np.arange(0, time_length, 1 / samplerate) + time_stamps = np.arange(0, time_length, samplerate) time_stamps -= onset rrf_arr = _rrf(time_stamps) rrf_arr = rrf_arr / max(abs(rrf_arr)) diff --git a/phys2denoise/metrics/utils.py b/phys2denoise/metrics/utils.py index 2faed26..6d2e53e 100644 --- a/phys2denoise/metrics/utils.py +++ b/phys2denoise/metrics/utils.py @@ -1,9 +1,13 @@ """Miscellaneous utility functions for metric calculation.""" +import functools +import inspect import logging import numpy as np +from loguru import logger from numpy.lib.stride_tricks import sliding_window_view as swv +from physutils.physio import Physio from scipy.interpolate import interp1d from scipy.stats import zscore @@ -34,7 +38,7 @@ def print_metric_call(metric, args): msg = f"{msg}\n" - LGR.info(msg) + logger.info(msg) def mirrorpad_1d(arr, buffer=250): @@ -59,7 +63,11 @@ def mirrorpad_1d(arr, buffer=250): post_mirror = np.take(mirror, idx, axis=0) except IndexError: len(arr) - LGR.warning( + # LGR.warning( + # f"Requested buffer size ({buffer}) is longer than input array length " + # f"({len(arr)}). Fixing buffer size to array length." + # ) + logger.warning( f"Requested buffer size ({buffer}) is longer than input array length " f"({len(arr)}). Fixing buffer size to array length." ) @@ -333,3 +341,117 @@ def export_metric( ) return fileprefix + + +def return_physio_or_metric(*, return_physio=True): + """ + Decorator to check if the input is a Physio object. + + Parameters + ---------- + func : function + Function to be decorated + + Returns + ------- + function + Decorated function + """ + + def determine_return_type(func): + convolved_metrics = [ + "respiratory_variance", + "heart_rate", + "heart_rate_variability", + "heart_beat_interval", + ] + + @functools.wraps(func) + def wrapper(*args, **kwargs): + physio, metric = func(*args, **kwargs) + default_args = get_default_args(func) + if isinstance(args[0], Physio): + if "lags" in kwargs: + has_lags = True if len(kwargs["lags"]) > 1 else False + elif "lags" in default_args: + has_lags = True if len(default_args["lags"]) > 1 else False + else: + has_lags = False + + is_convolved = True if func.__name__ in convolved_metrics else False + + physio._computed_metrics[func.__name__] = Metric( + func.__name__, + metric, + kwargs, + has_lags=has_lags, + is_convolved=is_convolved, + ) + + return_physio_value = kwargs.get("return_physio", return_physio) + if return_physio_value: + return physio + else: + return metric + else: + return metric + + return wrapper + + return determine_return_type + + +def get_default_args(func): + # Get the signature of the function + sig = inspect.signature(func) + + # Extract default values for each parameter + defaults = { + k: v.default + for k, v in sig.parameters.items() + if v.default is not inspect.Parameter.empty + } + + return defaults + + +class Metric: + def __init__(self, name, data, args, has_lags=False, is_convolved=False): + self.name = name + self._data = data + self._args = args + self._has_lags = has_lags + self._is_convolved = is_convolved + + def __array__(self): + return self.data + + def __getitem__(self, slicer): + return self.data[slicer] + + def __len__(self): + return len(self.data) + + @property + def ndim(self): + return self.data.ndim + + @property + def shape(self): + return self.data.shape + + @property + def data(self): + return self._data + + @property + def args(self): + return self._args + + @property + def has_lags(self): + return self._has_lags + + @property + def is_convolved(self): + return self._is_convolved diff --git a/phys2denoise/tests/conftest.py b/phys2denoise/tests/conftest.py index fcf05f3..757a071 100644 --- a/phys2denoise/tests/conftest.py +++ b/phys2denoise/tests/conftest.py @@ -1,5 +1,20 @@ import numpy as np import pytest +from _pytest.logging import LogCaptureFixture +from loguru import logger + + +@pytest.fixture +def caplog(caplog: LogCaptureFixture): + handler_id = logger.add( + caplog.handler, + format="{message}", + level=20, + filter=lambda record: record["level"].no >= caplog.handler.level, + enqueue=False, + ) + yield caplog + logger.remove(handler_id) @pytest.fixture(scope="module") diff --git a/phys2denoise/tests/test_metrics_cardiac.py b/phys2denoise/tests/test_metrics_cardiac.py index a93fc3b..a6c55a5 100644 --- a/phys2denoise/tests/test_metrics_cardiac.py +++ b/phys2denoise/tests/test_metrics_cardiac.py @@ -1,6 +1,8 @@ """Tests for phys2denoise.metrics.cardiac.""" import numpy as np +from loguru import logger +from physutils import physio from phys2denoise.metrics import cardiac @@ -8,20 +10,15 @@ def test_crf_smoke(): """Basic smoke test for CRF calculation.""" samplerate = 0.01 # in seconds - oversampling = 20 time_length = 20 onset = 0 tr = 0.72 crf_arr = cardiac.crf( - samplerate, - oversampling=oversampling, - time_length=time_length, - onset=onset, - tr=tr, + samplerate, time_length=time_length, onset=onset, inverse=False ) - pred_len = np.rint(time_length / (tr / oversampling)) + pred_len = np.rint(time_length * 1 / samplerate) assert crf_arr.ndim == 1 - assert crf_arr.shape == pred_len + assert len(crf_arr) == pred_len def test_cardiac_phase_smoke(): @@ -31,12 +28,55 @@ def test_cardiac_phase_smoke(): sample_rate = 1 / 0.01 slice_timings = np.linspace(0, t_r, 22)[1:-1] peaks = np.array([0.534, 0.577, 10.45, 20.66, 50.55, 90.22]) + data = np.zeros(peaks.shape) card_phase = cardiac.cardiac_phase( - peaks, - sample_rate=sample_rate, + data, + peaks=peaks, + fs=sample_rate, slice_timings=slice_timings, n_scans=n_scans, t_r=t_r, ) assert card_phase.ndim == 2 assert card_phase.shape == (n_scans, slice_timings.size) + + +def test_cardiac_phase_smoke_physio_obj(): + """Basic smoke test for cardiac phase calculation.""" + t_r = 1.0 + n_scans = 200 + sample_rate = 1 / 0.01 + slice_timings = np.linspace(0, t_r, 22)[1:-1] + peaks = np.array([0.534, 0.577, 10.45, 20.66, 50.55, 90.22]) + data = np.zeros(peaks.shape) + phys = physio.Physio(data, sample_rate, physio_type="cardiac") + phys._metadata["peaks"] = peaks + + # Test where the physio object is returned + phys = cardiac.cardiac_phase( + phys, + slice_timings=slice_timings, + n_scans=n_scans, + t_r=t_r, + ) + + assert phys.history[0][0] == "phys2denoise.metrics.cardiac.cardiac_phase" + assert phys.computed_metrics["cardiac_phase"].ndim == 2 + assert phys.computed_metrics["cardiac_phase"].shape == ( + n_scans, + slice_timings.size, + ) + assert phys.computed_metrics["cardiac_phase"].args["slice_timings"] is not None + assert phys.computed_metrics["cardiac_phase"].args["n_scans"] is not None + assert phys.computed_metrics["cardiac_phase"].args["t_r"] is not None + + # Test where the metric is returned + card_phase = cardiac.cardiac_phase( + phys, + slice_timings=slice_timings, + n_scans=n_scans, + t_r=t_r, + return_physio=False, + ) + assert card_phase.ndim == 2 + assert card_phase.shape == (n_scans, slice_timings.size) diff --git a/phys2denoise/tests/test_metrics_chest_belt.py b/phys2denoise/tests/test_metrics_chest_belt.py index 286b6fa..6e352e3 100644 --- a/phys2denoise/tests/test_metrics_chest_belt.py +++ b/phys2denoise/tests/test_metrics_chest_belt.py @@ -15,17 +15,14 @@ def test_rrf_smoke(): tr = 0.72 rrf_arr = chest_belt.rrf( samplerate, - oversampling=oversampling, time_length=time_length, onset=onset, - tr=tr, ) - pred_len = int(np.rint(time_length / (tr / oversampling))) + pred_len = int(np.rint(time_length * (1 / samplerate))) assert rrf_arr.ndim == 1 assert rrf_arr.size == pred_len -@mark.xfail def test_respiratory_phase_smoke(): """Basic smoke test for respiratory phase calculation.""" t_r = 1.0 @@ -36,7 +33,7 @@ def test_respiratory_phase_smoke(): resp = np.random.normal(size=n_samples) resp_phase = chest_belt.respiratory_phase( resp, - sample_rate=sample_rate, + fs=sample_rate, slice_timings=slice_timings, n_scans=n_scans, t_r=t_r, @@ -60,7 +57,7 @@ def test_env_smoke(): resp = np.random.normal(size=n_samples) samplerate = 1 / 0.01 window = 6 - env_arr = chest_belt.env(resp, samplerate=samplerate, window=window) + env_arr = chest_belt.env(resp, fs=samplerate, window=window) assert env_arr.ndim == 1 assert env_arr.shape == (n_samples,) @@ -71,6 +68,6 @@ def test_respiratory_variance_smoke(): resp = np.random.normal(size=n_samples) samplerate = 1 / 0.01 window = 6 - rv_arr = chest_belt.respiratory_variance(resp, samplerate=samplerate, window=window) + rv_arr = chest_belt.respiratory_variance(resp, fs=samplerate, window=window) assert rv_arr.ndim == 2 assert rv_arr.shape == (n_samples, 2) diff --git a/phys2denoise/tests/test_metrics_utils.py b/phys2denoise/tests/test_metrics_utils.py index 916c485..1995aaf 100644 --- a/phys2denoise/tests/test_metrics_utils.py +++ b/phys2denoise/tests/test_metrics_utils.py @@ -16,12 +16,12 @@ def test_mirrorpad(short_arr): assert all(arr_mirror == expected_arr_mirror) -def test_mirrorpad_exception(short_arr): +def test_mirrorpad_exception(short_arr, caplog): """When passing array that is too short to perform mirrorpadding, the function should give an error.""" arr = np.array(short_arr) - with pytest.raises(Exception) as e_info: - arr_mirror = mirrorpad_1d(short_arr) + arr_mirror = mirrorpad_1d(arr) + assert caplog.text.count("Fixing buffer size to array length.") > 0 def test_rms_envelope(): diff --git a/phys2denoise/tests/test_rvt.py b/phys2denoise/tests/test_rvt.py index 13e27d8..0bc4066 100644 --- a/phys2denoise/tests/test_rvt.py +++ b/phys2denoise/tests/test_rvt.py @@ -1,4 +1,5 @@ import peakdet +from physutils import io, physio from phys2denoise.metrics.chest_belt import respiratory_variance_time @@ -15,8 +16,49 @@ def test_respiratory_variance_time(fake_phys): phys = peakdet.Physio(fake_phys, fs=62.5) phys = peakdet.operations.filter_physio(phys, cutoffs=3, method="lowpass") phys = peakdet.operations.peakfind_physio(phys) - r = test_respiratory_variance_time( - phys.data, phys.peaks, phys.troughs, samplerate=phys.fs + + # TODO: Change to a simpler call once physutils are + # integrated to peakdet/prep4phys + r = respiratory_variance_time( + phys.data, fs=phys.fs, peaks=phys.peaks, troughs=phys.troughs + ) + assert r is not None + assert len(r) == 18750 + + +def test_respiratory_variance_time(fake_phys): + phys = peakdet.Physio(fake_phys, fs=62.5) + phys = peakdet.operations.filter_physio(phys, cutoffs=3, method="lowpass") + phys = peakdet.operations.peakfind_physio(phys) + + # TODO: Change to a simpler call once physutils are + # integrated to peakdet/prep4phys + r = respiratory_variance_time( + phys.data, fs=phys.fs, peaks=phys.peaks, troughs=phys.troughs ) assert r is not None assert len(r) == 18750 + + +def test_respiratory_variance_time_physio_obj(fake_phys): + phys = peakdet.Physio(fake_phys, fs=62.5) + phys = peakdet.operations.filter_physio(phys, cutoffs=3, method="lowpass") + phys = peakdet.operations.peakfind_physio(phys) + + # TODO: Change to a simpler call once physutils are + # integrated to peakdet/prep4phys + physio_obj = physio.Physio(phys.data, phys.fs) + physio_obj._metadata["peaks"] = phys.peaks + physio_obj._metadata["troughs"] = phys.troughs + physio_obj = respiratory_variance_time(physio_obj) + + assert ( + physio_obj.history[0][0] + == "phys2denoise.metrics.chest_belt.respiratory_variance_time" + ) + assert physio_obj.computed_metrics["respiratory_variance_time"].ndim == 2 + assert physio_obj.computed_metrics["respiratory_variance_time"].shape == (18750, 4) + assert physio_obj.computed_metrics["respiratory_variance_time"].has_lags == True + + # assert r is not None + # assert len(r) == 18750 diff --git a/setup.cfg b/setup.cfg index a3f8ff9..81268a1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,13 +19,15 @@ provides = phys2denoise [options] -python_requires = >=3.6.1 +python_requires = >=3.9 install_requires = - numpy >=1.9.3 - matplotlib >=3.1.1 + numpy >=1.9.3, <2 + matplotlib pandas scipy duecredit + loguru + physutils >=0.2.1 tests_require = pytest >=5.3 test_suite = pytest @@ -48,7 +50,7 @@ test = %(style)s pytest >=5.3 pytest-cov - peakdet + peakdet>=0.5.0 coverage devtools = pre-commit