diff --git a/.all-contributorsrc b/.all-contributorsrc index 4abc918..f6bd218 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -25,7 +25,7 @@ "code", "ideas", "infra", - "review", + "review" ] }, { @@ -38,7 +38,7 @@ "data", "ideas", "infra", - "projectManagement", + "projectManagement" ] }, { @@ -51,6 +51,20 @@ "review", "test" ] + }, + { + "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" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index de2f6a4..f677eb9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
Ross Markello

πŸ’» πŸ€” πŸš‡ πŸ‘€
Stefano Moia

πŸ’» πŸ”£ πŸ€” πŸš‡ πŸ“†
Eneko UruΓ±uela

πŸ’» πŸ‘€ ⚠️ + George Kikas
George Kikas

πŸ’» πŸ€” πŸš‡ πŸ› ⚠️ πŸ‘€ diff --git a/physutils/io.py b/physutils/io.py index 76a7f75..3cb8f79 100644 --- a/physutils/io.py +++ b/physutils/io.py @@ -9,7 +9,6 @@ import os.path as op import numpy as np -from bids import BIDSLayout from loguru import logger from physutils import physio @@ -28,7 +27,7 @@ def load_from_bids( suffix="physio", ): """ - Load physiological data from BIDS-formatted directory + Load physiological data from BIDS-formatted directory. Parameters ---------- @@ -50,6 +49,12 @@ def load_from_bids( data : :class:`physutils.Physio` Loaded physiological data """ + try: + from bids import BIDSLayout + except ImportError: + raise ImportError( + "To use BIDS-based feature, pybids must be installed. Install manually or with `pip install physutils[bids]`" + ) # check if file exists and is in BIDS format if not op.exists(bids_path): diff --git a/physutils/tasks.py b/physutils/tasks.py new file mode 100644 index 0000000..451ac89 --- /dev/null +++ b/physutils/tasks.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Helper class for holding physiological data and associated metadata information.""" + +import logging + +from .io import load_from_bids, load_physio +from .physio import Physio +from .utils import is_bids_directory + +# from loguru import logger + +try: + from pydra.mark import task +except ImportError: + from .utils import task + + +LGR = logging.getLogger(__name__) +LGR.setLevel(logging.DEBUG) + + +@task +def generate_physio( + input_file: str, mode="auto", fs=None, bids_parameters=dict(), col_physio_type=None +) -> Physio: + """ + Load a physio object from either a BIDS directory or an exported physio object. + + Parameters + ---------- + input_file : str + Path to input file + mode : 'auto', 'physio', or 'bids', optional + Mode to operate with + fs : None, optional + Set or force set sapmling frequency (Hz). + bids_parameters : dictionary, optional + Dictionary containing BIDS parameters + col_physio_type : int or None, optional + Object to pick up in a BIDS array of physio objects. + + """ + LGR.info(f"Loading physio object from {input_file}") + + if mode == "auto": + if input_file.endswith((".phys", ".physio", ".1D", ".txt", ".tsv", ".csv")): + mode = "physio" + elif is_bids_directory(input_file): + mode = "bids" + else: + raise ValueError( + "Could not determine input mode automatically. Please specify it manually." + ) + if mode == "physio": + physio_obj = load_physio(input_file, fs=fs, allow_pickle=True) + + elif mode == "bids": + if bids_parameters is {}: + raise ValueError("BIDS parameters must be provided when loading from BIDS") + else: + physio_array = load_from_bids(input_file, **bids_parameters) + physio_obj = ( + physio_array[col_physio_type] if col_physio_type else physio_array + ) + else: + raise ValueError(f"Invalid generate_physio mode: {mode}") + + return physio_obj diff --git a/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_physio.json b/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_physio.json new file mode 100644 index 0000000..68d9a70 --- /dev/null +++ b/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_physio.json @@ -0,0 +1,12 @@ +{ + "SamplingFrequency": 10000.0, + "StartTime": -3, + "Columns": [ + "time", + "respiratory_chest", + "trigger", + "cardiac", + "respiratory_CO2", + "respiratory_O2" + ] +} diff --git a/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_recording-cardiac_physio.json b/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_recording-cardiac_physio.json new file mode 100644 index 0000000..68d9a70 --- /dev/null +++ b/physutils/tests/data/non-bids-dir/sub-01/ses-01/func/sub-01_ses-01_task-rest_run-01_recording-cardiac_physio.json @@ -0,0 +1,12 @@ +{ + "SamplingFrequency": 10000.0, + "StartTime": -3, + "Columns": [ + "time", + "respiratory_chest", + "trigger", + "cardiac", + "respiratory_CO2", + "respiratory_O2" + ] +} diff --git a/physutils/tests/test_tasks.py b/physutils/tests/test_tasks.py new file mode 100644 index 0000000..4c6284e --- /dev/null +++ b/physutils/tests/test_tasks.py @@ -0,0 +1,107 @@ +"""Tests for physutils.tasks and their integration.""" + +import os + +import physutils.tasks as tasks +from physutils import physio +from physutils.tests.utils import create_random_bids_structure + + +def test_generate_physio_phys_file(): + """Test generate_physio task.""" + physio_file = os.path.abspath("physutils/tests/data/ECG.phys") + task = tasks.generate_physio(input_file=physio_file, mode="physio") + assert task.inputs.input_file == physio_file + assert task.inputs.mode == "physio" + assert task.inputs.fs is None + + task() + + physio_obj = task.result().output.out + assert isinstance(physio_obj, physio.Physio) + assert physio_obj.fs == 1000 + assert physio_obj.data.shape == (44611,) + + +def test_generate_physio_bids_file(): + """Test generate_physio task.""" + create_random_bids_structure("physutils/tests/data", recording_id="cardiac") + bids_parameters = { + "subject": "01", + "session": "01", + "task": "rest", + "run": "01", + "recording": "cardiac", + } + bids_dir = os.path.abspath("physutils/tests/data/bids-dir") + task = tasks.generate_physio( + input_file=bids_dir, + mode="bids", + bids_parameters=bids_parameters, + col_physio_type="cardiac", + ) + + assert task.inputs.input_file == bids_dir + assert task.inputs.mode == "bids" + assert task.inputs.fs is None + assert task.inputs.bids_parameters == bids_parameters + assert task.inputs.col_physio_type == "cardiac" + + task() + + physio_obj = task.result().output.out + assert isinstance(physio_obj, physio.Physio) + + +def test_generate_physio_auto(): + create_random_bids_structure("physutils/tests/data", recording_id="cardiac") + bids_parameters = { + "subject": "01", + "session": "01", + "task": "rest", + "run": "01", + "recording": "cardiac", + } + bids_dir = os.path.abspath("physutils/tests/data/bids-dir") + task = tasks.generate_physio( + input_file=bids_dir, + mode="auto", + bids_parameters=bids_parameters, + col_physio_type="cardiac", + ) + + assert task.inputs.input_file == bids_dir + assert task.inputs.mode == "auto" + assert task.inputs.fs is None + assert task.inputs.bids_parameters == bids_parameters + assert task.inputs.col_physio_type == "cardiac" + + task() + + physio_obj = task.result().output.out + assert isinstance(physio_obj, physio.Physio) + + +def test_generate_physio_auto_error(caplog): + bids_dir = os.path.abspath("physutils/tests/data/non-bids-dir") + task = tasks.generate_physio( + input_file=bids_dir, + mode="auto", + col_physio_type="cardiac", + ) + + assert task.inputs.input_file == bids_dir + assert task.inputs.mode == "auto" + assert task.inputs.fs is None + assert task.inputs.col_physio_type == "cardiac" + + try: + task() + except Exception: + assert caplog.text.count("ERROR") == 1 + assert ( + caplog.text.count( + "dataset_description.json' is missing from project root. Every valid BIDS dataset must have this file." + ) + == 1 + ) diff --git a/physutils/utils.py b/physutils/utils.py new file mode 100644 index 0000000..dd6074f --- /dev/null +++ b/physutils/utils.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Helper class for holding physiological data and associated metadata information.""" + +import logging +from functools import wraps + +from loguru import logger + +LGR = logging.getLogger(__name__) +LGR.setLevel(logging.DEBUG) + + +def task(func): + """ + Fake task decorator to import when pydra is not installed/used. + + Parameters + ---------- + func: function + Function to run the wrapper around + + Returns + ------- + function + """ + + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + LGR.debug( + "Pydra is not installed, thus generate_physio is not available as a pydra task. Using the function directly" + ) + + return wrapper + + +def is_bids_directory(path_to_dir): + """ + Check if a directory is a BIDS compliant directory. + + Parameters + ---------- + path_to_dir : os.path or str + Path to (supposed) BIDS directory + + Returns + ------- + bool + True if the given path is a BIDS directory, False is not. + """ + try: + from bids import BIDSLayout + except ImportError: + raise ImportError( + "To use BIDS-based feature, pybids must be installed. Install manually or with `pip install physutils[bids]`" + ) + try: + # Attempt to create a BIDSLayout object + _ = BIDSLayout(path_to_dir) + return True + except Exception as e: + # Catch other exceptions that might indicate the directory isn't BIDS compliant + logger.error( + f"An error occurred while trying to load {path_to_dir} as a BIDS Layout object: {e}" + ) + return False diff --git a/setup.cfg b/setup.cfg index 05d8cb3..619fbf5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,7 @@ classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 license = Apache-2.0 -description = Set of utilities meant to be used with Physiopy's libraries +description = Set of utilities meant to be used with Physiopy libraries long_description = file:README.md long_description_content_type = text/markdown; charset=UTF-8 platforms = OS Independent @@ -23,9 +23,7 @@ python_requires = >=3.6.1 install_requires = matplotlib numpy >=1.9.3 - scipy loguru - pybids tests_require = pytest >=3.6 test_suite = pytest @@ -34,6 +32,10 @@ packages = find: include_package_data = True [options.extras_require] +pydra = + pydra +bids = + pybids doc = sphinx >=2.0 sphinx-argparse @@ -49,6 +51,8 @@ test = scipy pytest >=5.3 pytest-cov + %(pydra)s + %(bids)s %(style)s devtools = pre-commit