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
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