diff --git a/.all-contributorsrc b/.all-contributorsrc
index de5f715..4e8b663 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -34,7 +34,8 @@
"infra",
"projectManagement",
"code",
- "ideas"
+ "ideas",
+ "doc"
]
},
{
@@ -47,6 +48,15 @@
"ideas",
"review"
]
+ },
+ {
+ "login": "ineschh",
+ "name": "Inés Chavarría",
+ "avatar_url": "https://avatars.githubusercontent.com/u/72545702?v=4",
+ "profile": "https://github.com/ineschh",
+ "contributions": [
+ "infra"
+ ]
}
],
"contributorsPerLine": 8,
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml
index 9e24640..4ddc1bd 100644
--- a/.github/workflows/pythonpublish.yml
+++ b/.github/workflows/pythonpublish.yml
@@ -18,7 +18,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v1
with:
- python-version: 3.7
+ python-version: '3.7'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -29,4 +29,4 @@ jobs:
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python -m python setup.py sdist bdist_wheel
- python -m twine upload dist/*
\ No newline at end of file
+ python -m twine upload dist/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..126ffa6
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,47 @@
+# 0.2.0 (Thu Feb 25 2021)
+
+#### 💥 Breaking Change during development
+
+- Add initial set of metrics [#19](https://github.com/physiopy/phys2denoise/pull/19) ([@CesarCaballeroGaudes](https://github.com/CesarCaballeroGaudes) [@tsalo](https://github.com/tsalo) [@smoia](https://github.com/smoia))
+
+#### Authors: 3
+
+- Cesar Caballero Gaudes ([@CesarCaballeroGaudes](https://github.com/CesarCaballeroGaudes))
+- Stefano Moia ([@smoia](https://github.com/smoia))
+- Taylor Salo ([@tsalo](https://github.com/tsalo))
+
+---
+
+# 0.1.0 (Thu Feb 25 2021)
+
+:tada: This release contains work from new contributors! :tada:
+
+Thanks for all your work!
+
+:heart: Stefano Moia ([@smoia](https://github.com/smoia))
+
+:heart: Inés Chavarría ([@ineschh](https://github.com/ineschh))
+
+#### 💥 Breaking Change during development
+
+- Add main workflow of phys2denoise [#17](https://github.com/physiopy/phys2denoise/pull/17) ([@smoia](https://github.com/smoia) [@ineschh](https://github.com/ineschh))
+
+#### ⚠️ Pushed to `master`
+
+- update all-contributors ([@smoia](https://github.com/smoia))
+- Correct attributes ([@smoia](https://github.com/smoia))
+- Add duecredit to setup ([@smoia](https://github.com/smoia))
+- General configuration of the infrastructure ([@smoia](https://github.com/smoia))
+- Correct development status ([@smoia](https://github.com/smoia))
+- Add infra files ([@smoia](https://github.com/smoia))
+- Initial commit ([@smoia](https://github.com/smoia))
+
+#### 🏠 Internal
+
+- Update PR template to match `phys2bids` [#20](https://github.com/physiopy/phys2denoise/pull/20) ([@smoia](https://github.com/smoia))
+- Add initial infrastructure [#12](https://github.com/physiopy/phys2denoise/pull/12) ([@smoia](https://github.com/smoia))
+
+#### Authors: 2
+
+- Inés Chavarría ([@ineschh](https://github.com/ineschh))
+- Stefano Moia ([@smoia](https://github.com/smoia))
diff --git a/README.md b/README.md
index 8742d16..d6b7b14 100644
--- a/README.md
+++ b/README.md
@@ -29,8 +29,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Cesar Caballero Gaudes 💻 🤔 |
Ross Markello 🚇 |
- Stefano Moia 🔣 🚇 📆 💻 🤔 |
+ Stefano Moia 🔣 🚇 📆 💻 🤔 📖 |
Taylor Salo 💻 🤔 👀 |
+ Inés Chavarría 🚇 |
diff --git a/phys2denoise/cli/run.py b/phys2denoise/cli/run.py
new file mode 100644
index 0000000..4103513
--- /dev/null
+++ b/phys2denoise/cli/run.py
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+"""Parser for phys2denoise."""
+
+
+import argparse
+
+from phys2denoise import __version__
+from phys2denoise.metrics.cardiac import crf
+from phys2denoise.metrics.chest_belt import rpv, rv, rvt, rrf, env
+
+
+def _get_parser():
+ """
+ Parse command line inputs for this function.
+
+ Returns
+ -------
+ parser.parse_args() : argparse dict
+
+ Notes
+ -----
+ Default values must be updated in __call__ method from MetricsArgDict class.
+ # Argument parser follow template provided by RalphyZ.
+ # https://stackoverflow.com/a/43456577
+ """
+
+ parser = argparse.ArgumentParser()
+ optional = parser._action_groups.pop()
+ required = parser.add_argument_group("Required Argument")
+ metrics = parser.add_argument_group("Metrics")
+ metric_arg = parser.add_argument_group("Metrics Arguments")
+ # Required arguments
+ required.add_argument("-in", "--input-file",
+ dest="filename",
+ type=str,
+ help="Full path and name of the file containing "
+ "physiological data, with or without extension.",
+ required=True)
+ # Important optional arguments
+ optional.add_argument("-outdir", "--output-dir",
+ dest="outdir",
+ type=str,
+ help="Folder where output should be placed. "
+ "Default is current folder.",
+ default=".")
+ # Metric selection
+ metrics.add_argument("-crf", "--cardiac-response-function",
+ dest="metrics",
+ action="append_const",
+ const=crf,
+ help="Cardiac response function. Requires the following "
+ "inputs:sample-rate, oversampling, time-length, "
+ "onset and tr.",
+ default=[])
+ metrics.add_argument("-rpv", "--respiratory-pattern-variability",
+ dest="metrics",
+ action="append_const",
+ const=rpv,
+ help="Respiratory pattern variability. Requires the following "
+ "input: window.",
+ default=[])
+ metrics.add_argument("-env", "--envelope",
+ dest="metrics",
+ action="append_const",
+ const=env,
+ help="Respiratory pattern variability calculated across a sliding "
+ "window. Requires the following inputs: sample-rate, window and lags.",
+ default=[])
+ metrics.add_argument("-rv", "--respiratory-variance",
+ dest="metrics",
+ action="append_const",
+ const=rv,
+ help="Respiratory variance. Requires the following inputs: "
+ "sample-rate, window and lags. If the input file "
+ "not a .phys file, it also requires peaks and troughs",
+ default=[])
+ """
+ metrics.add_argument("-rvt", "--respiratory-volume-per-time",
+ dest="metrics",
+ action="append_const",
+ const="rvt",
+ help="Respiratory volume-per-time. Requires the following inputs: "
+ "sample-rate, window, lags, peaks and troughs.",
+ default=[])
+ """
+ metrics.add_argument("-rrf", "--respiratory-response-function",
+ dest="metrics",
+ action="append_const",
+ const=rrf,
+ help="Respiratory response function. Requires the following inputs: "
+ "sample-rate, oversampling, time-length, onset and tr.",
+ default=[])
+ metrics.add_argument("-rcard", "--retroicor-card",
+ dest="metrics",
+ action="append_const",
+ const="r_card",
+ help="Computes regressors for cardiac signal. Requires the following "
+ "inputs: tr, nscans and n_harm.",
+ default=[])
+ metrics.add_argument("-rresp", "--retroicor-resp",
+ dest="metrics",
+ action="append_const",
+ const="r_resp",
+ help="Computes regressors for respiratory signal. Requires the following "
+ "inputs: tr, nscans and n_harm.",
+ default=[])
+ # Metric arguments
+ metric_arg.add_argument("-sr", "--sample-rate",
+ dest="sample_rate",
+ type=float,
+ help="Sampling rate of the physiological data in Hz.",
+ default=None)
+ metric_arg.add_argument("-pk", "--peaks",
+ dest="peaks",
+ type=str,
+ help="Full path and filename of the list with the indexed peaks' "
+ "positions of the physiological data.",
+ default=None)
+ metric_arg.add_argument("-tg", "--troughs",
+ dest="troughs",
+ type=str,
+ help="Full path and filename of the list with the indexed troughs' "
+ "positions of the physiological data.",
+ default=None)
+ metric_arg.add_argument("-os", "--oversampling",
+ dest="oversampling",
+ type=int,
+ help="Temporal oversampling factor. "
+ "Default is 50.",
+ default=50)
+ metric_arg.add_argument("-tl", "--time-length",
+ dest="time_length",
+ type=int,
+ help="RRF or CRF Kernel length in seconds.",
+ default=None)
+ metric_arg.add_argument("-onset", "--onset",
+ dest="onset",
+ type=float,
+ help="Onset of the response in seconds. "
+ "Default is 0.",
+ default=0)
+ metric_arg.add_argument("-tr", "--tr",
+ dest="tr",
+ type=float,
+ help="TR of sequence in seconds.",
+ default=None)
+ metric_arg.add_argument("-win", "--window",
+ dest="window",
+ type=int,
+ help="Size of the sliding window in seconds. "
+ "Default is 6 seconds.",
+ default=6)
+ metric_arg.add_argument("-lags", "--lags",
+ dest="lags",
+ nargs="*",
+ type=int,
+ help="List of lags to apply to the RV estimate "
+ "in seconds.",
+ default=None)
+ metric_arg.add_argument("-nscans", "--number-scans",
+ dest="nscans",
+ type=int,
+ help="Number of timepoints in the imaging data. "
+ "Also called sub-bricks, TRs, scans, volumes."
+ "Default is 1.",
+ default=1)
+ metric_arg.add_argument("-nharm", "--number-harmonics",
+ dest="n_harm",
+ type=int,
+ help="Number of harmonics.",
+ default=None)
+
+ # Other optional arguments
+ optional.add_argument("-debug", "--debug",
+ dest="debug",
+ action="store_true",
+ help="Only print debugging info to log file. Default is False.",
+ default=False)
+ optional.add_argument("-quiet", "--quiet",
+ dest="quiet",
+ action="store_true",
+ help="Only print warnings to log file. Default is False.",
+ default=False)
+ optional.add_argument("-v", "--version", action="version",
+ version=("%(prog)s " + __version__))
+
+ parser._action_groups.append(optional)
+
+ return parser
+
+
+if __name__ == "__main__":
+ raise RuntimeError("phys2denoise/cli/run.py should not be run directly;\n"
+ "Please `pip install` phys2denoise and use the "
+ "`phys2denoise` command")
diff --git a/phys2denoise/due.py b/phys2denoise/due.py
new file mode 100644
index 0000000..4ff2457
--- /dev/null
+++ b/phys2denoise/due.py
@@ -0,0 +1,77 @@
+# emacs: at the end of the file
+# ex: set sts=4 ts=4 sw=4 et:
+# ## ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### ### #
+"""Stub file for a guaranteed safe import of duecredit constructs if duecredit is not available.
+
+To use it, place it into your project codebase to be imported, e.g. copy as
+
+ cp stub.py /path/tomodule/module/due.py
+
+Note that it might be better to avoid naming it duecredit.py to avoid shadowing
+installed duecredit.
+
+Then use in your code as
+
+ from .due import due, Doi, BibTeX, Text
+
+See https://github.com/duecredit/duecredit/blob/master/README.md for examples.
+
+Origin: Originally a part of the duecredit
+Copyright: 2015-2019 DueCredit developers
+License: BSD-2
+"""
+
+__version__ = "0.0.8"
+
+
+class InactiveDueCreditCollector(object):
+ """Just a stub at the Collector which would not do anything."""
+
+ def _donothing(self, *args, **kwargs):
+ """Perform no good and no bad."""
+ pass
+
+ def dcite(self, *args, **kwargs):
+ """If I could cite I would."""
+
+ def nondecorating_decorator(func):
+ return func
+
+ return nondecorating_decorator
+
+ active = False
+ activate = add = cite = dump = load = _donothing
+
+ def __repr__(self):
+ """Perform magic."""
+ return self.__class__.__name__ + "()"
+
+
+def _donothing_func(*args, **kwargs):
+ """Perform no good and no bad."""
+ pass
+
+
+try:
+ from duecredit import BibTeX, Doi, Text, Url, due
+
+ if "due" in locals() and not hasattr(due, "cite"):
+ raise RuntimeError("Imported due lacks .cite. DueCredit is now disabled")
+except Exception as e:
+ if not isinstance(e, ImportError):
+ import logging
+
+ logging.getLogger("duecredit").error(
+ "Failed to import duecredit due to %s" % str(e)
+ )
+ # Initiate due stub
+ due = InactiveDueCreditCollector()
+ BibTeX = Doi = Url = Text = _donothing_func
+
+# Emacs mode definitions
+# Local Variables:
+# mode: python
+# py-indent-offset: 4
+# tab-width: 4
+# indent-tabs-mode: nil
+# End:
diff --git a/phys2denoise/metrics/__init__.py b/phys2denoise/metrics/__init__.py
index e69de29..8eb43c8 100644
--- a/phys2denoise/metrics/__init__.py
+++ b/phys2denoise/metrics/__init__.py
@@ -0,0 +1,15 @@
+"""Metrics derived from physiological signals."""
+from .cardiac import crf, cardiac_phase
+from .chest_belt import rpv, env, rv, rrf, respiratory_phase
+from .multimodal import retroicor
+
+__all__ = [
+ "crf",
+ "cardiac_phase",
+ "rpv",
+ "env",
+ "rv",
+ "rrf",
+ "respiratory_phase",
+ "retroicor",
+]
diff --git a/phys2denoise/metrics/cardiac.py b/phys2denoise/metrics/cardiac.py
new file mode 100644
index 0000000..f52d7b2
--- /dev/null
+++ b/phys2denoise/metrics/cardiac.py
@@ -0,0 +1,119 @@
+"""Denoising metrics for cardio recordings."""
+import numpy as np
+
+from .. import references
+from ..due import due
+
+
+def iht():
+ """Calculate instantaneous heart rate."""
+ pass
+
+
+@due.dcite(references.CHANG_GLOVER_2009)
+def crf(samplerate, oversampling=50, time_length=32, onset=0.0, tr=2.0):
+ """Calculate the cardiac response function using Chang and Glover's definition.
+
+ Parameters
+ ----------
+ samplerate : :obj:`float`
+ Sampling rate of data, in seconds.
+ oversampling : :obj:`int`, optional
+ Temporal oversampling factor, in seconds. Default is 50.
+ time_length : :obj:`int`, optional
+ RRF kernel length, in seconds. Default is 32.
+ onset : :obj:`float`, optional
+ Onset of the response, in seconds. Default is 0.
+
+ Returns
+ -------
+ crf : array-like
+ Cardiac or "heart" response function
+
+ Notes
+ -----
+ This cardiac response function was defined in [1]_, Appendix A.
+
+ The core code for this function comes from metco2, while several of the
+ parameters, including oversampling, time_length, and onset, are modeled on
+ nistats' HRF functions.
+
+ References
+ ----------
+ .. [1] C. Chang & G. H. Glover, "Relationship between respiration,
+ end-tidal CO2, and BOLD signals in resting-state fMRI," Neuroimage,
+ issue 47, vol. 4, pp. 1381-1393, 2009.
+ """
+
+ def _crf(t):
+ rf = 0.6 * t ** 2.7 * np.exp(-t / 1.6) - 16 * (
+ 1 / np.sqrt(2 * np.pi * 9)
+ ) * np.exp(-0.5 * (((t - 12) ** 2) / 9))
+ return rf
+
+ dt = tr / oversampling
+ time_stamps = np.linspace(
+ 0, time_length, np.rint(float(time_length) / dt).astype(np.int)
+ )
+ time_stamps -= onset
+ crf_arr = _crf(time_stamps)
+ crf_arr = crf_arr / max(abs(crf_arr))
+ return crf_arr
+
+
+def cardiac_phase(peaks, sample_rate, slice_timings, n_scans, t_r):
+ """Calculate cardiac phase from cardiac peaks.
+
+ Assumes that timing of cardiac events are given in same units
+ as slice timings, for example seconds.
+
+ Parameters
+ ----------
+ peaks : 1D array_like
+ Cardiac peak times, in seconds.
+ sample_rate : float
+ Sample rate of physio, in Hertz.
+ 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.
+
+ Returns
+ -------
+ phase_card : array_like
+ Cardiac phase signal, of shape (n_scans,)
+ """
+ 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
+ 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]
+ phase_card_crSlice = np.zeros(n_scans)
+ for j_scan in range(n_scans):
+ previous_card_peaks = np.asarray(
+ np.nonzero(card_peaks_sec < times_crSlice[j_scan])
+ )
+ if np.size(previous_card_peaks) == 0:
+ t1 = 0
+ else:
+ last_peak = previous_card_peaks[0][-1]
+ t1 = card_peaks_sec[last_peak]
+ next_card_peaks = np.asarray(
+ np.nonzero(card_peaks_sec > times_crSlice[j_scan])
+ )
+ if np.size(next_card_peaks) == 0:
+ t2 = n_scans * t_r
+ else:
+ next_peak = next_card_peaks[0][0]
+ t2 = card_peaks_sec[next_peak]
+ phase_card_crSlice[j_scan] = (
+ 2 * np.math.pi * (times_crSlice[j_scan] - t1)
+ ) / (t2 - t1)
+ phase_card[:, i_slice] = phase_card_crSlice
+
+ return phase_card
diff --git a/phys2denoise/metrics/chest_belt.py b/phys2denoise/metrics/chest_belt.py
index 1098bed..e6bb51e 100644
--- a/phys2denoise/metrics/chest_belt.py
+++ b/phys2denoise/metrics/chest_belt.py
@@ -1,7 +1,19 @@
"""Denoising metrics for chest belt recordings."""
+import matplotlib as mpl
import numpy as np
+import pandas as pd
+from scipy.ndimage.filters import convolve1d
+from scipy.signal import detrend
+from scipy.stats import zscore
from scipy.interpolate import interp1d
+mpl.use("TkAgg")
+import matplotlib.pyplot as plt
+
+from .. import references
+from ..due import due
+from . import utils
+
def rvt(belt_ts, peaks, troughs, samplerate, lags=(0, 4, 8, 12)):
"""
@@ -63,3 +75,246 @@ def rvt(belt_ts, peaks, troughs, samplerate, lags=(0, 4, 8, 12)):
rvt_lags[:, ind] = temp_rvt
return rvt_lags
+
+
+@due.dcite(references.POWER_2018)
+def rpv(resp, window=6):
+ """Calculate respiratory pattern variability.
+
+ Parameters
+ ----------
+ resp : 1D array_like
+ window : int
+
+ Returns
+ -------
+ rpv_val : float
+ Respiratory pattern variability value.
+
+ Notes
+ -----
+ This metric was first introduced in [1]_.
+
+ 1. Z-score respiratory belt signal
+ 2. Calculate upper envelope
+ 3. Calculate standard deviation of envelope
+
+ References
+ ----------
+ .. [1] J. D. Power et al., "Ridding fMRI data of motion-related influences:
+ Removal of signals with distinct spatial and physical bases in multiecho
+ data," Proceedings of the National Academy of Sciences, issue 9, vol.
+ 115, pp. 2105-2114, 2018.
+ """
+ # First, z-score respiratory traces
+ resp_z = zscore(resp)
+
+ # Collect upper envelope
+ rpv_upper_env = utils.rms_envelope_1d(resp_z, window)
+
+ # Calculate standard deviation
+ rpv_val = np.std(rpv_upper_env)
+ return rpv_val
+
+
+@due.dcite(references.POWER_2020)
+def env(resp, samplerate, window=10):
+ """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.
+ window : :obj:`int`, optional
+ Size of the sliding window, in seconds.
+ Default is 10.
+
+ Returns
+ -------
+ env_arr
+
+ Notes
+ -----
+ This metric was first introduced in [1]_.
+
+ Across a sliding window, do the following:
+ 1. Z-score respiratory belt signal
+ 2. Calculate upper envelope
+ 3. Calculate standard deviation of envelope
+
+ References
+ ----------
+ .. [1] J. D. Power et al., "Characteristics of respiratory measures in
+ young adults scanned at rest, including systematic changes and 'missed'
+ deep breaths," Neuroimage, vol. 204, 2020.
+ """
+ # Convert window to Hertz
+ window = int(window * samplerate)
+
+ # Calculate RPV across a rolling window
+ env_arr = (
+ pd.Series(resp).rolling(window=window, center=True).apply(rpv, args=(window,))
+ )
+ env_arr[np.isnan(env_arr)] = 0.0
+ return env_arr
+
+
+@due.dcite(references.CHANG_GLOVER_2009)
+def rv(resp, samplerate, window=6, lags=(0,)):
+ """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.
+ window : :obj:`int`, optional
+ Size of the sliding window, in seconds.
+ Default is 6.
+
+ Returns
+ -------
+ rv_out : (X, 2) :obj:`numpy.ndarray`
+ Respiratory variance values.
+ The first column is raw RV values, after detrending/normalization.
+ The second column is RV values convolved with the RRF, after detrending/normalization.
+
+ Notes
+ -----
+ Respiratory variance (RV) was introduced in [1]_, and consists of the
+ standard deviation of the respiratory trace within a 6-second window.
+
+ This metric is often lagged back and/or forward in time and convolved with
+ a respiratory response function before being included in a GLM.
+ Regressors also often have mean and linear trends removed and are
+ standardized prior to regressions.
+
+ References
+ ----------
+ .. [1] C. Chang & G. H. Glover, "Relationship between respiration,
+ end-tidal CO2, and BOLD signals in resting-state fMRI," Neuroimage,
+ issue 4, vol. 47, pp. 1381-1393, 2009.
+ """
+ # Convert window to Hertz
+ window = int(window * samplerate)
+
+ # Raw respiratory variance
+ rv_arr = pd.Series(resp).rolling(window=window, center=True).std()
+ rv_arr[np.isnan(rv_arr)] = 0.0
+
+ # Convolve with rrf
+ rrf_arr = rrf(samplerate, oversampling=1)
+ rv_convolved = convolve1d(rv_arr, rrf_arr, axis=0)
+
+ # Concatenate the raw and convolved versions
+ rv_combined = np.stack((rv_arr, rv_convolved), axis=-1)
+
+ # Detrend and normalize
+ rv_combined = rv_combined - np.mean(rv_combined, axis=0)
+ rv_combined = detrend(rv_combined, axis=0)
+ rv_out = zscore(rv_combined, axis=0)
+ return rv_out
+
+
+@due.dcite(references.CHANG_GLOVER_2009)
+def rrf(samplerate, oversampling=50, time_length=50, onset=0.0, tr=2.0):
+ """Calculate the respiratory response function using Chang and Glover's definition.
+
+ Parameters
+ ----------
+ samplerate : :obj:`float`
+ Sampling rate of data, in seconds.
+ oversampling : :obj:`int`, optional
+ Temporal oversampling factor. Default is 50.
+ time_length : :obj:`int`, optional
+ RRF kernel length, in seconds. Default is 50.
+ onset : :obj:`float`, optional
+ Onset of the response, in seconds. Default is 0.
+
+ Returns
+ -------
+ rrf : array-like
+ respiratory response function
+
+ Notes
+ -----
+ This respiratory response function was defined in [1]_, Appendix A.
+
+ The core code for this function comes from metco2, while several of the
+ parameters, including oversampling, time_length, and onset, are modeled on
+ nistats' HRF functions.
+
+ References
+ ----------
+ .. [1] C. Chang & G. H. Glover, "Relationship between respiration,
+ end-tidal CO2, and BOLD signals in resting-state fMRI," Neuroimage,
+ issue 47, vol. 4, pp. 1381-1393, 2009.
+ """
+
+ 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
+
+ dt = tr / oversampling
+ time_stamps = np.linspace(
+ 0, time_length, np.rint(float(time_length) / dt).astype(np.int)
+ )
+ time_stamps -= onset
+ rrf_arr = _rrf(time_stamps)
+ rrf_arr = rrf_arr / max(abs(rrf_arr))
+ return rrf_arr
+
+
+def respiratory_phase(resp, sample_rate, n_scans, slice_timings, t_r):
+ """Calculate respiratory phase from respiratory signal.
+
+ Parameters
+ ----------
+ resp : 1D array_like
+ Respiratory signal.
+ sample_rate : float
+ Sample rate of physio, in Hertz.
+ 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.
+
+ Returns
+ -------
+ phase_resp : array_like
+ Respiratory phase signal, of shape (n_scans, n_slices).
+ """
+ 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, _ = plt.hist(resp, bins=100)
+
+ # first compute derivative of respiration signal
+ resp_diff = np.diff(resp, n=1)
+
+ 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]
+ 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)])
+ ) # 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))
+ numerator = np.sum(resp_hist[0:thisBin])
+ phase_resp_crSlice[j_scan] = (
+ np.math.pi * np.sign(resp_diff[iphys]) * (numerator / len(resp))
+ )
+
+ phase_resp[:, i_slice] = phase_resp_crSlice
+
+ return phase_resp
\ No newline at end of file
diff --git a/phys2denoise/metrics/gas.py b/phys2denoise/metrics/gas.py
new file mode 100644
index 0000000..a4e3a59
--- /dev/null
+++ b/phys2denoise/metrics/gas.py
@@ -0,0 +1 @@
+"""Denoising metrics for gas recordings."""
diff --git a/phys2denoise/metrics/multimodal.py b/phys2denoise/metrics/multimodal.py
new file mode 100644
index 0000000..fb95eae
--- /dev/null
+++ b/phys2denoise/metrics/multimodal.py
@@ -0,0 +1,105 @@
+"""These functions compute RETROICOR regressors (Glover et al. 2000)."""
+
+import numpy as np
+
+from .cardiac import cardiac_phase
+from .chest_belt import respiratory_phase
+from .. import references
+from ..due import due
+
+
+@due.dcite(references.GLOVER_2000)
+def retroicor(
+ physio,
+ sample_rate,
+ t_r,
+ n_scans,
+ slice_timings,
+ n_harmonics,
+ card=False,
+ resp=False,
+):
+ """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.
+ t_r : float
+ Imaging sample rate, in seconds.
+ n_scans : int
+ Number of volumes in the imaging run.
+ slice_timings : array_like
+ Slice times, in seconds.
+ n_harmonics : int
+ ???
+ card : bool, optional
+ Whether the physio data correspond to cardiac or repiratory signal.
+ resp : bool, optional
+ Whether the physio data correspond to cardiac or repiratory signal.
+
+ Returns
+ -------
+ retroicor_regressors : list
+ phase : array_like
+ 2D array of shape (n_scans, n_slices)
+
+ Notes
+ -----
+ RETROICOR regressors should be regressed from the imaging data *before*
+ any other preprocessing, including slice-timing correction and motion correction.
+
+ References
+ ----------
+ * Glover, G. H., Li, T. Q., & Ress, D. (2000).
+ Image‐based method for retrospective correction of physiological
+ motion effects in fMRI: RETROICOR.
+ Magnetic Resonance in Medicine:
+ An Official Journal of the International Society for Magnetic Resonance in Medicine,
+ 44(1), 162-167.
+ """
+ n_slices = np.shape(slice_timings) # number of slices
+
+ # initialize output variables
+ retroicor_regressors = []
+ phase = np.empty((n_scans, n_slices))
+
+ for i_slice in range(n_slices):
+ retroicor_regressors.append(np.empty((n_scans, 2 * n_harmonics)))
+
+ # Initialize slice timings for current slice
+ crslice_timings = t_r * np.arange(n_scans) + slice_timings[i_slice]
+
+ # Compute physiological phases using the timings of physio events (e.g. peaks)
+ # slice sampling times
+ if card:
+ phase[:, i_slice] = cardiac_phase(
+ physio,
+ crslice_timings,
+ n_scans,
+ t_r,
+ )
+
+ if resp:
+ phase[:, i_slice] = respiratory_phase(
+ physio,
+ sample_rate,
+ n_scans,
+ slice_timings,
+ t_r,
+ )
+
+ # Compute retroicor regressors
+ for j_harm in range(n_harmonics):
+ retroicor_regressors[i_slice][:, 2 * j_harm] = np.cos(
+ (j_harm + 1) * phase[i_slice]
+ )
+ retroicor_regressors[i_slice][:, 2 * j_harm + 1] = np.sin(
+ (j_harm + 1) * phase[i_slice]
+ )
+
+ return retroicor_regressors, phase
diff --git a/phys2denoise/metrics/utils.py b/phys2denoise/metrics/utils.py
new file mode 100644
index 0000000..7426a5c
--- /dev/null
+++ b/phys2denoise/metrics/utils.py
@@ -0,0 +1,125 @@
+"""Miscellaneous utility functions for metric calculation."""
+import logging
+
+import numpy as np
+
+LGR = logging.getLogger(__name__)
+LGR.setLevel(logging.INFO)
+
+
+def print_metric_call(metric, args):
+ """
+ Log a message to describe how a metric is being called.
+
+ Parameters
+ ----------
+ metric : function
+ Metric function that is being called
+ args : dict
+ Dictionary containing all arguments that are used to parametrise metric
+
+ Notes
+ -----
+ Outcome
+ An info-level message for the logger.
+ """
+ msg = f'The {metric} regressor will be computed using the following parameters:'
+
+ for arg in args:
+ msg = f'{msg}\n {arg} = {args[arg]}'
+
+ msg = f'{msg}\n'
+
+ LGR.info(msg)
+
+
+def mirrorpad_1d(arr, buffer=250):
+ """Pad both sides of array with flipped values from array of length 'buffer'.
+
+ Parameters
+ ----------
+ arr
+ buffer
+
+ Returns
+ -------
+ arr_out
+ """
+ mirror = np.flip(arr, axis=0)
+ idx = range(arr.shape[0] - buffer, arr.shape[0])
+ pre_mirror = np.take(mirror, idx, axis=0)
+ idx = range(0, buffer)
+ post_mirror = np.take(mirror, idx, axis=0)
+ arr_out = np.concatenate((pre_mirror, arr, post_mirror), axis=0)
+ return arr_out
+
+
+def rms_envelope_1d(arr, window=500):
+ """Conceptual translation of MATLAB 2017b's envelope(X, x, 'rms') function.
+
+ Parameters
+ ----------
+ arr
+ window
+
+ Returns
+ -------
+ rms_env : numpy.ndarray
+ The upper envelope.
+
+ Notes
+ -----
+ https://www.mathworks.com/help/signal/ref/envelope.html
+ """
+ assert arr.ndim == 1, "Input data must be 1D"
+ assert window % 2 == 0, "Window must be even"
+ n_t = arr.shape[0]
+ buf = int(window / 2)
+
+ # Pad array at both ends
+ arr = np.copy(arr).astype(float)
+ mean = np.mean(arr)
+ arr -= mean
+ arr = mirrorpad_1d(arr, buffer=buf)
+ rms_env = np.empty(n_t)
+ for i in range(n_t):
+ # to match matlab
+ start_idx = i + buf
+ stop_idx = i + buf + window
+
+ # but this is probably more appropriate
+ # start_idx = i + buf - 1
+ # stop_idx = i + buf + window
+ window_arr = arr[start_idx:stop_idx]
+ rms = np.sqrt(np.mean(window_arr ** 2))
+ rms_env[i] = rms
+ rms_env += mean
+ return rms_env
+
+
+def apply_lags(arr1d, lags):
+ """Apply delays (lags) to an array.
+
+ Parameters
+ ----------
+ arr1d : (X,) :obj:`numpy.ndarray`
+ One-dimensional array to apply delays to.
+ lags : (Y,) :obj:`tuple` or :obj:`int`
+ Delays, in the same units as arr1d, to apply to arr1d. Can be negative,
+ zero, or positive integers.
+
+ Returns
+ -------
+ arr_with_lags : (X, Y) :obj:`numpy.ndarray`
+ arr1d shifted according to lags. Each column corresponds to a lag.
+ """
+ arr_with_lags = np.zeros((arr1d.shape[0], len(lags)))
+ for i_lag, lag in enumerate(lags):
+ if lag < 0:
+ arr_delayed = np.hstack((arr1d[lag:], np.zeros(lag)))
+ elif lag > 0:
+ arr_delayed = np.hstack((np.zeros(lag), arr1d[lag:]))
+ else:
+ arr_delayed = arr1d.copy()
+ arr_with_lags[:, i_lag] = arr_delayed
+ return arr_with_lags
diff --git a/phys2denoise/phys2denoise.py b/phys2denoise/phys2denoise.py
new file mode 100644
index 0000000..0cc681e
--- /dev/null
+++ b/phys2denoise/phys2denoise.py
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+
+"""
+Phys2denoise is a python3 library meant to prepare physiological regressors for fMRI denoising.
+
+The project is under development.
+
+Copyright 2020, The physiopy community.
+Please scroll to bottom to read full license.
+
+"""
+
+import datetime
+import logging
+import os
+import sys
+from inspect import signature, _empty
+
+import numpy as np
+import pandas as pd
+
+from phys2denoise.cli.run import _get_parser
+from phys2denoise.metrics.cardiac import crf
+from phys2denoise.metrics.chest_belt import rpv, rv, rvt, rrf
+from phys2denoise.metrics.retroicor import retroicor
+
+from . import __version__
+from .due import due, Doi
+
+LGR = logging.getLogger(__name__)
+LGR.setLevel(logging.INFO)
+
+
+def save_bash_call(outdir):
+ """
+ Save the bash call into file `p2d_call.sh`.
+
+ Parameters
+ ----------
+ metric : function
+ Metric function to retrieve arguments for
+ metric_args : dict
+ Dictionary containing all arguments for all functions requested by the
+ user
+ """
+ arg_str = ' '.join(sys.argv[1:])
+ call_str = f'phys2denoise {arg_str}'
+ outdir = os.path.abspath(outdir)
+ log_path = os.path.join(outdir, 'code', 'logs')
+ os.makedirs(log_path)
+ f = open(os.path.join(log_path, 'p2d_call.sh'), "a")
+ f.write(f'#!bin/bash \n{call_str}')
+ f.close()
+
+
+def select_input_args(metric, metric_args):
+ """
+ Retrieve required args for metric from a dictionary of possible arguments.
+
+ This function checks what parameters are accepted by a metric.
+ Then, for each parameter, check if the user provided it or not.
+ If they did not, but the parameter is required, throw an error -
+ unless it's "physio", reserved name for the timeseries input to a metric.
+ Otherwise, use the default.
+
+ Parameters
+ ----------
+ metric : function
+ Metric function to retrieve arguments for
+ metric_args : dict
+ Dictionary containing all arguments for all functions requested by the
+ user
+
+ Returns
+ -------
+ args : dict
+ Arguments to provide as input to metric
+
+ Raises
+ ------
+ ValueError
+ If a required argument is missing
+
+ """
+ args = {}
+
+ # Check the parameters required by the metric and given by the user (see docstring)
+ for param in signature(metric).parameters.values():
+ if param.name not in metric_args:
+ if param.default == _empty and param.name != 'physio':
+ raise ValueError(f'Missing parameter {param} required '
+ f'to run {metric}')
+ else:
+ args[param.name] = param.default
+ else:
+ args[param.name] = metric_args[param.name]
+
+ return args
+
+
+@due.dcite(
+ Doi(''),
+ path='phys2denoise',
+ description='Creation of regressors for physiological denoising',
+ version=__version__,
+ cite_module=True)
+def phys2denoise(filename, outdir='.',
+ metrics=[crf, rpv, rv, rvt, rrf, 'retroicor_card', 'retroicor_resp'],
+ debug=False, quiet=False, **kwargs):
+ """
+ Run main workflow of phys2denoise.
+
+ Runs the parser, does some checks on input, then computes the required metrics.
+
+ Notes
+ -----
+ Any metric argument should go into kwargs!
+ The code was greatly copied from phys2bids (copyright the physiopy community)
+
+ """
+ # Check options to make them internally coherent pt. I
+ # #!# This can probably be done while parsing?
+ outdir = os.path.abspath(outdir)
+ log_path = os.path.join(outdir, 'code', 'logs')
+ os.makedirs(log_path)
+
+ # Create logfile name
+ basename = 'phys2denoise_'
+ extension = 'tsv'
+ isotime = datetime.datetime.now().strftime('%Y-%m-%dT%H%M%S')
+ logname = os.path.join(log_path, (basename + isotime + '.' + extension))
+
+ # Set logging format
+ log_formatter = logging.Formatter(
+ '%(asctime)s\t%(name)-12s\t%(levelname)-8s\t%(message)s',
+ datefmt='%Y-%m-%dT%H:%M:%S')
+
+ # Set up logging file and open it for writing
+ log_handler = logging.FileHandler(logname)
+ log_handler.setFormatter(log_formatter)
+ sh = logging.StreamHandler()
+
+ if quiet:
+ logging.basicConfig(level=logging.WARNING,
+ handlers=[log_handler, sh],
+ format='%(levelname)-10s %(message)s')
+ elif debug:
+ logging.basicConfig(level=logging.DEBUG,
+ handlers=[log_handler, sh],
+ format='%(levelname)-10s %(message)s')
+ else:
+ logging.basicConfig(level=logging.INFO,
+ handlers=[log_handler, sh],
+ format='%(levelname)-10s %(message)s')
+
+ version_number = __version__
+ LGR.info(f'Currently running phys2denoise version {version_number}')
+ LGR.info(f'Input file is {filename}')
+
+ # Check options to make them internally coherent pt. II
+ # #!# This can probably be done while parsing?
+ # filename, ftype = utils.check_input_type(filename)
+
+ if not os.path.isfile(filename) and filename is not None:
+ raise FileNotFoundError(f'The file {filename} does not exist!')
+
+ # Read input file
+ physio = np.genfromtxt(filename)
+
+ # Prepare pandas dataset
+ regr = pd.DataFrame()
+
+ # Goes through the list of metrics and calls them
+ for metric in metrics:
+ if metric == 'retroicor_card':
+ args = select_input_args(retroicor, kwargs)
+ args['card'] = True
+ retroicor_regrs = retroicor(physio, **args)
+ for vslice in range(len(args['slice_timings'])):
+ for harm in range(args['n_harm']):
+ key = f'rcor-card_s-{vslice}_hrm-{harm}'
+ regr[f'{key}_cos'] = retroicor_regrs[vslice][:, harm*2]
+ regr[f'{key}_sin'] = retroicor_regrs[vslice][:, harm*2+1]
+ elif metric == 'retroicor_resp':
+ args = select_input_args(retroicor, kwargs)
+ args['resp'] = True
+ retroicor_regrs = retroicor(physio, **args)
+ for vslice in range(len(args['slice_timings'])):
+ for harm in range(args['n_harm']):
+ key = f'rcor-resp_s-{vslice}_hrm-{harm}'
+ regr[f'{key}_cos'] = retroicor_regrs[vslice][:, harm*2]
+ regr[f'{key}_sin'] = retroicor_regrs[vslice][:, harm*2+1]
+ else:
+ args = select_input_args(metric, kwargs)
+ regr[metric.__name__] = metric(physio, **args)
+
+ #!# Add regressors visualisation
+
+ # Export regressors and sidecar
+ out_filename = os.join(outdir, 'derivatives', filename)
+ regr.to_csv(out_filename, sep='\t', index=False, float_format='%.6e')
+ #!# Add sidecar export
+
+
+def _main(argv=None):
+ options = _get_parser().parse_args(argv)
+
+ save_bash_call(options.outdir)
+
+ phys2denoise(**vars(options))
+
+
+if __name__ == '__main__':
+ _main(sys.argv[1:])
+
+"""
+Copyright 2019, The phys2denoise community.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
diff --git a/phys2denoise/references.py b/phys2denoise/references.py
new file mode 100644
index 0000000..eeb914a
--- /dev/null
+++ b/phys2denoise/references.py
@@ -0,0 +1,10 @@
+"""References to be imported and injected throughout the package."""
+from .due import Doi
+
+CHANG_GLOVER_2009 = Doi("10.1016/j.neuroimage.2009.04.048")
+
+POWER_2018 = Doi("10.1073/pnas.1720985115")
+
+POWER_2020 = Doi("10.1016/j.neuroimage.2019.116234")
+
+GLOVER_2000 = Doi("10.1002/1522-2594(200007)44:1<162::AID-MRM23>3.0.CO;2-E")
diff --git a/phys2denoise/tests/test_metrics_cardiac.py b/phys2denoise/tests/test_metrics_cardiac.py
new file mode 100644
index 0000000..bb10d76
--- /dev/null
+++ b/phys2denoise/tests/test_metrics_cardiac.py
@@ -0,0 +1,41 @@
+"""Tests for phys2denoise.metrics.cardiac."""
+import numpy as np
+
+from phys2denoise.metrics import cardiac
+
+
+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,
+ )
+ pred_len = np.rint(time_length / (tr / oversampling))
+ assert crf_arr.ndim == 1
+ assert crf_arr.shape == pred_len
+
+
+def test_cardiac_phase_smoke():
+ """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])
+ card_phase = cardiac.cardiac_phase(
+ peaks,
+ sample_rate=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)
diff --git a/phys2denoise/tests/test_metrics_chest_belt.py b/phys2denoise/tests/test_metrics_chest_belt.py
new file mode 100644
index 0000000..f256918
--- /dev/null
+++ b/phys2denoise/tests/test_metrics_chest_belt.py
@@ -0,0 +1,73 @@
+"""Tests for phys2denoise.metrics.chest_belt."""
+import numpy as np
+
+from phys2denoise.metrics import chest_belt
+
+
+def test_rrf_smoke():
+ """Basic smoke test for RRF calculation."""
+ samplerate = 0.01 # in seconds
+ oversampling = 20
+ time_length = 20
+ onset = 0
+ 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)))
+ assert rrf_arr.ndim == 1
+ assert rrf_arr.size == pred_len
+
+
+def test_respiratory_phase_smoke():
+ """Basic smoke test for respiratory phase calculation."""
+ t_r = 1.0
+ n_scans = 200
+ sample_rate = 1 / 0.01
+ slice_timings = np.linspace(0, t_r, 22)[1:-1]
+ n_samples = int(np.rint((n_scans * t_r) * sample_rate))
+ resp = np.random.normal(size=n_samples)
+ resp_phase = chest_belt.respiratory_phase(
+ resp,
+ sample_rate=sample_rate,
+ slice_timings=slice_timings,
+ n_scans=n_scans,
+ t_r=t_r,
+ )
+ assert resp_phase.ndim == 2
+ assert resp_phase.shape == (n_scans, slice_timings.size)
+
+
+def test_rpv_smoke():
+ """Basic smoke test for respiratory pattern variability calculation."""
+ n_samples = 2000
+ resp = np.random.normal(size=n_samples)
+ window = 50
+ rpv_val = chest_belt.rpv(resp, window)
+ assert isinstance(rpv_val, float)
+
+
+def test_env_smoke():
+ """Basic smoke test for ENV calculation."""
+ n_samples = 2000
+ resp = np.random.normal(size=n_samples)
+ samplerate = 1 / 0.01
+ window = 6
+ env_arr = chest_belt.env(resp, samplerate=samplerate, window=window)
+ assert env_arr.ndim == 1
+ assert env_arr.shape == (n_samples,)
+
+
+def test_rv_smoke():
+ """Basic smoke test for respiratory variance calculation."""
+ n_samples = 2000
+ resp = np.random.normal(size=n_samples)
+ samplerate = 1 / 0.01
+ window = 6
+ rv_arr = chest_belt.rv(resp, samplerate=samplerate, window=window)
+ assert rv_arr.ndim == 2
+ assert rv_arr.shape == (n_samples, 2)