From ebfb9c9b56d36eac2bca780edb46d945b4618739 Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Wed, 9 Nov 2022 12:51:06 +0100 Subject: [PATCH 01/22] Starting to extend doc (copy from other branch due to complex rebase) --- docs/api/api.rst | 7 + docs/contribution.rst | 4 +- docs/extending/datagrabber.rst | 268 ++++++++++++++++++ docs/extending/index.rst | 26 ++ docs/index.rst | 1 + docs/links.inc | 1 + docs/understanding/data.rst | 3 + docs/understanding/index.rst | 2 + examples/run_datagrabber_bids_datalad.py | 4 +- .../tests/test_datagrabber_utils.py | 12 +- junifer/datagrabber/tests/test_multiple.py | 12 +- .../datagrabber/tests/test_pattern_datalad.py | 22 +- .../datareader/tests/test_default_reader.py | 18 +- 13 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 docs/extending/datagrabber.rst create mode 100644 docs/extending/index.rst diff --git a/docs/api/api.rst b/docs/api/api.rst index c65c5ce6c..12ef181b2 100644 --- a/docs/api/api.rst +++ b/docs/api/api.rst @@ -1,10 +1,17 @@ API Functions ============= +Main API functions +------------------ + .. automodule:: junifer.api :members: :imported-members: + +Decorators +---------- + .. automodule:: junifer.api.decorators :members: :imported-members: diff --git a/docs/contribution.rst b/docs/contribution.rst index db6dba11e..83359729e 100644 --- a/docs/contribution.rst +++ b/docs/contribution.rst @@ -174,10 +174,10 @@ texts. # The BIDS datagrabber requires three parameters: the types of data we want, # the specific pattern that matches each type, and the variables that will be # replaced int he patterns. - types = ["T1w", "bold"] + types = ["T1w", "BOLD"] patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } replacements = ["subject"] ############################################################################### diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst new file mode 100644 index 000000000..5eaf88fc6 --- /dev/null +++ b/docs/extending/datagrabber.rst @@ -0,0 +1,268 @@ +.. include:: ../links.inc + +.. _extending_datagrabbers: + +Creating DataGrabbers +===================== + +DataGrabbers are the first step of the pipeline. It's purpose is to interpret +the structure of a dataset and provide two specific functionalities: + +1) Given an *element*, provide the path to each kind of data available for this + element (e.g. the path to the T1 image, the path to the T2 image, etc.) +2) Provide the list of *elements* available in the dataset. + +In this section, we will see how to create a datagrabber for a dataset. Basic +aspects of datagrabbers are covedered in the +:ref:`Understanding DataGrabbers ` section. + +.. _extending_datagrabbers_think: + +Step 1: Think about the element +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Like with any programming-related task, the first step is to think. When +creating a DataGrabber, we need to first define what an *element* is. +The *element* should be the smallest unit of data that can be processed. That +is, for each element, there should be a set of data that can be processed, but +only one of each *data type* (see :ref:`data_types`). + +For example, if we have a dataset from an fMRI study in which: + +a) both T1w and fMRI was acquired +b) 20 subjects went through a experiment twice +c) the experiment included resting-stage fMRI and a task named *stroop* + +then the *element* should be composed of 3 items: + +* ``subject``: The subject IDs, e.g. `sub001`, `sub002`, ... `sub020` +* ``session``: The sesion number, e.g. `ses1`, `ses2` +* ``task``: The task performed, e.g. `rest`, `stroop` + +If any of this items were not part of the element, then we will have more than +one ``T1w`` and/or ``BOLD`` image for each subject, which is not allowed. + +Importantly, nothing prevents that one image is part of two different elements. +For example, it is usually the case that the ``T1w`` image is not acquired for +each task, but once in the entire session. So in this case, the ``T1w`` image +for the element (``sub001``, ``ses1``, ``rest``) will be the same as the +``T1w`` image for the element (``sub001``, ``ses1``, ``stroop``). + +We will now continue this section using as an example, a dataset in BIDS format +in which each 9 subjects (`sub-01` to `sub-09`) were scanned each in 3 +sessions (`ses-01`, `ses-02`, `ses-03`) and each session included a `T1w` and +a `BOLD` image (resting-state), except for `ses-03` which was only anatomical. + +Step 2: Think about the dataset's structure +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Now that we have our element defined, we need to think about the structure of +the dataset. Mainly, because the structure of the dataset will determine how +the DataGrabber needs to be implemented. + +Junifer provides an abstract class to deal with datasets that can be thought in +terms of *patterns*. A *pattern* is a string that contains placeholders that are +replaced by the actual values of the element. In our BIDS example, the path +to the T1w image of subject `sub-01` and session `ses-01`, relative to the +dataset location, is ``sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz``. By +replacing ``sub-01`` with ``sub-02``, we can obtain the T1w image of the first +session of the second subject. Indeed, the path to the T1w images can be +expressed as a pattern: + +``{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz`` + +where ``{subject}`` is the replacement for the subject id and ``{session}}`` +is the replacement for the session id. + +Since it is a BIDS dataaset, the same happens with the BOLD images. The path to +the BOLD images can be expressed as a pattern: + +``{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz`` + + +This will be the norm in most of the datasets. If your dataset can be expressed +in terms of patterns, then follow :ref:`extending_datagrabbers_pattern`. +Otherwise, we recommend that you take time to re-think about your dataset +structure and why it does not have clear *patterns*. Feel free to open a +discussion in the `junifer Discussions`_ page. Most probably we can help you +get your dataset in order. + +If there is no other way, then you can follow :ref:`extending_datagrabbers_base` +to create a DataGrabber from scratch. + + +.. _extending_datagrabbers_pattern: + +Option A: Extending from PatternDataGrabber +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The :py:class:`~junifer.datagrabber.PatternDataGrabber` class is an +abstract class that has the functionality of understanding patterns embeded +in it. + +Before creating the datagrabber, we need to define 3 variables: + +* ``types``: A list with the available :ref:`data_types` in our dataset +* ``patterns``: A dictionary that specifies the pattern for each data type. +* ``replacements``: A list indicating which of the elements in the patterns + should be replaced by the values of the element. + +For example, in our BIDS example, the variables will be: + +.. code-block:: python + + types = ["T1w", "BOLD"] + patterns = { + "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": "{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + replacements = ["subject", "session"] + +An additional fourth variable is the ``datadir``, which should be the path to +where the dataset is located. For example, if the dataset is located in +``/data/project/test/data``, then ``datadir`` should be +``/data/project/test/data``. Or, if we want to allow the user to specify the +location of the dataset, we can expose the variable in the constructor, as in +this example + +With this defined, we can now create our datagrabber, we will name it +``ExampleBIDSDataGrabber``: + + +.. code-block:: python + + from junifer.datagrabber.pattern import PatternDataGrabber + + class ExampleBIDSDataGrabber(PatternDataGrabber): + + def __init__(self, datadir): + types = ["T1w", "BOLD"] + patterns = { + "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": "{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + replacements = ["subject", "session"] + super().__init__( + datadir=datadir, + types=types, + patterns=patterns, + replacements=replacements) + +Our datagrabber is ready to be used by junifer. However, it is still unknown +to the library. We need to register it in the library. To do so, we need to +use the :py:func:`~junifer.api.decorators.register_datagrabber` decorator. + + +.. code-block:: python + + from junifer.datagrabber.pattern import PatternDataGrabber + from junifer.api.decorators import register_datagrabber + + + @register_datagrabber + class ExampleBIDSDataGrabber(PatternDataGrabber): + + def __init__(self, datadir): + types = ["T1w", "BOLD"] + patterns = { + "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": "{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + replacements = ["subject", "session"] + super().__init__( + datadir=datadir, + types=types, + patterns=patterns, + replacements=replacements) + + +Now, we can use our datagrabber in junifer, by setting the ``datagrabber`` kind +in the yaml file to ``ExampleBIDSDataGrabber``. Remember that we still need to +set the ``datadir``. + +.. code-block:: yaml + + datagrabber: + kind: ExampleBIDSDataGrabber + datadir: /data/project/test/data + + +Optional: Using datalad +----------------------- + +If you are using datalad, you can use the +:py:class:`~junifer.datagrabber.PatternDataladDataGrabber` instead of the +:py:class:`~junifer.datagrabber.PatternDataGrabber`. This class will also +interpret patterns, but will also use datalad to `clone` and `get` the data. + +The main difference between the two is that the ``datadir`` is not the actual +location of the dataset, but the location where the dataset will be cloned. It +can now be ``None``, which means that the data will be downloaded to a +temporary directory. To set the location of the dataset, you can use the +``uri`` argument in the constructor. Additionally, a ``rootdir`` argument can +be used to specify the path to the root directory of the dataset after doing +``datalad clone`` + +In the example, the dataset is hosted in gin +(``https://gin.g-node.org/juaml/datalad-example-bids``). + +When we clone this dataset, we will see the following structure: + +.. code-block:: + + . + └── example_bids_ses + ├── sub-01 + │ ├── ses-01 + │ ├── ses-02 + │ └── ses-03 + ├── sub-02 + │ ├── ses-01 + │ ├── ses-02 + │ └── ses-03 + ├── sub-03 + ... + +So the patterns will start after ``example_bids_ses``. This is our ``rootdir``. + +Now we have our 2 additional variables: + +.. code-block:: python + + uri = "https://gin.g-node.org/juaml/datalad-example-bids" + rootdir = "example_bids_ses" + +And we can create our datagrabber: + + +.. code-block:: python + + from junifer.datagrabber.pattern import PatternDataladDataGrabber + from junifer.api.decorators import register_datagrabber + + + @register_datagrabber + class ExampleBIDSDataGrabber(PatternDataladDataGrabber): + + def __init__(self): + types = ["T1w", "BOLD"] + patterns = { + "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": "{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + replacements = ["subject", "session"] + uri = "https://gin.g-node.org/juaml/datalad-example-bids" + rootdir = "example_bids_ses" + super().__init__( + datadir=None, + uri=uri, + rootdir=rootdir, + types=types, + patterns=patterns, + replacements=replacements) + + +.. _extending_datagrabbers_base: + +Option B: Extending from BaseDataGrabber +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \ No newline at end of file diff --git a/docs/extending/index.rst b/docs/extending/index.rst new file mode 100644 index 000000000..0f88dc54f --- /dev/null +++ b/docs/extending/index.rst @@ -0,0 +1,26 @@ +.. include:: ../links.inc + +.. _extending: + +Extending junifer +================= + +While we aim to provide as many datasets and markers as possible, we are also +interested in allowing users to extend the functionality with their own +datagrabbers, preprocessing, markers, etc. + +This does not mean that the new functinality will have to be included in +junifer before the user can use them. Instead, the user can simply +create a new python file, code the desired functionality and use it with +junifer. This is the first step towards including the new functionality in +the junifer package. + +In this section we will show how to extend junifer, by creating new +datagrabbers, preprocessing and markers, following the *junifer* way. + + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + datagrabber \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 5aaa20d88..5d77b0a21 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ enabling others to extend it easily. installation understanding/index.rst builtin + extending/index.rst auto_examples/index.rst api/index.rst contribution diff --git a/docs/links.inc b/docs/links.inc index 268bae5d8..27f6489c8 100644 --- a/docs/links.inc +++ b/docs/links.inc @@ -27,6 +27,7 @@ .. _`Github`: https://github.com/ .. _`junifer Github`: https://github.com/juaml/junifer +.. _`junifer Discussions`: https://github.com/juaml/junifer/discussions .. _`Wikipedia`: https://wikipedia.org diff --git a/docs/understanding/data.rst b/docs/understanding/data.rst index 8be67505c..fe8fea9e1 100644 --- a/docs/understanding/data.rst +++ b/docs/understanding/data.rst @@ -42,6 +42,9 @@ Data types * - ``BOLD`` - BOLD image (4D) - Preprocessed/Denoised BOLD image (fmriprep output) + * - ``BOLD_confounds`` + - BOLD image confounds (CSV/TSV file) + - Confounds that can be applied to the BOLD image. * - ``VBM_GM`` - VBM Gray Matter segmentation (3D) - CAT output (`m0wp1` images) diff --git a/docs/understanding/index.rst b/docs/understanding/index.rst index 5f00555c1..9357eadad 100644 --- a/docs/understanding/index.rst +++ b/docs/understanding/index.rst @@ -1,5 +1,7 @@ .. include:: ../links.inc +.. _understanding: + Understanding junifer ===================== diff --git a/examples/run_datagrabber_bids_datalad.py b/examples/run_datagrabber_bids_datalad.py index eb14892a6..da3a7b34c 100644 --- a/examples/run_datagrabber_bids_datalad.py +++ b/examples/run_datagrabber_bids_datalad.py @@ -23,10 +23,10 @@ configure_logging(level="INFO") # The BIDS datagrabber requires three parameters: the types of data we want, # the specific pattern that matches each type, and the variables that will be # replaced int he patterns. -types = ["T1w", "bold"] +types = ["T1w", "BOLD"] patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } replacements = ["subject"] ############################################################################### diff --git a/junifer/datagrabber/tests/test_datagrabber_utils.py b/junifer/datagrabber/tests/test_datagrabber_utils.py index c26e4be97..4b203a718 100644 --- a/junifer/datagrabber/tests/test_datagrabber_utils.py +++ b/junifer/datagrabber/tests/test_datagrabber_utils.py @@ -19,7 +19,7 @@ def test_validate_types() -> None: with pytest.raises(TypeError, match="must be a list of strings"): validate_types([1]) # type: ignore - validate_types(["T1w", "bold"]) + validate_types(["T1w", "BOLD"]) def test_validate_replacements() -> None: @@ -31,7 +31,7 @@ def test_validate_replacements() -> None: patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } with pytest.raises(TypeError, match="must be a list of strings"): @@ -42,7 +42,7 @@ def test_validate_replacements() -> None: wrong_patterns = { "T1w": "{subject}/anat/_T1w.nii.gz", - "bold": "{session}/func/_task-rest_bold.nii.gz", + "BOLD": "{session}/func/_task-rest_bold.nii.gz", } with pytest.raises(ValueError, match="At least one pattern"): @@ -53,7 +53,7 @@ def test_validate_replacements() -> None: def test_validate_patterns() -> None: """Test validation of patterns.""" - types = ["T1w", "bold"] + types = ["T1w", "BOLD"] with pytest.raises(TypeError, match="must be a dict"): validate_patterns(types, "wrong") # type: ignore @@ -74,12 +74,12 @@ def test_validate_patterns() -> None: patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } wrongpatterns = { "T1w": "{subject}/anat/{subject}*.nii", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } with pytest.raises(ValueError, match="following a replacement"): diff --git a/junifer/datagrabber/tests/test_multiple.py b/junifer/datagrabber/tests/test_multiple.py index 2b1277397..47abdd525 100644 --- a/junifer/datagrabber/tests/test_multiple.py +++ b/junifer/datagrabber/tests/test_multiple.py @@ -28,7 +28,7 @@ def test_multiple() -> None: "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", } pattern2 = { - "bold": "{subject}/{session}/func/" + "BOLD": "{subject}/{session}/func/" "{subject}_{session}_task-rest_bold.nii.gz", } dg1 = PatternDataladDataGrabber( @@ -42,7 +42,7 @@ def test_multiple() -> None: dg2 = PatternDataladDataGrabber( rootdir=rootdir, uri=repo_uri, - types=["bold"], + types=["BOLD"], patterns=pattern2, replacements=replacements, ) @@ -51,7 +51,7 @@ def test_multiple() -> None: types = dg.get_types() assert "T1w" in types - assert "bold" in types + assert "BOLD" in types expected_subs = [ (f"sub-{i:02d}", f"ses-{j:02d}") @@ -65,7 +65,7 @@ def test_multiple() -> None: data = dg[("sub-01", "ses-01")] assert "T1w" in data - assert "bold" in data + assert "BOLD" in data meta = dg.get_meta() assert "class" in meta @@ -86,7 +86,7 @@ def test_multiple_no_intersection() -> None: "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", } pattern2 = { - "bold": "{subject}/{session}/func/" + "BOLD": "{subject}/{session}/func/" "{subject}_{session}_task-rest_bold.nii.gz", } dg1 = PatternDataladDataGrabber( @@ -100,7 +100,7 @@ def test_multiple_no_intersection() -> None: dg2 = PatternDataladDataGrabber( rootdir=rootdir, uri=repo_uri2, - types=["bold"], + types=["BOLD"], patterns=pattern2, replacements=replacements, ) diff --git a/junifer/datagrabber/tests/test_pattern_datalad.py b/junifer/datagrabber/tests/test_pattern_datalad.py index 61bf7db94..2deace6eb 100644 --- a/junifer/datagrabber/tests/test_pattern_datalad.py +++ b/junifer/datagrabber/tests/test_pattern_datalad.py @@ -46,11 +46,11 @@ def test_bids_PatternDataladDataGrabber(tmp_path: Path) -> None: """ # Define types - types = ["T1w", "bold"] + types = ["T1w", "BOLD"] # Define patterns patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } # Define replacements replacements = ["subject"] @@ -76,8 +76,8 @@ def test_bids_PatternDataladDataGrabber(tmp_path: Path) -> None: assert t_sub["T1w"]["path"] == ( dg.datadir / f"{elem}/anat/{elem}_T1w.nii.gz" ) - assert "path" in t_sub["bold"] - assert t_sub["bold"]["path"] == ( + assert "path" in t_sub["BOLD"] + assert t_sub["BOLD"]["path"] == ( dg.datadir / f"{elem}/func/{elem}_task-rest_bold.nii.gz" ) @@ -105,11 +105,11 @@ def test_bids_PatternDataladDataGrabber_datadir(tmp_path: Path) -> None: """ # Define types - types = ["T1w", "bold"] + types = ["T1w", "BOLD"] # Define patterns patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", - "bold": "{subject}/func/{subject}_task-rest_bold.nii.gz", + "BOLD": "{subject}/func/{subject}_task-rest_bold.nii.gz", } # Define replacements replacements = ["subject"] @@ -119,7 +119,7 @@ def test_bids_PatternDataladDataGrabber_datadir(tmp_path: Path) -> None: datadir = "dataset" # use string and not absolute path patterns = { "T1w": "example_bids/{subject}/anat/{subject}_T*w.nii.gz", - "bold": "example_bids/{subject}/func/{subject}_task-rest_*.nii.gz", + "BOLD": "example_bids/{subject}/func/{subject}_task-rest_*.nii.gz", } with PatternDataladDataGrabber( uri=repo_uri, @@ -135,18 +135,18 @@ def test_bids_PatternDataladDataGrabber_datadir(tmp_path: Path) -> None: assert t_sub["T1w"]["path"] == ( dg.datadir / f"{elem}/anat/{elem}_T1w.nii.gz" ) - assert "path" in t_sub["bold"] - assert t_sub["bold"]["path"] == ( + assert "path" in t_sub["BOLD"] + assert t_sub["BOLD"]["path"] == ( dg.datadir / f"{elem}/func/{elem}_task-rest_bold.nii.gz" ) def test_bids_PatternDataladDataGrabber_session(): """Test a subject and session-based BIDS datalad datagrabber.""" - types = ["T1w", "bold"] + types = ["T1w", "BOLD"] patterns = { "T1w": "{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", - "bold": "{subject}/{session}/func/" + "BOLD": "{subject}/{session}/func/" "{subject}_{session}_task-rest_bold.nii.gz", } replacements = ["subject", "session"] diff --git a/junifer/datareader/tests/test_default_reader.py b/junifer/datareader/tests/test_default_reader.py index a645844ce..4ebf61b34 100644 --- a/junifer/datareader/tests/test_default_reader.py +++ b/junifer/datareader/tests/test_default_reader.py @@ -42,7 +42,7 @@ def test_meta() -> None: nib_data_path = Path(nib_testing.data_path) t_path = nib_data_path / "example4d.nii.gz" - input = {"bold": {"path": t_path}} + input = {"BOLD": {"path": t_path}} output = reader.fit_transform(input) assert "meta" in output assert "datareader" in output["meta"] @@ -67,23 +67,23 @@ def test_read_nifti(fname: str) -> None: t_path = nib_data_path / fname - input = {"bold": {"path": t_path}} + input = {"BOLD": {"path": t_path}} output = reader.fit_transform(input) assert isinstance(output, dict) - assert "bold" in output - assert isinstance(output["bold"], dict) - assert "path" in output["bold"] - assert "data" in output["bold"] + assert "BOLD" in output + assert isinstance(output["BOLD"], dict) + assert "path" in output["BOLD"] + assert "data" in output["BOLD"] - read_img = output["bold"]["data"] + read_img = output["BOLD"]["data"] t_read_img = nib.load(t_path) assert_array_equal(read_img.get_fdata(), t_read_img.get_fdata()) - input = {"bold": {"path": t_path.as_posix()}} + input = {"BOLD": {"path": t_path.as_posix()}} output2 = reader.fit_transform(input) - assert output["bold"]["path"] == output2["bold"]["path"] + assert output["BOLD"]["path"] == output2["BOLD"]["path"] def test_read_unknown() -> None: -- 2.52.0 From 22cbe0a4ec847b0a771de5a5167198c093f63a6c Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Thu, 10 Nov 2022 11:49:25 +0100 Subject: [PATCH 02/22] more on extending datagrabber --- docs/extending/datagrabber.rst | 109 +++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst index 5eaf88fc6..4e2d1f513 100644 --- a/docs/extending/datagrabber.rst +++ b/docs/extending/datagrabber.rst @@ -19,7 +19,7 @@ aspects of datagrabbers are covedered in the .. _extending_datagrabbers_think: Step 1: Think about the element -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------- Like with any programming-related task, the first step is to think. When creating a DataGrabber, we need to first define what an *element* is. @@ -54,7 +54,7 @@ sessions (`ses-01`, `ses-02`, `ses-03`) and each session included a `T1w` and a `BOLD` image (resting-state), except for `ses-03` which was only anatomical. Step 2: Think about the dataset's structure -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------------------- Now that we have our element defined, we need to think about the structure of the dataset. Mainly, because the structure of the dataset will determine how @@ -94,7 +94,7 @@ to create a DataGrabber from scratch. .. _extending_datagrabbers_pattern: Option A: Extending from PatternDataGrabber -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------------------- The :py:class:`~junifer.datagrabber.PatternDataGrabber` class is an abstract class that has the functionality of understanding patterns embeded @@ -188,7 +188,7 @@ set the ``datadir``. Optional: Using datalad ------------------------ +^^^^^^^^^^^^^^^^^^^^^^^ If you are using datalad, you can use the :py:class:`~junifer.datagrabber.PatternDataladDataGrabber` instead of the @@ -265,4 +265,103 @@ And we can create our datagrabber: .. _extending_datagrabbers_base: Option B: Extending from BaseDataGrabber -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ \ No newline at end of file +---------------------------------------- + +While we could not think of a use case in which the pattern-based data grabber would not be suitable, it is still +possible to create a datagrabber extending from the :py:class:`~junifer.datagrabber.base.BaseDataGrabber` class. + +In order to create a datagrabber extending from :py:class:`~junifer.datagrabber.base.BaseDataGrabber`, we need to +implement the following methods: + +- ``get_item``: to get a single item from the dataset. +- ``get_elements``: to get the list fo all elements present in the dataset +- ``get_element_keys``: to get the keys of the elements in the dataset. + +.. note:: + The ``__init__`` method could also be implemented, but it is not mandatory. This is required if the datagrabber + requires any parameter. + +We will now implement our BIDS example with this method. + +The first method, ``get_item``, needs to obtain a single +item from the dataset. Since this dataset requires two variables, ``subject`` and ``session``, we will use them +as parameters of ``get_item``: + +.. code-block:: python + + def get_item(self, subject, session): + out = { + "T1w": f"{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": f"{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + return out + + +The second method, ``get_elements``, needs to return a list of all the elements in the dataset. In this case, we +know that the dataset contains 3 subjects and 3 sessions, so we can create a list of all the possible combinations. +However, we need to remember that for session *ses-03* there is no bold. + +.. code-block:: python + + def get_elements(self): + subjects = ["sub-01", "sub-02", "sub-03"] + sessions = ["ses-01", "ses-02"] + + # If we are not working on BOLD data, we can add "ses-03" + if "BOLD" not in self.types: + sessions.append("ses-03") + elements = [] + for subject in subjects: + for session in sessions: + elements.append({"subject": subject, "session": session}) + return elements + + +And finally, we cna implement the ``get_element_keys`` method. This method needs to return a list of the keys that +are represent each of the items in the element tuple. As a rule of thumb, they should be the parameters of the +``get_item`` method, in the same order. + +.. code-block:: python + + def get_element_keys(self): + return ["subject", "session"] + + +So, to summarize, our datagrabber will look like this: + +.. code-block:: python + + from junifer.datagrabber.base import BaseDataGrabber + from junifer.api.decorators import register_datagrabber + + @register_datagrabber + class ExampleBIDSDataGrabber(BaseDataGrabber): + + def get_item(self, subject, session): + out = { + "T1w": f"{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz", + "BOLD": f"{subject}/{session}/func/{subject}_{session}_task-rest_bold.nii.gz", + } + return out + + def get_elements(self): + subjects = ["sub-01", "sub-02", "sub-03"] + sessions = ["ses-01", "ses-02"] + + # If we are not working on BOLD data, we can add "ses-03" + if "BOLD" not in self.types: + sessions.append("ses-03") + elements = [] + for subject in subjects: + for session in sessions: + elements.append({"subject": subject, "session": session}) + return elements + + def get_element_keys(self): + return ["subject", "session"] + +Optional: Using datalad +^^^^^^^^^^^^^^^^^^^^^^^ + +If this dataset is in a datalad dataset, we can extend from :class:`junifer.datagrabber.DataladDataGrabber` instead of +:class:`junifer.datagrabber.BaseDataGrabber`. This will allow us to use the datalad API to obtain the data. \ No newline at end of file -- 2.52.0 From fb2580ebe00159c38e3c5e2e755b3050b424eeff Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Thu, 10 Nov 2022 12:27:43 +0100 Subject: [PATCH 03/22] extending datagrabbers done --- docs/extending/datagrabber.rst | 64 +++++++++++++++++++++++++++++++--- docs/extending/index.rst | 2 +- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst index 4e2d1f513..1927202e3 100644 --- a/docs/extending/datagrabber.rst +++ b/docs/extending/datagrabber.rst @@ -93,8 +93,11 @@ to create a DataGrabber from scratch. .. _extending_datagrabbers_pattern: +Step 3: Create a DataGrabber +---------------------------- + Option A: Extending from PatternDataGrabber -------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The :py:class:`~junifer.datagrabber.PatternDataGrabber` class is an abstract class that has the functionality of understanding patterns embeded @@ -188,7 +191,7 @@ set the ``datadir``. Optional: Using datalad -^^^^^^^^^^^^^^^^^^^^^^^ +""""""""""""""""""""""" If you are using datalad, you can use the :py:class:`~junifer.datagrabber.PatternDataladDataGrabber` instead of the @@ -265,7 +268,7 @@ And we can create our datagrabber: .. _extending_datagrabbers_base: Option B: Extending from BaseDataGrabber ----------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ While we could not think of a use case in which the pattern-based data grabber would not be suitable, it is still possible to create a datagrabber extending from the :py:class:`~junifer.datagrabber.base.BaseDataGrabber` class. @@ -361,7 +364,58 @@ So, to summarize, our datagrabber will look like this: return ["subject", "session"] Optional: Using datalad -^^^^^^^^^^^^^^^^^^^^^^^ +""""""""""""""""""""""" If this dataset is in a datalad dataset, we can extend from :class:`junifer.datagrabber.DataladDataGrabber` instead of -:class:`junifer.datagrabber.BaseDataGrabber`. This will allow us to use the datalad API to obtain the data. \ No newline at end of file +:class:`junifer.datagrabber.BaseDataGrabber`. This will allow us to use the datalad API to obtain the data. + + +Step 4: Optional: Adding *BOLD confounds* +----------------------------------------- + +For some analyses, it is useful to have the confounds associated with the BOLD data. This corresponds to the +``BOLD_confounds`` item in the :ref:`Data Object ` (see :ref:`data_types`). However, the ``BOLD_confounds`` +element does not only consists of a ``path``, but it requries more information about the format of the confounds file. +Thus, the ``BOLD_confounds`` element is a dictionary with the following keys: + +- ``path``: the path to the confounds file. +- ``format``: the format of the confounds file. Currently, this can be either ``fmriprep`` or ``adhoc``. + +The ``fmriprep`` format corresponds to the format of the confounds files generated by `fMRIPrep`_. The +``adhoc`` format corresponds to a format that is not standardized. + +.. note:: + The ``mappings`` key is only required if the ``format`` is ``adhoc``. If the ``format`` is ``fmriprep``, the + ``mappings`` key is not required. + + +Currently, Junifer provides only one confound remover step +(:class:`junifer.preprocess.fMRIPrepConfoundRemover`), which relies entirely on the ``fmriprep`` confound +variable names. Thus, if the confounds are not in ``fmriprep`` format, the user will need to provide the mappings +between the *ad-hoc* variable names and the the ``fmriprep`` variable names. +This is done by specifying the ``adhoc`` format and providing the mappings as a dictionary in the ``mappings`` key. + +In the following example, the confounds file has 3 variables that are not in the ``fmriprep`` format. Thus, we will +provide the mappings for these variables to the ``fmriprep`` format. + +.. code-block:: python + + out["BOLD_confounds"]: { + "path": f"{subject}/{session}/func/{subject}_{session}_confounds.tsv", + "format": "adhoc", + "mappings": { + "fmriprep": { + "variable1": "rot_x", + "variable2": "rot_z", + "variable3": "rot_y", + } + }, + } + + +.. note:: + Not all of the mappings need to be provided. For the moment, this is used only by the + :class:`junifer.preprocess.fMRIPrepConfoundRemover` step, which requires variables based on the + strategy selected. However, it is recommended to provide all the mappings, as this will allow the user to + choose different strategies with the same dataset. + diff --git a/docs/extending/index.rst b/docs/extending/index.rst index 0f88dc54f..53afac31d 100644 --- a/docs/extending/index.rst +++ b/docs/extending/index.rst @@ -20,7 +20,7 @@ datagrabbers, preprocessing and markers, following the *junifer* way. .. toctree:: - :maxdepth: 1 + :maxdepth: 2 :caption: Contents: datagrabber \ No newline at end of file -- 2.52.0 From 3d7e14534841e7375dbe54bca403efe71d5b894d Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 15 Nov 2022 08:34:39 +0100 Subject: [PATCH 04/22] more on creating markers --- docs/extending/index.rst | 3 ++- docs/extending/marker.rst | 32 ++++++++++++++++++++++++++++++++ docs/understanding/storage.rst | 29 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 docs/extending/marker.rst diff --git a/docs/extending/index.rst b/docs/extending/index.rst index 53afac31d..a9b773eba 100644 --- a/docs/extending/index.rst +++ b/docs/extending/index.rst @@ -23,4 +23,5 @@ datagrabbers, preprocessing and markers, following the *junifer* way. :maxdepth: 2 :caption: Contents: - datagrabber \ No newline at end of file + datagrabber + marker \ No newline at end of file diff --git a/docs/extending/marker.rst b/docs/extending/marker.rst new file mode 100644 index 000000000..202d8a552 --- /dev/null +++ b/docs/extending/marker.rst @@ -0,0 +1,32 @@ +.. include:: ../links.inc + +.. _extending_markers: + +Creating Markers +================ + +Computing a marker (a.k.a. *feature*) is the main goal of junifer. While we aim to provide as many markers as possible, +it might be the case that the marker you are looking for is not available. In this case, you can create your own marker +by following this tutorial. + +Most of the functionality of a junifer marker has been taken care by the :class:`junifer.markers.BaseMarker` class. +Thus, only a few methods are required: + +1. ``get_valid_inputs``: a method to obtain the list of valid inputs for the marker. This is used to check that the + inputs provided by the user are valid. This method should return a list of strings, representing + :ref:`data types ` +2. ``get_output_kind``: a method to obtain the kind of output of the marker. This is used to check that the output + of the marker is compatible with the storage. This method should return a string, representing + :ref:`storage types ` +3. ``compute``: the method that given the data, computes the marker. +4. ``store``: the method that stores the computed marker. +5. ``__init__``: the initialization method, where the marker is configured. + + +.. _extending_markers_input_output: + +Step 1: Configure input and output +---------------------------------- + + + diff --git a/docs/understanding/storage.rst b/docs/understanding/storage.rst index 25ceed0b3..b18a81ba9 100644 --- a/docs/understanding/storage.rst +++ b/docs/understanding/storage.rst @@ -24,6 +24,35 @@ methods respectively. For storage interfaces not supported by junifer yet, you can either make your own ``Storage`` by providing a concrete implementation of :class:`junifer.storage.BaseFeatureStorage` or open an issue on `junifer Github`_ and we can help you out. + +.. _storage_types: + +Currently supported storage types +--------------------------------- + +.. list-table:: + :widths: auto + :header-rows: 1 + + * - Storage Type + - Description + - Options + - Reference + * - ``matrix`` + - A 2D matrix with row and column names + - ``col_names``, ``row_names``, ``matrix_kind``, ``diagonal`` + - :meth:`junifer.storage.BaseFeatureStorage.store_matrix` + * - ``table`` + - A vector of values with column names + - ``columns`` + - :meth:`junifer.storage.BaseFeatureStorage.store_table` + * - ``timeseries`` + - A 2D matrix of values with column names + - ``columns`` + - :meth:`junifer.storage.BaseFeatureStorage.store_timeseries` + +.. _storage_interfaces: + Currently supported storage interfaces -------------------------------------- -- 2.52.0 From 11a6aef665ed66f39a1691a977cac28a574445a4 Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Tue, 15 Nov 2022 08:46:25 +0100 Subject: [PATCH 05/22] WIP --- docs/extending/marker.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/extending/marker.rst b/docs/extending/marker.rst index 202d8a552..28fb7c649 100644 --- a/docs/extending/marker.rst +++ b/docs/extending/marker.rst @@ -22,6 +22,9 @@ Thus, only a few methods are required: 4. ``store``: the method that stores the computed marker. 5. ``__init__``: the initialization method, where the marker is configured. +As an example, we will develop a Parcel Mean marker, that is, a marker that first applies a parecellation and +then computes the mean of the data in each parcel. This is a very simple example, but it will show you how to create +a new marker. .. _extending_markers_input_output: -- 2.52.0 From 3fd87fbb2b135f2287823d8c5ba7c8c74eb7f8e0 Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 15 Nov 2022 16:43:45 +0100 Subject: [PATCH 06/22] Doc WIP --- docs/extending/extension.rst | 22 +++ docs/extending/index.rst | 1 + docs/extending/marker.rst | 235 ++++++++++++++++++++++++++ docs/images/pipeline/pipeline.001.png | Bin 0 -> 28509 bytes docs/images/pipeline/pipeline.002.png | Bin 0 -> 105281 bytes docs/index.rst | 1 + docs/links.inc | 2 + docs/understanding/data.rst | 9 + docs/understanding/index.rst | 4 + docs/understanding/pipeline.rst | 30 ++++ docs/understanding/storage.rst | 4 +- docs/using/codeless.rst | 10 ++ docs/using/index.rst | 18 ++ docs/using/queueing.rst | 6 + docs/using/running.rst | 6 + 15 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 docs/extending/extension.rst create mode 100644 docs/images/pipeline/pipeline.001.png create mode 100644 docs/images/pipeline/pipeline.002.png create mode 100644 docs/understanding/pipeline.rst create mode 100644 docs/using/codeless.rst create mode 100644 docs/using/index.rst create mode 100644 docs/using/queueing.rst create mode 100644 docs/using/running.rst diff --git a/docs/extending/extension.rst b/docs/extending/extension.rst new file mode 100644 index 000000000..7d907f1a7 --- /dev/null +++ b/docs/extending/extension.rst @@ -0,0 +1,22 @@ +.. include:: ../links.inc + +.. _extending_extension: + +Creating a Junifer extension +============================ + +Junifer is designed to be easily extensible. Through the use of a registry and decorators, we can easily add new +functinality to junifer on runtime. This is done by creating a new python module and importing it before running +junifer. + +A special consideration has to be made when using the :ref:`code-less configuration`. In this case, the +``with`` statement can be used to import a module or run a python ``.py`` file. + +In the following example, we instruct junifer to first import ``my_module`` and then run the ``my_file.py`` file. + +.. code-block:: yaml + + with: + - my_module + - my_file.py + diff --git a/docs/extending/index.rst b/docs/extending/index.rst index a9b773eba..0d5907e08 100644 --- a/docs/extending/index.rst +++ b/docs/extending/index.rst @@ -23,5 +23,6 @@ datagrabbers, preprocessing and markers, following the *junifer* way. :maxdepth: 2 :caption: Contents: + extension datagrabber marker \ No newline at end of file diff --git a/docs/extending/marker.rst b/docs/extending/marker.rst index 28fb7c649..e93850682 100644 --- a/docs/extending/marker.rst +++ b/docs/extending/marker.rst @@ -31,5 +31,240 @@ a new marker. Step 1: Configure input and output ---------------------------------- +This step is quite simple: we need to define the input and output of the marker. Based on the current +:ref:`data types `, we can define as valid inputs ``BOLD``, ``VBM_WM`` and ``VBM_GM``. + +.. code-block:: python + + def get_valid_inputs(self): + return ['BOLD', 'VBM_WM', 'VBM_GM'] + +The output of the marker depends on the input. For ``BOLD``, it will be ``timeseries``, while for the rest of the inputs, +it will be ``table``. Thus, we can define the output as: + +.. code-block:: python + + def get_output_kind(self, input_kind): + if input_kind == 'BOLD': + return 'timeseries' + else: + return 'table' + +.. _extending_markers_init: + +Step 2: Initialize the marker +----------------------------- + +In this step we need to define the parameters of the marker. That is, all the parameters that the user can provide +to configure how the marker will behave. + +The parameters of the marker are defined in the ``__init__`` method. The :class:`junifer.markers.BaseMarker` class +requires two optional parameters: + +1. ``name``: the name of the marker. This is used to identify the marker in the configuration file. +2. ``on``: a list or string with the names of the inputs that the marker will be applied to. + +.. attention:: Only basic types (*int*, *bool* and *str*) as well as Lists, Tuples and Dictionaries are allowed as + parameters. This is because the parameters are stored in a JSON file, and JSON only supports these types. +In this example, the is only paramater required for the computation is the name of the parcelation to use. Thus, we can +define the ``__init__`` method as follows: + +.. code-block:: python + + def __init__(self, parcelation_name, on=None, name=None): + self.parcelation_name = parcelation_name + super().__init__(on=on, name=name) + +.. caution:: Parameters of the marker must be stored as object attributes without using ``_`` as prefix. This is + because any attribute that starts with ``_`` will not be considered as a parameter and not stored as + part of the metadata of the marker. + + +.. _extending_markers_compute: + +Step 3: Compute the marker +-------------------------- + +In this step, we will define the method that computes the marker. This method will be called by junifer when needed, +using the data provided by the datagrabber, as configured by the user. The function ``compute`` has two arguments: + +* ``input``: a dictionary with the data to be used to compute the marker. This will be the corresponding element in the + :ref:`Data Object` alredy indexing. Thus, the dictionary has at least two keys: ``data`` and ``path``. + The first one contains the data, while the second one contains the path to the data. The dictionary can also contain + other keys, depending on the data type. +* ``extra_input``: the rest of the :ref:`Data Object`. This is usefull if you want to use other data to + compute the marker (e.g.: ``BOLD_confounds`` can be used to de-confound the ``BOLD`` data). + +Following the example, we will compute the mean of the data in each parcel using +:class:`nilearn.maskers.NiftiLabelsMasker`. Importantly, the output of the compute function must be a dictionary. +This dictionary will later be passed onto the ``store`` method. + +.. hint:: To simplify the ``store`` method, define keys of the dictionary based on the corresponding store functions + in the :ref:`storage types `. For example, if the output is a ``table``, the keys of the + dictionary should be ``data`` and ``columns``. + +.. code-block:: python + + from nilearn.maskers import NiftiLabelsMasker + from junifer.data import load_parcellation + + def compute(self, input, extra_input): + # Get the data + data = input["data"] + + # Get the min of the voxels sizes and use it as the resolution + resolution = np.min(data.header.get_zooms()[:3]) + + # Load the parcellation + t_parcellation, t_labels, _ = load_parcellation( + name=self.parcelation_name, + resolution=resolution, + ) + + # Create a masker + masker = NiftiLabelsMasker( + labels_img=t_parcellation, standardize=True, + memory='nilearn_cache', verbose=5) + + # mask the data + out_values = masker.fit_transform([data]) + + # Create the output dictionary + out = {"data": out_values, "columns": t_labels} + + # If its 3D (BOLD), name each row as "scan" + if out_values.shape[0] > 1: + out["row_names"] = "scan" + return out + + +.. _extending_markers_store: + +Step 4: Store the marker +------------------------ + +In this step, we will define the method that stores the marker. This method will be called by junifer when needed, +using the data provided by the ``compute`` method. The function ``store`` has three arguments: + +* ``kind``: A string indicating the :ref:`type of data ` that was used to compute the marker. +* ``out``: The output of the ``compute`` method. +* ``storage``: The storage object, that will be used to store the marker. + +.. code-block:: python + + def store(self, kind, out, storage): + if kind in ["VBM_GM", "VBM_WM"]: + storage.store(kind="table", **out) + elif kind in ["BOLD"]: + storage.store(kind="timeseries", **out) + +.. hint:: Check the hint on :ref:`extending_markers_compute`. If the output of the ``compute`` method is a dictionary + with keys based on the :ref:`storage types `, the ``store`` method can simple call the right + storage function, based on the ``kind`` parameter, with ``**out``. + + +.. _extending_markers_finalize: + +Step 5: Finalize the marker +--------------------------- + +One all of the above-mentioned methods are defined, we just need to give our marker a name an register it using the +``@register_marker`` decorator: + +.. code-block:: python + + from nilearn.maskers import NiftiLabelsMasker + from junifer.data import load_parcellation + from junifer.api.decorators import register_marker + from junifer.markers.base import BaseMarker + + @register_marker + class ParcelMean(BaseMarker): + + def __init__(self, parcelation_name, on=None, name=None): + self.parcelation_name = parcelation_name + super().__init__(on=on, name=name) + + def get_valid_inputs(self): + return ['BOLD', 'VBM_WM', 'VBM_GM'] + + def get_output_kind(self, input_kind): + if input_kind == 'BOLD': + return 'timeseries' + else: + return 'table' + + def compute(self, input, extra_input): + # Get the data + data = input["data"] + + # Get the min of the voxels sizes and use it as the resolution + resolution = np.min(data.header.get_zooms()[:3]) + + # Load the parcellation + t_parcellation, t_labels, _ = load_parcellation( + name=self.parcelation_name, + resolution=resolution, + ) + + # Create a masker + masker = NiftiLabelsMasker( + labels_img=t_parcellation, standardize=True, + memory='nilearn_cache', verbose=5) + + # mask the data + out_values = masker.fit_transform([data]) + + # Create the output dictionary + out = {"data": out_values, "columns": t_labels} + + # If its 3D (BOLD), name each row as "scan" + if out_values.shape[0] > 1: + out["row_names"] = "scan" + return out + + def store(self, kind, out, storage): + if kind in ["VBM_GM", "VBM_WM"]: + storage.store(kind="table", **out) + elif kind in ["BOLD"]: + storage.store(kind="timeseries", **out) + + +.. _extending_markers_template: + +Template for a custom Marker +---------------------------- + +.. code-block:: python + + from junifer.api.decorators import register_marker + from junifer.markers.base import BaseMarker + + @register_marker + class TemplateMarker(BaseMarker): + + def __init__(self, on=None, name=None): + # TODO: add marker-specific parameters + super().__init__(on=on, name=name) + + def get_valid_inputs(self): + # TODO: Complete with the valid inputs + valid = [] + return valid + + def get_output_kind(self, input_kind): + # TODO: Return the valid output kind for each input kind + pass + + def compute(self, input, extra_input): + # TODO: compute the marker and create the output dictionary + + # Create the output dictionary + out = {"data": None, "columns": None} + return out + + def store(self, kind, out, storage): + # TODO: store out using the storage object, based on the kind of data + pass \ No newline at end of file diff --git a/docs/images/pipeline/pipeline.001.png b/docs/images/pipeline/pipeline.001.png new file mode 100644 index 0000000000000000000000000000000000000000..5f222fcd4edfb79785fa49076c67bc04b4eaae8a GIT binary patch literal 28509 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

XB=qp2rD%CQ4bM>GSNpLb&h z)i(9@?H_aLRny0fSV5wy44)`C9xw{ruwJZwn{{6dpH^gr$KC%$q$UH)l=6QcPwhI^ zY+JWg>8jz>z!qL=+?F$GeUD%Hu9YkZ$vfD)AA20$IO|5K4nimGo4on>*z_rt)P2&C zy&@Y0&Si)Cp;90_PQ-N3iS`sqAdLIbs#8jMTQ=7)iSTJi*|gk?=-eNAnT1`6Thl{vn*=kr{N7iFM~w&% z%LP~ZgAk%U%iEChAyrXkUn9}cA2J!H-)CSOH3z+-+)3^g1ymO^UI#rU5qyVwge->3 zjr$XIS)L{5pAWG|5SjQX7ed`S#3p`(I)2b_v?_+?*T4P09rOskc}ZjoQd0{)Ki75P z53KJXK@rSk1pP9 zZOiG1sPjYgjeK(fW;Vn89#MvWl0FQ5f@k|A?ofZnXUEx`0iXg;KG;ZqeKjrg6Gxwo zB<0i44d>41ql+WXC^3Ja`8GMSY5B$+S%{dSdy)t?;6QlTj;Q$#wgbKsGj6W{I zppy|r^r8DDE%-{@u5@cALJwFgsEHI*=XDf}p&$n{6BxB3Cw7pV&ieR`xf$mdmGQF& z<2?#HLf^$VBac|Azj#Q6z7ml=yZhn#u@b#Pqlxh3Ely6^Gzg~dH)OBaL3jC3cpbg1 z7UBuWHk9T2{w&(~ZWLC0+w?d6A2jjB$R zv9O{$0+>ww37lyV4qIgFNZ%`qw>SHJ$n^SLp3dYBG+oTk& z!X#RAJTx1CkH=}H<3;b6KPye_*ZB0kG#Z(%KOUp-GG+QmVP?rczpzp_y^PwtiJH1q zI9P@=RPI;0{jYVkI{1;j@5N+D`4~01EP&st?1WXHMI-co6|NX zbMvW^&`8=%a|SZI>iIivz`fMz**E-wEVDWml6~VDgRZ;(wXFVcFnlbMXskhK!^QLv zJVde%o3Pm*njDCGAnUc`Ih(&RoOS!tZBcH-r?AVdBixz@6kK*Cjh2fS%N}&OvP5Zy z@q%hax%>o#?p&VWKvvyyLn9f6wE~MUzKkzos~_XV8b%f1SsC=p?C^lne!F{_Y%Ps) zP6df;ea(h$)ikK0=pk#r6~qmI49u{I%Qbi!^1(NM1Y`Ew$AeJb==6)nK^Og{#T(tO z?)pMMM5a0z<+**RZqs&tHxZ(d4z#VkQ`Z#jGC0)#HJ@y}gDKe2xFq`EYXOky4dJTH z->TM(p>ZL%*?#~pJ1jG6!Rh=lNM|H4KabKQf+Oo#sl$4j47rIS$^_mBaTUk%ZRVR> zLZ6Om7@7iyTbqg&i+d+X)a_##_%jx!8^TIkezD+^A|h1LLPfOaBObdSH~cCb)1NnF z?ydh^2`B?uLUdED^1ktDRjB*oAJ_7e=Mey@-6OFGKp11v!y-(h`?+LB%k}!^9ej72 zv0|Z<*0G4PCxNa8;FvGJIoM=PagfTvYf){t4%!_oZaiP7Fc346K!nb68$F96RTTWD)NX?=*b%L;XT%Yy%BD7VuEBi-!rjyE|H*&7S1t!{W zH=MSTs8xHPOpiuGr=N?>QczlK+CfG#<* z29Wx2+b|tYedaYLhHK2m*AC}u+n@cqulG19HYlwGSW=-(8WuZlp+ev5hthRiz>@a4 zA)?Y(3Z`#OfhSUx;A1T9;gIib@V#85jR@3vJfHokN^qazRyh5~R}m7|?o}Y0o>b|8 z75>)ygAR%XR4n`a_G~5DzIRHrCj2ANVmgO0M1H0|_4+V(K4#FA|IkV|=@l>?nKV@l zp>7bkB0gnWw^6$lST7Wwl4={cXw@sG0<(uj{je$PNywt92Ea{Bo#%4A_SEV?CO=~u zWr&@*Gea+e?$g)C>+_9i!N$Gt5_W^iK|@`q4(8a9TaNxis%9Tr=!DW8ROcVu)S*ZWgflFsMu)#A`uro>fYztM(hrQ50QgcBtN>5J@Eaei z^;{axm7}H z-Qw2cae&M?!MBD-pL<$@+CYI-#}q?rr4#$Ztxv;zj=ipOzF23wEd+Mq^A zYm@SEekd#B_HI`PDUhfrl9*)ng}vb&14%*s@r3oUacxApq#Y}V2gG3yQ#dQ2lsGlA z9j!?Ceo-?0(^vbw<|oz$K=KmU0L&vD{7q@EpN(`KGr|%)=;qJmdDk#x2RZ2#-Khr9 z6!w;tyW#sYTp?BHGs>svdcn_Sl28TT)D}+gKepfpSgXv z@qf9JbMy>67AIz)RNuT!(?Tc*-^u7BNSg&hh}v2q-IQArB*qVFT>u{T9ul4!a9@}0 zit&V`Vef$?-fap|SDQatL>;7Rtrf%SN~WFEj;%xycSb?Y9wTG%ZjLZ1WH2P)2|fyj zG=ol|F-GzdX~;;_pReaDs=98(nGY6fy?V_|G4t~r1&{O!BdG)dR7|e+p*>4v>GT3X zPkhK>EL2EhdM{n8+z0nQ9@i37Z5`6GpTiXTJI+Vsbe{xoa;d% zPQ8Xn5nw5L9~-;FT000<0W|#ph5=HZEb3qKW=B&-LDVb#Pe;X6$d;r$%jPY0)OP8PTvjS0Ri}hwpFUD_q;u)Vu79M=#{f)*^r~}CCVAorSU46D_4=zb?59DOZ4pc@Cz>%el zXKMZJCvc&Q+zDASSttS-)RS*1M3SEZAIB)NBr%m4YgMlFHaXKSU36t5LYADwiX z43Ak(K`rEmEG?1G!_+!?F@HUUzS}&d`WwWlFnR`|-8Ue@9p3e=SOTX_`H-q+6i3~* zGR=AbN^g3<_S0LuL#l6Ivyop^yxtyN`97Ha5i$Cblj%pn+L8rBvHqqFvRtTX?PWgm z@W;XQbkp05ACnS4&f!%NZ5q`X7=lhjQn`R@s)6~>lwag;rtF@NEPg%D!}uEtv@!eD z0NkI20`~J$w%&v2WIU|K36aOjRGHN}luQ7~xw5|{8!Lod!Gaycj^`LMn$e*S0RJiU zc=*uJ9PW~E(M;g;qO1THmAi7GXE30AmYJPH!_i2Wt+g4hEUiM}&-N8v0kR84+o;h5 z+xP~5)hXYbD}HTd(hF{%y)fs=^Nr#i04lH7s<+NLWX5J7=E4p!b%FRF@S)_REZ*6^ z0zhk$CvASlXcqB^WOIZ^9)|5AsqP?D`j|{vtpNLkRCeQ4Fdg?7{bqy<=q;(Jck$?l z&y=HD4Jgp3QsK`z0LC@~323+Rg_j6X2yIToA*o4%Sq1?<^KgO0j}Wg+7@0D}t9S5Ho;qGe@%8Nwyx&1DMKrK@ZVnm1 zYBdj9G-|zVO=8S(+rt38mmDRQoOS+)V0;+&Qe;(_0qrxCVYP;5;66zKws``gG~p*oOk>BGdie&p@DVED=d#rj7zJ@)IK+Mzd-@ z^QU$r3x1MxzV#?!&j+?7m>2g9z(zg)EbHL5S)%mhjd}mI36Xr3Ym^w=zo%@YsIC^& zRbr&jN!u!eT08Bp+^dY0ZENx&gl#!eH&HO|PnGG91<25KHl?)(=i5A_VH6n6CEo}; zqq!m2wIvD`eY&3fJ2b)xl6*xyBVFbbMdh!FY^0o77+c2$eJ)SS;oUwHOEL}oPQ2v- z<23%mA;-Fn`|-v81}Mi=9xPdRM3q^R#PrZWj@6(5*>Aro9m$JpcDOoV2}{!Hd!1ZL z0mGm6zu5*Rs%#l6@(@BP$1kAQx4icAa7p(Y15Y$9?8Uc&M zsQJ!!n_Kev7W_CoDVRutZ>H^cQ^^%2WkdsSN2_K<8416b&;J;U6D0ByU+CLD?B;Xp zJm}%LK2SK43CBN#)$UdIsMY_ux~LJ1Rg!QUHJxUFDvyR&4IGSK{unasgYPFHC-X>j5Ylbw8|wqmthmzHM%PDeu7;y*#YmcS{XY2O(NxADbgT8vNzHG_ zLEz;6QlI*7&h?`y;X$ZN*+$lfCzv5it>63^t!mdyw96OIY#av*?Jmnw$DzjZ+#7*C z?Au#&GpoYCB`yLd7qa>erx?oAHhQO3j`cI~!aDhz#n>-C=i6MLA=NM{7ru8pwos7> z?Il&Q3i&TMNgpL`Fm7?2e7M#`lRZ|nSv#7l?mL=#UH8c$tmkOi2Hkrd8-oBaGS;Dx zs*+JCQnBypR?ToGvZQ6V3S8gw)Z>^~kMpg?O7&qBM!pvHgb1F@9i}i$O`f0Xdh%sp z2D@^TWMr>;TL;eRw{`TsN#ZFeM$Gq`x>%&5nl6%W*$$~P<9_Os%c?zt6$O8)1l_Lk zShN9683PpbBa;Tl3?5ZUVypWpv+VshdTh+ZO%indGs3hXV zTOJ=et7lbN|89Z>>=YfdAIh!J_w{K(8KI9X7al3piYZC*|a2gU=Jvplc}Ws zP@jFeF4cXy;aXMToS*7F;v#w!IvbC;t58k~8o=)no{&(x&Gx(08DWoxpwK>{_$uW6 zC1?%Aj5HjIV2 zx|~2ta=K@EU%9;cc{-Rx<%xtN?($AsK1suc2?z9JxS~FRO8M+Qz@Q+ zWesEjRj&x9U#kP`N)D%eV1|u19>9mI0vG}r2KUPBJYltl;1&$KeZY`_IUbcBnPE7U zyB!P-RfI`C1+=^TBnwoxgMct9vjK}H!w!|P$GxZ8%62c;0GC)`=FBaEmsMa7FP=+A z9IJ~>uwxh~p?=rfxz+(J&VW%@RFS4EYsAhsxYmb=4MliEe_)FOybLkIx_J#w4C{;ZQKXA?Iy#6p z784ojv8{ml8eE+^qtxm`7=_5c6%!gPnMnM(TB%qsk@H{KOmrC3vXVsG1B<7JLu%6! z*Lwsfxe`|$(=MY1sOaTf+;IHmFRA77pfB~3v*~i*ZZTZYHCCBV(fP37xBlvQa+=z0 zD%!9ebhuXUzwz2Tgfen~X7V_Tqk9Il!E9Xuno@_V<7ZvH;! zRIhzS1MQIBWXInWB=8U7GOULgsAOt8C)w_~y)6&yNudk}n848Af>~6zErN>8hxntq z)|`OBa*&-`!GO^qHHYe`B;VhKWUK}KD9gF z;<=pQLi>@4$LJ@f8wfh06C>@){5^4}#CKj7;*LO; zr2e2Ga54qJ)7bSH+p1}SfhO-;vVs$2z#$7puC3*!s`^}B9~Jv16{Z&MzdLa2c{*B+ zY87ua-CWx>T^&O+<-(oboc<)w>m>LS2mt$90E=@%t^lt8Ud@bj?GwMS(Q3w`asROwL`QjMYT9-D@grvNLG7xThZUvf_+Yd-rW3t%9h6;q5(X&<x)cF+r?4MQ_Q~| zFGk)FI;1@SeXV*zlna$OD>R*o98@t*Q311u-T|O}tZ*%l-s4le)1_X>iGEc#UsrW5 z0`_PI?j^nu%*eO1;n+rT<7T^!mS)~d*Nqryw#NKD?>?!^);e%osJ=wG>>=Z!SP%MXeX_s~Lh3T2b zYfW`>6mOzv9`04B4)`=^@GQ;%@`i`@g>)Fsq4#r&!0Q3%{XoR+nTyS`$Vt$o zjJ@=vSK}qRmt`%y-_96Yzn#7bkZ--eJ=AD|f7+Z3{o$DXjxrmqKMBPG*J4C>Kq>3G z({$sb>+iu`RK0*(1)!JQ$@0HIB?`M$P&4zr^~%8I&vlK*w51ve9i$mRhlw@vRN^S` zlsHb-1R8}Z(XWvBRtDZps7TiIEbepJ4QmLYf zf2@^i{id{}pn8G#^q>BuAPJo;UJY{?CS%ao544waSV;^| zqr1UoaSVu9YJLPi3vOiYOz*8oGB1}-;h zRrZiox*GY#nyU)HRH7oX8$?CP)-^ZR7qRX9Maz@3*Oe_VD1*f>fv1V7t*!!{;Ku`s zt0Oz!JDzr->^bcXS&#C z+;&wA_!xG8>!ns)XLFl!6$RUF$~&)UgMzZP@_a?GDQQZ3ymV7KHQl`Q;B8nDF6{nA z8Jm=#Ptll}5d+`$T~A)@6XNyx*O@z~Ew6qjbyURUWYTzm<3J@sa@BQS)p%6MbcKbC z>9_IsbXZ8F0JzRU7& ztjgN3I}f%HNNk#=dLPTsrpLNRhWWeQuVYhiKRLaA1IQ5_GDY=W^?G9>OYxDm)HdWZ z&knI*r0|Xl@uOjlAgdwM0ymFDk6gu6X{_5j3|%$ed05V;z_yNt8x=WtIVugXM14p? z{f`|#i{6_gk~@aT6TJ4bEYZsSV)aO{1&@DhdQGGWQre1bc)|ylA1=xLEi}b7>edmIP${%cT%WeTXnCG8o z*=)21oqiZFV8CV2L<8L>ad#x6ckN;uHvn3w7RDpU=(2kqjX~BNZGU@`dlmc8bAYL9 zOlr)$K6X{{kY#!+I5b_A3i!i&%$oWhw~0aGJS8^S&25#)Y?>YaKB^7h zAA?udSj9hieHem51-}e#QJo+qZiFGdl#cN`UPw_g31t?I--fw2>e@bMU2k61H%HTu zzw(OnUJ;TY&0RQnqjeb;J5IBH!0qLvGVfk@DEEBj!FDqxAK8vgmkohS>EYQnMLC&(icFwf!YX3XMDpRD-2xKOies;PClu7~okty1=OD6uFZXo(y+T zI=}wpYbM2N@TKS%VfD?on^CA1j{tGi z$V?RWebN+~iTn*-mDpVL+Fiq0+Zp(Je_eQ%DE=(sfa_FF-7MKVvZN=}r*A;JauWsL zh1x&ST!5YiDh6JJ3(n~5;b(W3R?;~40JKdnQ6<;_30Dbl@d#47O}#+*B*{8h;`ES| zY&NR1Flb}kI1=(`rj&#>!AC9&G=! z{4UU|LgJaEewqb?7-trApN~JG3sv8g>x~%>uIB63G$InHW5&rRDa&sx%37s7u_*Ia z>$*J}pjitVgRMaRZWllhtRy?kqb>bBt_r#IkhlINx*BWjZLVbsS^?dhLEf&v zDa(mDgX?O)^=u_@L;Uf>+S%6{?vU`0AxY(Rb6C z3lv~{-wD^ngY`{LVQ)qJm07@|&Y-xg7nZ|Ht+%|)cgPN{?NWxh6ui;+`vNjm$=PCN z52VMPDP5xD9HaVBm0zN#{F5y%=lCn3J0?B(c~+FRX}j0`0;s)u;m!-sTd&IPQO9Y` zj$WP2HXXh~CD^!?3a6PW%cA*@#Hp>c4CXKF6MeWM^3E+cjP&kT90|=}$!QifU7zO} zx7?Y$bF8}G8dL?)MXhm()A0`}fUi$BE36BfF4sd*aD$|s5*zu`e+qAT2f7s9y)o@> z%f;+2ynBBvL-uirk?&Fdn`N3w70Nx!pc5?cT1~)A!ZXDxktxT5MM?ZOLJ8uDp3;xK zxV3K^KfXLetak5>%&T4r89J~`Lch~IWUl|(sQuN-S6|%gw@?`6o6;8Q%tvwDdhZ_Z z5x)+h-lmEic=?Lpf+vvY_rAX>hREH{Pn|ECcG&ahEW(%tx?hmaKd5_bmDYB2Z`mAl zhGKlKkO?gfU*aQ6I_lkgMo#ji8s!@o81wj+H$@+g41@(yyXySevq|$?%jB>gAzIgU z9XI6=ho$Fn(Kcx9Mejb{n0KQeN%p{Z6XEbOk?c9I=AzORN-Su_cRLgwrP*eDyzEdl zTt4$0LweaE(naxfdy-k8g+7Q%eYqf%dRwU4p=i?hH37bxjRb9}ZsNV4j+kbr3OV|l zm@nss!~3)j$K}6ikK1T|YJ7Cpq~vJ+YmPD3{f9yx$Jr2p0%P;v0$*qiUhG8|yUd2s zHX8h?84qrOFD~}}a`1Ll+?7SV^&RRjH$52fUN2Lopq>AsZJ<%vF+W54iL*G1D5x%# z>FL2$vp685o04xbh7Soa*vi^u=s5VF6LlvYEO{r6ugF_0xh}NfyGWhRiGJxl%Vs?uY;gT3&TZbJINQ(iA28iC&3hk>;`%5}=e;02TK(-W*92{ge_ zPU&0%K?&VRf`vqhUzyCnI)J{Qeuv&0_o6=xkYxJ|X6lq_G(|PP?9T`(1dhZgA&l%_ zDKO46CT!-Ic!PHuI+l*^-u5DWnbD?T`hK_W?U5OqdE!j4gEDq|zZl2m9iDn_nh!lk zAUd;DTKo|?Pq9u6mTV#_sr5IiyxeZvc`E(E+q~g`@%Z^3PYA>kJrtADU$OjaA1?bv zzft2$6G7G%_tKsRc9dPfE534p;AJ26W=_rj$|9B___ZK9nG&hmXT#!++2ZZpYGGCv z>7}eL#gih4y>?5%(`ZYH)_chd3&9A#Cy+q4*$t2g36-8U@#=9!nsK>2PW4NpjSOgE z6uArVQ)S5yb&;nMS8Hk=y!y1TA$g=WJcyKvm(sc1o{B}fZcDLLwp7vf%N^SOcVQL= zm=75wbxAqA)T}FxY&>Qb`{hg&*U>q$c;6aQsq4LNrb+iIz#5SDAq{0@wn}nG`g{>U zr^JH%sL-L8ML*b%3dErgz!PBQ&_;P zkdr1>!n_cIevR?1b$~PeZg~@ytFI0#C;cq7P;_3H*XSPfchOO%Obb1#J|m%<=4)De z)vrA!y2`;~b<_3cI%Zu&kIS&F@-B>S2ab190{;*ta+g~1IXTC0e4naSAS?(yoThd&n;%j%tX7fJx#%VRoA zpMtld#`zh7g>N+k*0n3zP9|WlpeGa4@lyoCp9^*wpH#WgRjGgYm^uX$O0>Y9GudM| zp$cc4Ov@aQyOwbJvM@|PD(0~^0ti~&%rQZQ328c?my=Soh65O>+VI2KImD&%*0akm zT10AY95UvyS?yW4t7+d+xUwqY5!3E{84rB6=D9g&%XXI(H}w6+&yHVVo0%_$_@;dI z)lhE)yuI8VG%k1>{FCgX6sl5sfR5+pH2uqWi9Wj}+)c=pW|1z8wfi7iI^-JHT;Bd| zc~o)43H)S6-Y00fV^BWE{2>Coz&$h{h!DR%T_iD3Kxpja#aa&$*mn(+Y{=r_uyB+~ z87IgwcpUW`ugc>k!2D~(ZR9cTInZXW@YwYmt#aaVZPaHv$}fJUp|^h|LTRp%^zxOO z!6QbDX-TwXB&ZpcXQ(BZ#^zo&a{&s(k2UT-8JZHSQ!2t5@FZE@;zL+$PbEDI{HJvhF?U4 z{bR!?)dR~0sXddBmMMf_Sy?ZEP>K7jlLlk|SkS>3G#ymDesY`9-<2sLwkaw&;WQBa?dZ;jc$|A9Q(ed>f){c!Mt zf#39#RTmMK`Q};BF-`pz+54-6+nMXdd(TQKEc7PGSdLGt$Gc*u!sDdHI2K=t4+#9D z-*BFC^OnZ3-U?*-hSzAxMZug-%g7SNJA%xhwbcr#BHt9R3jeo>QT9ombAlh{ggB-o zDw||5MFM{Cwo%3Qb{Q9k%zB9AmME<%XYrHHJ`}**v3y*f|5KF0jc#!7{xNT)EMYG- z)?1*6N$7GpintQy-_<3-8RQa?-2Mo;BXIkgPf*tP^G)U&u&F&g|itA zjpHfm{{>J#MeB+W^GjKKR`Tn#umpo(n*+NEXQV2)s3oF|Iy&8^o{4AlP8&}#!7+U$ zf90c5Ims~F6VPJM#$~!ocSW$8mZhiB83lk|g`0LFU8WgD&2a#USzVt6zRd|0%Em(; zs3NAQkUkhAhq`QT0M0R6$ECegN?>mjE@)*p{n{nCYZL7ZH}c+lAw^pnw|x?^*$-p-&K-K%y2%YeRXc|q@4Zzh#rZinLxYa-t?H)|e5khk8r zn;)Nb{QglGJi&XF_~IB)qm{aTvzw0X zh^{zoAwZ=ebRZrR(<(?jSi6bKey2qg9CZoPO$yq}sBtwBw%NH-EvAi`j`&jZuU&4B znJyGc$#Z3K*zYWcdNHy?#noK`JiOYU+84(UKlS(yaHUu6thCn!MIlh`3)RlNN0J(V(hi`jBK2v3ePEu62&e&e%ilppiNd9cB|A}us%}ls<$Gpi#`;tbwD(o!4MCwEK(Q^t9L0TuIBo19^y_ zmFJX&U$S#=!{+V+wW!vGcfV|2tdwvnm>(0BwDzeIo4KbcSySyKFb zlC1Nf+vc^?P;4WcLW=rtx5|~Auszk2yhAJ5W1Fvf?4}qYy@tn9&yyq{G{}k$`XIYT z28ikbPyL`3i_~O&fjaWGXAr63v6$@HqPMxPR;TG8rf$fV&bp~#Sp6aYn9gzi>ZI6S z#FetJSMoD2EJH1;ytkf#d`4@xxcH77?2^?3YaAZ?9^y0!dwjSVH1v#yC%W0N0!kRg z4QVpHk=fo0?bkc_{2oKG1{;wUt$g$u$-!)}fOkd~Tn&2jkzZ5ALKvB>Wyy&+x8JPR z=f#Y7TxDq{c{`d|nC0F`YRXbM-tQzNyKiP-M%l1LPyD_wfndPqkZlDA;@bF;aavX_ zyD#&zPlR?b_Fq0&|3-BR3Vwc)h?Z#*%*4dk9@@=cXlQn~K4bn%#&|J_8YLhr@dc-Ue z^)-XRC{B{fBc9vAHunByDHl&Aj`Fq6^Op zKP5!e^-tWF-=968|MfM8PG$?4&0s9?GI!NH1RF0-lF{~;Tp!T}<6uo|=4WYm7JA5# zcWYbFccV4YqMuf^CNgS|_kv}QJ%iN#uvui#clp0$5$Jiw)+Gu9r0>AU4&vz`8j45H zv`NM(CZ@*BE=O8FaE63iGgoqNHTo4DBojw2`LVJy?fbWDS-0#%!i$G!DlLq0gLnm%&I|QMuyu3jmEZGC4##H=74Kakj9`9x zcMHN%amX){;G8KCpdy5LDe_FP^ph<^XF7kdbJaT+62=a8693sK_a*hQHU)BZ3o@KW zF@~{^{Q_0T7Gu4{awFuy5@>aWv)R|J?;vuW_$_lI3T{8ipZ4ikj14`oxU15m(&|CI{(fj$8%)UW}bn*XH z-C0JpwXOfUNO7mQyIY`0u%dzDg;FR5iZ!$lq_`G$fb2Ao~g9nf!8ewi%IsfHaq5%ZQ#F9|#et zsHnd$mMYlVsvx=fb;=jI!}fC$XdiwASMEyvSoM&5kuXmcM84%p5J4BTxE#<=OsPji zxG92c;rx)hOj}8Wn0bn1*#Yt0BWhUYM-0Jk!o}2KvS+#wr_whvX3_3)h&3g~_OG~i zfu_$@O4trQnHsj)ErJY*HcauI3Ac_#g>iDa5Nr4r^0LvdF>&J&R(B5FWo2FT z$f`oMrf5lmN~(SNYd+8i@-vnz3Mk>;fiAXl*4|uJWljEZpj8&`zyT{qpWmSu`*tnw zwU_Lo@UaI?2}Fg^k`>7>NolBqZt=yalu2b-hfy!FU%BN$jCYk*=qVR$zT!1^>hvoE zjGpZIP^ofXsxwLq*OtNW#L~WA6K|d!=+6g8N9!0X5E(bqF{D#HB7%48G8UVrcT{s$ zzez5B+A!(t__{FGp*iF86!Gk;&Ps@ClZ0qsFA6R8aaY?h+v>>=n%!E{!Zn9WYP;#$TuhFBiS5oSAEkgnIy6_+kPIPA9-fBLJmCQ zD^~Jzx*Lr+R$ql%HEw8%0j<3xdF(FrSTJ)t#E%|c;#siIqy!a ze80AF!cB{e;AgB)Rs0m0Uiv&~Kn$W!>AG0~;uuRgYK&2tUf1R=O!Bg*FJd>%)J>b= zL!MX7-1S;*3fm1sczi)3M(5MyweduqWh;f3@krU`qd+4_ep%tkn;QCgS6<3lO;9*u zrp0bTz!;`k4zbd^Pf<50#e66f-a?#-Skwb7s4;wD5%3Ls25C092bjRl8DGb4o#e5R z7$m0S3h~1Q%1-01aGtq0FXvO8?Nm!_F&%OU4~h|>?mm|i)%KIZJng(PWCPmc@ zylk->H4UHEa?G+$uT1lD%dras55JpMUVg=G)fgPVd{@*?)7a5*F2}Lv$d)JYLA2D` zPEov+>yGy@Pyb=v<|~6WEBr3@@^rvX44rg~(y>68jfeUp~otZ8=fu zFH~$H#-r;;&9?T2n{OPeChW(x*6d1uHe{o~Q}@5SwBF!4n;U!XBZda55=;#e#C$Ib zU-%Wjw7oRqo~*$;N1#y<6Ahz0!ft=D68|vX#1gyRW+$OSkwPm?s@rg&i&XlPs-<@6 zn%Ll8`OWa?6Wo!{yG&Tz2cqud#}SVWjHw9;8T;(Y@D=-lK#BG=-^HAYFix0|PBe|y zG+z56wVDSACHgJxG7t$Neh;`WhK|nx5UMs*OVk*_9X^aXg0}pTkxgT22&C~l@OGfS zM$=et5D40HG?%HV5N@I)TO3fG(~0QW#S)mLlhMfgvkj?-QgOM(qzmlSs^+6Wk`i2% zn&w|rFQ^(;0KY?7)JswDMCn7XII(`Tm?*t_;wY?svSgi8-ZO0R%$>&TWTaOq6=9WE z8D}YQhX=6b-Ir*LZbSL$?xq{jdqVjet_g&`iWnU*g`h*h0v zy;xucz31_^UDRUUg;&2BVN&Q$S$F(p3C8y8+{eBx3T?IoA8yjulm%g*-^>)aP0rxY zUj0}eRB7ecxn}uAy!sP77v6+nAElvHSJwEQej4Zx1Hswv>Vrjn@+lzhk6Do?@5_sg zLaBm|?Ae7rsmk_6tb)bNy>t_3E!C1fF=Xf zcytT*eRuJjd6uW#UArt+z_LK@^oa=3QVo^#N0RKR+qE`l?Kf#!3{_Og+QEZ8^tq;Q zbym(fycd6MhxY?d1G6ye=hT~yczRIr^KU>ql?|pg(=r~eaoEEYr8=`(y->EGYQRzb z99Rb5a|E*Z#?wS;GY6wJcrLVvj85^mbsxotF}vQ?C!ZFvjY29At&h3*AM$74;IzK! zhsGKFR6Emo^etX&$}_|ReW1jg1YK&df`08b{5T-UD>WqxY~i`9NeRcUn^;Y~lsA8* z=14O5FJ=NtL|?;vP!@T`up%is=e7uYhf2Ms5QmgF&(OYA#NW#>f_>6sL7FbA2C*i=k}Dab7s_}DPqA(qTyj()Pp4z zH;bZ$#i&p{y&x}~d5d`Zt;=5lp;^U^O6eNEa=PnLH`E1p85L#Q1o=CaU3yuPnBTrG zDq%)vm+(X3em0G3&3y|b+og$60T>AK0d}cMJk@okOC3<<=Zo zf=4zPH*J9`N@o+lg44(<%p)VLZ=#Zhtr^xboRhR+oySYP7KzbrX~$23iObFt+gfTe}jVv$Q)wm%%@tk_VC~+rJ z@gcEfpo>?U(+_1zQK6uZY)lw5zHWogXGvQMI6Nu%>8TygrW=|c^i~4gFP4Mby9+El zZRHzCC?r$EF+JKEM_i+F5C!U)xQxS4)Q`WjdRmdg6!+pKJmvjgqT_4DvZXmbGa50c z`uc*m>0;&k-BN^1#*`3VE}}AK<&6Gh7O+;NqdL6aIU;+u*-;;Wra^(O_z=yoEtcIW zKuJxJIH^~Tbp>kj6uYBkD9UoZ19!7)YJkR0hBYPrl@pI$eqip1c} zV#W23n#8b8ML3tl)T&w+f;JSi8pzOcH}I-CIo_4~UmIS{EDz$(0UoH-OT5(_9_g$K=4cDvOfUI`Yg({pRFER_~5LOPRil^jvZ9iTzr`n&P4@#jen2r#0p_78?u>(W2lHzNB`OcQEAa_x73COUw63+ z?6>5u@(*cs*gau=#7!J@HI3ODG80DQw|oKg5v50xmKTQiTBk|-04n5G-RIm-ht=&X zvO1t%Vm7X|h!+vdpEs^*0ysu<=Vd!K*i&xN#t{l(0zZatLiB#aF>eBuUl(b;uo6}YEP30?~U zQAfTsjxdK*oy;mPQ-;xNi1d}#b142n;{a0xUOC}%L~ZL4u%weuB}qQ3SuTZb5&TZs z9cA=Ir=Lye9w(UA8l0Nh-&Srv8^3oW)d4%xT;N^x{ON{QUpo)Wv4k0J%8Fz8dbwTI zr8~^HAOBe0=zT@Gd98PmmWeW9gie!v>2?=KbAkp-BAJj=JFbY%H@Vewv2krH{ZgAY#r9B}&TUlc`5#TW z)JQOUD5|>0vz$asr|Hn7qZrbMMbwVNGQDfxT6I73*rM3<503R;v+n;)DFj080;}mv zjFS{k#XT<$J1)d_iYlpN9>#y}4o8c>eKzTkUIDU)bGi?cgW3;;DYMz}7@K-;$%eqH zxuRXF>As|c${nn@3fUbLXTeA^0a6^aAuTwv0Z7s-7JhBhK(LDjmwJ2o<<^!d`5Hi2 zq>DYut5ef(=wS#pPgNrqz`16Cp>DBnPUPV5H9a;JVC);6GFSf>Rt#ex`0ewO2?;`k zC&YC#ESMTOZn%j{sRQIklz+8b_XAcJ^Z_2g$SBG{`2!V%&Tyo_dB@wrUP0L@z7 z8M_$w9qpd5Q^EiYJ7S?UGTU)^H~A6^Zyhm^P;^6PzCO;KO03z*^{&uLKxK^H4?9op zLSi!Lg43j^>RN2P&BPm3nnE1OH_j+OC@&sX5N-I1Zh*_?xl7;HOA0sZv7ggd(qTFe zcEB~1eZ+Ldwb^MWEwt|ET}6Ym%Z7ymUAkZRc0?YgcY#cb%0J8Der7%RdUg;3|ZnfHKU4TY2U9* ztj$C4XPnvMW+&grn)7Dj(v>v~D*%Apt5_*CD((nfR>s+K^Nci8Ba)^^)bl9XXhi_=YWMLB7jJb=sR@M4pHEz`^S~ z6>TmVXAH;lhc>UpV*!B>DtQ0kdDbPwWmIuGNhZVCl8uMBCMIy(n&y&DLmgPqn!DOw2pGr-uHYF zjAcj;)lFqm143iWcXfx!EKIziP2Gw(OtP10*{c&+X+1<5Ex&#KJBu`_Nwb?alMurM z(QO&&$*|qk@htUpk8q)d+O&!Xk9v*o&@3k3p83FXWljPNMdI3lE zP$r@KEU;966TK655I8j6jb1uTSuiGk%Gq?zqaF2vG#TtECsx^_$=_eIfs$^08^XL`g*rUbCEr+W(n1t@_^COyXxDy*xBP5G&qks4tRZi|urEWDD z8BEV@*4d9b&|JIHdAiG9c?cB*x(|vw2rhbE)5aHjpgVPn!v$}KU!0Lk%L-IV-z|yn zaxT~>ignRu)a=t3*gx*wP;?5j#nBxOFSkYSLD;iG-MP;alIwAMR;EPNw_`TM$T ze|BmiX;S!kTzw6nh)6?cGZ_4FAZ{*r?TK#bzVF(J?=cwv)7$K5aG*XtCn~6aJaB%2 zb2$xjkKqr0E4u`ncgi-&BIb)Mm|64cY0+`X<&w|b*ae|G$$`YXeDy_>7e~HG$yYZ@ z4XQK?$_dkSQWtnNs$wM>Ft?SQFM<|cC+;u5=jHY776!|)R{~J+m(u`}#Y-{z>hUd_ znXFIdTac?q+n|GZ^*&dKcjr)@z>>!jq8fJ9PIp&U#(RLHF}RiHkPMRg$n7xid6*_j zvd9s~K~_t`Bj&>Gt)ut_Z;_CiOj-I^!g}7jZO_qo0qei=Il4z9yCigvqA%ey2UgSd z2Q38`58wp`=SaALr=3j_2UC39DK7gG;tALxd+G<=rS-hR7d};C`1_WdsYgy1c=fq9 zjtwqhlh4UuItEcLebAC9=Yv=k-eeB5{*B^mct{w2NYD5f58dG(5uDElOmnwn%%S@X zKamJkg>jLu!vaF9LnK0idr1;Xzoq5#x<|hY{cZA{rY$826jZ-yan9%V_S;DqG2!z< zvc6_Z@yCsXt` z4M?eqedIzRjpcGGZ;KtJ`3?7CM1?KnEN`M15 zIa7v!KeSSDAm986iCOfN5X)aLt0|oXevOLDr>bo!1b~@7SUZ}F+Aa`D%CCJ+>!B=> zcbIU%9S_r)!j+#ZRx_) zlYQ(BF}ZK#pE?f>M(gyJRpglbv&eC7vzO(W_;bZWwZ{%Y!F-aZ@nYkbM{H#L?Pivq z)A5`_>*U!z{4d`#nIQz$hVhUvaity+zkTT!z3$_ghr_iDN%icO>FErO3kwP#;37XW zyNK>$Rb};+c}Nws&wn%c#VC^-ZXa|r@&tF1c)LOchargO8GM-!rysMX=mJvpLAQXXyy`yVq1kp#FR90t5{yXm7zh| z0|~vC6rgaafqrRa8&k?0p$esMuY>zy7JP%qZ$)!cr`m4wHq`?;EG#+7YDfX%#IH8lS^i#~ zrx%tNuYe?l(6eKKaSE!)Dq&$SoDBvj=qlNyB|a)}T+dO-4p#_NT=Iqc+&}XBkto5c zI?*qTxL_~_yp(>^Du@@|D}3)2^QPCeYB(5TcbL__wX2YE9Ly6c*)R1g>1wn3``hu0 z{-xnyG)#VcWMN!&oSApIT{y5@&F7YtMA~tnp0!qAbsc_nDrv z9hh)eLH5Ms&b@=pti=}k5m)RgR6nXqUHQUM=Ywt`SQMirv3K+s?tTujoZ<9k*~c+x z*lZLQJsQ-bEK8d!B0lkX--~^LA4W-Tcxc|e9%wLuPbl|{cXd3}%COtKPo$m7*F+*V)Pw1r7>W$e+?t{B2E8>omuhXj-RIbe ztw~SP_*c$DFQvkeN6RAj>UI%wsqI+qpgFQx9s>jQ9Y%f~RLhhjBSJkpAfCa}e0nBM zRf260w)o-3E!VBQDO)p$1svjnok8VfS`!V%GE}XnkEeSihE&~4Y&mLShFcK(FB_(GFh?LTj@yh~6{(y=?ud$GIvcTw`+hO* z6P9?WqsJ!G%$J)HEr8M|)AZtWt0roOZR&c5D`j9{kG@1-c6rduu(zNH{?dNbh)4|~ zxNMzULeWsOGl;6=`D%LcYOJo(McF>l3GKZGoJE1 z7AlcIvp8@qEPbSVez38iYO_3NnB;WN(}^sq4;fvfXn05h-!$$vQ`BSiAJ65dggWInE4c)@CsPxmQ>d4V<{7h(^$_Jr z<9*o2o=^gzV-lSSqd*?tSwRVy^6i{~l0^RBVu z6NJ3V6YzQM5rZG=66!JshRIz5yH5Bgzwo2SPUZ41mnp045swf=^Z3%!YqUe_XOpcJ z;Qem=tm7!6L;?B?JuOH9w>8@;i`Wk;BM0B`G0G7T8TV_n6eqUhB{^bf8xX#+tQ{M+ z3H&cS+oOae72ROt0=j5tkb05~IEc>P&et{I&ZWyi58=Z`IeS5OOJ1;!lHLZo?ar_5 z9tQpJUh$@h*^jHp-F5`2%Fcco$Zf;Y81QSwQ?vt-Y6`d&oDX{iYm)yXi+jrAgX|w$ zb^{LJ@_*Pfj^t&E+zKwzS53EXWkY;z9BFES3t%5#~7ros^!#iS>w&KT~<;pGS__gF=#Y2U^vi7O4aaTlLzYYn5GJTqz?4v_DGwG*zxZA0+I>{ zvyk-8Djq*0vv8}E9rDWQp7|xDv{n3v7>_av1@>g+_WD~QodQ#!qP8Hacp>}y@|Y|1!W28Z2{TBdp?1hP50~JhYvCD7 zrhBRB$+$ea?N12bPt1`!UGhkv<@koM<+9bda+ZHw`H@)-77~s>rfJJt#b|>D)J8R^ za`NGM>O%iVlMAvGK^~}iHWt5{u7>q@@ z`-;_nurPcCS-!~N>!dE@QcZfMv4M+p$%`nJ9SBxE^iS^Y5Bd(y>eD`twM7cU;y)9R z^>L)NpAig7zTYq4k0bL1CVx$o8njPIeLbg4>tIei2UQ9BX6&N?W$Yud*mXk90FPt6 z0@nYyE-7K=NRPR%$*AloK44jm(av3>KbP1dZl8Oy4ElIr)=Bc>9v}SQqqxTa)0xZW zooDN~N@^J!Mc$Xh(fAVzMr1SYk$tdtKGIef>r5D!j?v9esK;b)YqZhf1dww8C;jUH z9+*nM9RR7S?oZI?g12Lx07UrDceAAO1^yhdel=Y|oXOXj zaS1FYJ5|Shx(8>JS@UdW70!A?0b@}ddhzVvMwrFQ52$X~i0wd_&ma-5p5enG>?vZ~O%z6BV` zCO7iEYPA6{Ud;mrrr2Jc;}Ur2N9Bq0VL5!39Q5Np%h@Fi_Rrs!F19THJ#+$JFlH*i z-#I1f2oSQf@*g0M>sR68gET;nZy!J^?D!Ceejb1rEDjSf)x9=tZ~np_HUqc{H2ILg z2-n4b2?pqq)6^b#4J-n-@5Gs>woR8`QUE5ArkaR&81%Iu*XdqSKw~fg*xde_-+OeI z5m~+>E-wDwa`Ptz8S*5tC0<*7D+re=@y6nOQ@aq8{j+KzW})}rhWzKW|26*vzD@T$ zw6VmS`P{!<>#vLcb@HU}e%FF`Z3V`Emg8S_`S+7dzI#BOEJ|MlaE$zY$p3LZ{0<=O zYd)22-+R&j@5d(gJ&50F_BYz|-yis|u>o0$Pk=O_n6>B%;JyES!~ECzmEZk_4@V!J z;{IuD^vK2c64(EispEeuc^!k@Ky1<4LnR%Fq!>emauMdr`4pB`P)hDm` zr1a*s`+ZC|*9F%K`ZO`h!-fGvjH8`SWqdPC4Zzg>MGIM8ACoZ);EH zd}_Bc?^uSdB`0+NpgH4a=zbTI+iOS14PfY!8c*CyHt5Q2G*Pe8TObu1S9jv{4;Y7c zfQ!1{V(#1lbCA1nLPOx2#gZ8hlIu5y{txp^h)nz(fwOKTb#b9Rxm$MK`YH-e;%mdg zzjLBp%jazpYtwj>-ej{?m+p! zR3QYu-uJt6NJ}}*1CGgmz8p}Qj=2Sz|5PFJnRNGc9;IP;{vY?;e-^MKP?;h$UpfE% zSo%Me@h0THfLFCACI9);3Z(&+sRk*)`X35726)-+#kkYZKNqkX8!#vT|4-d-bP`iS Vn3V0*{T6KxloizFOJraB{V(r}F9-kt literal 0 HcmV?d00001 diff --git a/docs/images/pipeline/pipeline.002.png b/docs/images/pipeline/pipeline.002.png new file mode 100644 index 0000000000000000000000000000000000000000..dc1c8dfafdc242ed73136ddee3b695e23cf142e1 GIT binary patch literal 105281 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: Interpret the database and provide a list of files. +2. :ref:`Data Reader `: Read the files. +3. :ref:`Pre-processing `: Prepare the images for marker computation. +4. :ref:`Marker Computation `: Compute the marker. +5. :ref:`Storage `: Store the marker values. + +The element that is passed accross the pipeline is called the :ref:`Data Object`. + +The following is a graphical representation of the pipeline: + +.. image:: ../images/pipeline/pipeline.001.png + +However, it is usually the case that several markers are computed for the same data. Thus, the *markers* step +of the pipeline is defined as a list of markers. The following is a graphical representation of the pipeline execution +on multiple markers: + +.. image:: ../images/pipeline/pipeline.002.png + + +.. note:: To avoid keeping in memory all of the computed marker, the storage step is called after each marker + computation, releasing the memory used to compute each marker. \ No newline at end of file diff --git a/docs/understanding/storage.rst b/docs/understanding/storage.rst index b18a81ba9..6d3ca9abe 100644 --- a/docs/understanding/storage.rst +++ b/docs/understanding/storage.rst @@ -44,11 +44,11 @@ Currently supported storage types - :meth:`junifer.storage.BaseFeatureStorage.store_matrix` * - ``table`` - A vector of values with column names - - ``columns`` + - ``columns``, ``row_names`` - :meth:`junifer.storage.BaseFeatureStorage.store_table` * - ``timeseries`` - A 2D matrix of values with column names - - ``columns`` + - ``columns``, ``row_names`` - :meth:`junifer.storage.BaseFeatureStorage.store_timeseries` .. _storage_interfaces: diff --git a/docs/using/codeless.rst b/docs/using/codeless.rst new file mode 100644 index 000000000..ea6466df8 --- /dev/null +++ b/docs/using/codeless.rst @@ -0,0 +1,10 @@ +.. include:: ../links.inc + +.. _codeless: + +Code-less configuration +======================= + +On of the most important features of junifer is its capacity to run without writing a single line of code. This is +achieved by using a configuration file that is written in YAML_. In this file, we configure the different steps of +:ref:`pipeline`. \ No newline at end of file diff --git a/docs/using/index.rst b/docs/using/index.rst new file mode 100644 index 000000000..396c031c2 --- /dev/null +++ b/docs/using/index.rst @@ -0,0 +1,18 @@ +.. include:: ../links.inc + +.. _using: + +Using junifer +============= + +In this section, we will cover the main aspects behind using junifer. We will first explain the basics behind junifer's +code-less configuration. Then we will show how to use the command line interface to ``run`` junifer. Finally, we will +show how to use the ``queue`` command to interact with HPC and HTC systems. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + codeless + running + queueing \ No newline at end of file diff --git a/docs/using/queueing.rst b/docs/using/queueing.rst new file mode 100644 index 000000000..7a6251197 --- /dev/null +++ b/docs/using/queueing.rst @@ -0,0 +1,6 @@ +.. include:: ../links.inc + +.. _queueing: + +Queueing jobs (HPC, HTC) +======================== \ No newline at end of file diff --git a/docs/using/running.rst b/docs/using/running.rst new file mode 100644 index 000000000..12ddfb0a1 --- /dev/null +++ b/docs/using/running.rst @@ -0,0 +1,6 @@ +.. include:: ../links.inc + +.. _running: + +Running jobs +============ \ No newline at end of file -- 2.52.0 From b785c852871b7f7aa9c47de82964900b69b5f88c Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Wed, 16 Nov 2022 14:37:20 +0100 Subject: [PATCH 07/22] Section on extending junifer, codeless and datareader decorator --- docs/extending/extension.rst | 6 ++ docs/using/codeless.rst | 196 +++++++++++++++++++++++++++++++++- docs/using/index.rst | 4 +- junifer/api/decorators.py | 26 ++++- junifer/datareader/default.py | 2 + 5 files changed, 230 insertions(+), 4 deletions(-) diff --git a/docs/extending/extension.rst b/docs/extending/extension.rst index 7d907f1a7..f37f7e690 100644 --- a/docs/extending/extension.rst +++ b/docs/extending/extension.rst @@ -20,3 +20,9 @@ In the following example, we instruct junifer to first import ``my_module`` and - my_module - my_file.py +Thus, the code from ``my_file.py`` will be executed before running junifer. This is the ideal place to create junifer +extensions. + +.. important:: Some junifer commands will not consider files imported from files included in the ``with`` statement. + That is, if ``my_file.py`` imports ``my_other_file.py``, some of the junifer commands will not consider + ``my_other_file.py``. Either place all the code in one file or add multiple files to the ``with`` statement. \ No newline at end of file diff --git a/docs/using/codeless.rst b/docs/using/codeless.rst index ea6466df8..5da86bc30 100644 --- a/docs/using/codeless.rst +++ b/docs/using/codeless.rst @@ -7,4 +7,198 @@ Code-less configuration On of the most important features of junifer is its capacity to run without writing a single line of code. This is achieved by using a configuration file that is written in YAML_. In this file, we configure the different steps of -:ref:`pipeline`. \ No newline at end of file +:ref:`pipeline`. + +As a reminder, this is how the pipeline looks like: + +.. image:: ../images/pipeline/pipeline.001.png + +Thus, the configuration file must configure each of the sections of the pipeline, as well as some general parameters. + +As an example, we will generate the configuration file for a pipeline that will extract the mean ``VBM_GM`` values +using two different parcellations and one set of coordinates, from the *Oasis VBM Testing dataset* included in +junifer. + + +General Parameters +------------------ + +The general parameters are the ones that are not specific to any of the sections of the pipeline, but configure +junifer as a whole. These parameters are: + +* ``with``: A section used to specify modules and junifer extensions to use. +* ``workdir``: The working directory where junifer will store temporary files. + +Since the example uses a specific datagrabber for testing, we need to add ``junifer.testing.registry`` to the +``with`` section. This will allow junifer to find the datagrabber. We will set the ``workdir`` to ``/tmp``. + +.. code-block:: yaml + + with: junifer.testing.registry + workdir: /tmp + +Step-by-step configuration +-------------------------- + +In order to configure the pipeline, we need to configure each step: + +* ``datagrabber`` +* ``datareader`` +* ``preproces`` +* ``markers`` +* ``storage`` + +.. important:: The datareader step configuration is optional, as junifer only provides one datareader. Nevertheless, + it is possible to extend junifer with custom datareaders, and thus, it is also possible to configure this step. + + +Data Grabber +^^^^^^^^^^^^ + +The ``datagrabber`` section must be configured using the ``kind`` key to specify the datagrabber to use. Additional +keys correspond to the parameters of the datagrabber. + +For example, to use the :class:`junifer.datagrabber.DataladAOMICPIOP1` datagrabber, we just need to +specify its name as the ``kind`` key. + +.. code-block:: yaml + + datagrabber: + kind: DataladAOMICPIOP1 + +However, it is also possible to pass parameters to the datagrabber. In this case, we can restrict the datagrabber to +fetch only the ``restingstate`` task. + +.. code-block:: yaml + + datagrabber: + kind: DataladAOMICPIOP1 + tasks: restingstate + +In the *Oasis VBM Testing dataset* example, the section will look like this: + +.. code-block:: yaml + + datagrabber: + kind: OasisVBMTesting + + +Data Reader +^^^^^^^^^^^ + +As mentioned before, this section is entirely optional, as junifer only provides one data reader +(:class:`junifer.datareader.DefaultDataReader`), which is the default in case the section is not specified. + +In any case, the syntax of the section is the same as for the ``datagrabber`` section, using the ``kind`` key to +specify the data reader to use, and additional keys to pass parameters to the data reader: + +.. code-block:: yaml + + datareader: + kind: DefaultDataReader + + +For the *Oasis VBM Testing dataset* example, we will not specify a data reader step. + +Preprocessing +^^^^^^^^^^^^^ + +Preprocessing is also an optional step, as it might be the case that no pre-processing is needed. In the case that +preprocessing is needed, the section must be configured using the ``kind`` key to specify the preprocessor to use, +and additional keys to pass parameters to the preprocessor. + +For example, to use the :class:`junifer.preprocess.fMRIPrepConfoundRemover` preprocessor, we just need to specify its +name as the ``kind`` key, as well as its parameters. + + +.. code-block:: yaml + + preproces: + kind: fMRIPrepConfoundRemover + strategy: + motion: full + wm_csf: full + global_signal: basic + spike: 0.2 + detrend: False + standardize: True + + +For the *Oasis VBM Testing dataset* example, we will not specify a preprocessing step. + + +Markers +^^^^^^^ + +The ``markers`` section diverges from the previous ones, as we need to specify a list of markers. Each marker has a +name that we can use to refer to it later, and a set of parameters that will be passed to the marker. + +For the *Oasis VBM Testing dataset* example, we want to comute the mean ``VBM_GM`` value for each parcel using the +Schaefer parcellation (100 parcels, 7 networks), Schaefer parcellation (200 parcels, 7 networks), and the *DMNBuckner* +network, using 5mm spheres. Thus, we will configure the ``markers`` section as follows: + +.. code-block:: yaml + + markers: + - name: Schaefer100x7_mean + kind: ParcelAggregation + parcellation: Schaefer100x7 + method: mean + - name: Schaefer200x7_mean + kind: ParcelAggregation + parcellation: Schaefer200x7 + method: mean + - name: DMNBuckner_5mm_mean + kind: SphereAggregation + coords: DMNBuckner + radius: 5 + method: mean + + +Storage +^^^^^^^ + +Finally, we need to define how and where the results will be stored. This is done using the ``storage`` section, +which must be configured using the ``kind`` key to specify the storage to use, and additional keys to pass parameters. + +For example, to use the :class:`junifer.storage.SQLiteFeatureStorage` storage, we just need to specify where we want +to store the results: + +.. code-block:: yaml + + storage: + kind: SQLiteFeatureStorage + uri: /data/junifer/example/oasis_vbm_testing.sqlite + + +The full example +---------------- + +This is how the full *Oasis VBM Testing dataset* example configuration file looks like: + +.. code-block:: yaml + + with: junifer.testing.registry + workdir: /tmp + + datagrabber: + kind: OasisVBMTesting + + markers: + - name: Schaefer100x7_mean + kind: ParcelAggregation + parcellation: Schaefer100x7 + method: mean + - name: Schaefer200x7_mean + kind: ParcelAggregation + parcellation: Schaefer200x7 + method: mean + - name: DMNBuckner_5mm_mean + kind: SphereAggregation + coords: DMNBuckner + radius: 5 + method: mean + + storage: + kind: SQLiteFeatureStorage + uri: /data/junifer/example/oasis_vbm_testing.sqlite diff --git a/docs/using/index.rst b/docs/using/index.rst index 396c031c2..75ebdd027 100644 --- a/docs/using/index.rst +++ b/docs/using/index.rst @@ -6,8 +6,8 @@ Using junifer ============= In this section, we will cover the main aspects behind using junifer. We will first explain the basics behind junifer's -code-less configuration. Then we will show how to use the command line interface to ``run`` junifer. Finally, we will -show how to use the ``queue`` command to interact with HPC and HTC systems. +code-less configuration. Then we will show how to use the command line interface to ``run`` junifer and ``collect`` +the results. Finally, we will show how to use the ``queue`` command to interact with HPC and HTC systems. .. toctree:: :maxdepth: 2 diff --git a/junifer/api/decorators.py b/junifer/api/decorators.py index c41450e3f..1c057c9cb 100644 --- a/junifer/api/decorators.py +++ b/junifer/api/decorators.py @@ -33,7 +33,31 @@ def register_datagrabber(klass: Type) -> Type: return klass -def register_preprocessor(klass: Type) -> Type: +def register_datareader(klass: type) -> type: + """Datareader registration decorator. + + Registers the datareader so it can be used by name. + + Parameters + ---------- + klass: class + The class of the datagrabber to register. + + Returns + ------- + klass: class + The unmodified input class. + + """ + register( + step="datareader", + name=klass.__name__, + klass=klass, + ) + return klass + + +def register_preprocessor(klass: type) -> type: """Preprocessor registration decorator. Registers the preprocessor so it can be used by name. diff --git a/junifer/datareader/default.py b/junifer/datareader/default.py index 632262905..d1a74a8ef 100644 --- a/junifer/datareader/default.py +++ b/junifer/datareader/default.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional import nibabel as nib import pandas as pd +from ..api.decorators import register_datareader from ..pipeline import PipelineStepMixin from ..utils.logging import logger, warn_with_log @@ -28,6 +29,7 @@ _readers["CSV"] = {"func": pd.read_csv, "params": None} _readers["TSV"] = {"func": pd.read_csv, "params": {"sep": "\t"}} +@register_datareader class DefaultDataReader(PipelineStepMixin): """Mixin class for default data reader.""" -- 2.52.0 From 1cb635231d548637cc3bfec571f44300afe82cc6 Mon Sep 17 00:00:00 2001 From: Fede Date: Mon, 21 Nov 2022 15:38:26 +0100 Subject: [PATCH 08/22] Doc on running --- docs/using/running.rst | 43 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/docs/using/running.rst b/docs/using/running.rst index 12ddfb0a1..243898d18 100644 --- a/docs/using/running.rst +++ b/docs/using/running.rst @@ -3,4 +3,45 @@ .. _running: Running jobs -============ \ No newline at end of file +============ + +Once we have the :ref:`code-less configuration file `, we can use the command line interface to extract the +features. This is achieved in a two-step process: ``run`` and ``collect``. + +The ``run`` command is used to extract the features from each element in the dataset. However, depending on the +storage interface, this may create one file per subject. The ``collect`` command is then used to collect all of the +individual results into a single file. + +Assuming that we have a configuration file named ``config.yaml``, the following commands will extract the features: + +.. code-block:: bash + + junifer run config.yaml + +Additional arguments + +The ``run`` command accepts the following additional arguments: + +* ``--help``: Show a help message. +* ``--verbose`` Set the verbosity level. Options are ``warning``, ``info``, ``debug``. +* ``--element``: The *element* to run. If not specified, all elements will be run. This parameter can be specified + multiple times to run multiple elements. If the *element* requires several parameters, they can be specified + by separating them with ``,``. + + +Example on running two elements: + +.. code-block:: bash + + junifer run config.yaml --element sub-01 --element sub-02 + +Example on elements with multiple parameters and verbose output: + +.. code-block:: bash + + junifer run --verbose info config.yaml --element sub-01,ses-01 + +.. _collect: + +Collecting results +================== \ No newline at end of file -- 2.52.0 From 69294efc724f304ecff0d90f200700a961dbf966 Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 22 Nov 2022 15:24:39 +0100 Subject: [PATCH 09/22] Done with using junifer --- docs/links.inc | 4 ++ docs/using/codeless.rst | 106 ++++++++++++++++++++-------------------- docs/using/queueing.rst | 85 +++++++++++++++++++++++++++++++- docs/using/running.rst | 19 +++++-- 4 files changed, 157 insertions(+), 57 deletions(-) diff --git a/docs/links.inc b/docs/links.inc index 405a7c236..30ea8b9f2 100644 --- a/docs/links.inc +++ b/docs/links.inc @@ -37,3 +37,7 @@ .. _`sphinx gallery`: https://sphinx-gallery.github.io/stable/index.html .. _`sphinx reST reference`: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#inline-markup + +.. _`HTCondor`: https://research.cs.wisc.edu/htcondor/ +.. _`SLURM`: https://slurm.schedmd.com +.. _`GNU Parallel`: https://www.gnu.org/software/parallel/ \ No newline at end of file diff --git a/docs/using/codeless.rst b/docs/using/codeless.rst index 5da86bc30..c0b169d9f 100644 --- a/docs/using/codeless.rst +++ b/docs/using/codeless.rst @@ -34,8 +34,8 @@ Since the example uses a specific datagrabber for testing, we need to add ``juni .. code-block:: yaml - with: junifer.testing.registry - workdir: /tmp + with: junifer.testing.registry + workdir: /tmp Step-by-step configuration -------------------------- @@ -63,24 +63,24 @@ specify its name as the ``kind`` key. .. code-block:: yaml - datagrabber: - kind: DataladAOMICPIOP1 + datagrabber: + kind: DataladAOMICPIOP1 However, it is also possible to pass parameters to the datagrabber. In this case, we can restrict the datagrabber to fetch only the ``restingstate`` task. .. code-block:: yaml - datagrabber: - kind: DataladAOMICPIOP1 - tasks: restingstate + datagrabber: + kind: DataladAOMICPIOP1 + tasks: restingstate In the *Oasis VBM Testing dataset* example, the section will look like this: .. code-block:: yaml - datagrabber: - kind: OasisVBMTesting + datagrabber: + kind: OasisVBMTesting Data Reader @@ -113,15 +113,15 @@ name as the ``kind`` key, as well as its parameters. .. code-block:: yaml - preproces: - kind: fMRIPrepConfoundRemover - strategy: - motion: full - wm_csf: full - global_signal: basic - spike: 0.2 - detrend: False - standardize: True + preproces: + kind: fMRIPrepConfoundRemover + strategy: + motion: full + wm_csf: full + global_signal: basic + spike: 0.2 + detrend: False + standardize: True For the *Oasis VBM Testing dataset* example, we will not specify a preprocessing step. @@ -139,20 +139,20 @@ network, using 5mm spheres. Thus, we will configure the ``markers`` section as f .. code-block:: yaml - markers: - - name: Schaefer100x7_mean - kind: ParcelAggregation - parcellation: Schaefer100x7 - method: mean - - name: Schaefer200x7_mean - kind: ParcelAggregation - parcellation: Schaefer200x7 - method: mean - - name: DMNBuckner_5mm_mean - kind: SphereAggregation - coords: DMNBuckner - radius: 5 - method: mean + markers: + - name: Schaefer100x7_mean + kind: ParcelAggregation + parcellation: Schaefer100x7 + method: mean + - name: Schaefer200x7_mean + kind: ParcelAggregation + parcellation: Schaefer200x7 + method: mean + - name: DMNBuckner_5mm_mean + kind: SphereAggregation + coords: DMNBuckner + radius: 5 + method: mean Storage @@ -178,27 +178,27 @@ This is how the full *Oasis VBM Testing dataset* example configuration file look .. code-block:: yaml - with: junifer.testing.registry - workdir: /tmp + with: junifer.testing.registry + workdir: /tmp - datagrabber: - kind: OasisVBMTesting + datagrabber: + kind: OasisVBMTesting - markers: - - name: Schaefer100x7_mean - kind: ParcelAggregation - parcellation: Schaefer100x7 - method: mean - - name: Schaefer200x7_mean - kind: ParcelAggregation - parcellation: Schaefer200x7 - method: mean - - name: DMNBuckner_5mm_mean - kind: SphereAggregation - coords: DMNBuckner - radius: 5 - method: mean + markers: + - name: Schaefer100x7_mean + kind: ParcelAggregation + parcellation: Schaefer100x7 + method: mean + - name: Schaefer200x7_mean + kind: ParcelAggregation + parcellation: Schaefer200x7 + method: mean + - name: DMNBuckner_5mm_mean + kind: SphereAggregation + coords: DMNBuckner + radius: 5 + method: mean - storage: - kind: SQLiteFeatureStorage - uri: /data/junifer/example/oasis_vbm_testing.sqlite + storage: + kind: SQLiteFeatureStorage + uri: /data/junifer/example/oasis_vbm_testing.sqlite diff --git a/docs/using/queueing.rst b/docs/using/queueing.rst index 7a6251197..15b6c54b1 100644 --- a/docs/using/queueing.rst +++ b/docs/using/queueing.rst @@ -3,4 +3,87 @@ .. _queueing: Queueing jobs (HPC, HTC) -======================== \ No newline at end of file +======================== + +Yet another interesting feature of junifer is the ability to queue jobs on computational clusters. This is done by +adding the ``queue`` section in the :ref:`codeless` file and executing the ``junifer queue`` command. + +While junifer is meant to support `HTCondor`_, `SLURM`_ and local queueing using `GNU Parallel`_, only HTCondor is +currently supported. This will be implemented in future relases of junifer. If you are in immediate need of any of this +schedulers, please create an issue on the `junifer github`_ repository. + +The ``queue`` section of the :ref:`codeless` must start by defining the following general parameters: + +* ``jobname``: name of the job to be queued. This will be used to name the folder where the job files will be created, + as well as any relevant file. Depending on the schduler, it will also be listed in the queueing system with this + name. +* ``kind``: The kind of schduler to be used. Currently, only ``htcondor`` is supported. + +Example: + +.. code-block:: yaml + + queue: + jobname: TestHTCondorQueue + kind: HTCondor + + +The rest of the parameters depend on the scheduler you are using. + +.. _queueing_condor: + +HTCondor +-------- + +When using HTCondor, junifer will use a DAG to queue one job per element (``junifer run``). As an option, the DAG can +include a final job (``junifer collect``) to collect the results once all of the individual element jobs are finished. + +The following parameters are avilable for HTCondor: + +* ``env``: Definition of the python enviroment. It must provide two variables: ``kind`` and ``name``. The ``kind`` + corresponds to the kind of virtual environment to use: ``conda``, ``virtualenv`` (not yet supported) or + ``local`` (no virtual enviroment). The ``name`` is the name of the enviroment to use in case a virtual environment + is used. +* ``mem``: Memory to be used by the job. It must be provided as a string with the units (e.g. ``2GB``). +* ``cpus``: Number of CPUs to be used by the job. It must be provided as an int. +* ``disk``: Disk space to be used by the job. It must be provided as a string with the units (e.g. ``2GB``). Keep in + mind that junifer uses a local working directory for each job, and datalad datasets might be cloned in this temporary + directory. +* ``extra_preamble``: Extra lines to be added to the HTCondor submit file. This can be used to add + extra parameters to the job, such as ``requirements``. +* ``collect``: If ``True``, a final job will be added to the DAG to collect the results once all of the individual + element jobs are finished. This is useful if you want to run a ``junifer collect`` job only once all of the + individual element jobs are finished. If not specified, it will default to ``True``. + + +Example: + +.. code-block:: yaml + + queue: + jobname: TestHTCondorQueue + kind: HTCondor + env: + kind: conda + name: junifer + mem: 8G + disk: 2GB + collect: True + + +Once the :ref:`codeless` file is ready, including the ``queue`` section, you can queue the jobs by executing +the ``junifer queue`` command. + +The ``queue`` command will create a folder with the name of the job (``jobname``) under the ``junifer_jobs`` directory +in the current working directory. + +The ``queue`` command accepts the following arguments: + +* ``--help``: Show a help message. +* ``--verbose`` Set the verbosity level. Options are ``warning``, ``info``, ``debug``. +* ``--submit``: Submit the jobs to the queueing system. If not specified, the job submit files will be created but not + submitted. +* ``--overwrite``: Overwrite the job folder if it already exists. If not specified, the command will fail if the job + folder already exists. +* ``--element``: Queue only the specified element(s). If not specified, all elements will be queued. + diff --git a/docs/using/running.rst b/docs/using/running.rst index 243898d18..06147f2d0 100644 --- a/docs/using/running.rst +++ b/docs/using/running.rst @@ -18,8 +18,6 @@ Assuming that we have a configuration file named ``config.yaml``, the following junifer run config.yaml -Additional arguments - The ``run`` command accepts the following additional arguments: * ``--help``: Show a help message. @@ -44,4 +42,19 @@ Example on elements with multiple parameters and verbose output: .. _collect: Collecting results -================== \ No newline at end of file +================== + +Once the ``run`` command has been executed, the results are stored in the output directory. However, depending on the +storage interface, this may create one file per subject. The ``collect`` command is then used to collect all of the +individual results into a single file. + +Assuming that we have a configuration file named ``config.yaml``, the following commands will collect the results: + +.. code-block:: bash + + junifer collect config.yaml + +The ``collect`` command accepts the following additional arguments: + +* ``--help``: Show a help message. +* ``--verbose`` Set the verbosity level. Options are ``warning``, ``info``, ``debug``. -- 2.52.0 From 6b76df33119840afd2738590020bca927d7e48ef Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Mon, 7 Nov 2022 15:49:41 +0100 Subject: [PATCH 10/22] Fix for #53 + refactor of datagrabber classes --- junifer/datagrabber/tests/test_datalad_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/junifer/datagrabber/tests/test_datalad_base.py b/junifer/datagrabber/tests/test_datalad_base.py index 50ebcbe76..a5d64edf6 100644 --- a/junifer/datagrabber/tests/test_datalad_base.py +++ b/junifer/datagrabber/tests/test_datalad_base.py @@ -9,6 +9,10 @@ from typing import Type import datalad.api as dl import pytest +from pathlib import Path + +import datalad.api as dl + from junifer.datagrabber.datalad_base import DataladDataGrabber _testing_dataset = { @@ -24,7 +28,6 @@ _testing_dataset = { }, } - def test_datalad_base_abstractness() -> None: """Test datalad base is abstract.""" with pytest.raises(TypeError, match=r"abstract"): -- 2.52.0 From 9ef3bf14b6d8207420d41a76bb0c54ca9ecb13e8 Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Thu, 10 Nov 2022 14:16:13 +0100 Subject: [PATCH 11/22] fix typing --- junifer/api/decorators.py | 4 ++-- junifer/datagrabber/tests/test_datalad_base.py | 2 ++ junifer/pipeline/__init__.py | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/junifer/api/decorators.py b/junifer/api/decorators.py index 1c057c9cb..b8317bf2a 100644 --- a/junifer/api/decorators.py +++ b/junifer/api/decorators.py @@ -33,7 +33,7 @@ def register_datagrabber(klass: Type) -> Type: return klass -def register_datareader(klass: type) -> type: +def register_datareader(klass: Type) -> Type: """Datareader registration decorator. Registers the datareader so it can be used by name. @@ -57,7 +57,7 @@ def register_datareader(klass: type) -> type: return klass -def register_preprocessor(klass: type) -> type: +def register_preprocessor(klass: Type) -> Type: """Preprocessor registration decorator. Registers the preprocessor so it can be used by name. diff --git a/junifer/datagrabber/tests/test_datalad_base.py b/junifer/datagrabber/tests/test_datalad_base.py index a5d64edf6..861241ca0 100644 --- a/junifer/datagrabber/tests/test_datalad_base.py +++ b/junifer/datagrabber/tests/test_datalad_base.py @@ -9,6 +9,8 @@ from typing import Type import datalad.api as dl import pytest +from typing import Type + from pathlib import Path import datalad.api as dl diff --git a/junifer/pipeline/__init__.py b/junifer/pipeline/__init__.py index f0ddfb38d..2bbf3f09c 100644 --- a/junifer/pipeline/__init__.py +++ b/junifer/pipeline/__init__.py @@ -5,3 +5,4 @@ from . import registry from .pipeline_step_mixin import PipelineStepMixin +from . import registry \ No newline at end of file -- 2.52.0 From 198d9c1eea93ed7514a9d20665809cffdc9d6d50 Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Thu, 10 Nov 2022 14:25:59 +0100 Subject: [PATCH 12/22] black + isort --- junifer/api/cli.py | 12 +--- junifer/api/functions.py | 12 +--- junifer/api/tests/test_api_utils.py | 4 +- junifer/api/tests/test_cli.py | 4 +- junifer/api/tests/test_functions.py | 5 +- junifer/api/tests/test_parser.py | 4 +- .../juseless/datagrabbers/camcan_vbm.py | 5 +- .../configs/juseless/datagrabbers/ixi_vbm.py | 9 +-- .../tests/test_aomic_id1000_vbm.py | 5 +- .../datagrabbers/tests/test_ixi_vbm.py | 4 +- .../juseless/datagrabbers/tests/test_ucla.py | 4 +- junifer/configs/juseless/datagrabbers/ucla.py | 11 +--- junifer/data/coordinates.py | 15 ++--- junifer/data/parcellations.py | 21 ++----- junifer/data/tests/test_parcellations.py | 22 ++----- junifer/datagrabber/aomic/piop1.py | 3 +- junifer/datagrabber/aomic/piop2.py | 3 +- .../datagrabber/aomic/tests/test_id1000.py | 11 +--- junifer/datagrabber/aomic/tests/test_piop1.py | 15 ++--- junifer/datagrabber/aomic/tests/test_piop2.py | 15 ++--- junifer/datagrabber/pattern.py | 13 +--- .../datagrabber/tests/test_datalad_base.py | 44 ++++--------- junifer/datagrabber/tests/test_multiple.py | 4 +- junifer/datagrabber/tests/test_pattern.py | 8 +-- .../datagrabber/tests/test_pattern_datalad.py | 8 +-- junifer/datagrabber/utils.py | 12 +--- junifer/datareader/default.py | 8 +-- .../datareader/tests/test_default_reader.py | 8 +-- junifer/markers/collection.py | 3 +- .../functional_connectivity_parcels.py | 8 +-- .../functional_connectivity_spheres.py | 8 +-- .../test_functional_connectivity_spheres.py | 8 +-- .../markers/tests/test_parcel_aggregation.py | 4 +- junifer/pipeline/__init__.py | 1 - junifer/pipeline/tests/test_registry.py | 7 +-- .../confounds/fmriprep_confound_remover.py | 19 ++---- .../tests/test_fmriprep_confound_remover.py | 56 +++++------------ junifer/stats.py | 13 ++-- junifer/storage/base.py | 4 +- junifer/storage/sqlite.py | 26 +++----- junifer/storage/tests/test_sqlite.py | 62 +++++-------------- junifer/storage/tests/test_utils.py | 10 +-- junifer/storage/utils.py | 16 ++--- junifer/testing/datagrabbers.py | 4 +- junifer/tests/test_stats.py | 16 ++--- junifer/utils/logging.py | 4 +- junifer/utils/tests/test_logging.py | 4 +- 47 files changed, 142 insertions(+), 420 deletions(-) diff --git a/junifer/api/cli.py b/junifer/api/cli.py index 9293dcc81..e75e91cc8 100644 --- a/junifer/api/cli.py +++ b/junifer/api/cli.py @@ -81,9 +81,7 @@ def cli() -> None: # pragma: no cover @cli.command() @click.argument( "filepath", - type=click.Path( - exists=True, readable=True, dir_okay=False, path_type=pathlib.Path - ), + type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), ) @click.option("--element", type=str, multiple=True) @click.option( @@ -126,9 +124,7 @@ def run(filepath: click.Path, element: str, verbose: click.Choice) -> None: @cli.command() @click.argument( "filepath", - type=click.Path( - exists=True, readable=True, dir_okay=False, path_type=pathlib.Path - ), + type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), ) @click.option( "-v", @@ -158,9 +154,7 @@ def collect(filepath: click.Path, verbose: click.Choice) -> None: @cli.command() @click.argument( "filepath", - type=click.Path( - exists=True, readable=True, dir_okay=False, path_type=pathlib.Path - ), + type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), ) @click.option("--element", type=str, multiple=True) @click.option("--overwrite", is_flag=True) diff --git a/junifer/api/functions.py b/junifer/api/functions.py index 91b179c39..2f58b25e4 100644 --- a/junifer/api/functions.py +++ b/junifer/api/functions.py @@ -199,9 +199,7 @@ def queue( "or set overwrite=True." ) else: - logger.info( - f"Deleting existing job directory at {str(jobdir.absolute())}" - ) + logger.info(f"Deleting existing job directory at {str(jobdir.absolute())}") shutil.rmtree(jobdir) jobdir.mkdir(exist_ok=True, parents=True) @@ -325,9 +323,7 @@ def _queue_condor( f"run {str(yaml_config.absolute())} " f"--verbose {verbose} --element $(element)" ) - collect_junifer_args = ( - f"collect {str(yaml_config.absolute())} --verbose {verbose} " - ) + collect_junifer_args = f"collect {str(yaml_config.absolute())} --verbose {verbose} " # Set up the env_name, executable and arguments according to the # environment type @@ -449,9 +445,7 @@ def _queue_condor( logger.info("HTCondor job submitted") else: cmd = f"condor_submit_dag {str(dag_fname.absolute())}" - logger.info( - f"HTCondor job files created, to submit the job, run `{cmd}`" - ) + logger.info(f"HTCondor job files created, to submit the job, run `{cmd}`") def _queue_slurm( diff --git a/junifer/api/tests/test_api_utils.py b/junifer/api/tests/test_api_utils.py index 33dd4db42..4f5716fef 100644 --- a/junifer/api/tests/test_api_utils.py +++ b/junifer/api/tests/test_api_utils.py @@ -47,9 +47,7 @@ def test_get_dependency_information_short() -> None: def test_get_dependency_information_long() -> None: """Test long version of _get_dependency_information().""" dependency_information = _get_dependency_information(long_=True) - dependency_information_keys = [ - key for key in dependency_information.keys() - ] + dependency_information_keys = [key for key in dependency_information.keys()] for key in [ "click", "numpy", diff --git a/junifer/api/tests/test_cli.py b/junifer/api/tests/test_cli.py index 27935ad9b..9686bd86c 100644 --- a/junifer/api/tests/test_cli.py +++ b/junifer/api/tests/test_cli.py @@ -25,9 +25,7 @@ runner = CliRunner() ("sub-01", "sub-02", "sub-04"), ], ) -def test_run_and_collect_commands( - tmp_path: Path, elements: Tuple[str, ...] -) -> None: +def test_run_and_collect_commands(tmp_path: Path, elements: Tuple[str, ...]) -> None: """Test run and collect commands.""" # Get test config infile = Path(__file__).parent / "data" / "gmd_mean.yaml" diff --git a/junifer/api/tests/test_functions.py b/junifer/api/tests/test_functions.py index c931bd07d..7f05bf60f 100644 --- a/junifer/api/tests/test_functions.py +++ b/junifer/api/tests/test_functions.py @@ -17,7 +17,6 @@ from junifer.api.functions import collect, queue, run from junifer.datagrabber.base import BaseDataGrabber from junifer.pipeline.registry import build - # Define datagrabber datagrabber = { "kind": "OasisVBMTestingDatagrabber", @@ -168,9 +167,7 @@ def test_run_and_collect(tmp_path: Path) -> None: storage=storage, ) # Get datagrabber - dg = build( - step="datagrabber", name=datagrabber["kind"], baseclass=BaseDataGrabber - ) + dg = build(step="datagrabber", name=datagrabber["kind"], baseclass=BaseDataGrabber) elements = dg.get_elements() # type: ignore # This should create 10 files files = list(outdir.glob("*.db")) diff --git a/junifer/api/tests/test_parser.py b/junifer/api/tests/test_parser.py index e61ff9fdb..16f2e675e 100644 --- a/junifer/api/tests/test_parser.py +++ b/junifer/api/tests/test_parser.py @@ -69,9 +69,7 @@ def test_parse_yaml_failure_with_multi_module_autoload(tmp_path: Path) -> None: """ # Write test file fname = tmp_path / "test_parse_yaml_with_multi_module_autoload.yaml" - fname.write_text( - "foo: bar\nwith:\n - numpy\n - junifer.testing.wrong_config" - ) + fname.write_text("foo: bar\nwith:\n - numpy\n - junifer.testing.wrong_config") # Check test file with pytest.raises(ImportError, match="wrong_config"): parse_yaml(fname) diff --git a/junifer/configs/juseless/datagrabbers/camcan_vbm.py b/junifer/configs/juseless/datagrabbers/camcan_vbm.py index 602f90b64..713fc0e88 100644 --- a/junifer/configs/juseless/datagrabbers/camcan_vbm.py +++ b/junifer/configs/juseless/datagrabbers/camcan_vbm.py @@ -28,10 +28,7 @@ class JuselessDataladCamCANVBM(PatternDataladDataGrabber): """ def __init__(self, datadir: Union[str, Path, None] = None) -> None: - uri = ( - "ria+http://cat_12.5.ds.inm7.de" - "#a139b26a-8406-11ea-8f94-a0369f287950" - ) + uri = "ria+http://cat_12.5.ds.inm7.de" "#a139b26a-8406-11ea-8f94-a0369f287950" types = ["VBM_GM"] replacements = ["subject"] patterns = {"VBM_GM": "sub-{subject}/mri/m0wp1sub-{subject}.nii.gz"} diff --git a/junifer/configs/juseless/datagrabbers/ixi_vbm.py b/junifer/configs/juseless/datagrabbers/ixi_vbm.py index 3b6a69e6a..9efd3bac8 100644 --- a/junifer/configs/juseless/datagrabbers/ixi_vbm.py +++ b/junifer/configs/juseless/datagrabbers/ixi_vbm.py @@ -35,15 +35,10 @@ class JuselessDataladIXIVBM(PatternDataladDataGrabber): datadir: Union[str, Path, None] = None, sites: Union[str, List[str], None] = None, ) -> None: - uri = ( - "ria+http://cat_12.5.ds.inm7.de" - "#b7107c52-8408-11ea-89c6-a0369f287950" - ) + uri = "ria+http://cat_12.5.ds.inm7.de" "#b7107c52-8408-11ea-89c6-a0369f287950" types = ["VBM_GM"] replacements = ["site", "subject"] - patterns = { - "VBM_GM": "{site}/sub-{subject}/mri/m0wp1sub-{subject}.nii.gz" - } + patterns = {"VBM_GM": "{site}/sub-{subject}/mri/m0wp1sub-{subject}.nii.gz"} # validate and/or transform 'site' input all_sites = ["HH", "Guys", "IOP"] diff --git a/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py b/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py index fa3279e1c..6063f867e 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py @@ -25,8 +25,5 @@ def test_juselessdataladaomicid1000vbm_datagrabber() -> None: test_element = all_elements[0] out = dg[test_element] assert "VBM_GM" in out - assert ( - out["VBM_GM"]["path"].name - == f"mwp1sub-{test_element}_run-2_T1w.nii.gz" - ) + assert out["VBM_GM"]["path"].name == f"mwp1sub-{test_element}_run-2_T1w.nii.gz" assert out["VBM_GM"]["path"].exists() diff --git a/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py b/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py index 73d655307..daf3ddcca 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py @@ -26,9 +26,7 @@ def test_juselessdataladixivbm_datagrabber() -> None: test_element = all_elements[0] out = dg[test_element] assert "VBM_GM" in out - assert ( - out["VBM_GM"]["path"].name == f"m0wp1sub-{test_element[1]}.nii.gz" - ) + assert out["VBM_GM"]["path"].name == f"m0wp1sub-{test_element[1]}.nii.gz" assert out["VBM_GM"]["path"].exists() diff --git a/junifer/configs/juseless/datagrabbers/tests/test_ucla.py b/junifer/configs/juseless/datagrabbers/tests/test_ucla.py index eb7f224ff..32887ffe9 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_ucla.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_ucla.py @@ -82,7 +82,5 @@ def test_juseless_ucla_datagrabber_task_params(tasks: Optional[str]) -> None: def test_juseless_ucla_datagrabber_invalid_tasks() -> None: """Test juseless ucla datagrabber with invalid task parameters.""" - with pytest.raises( - ValueError, match="invalid is not a valid task in the UCLA" - ): + with pytest.raises(ValueError, match="invalid is not a valid task in the UCLA"): JuselessUCLA(tasks="invalid") diff --git a/junifer/configs/juseless/datagrabbers/ucla.py b/junifer/configs/juseless/datagrabbers/ucla.py index 443a1257f..377038341 100644 --- a/junifer/configs/juseless/datagrabbers/ucla.py +++ b/junifer/configs/juseless/datagrabbers/ucla.py @@ -31,9 +31,7 @@ class JuselessUCLA(PatternDataGrabber): def __init__( self, - datadir: Union[ - str, Path - ] = "/data/project/psychosis_thalamus/data/fmriprep", + datadir: Union[str, Path] = "/data/project/psychosis_thalamus/data/fmriprep", tasks: Union[str, List[str], None] = None, ) -> None: types = [ @@ -64,9 +62,7 @@ class JuselessUCLA(PatternDataGrabber): else: for t in tasks: if t not in all_tasks: - raise_error( - f"{t} is not a valid task in the UCLA dataset!" - ) + raise_error(f"{t} is not a valid task in the UCLA dataset!") self.tasks = tasks @@ -76,8 +72,7 @@ class JuselessUCLA(PatternDataGrabber): "MNI152NLin2009cAsym_preproc.nii.gz" ), "BOLD_confounds": ( - "sub-{subject}/func/sub-{subject}_" - "task-{task}_bold_confounds.tsv" + "sub-{subject}/func/sub-{subject}_" "task-{task}_bold_confounds.tsv" ), "T1w": ( "sub-{subject}/anat/sub-{subject}_" diff --git a/junifer/data/coordinates.py b/junifer/data/coordinates.py index bb1500161..4d96fb6d0 100644 --- a/junifer/data/coordinates.py +++ b/junifer/data/coordinates.py @@ -71,8 +71,7 @@ def register_coordinates( if name in _available_coordinates: if isinstance(_available_coordinates[name], Path): raise_error( - f"Coordinates {name} already registered as built-in " - "coordinates." + f"Coordinates {name} already registered as built-in " "coordinates." ) if overwrite is True: logger.info(f"Overwriting coordinates {name}") @@ -83,17 +82,11 @@ def register_coordinates( ) if not isinstance(coordinates, np.ndarray): - raise_error( - f"Coordinates must be a numpy.ndarray, not {type(coordinates)}." - ) + raise_error(f"Coordinates must be a numpy.ndarray, not {type(coordinates)}.") if coordinates.ndim != 2: - raise_error( - f"Coordinates must be a 2D array, not {coordinates.ndim}D." - ) + raise_error(f"Coordinates must be a 2D array, not {coordinates.ndim}D.") if coordinates.shape[1] != 3: - raise_error( - f"Each coordinate must have 3 values, not {coordinates.shape[1]} " - ) + raise_error(f"Each coordinate must have 3 values, not {coordinates.shape[1]} ") if len(voi_names) != coordinates.shape[0]: raise_error( f"Length of voi_names ({len(voi_names)}) does not match the " diff --git a/junifer/data/parcellations.py b/junifer/data/parcellations.py index e1ac64498..24bf6d996 100644 --- a/junifer/data/parcellations.py +++ b/junifer/data/parcellations.py @@ -106,10 +106,7 @@ def register_parcellation( if name in _available_parcellations: if overwrite is True: logger.info(f"Overwriting {name} parcellation") - if ( - _available_parcellations[name]["family"] - != "CustomUserParcellation" - ): + if _available_parcellations[name]["family"] != "CustomUserParcellation": raise_error( f"Cannot overwrite {name} parcellation. " "It is a built-in parcellation." @@ -277,9 +274,7 @@ def _retrieve_parcellation( If the parcellation's name is invalid. """ if parcellations_dir is None: - parcellations_dir = ( - Path().home() / "junifer" / "data" / "parcellations" - ) + parcellations_dir = Path().home() / "junifer" / "data" / "parcellations" # Create default junifer data directory if not present parcellations_dir.mkdir(exist_ok=True, parents=True) # Convert str to Path @@ -308,9 +303,7 @@ def _retrieve_parcellation( **kwargs, ) else: - raise_error( - f"The provided parcellation name {family} cannot be retrieved." - ) + raise_error(f"The provided parcellation name {family} cannot be retrieved.") return parcellation_fname, parcellation_labesl @@ -569,9 +562,7 @@ def _retrieve_tian( ) # define 7T labels (b/c currently no labels file available for 7T) scale7Trois = {1: 16, 2: 34, 3: 54, 4: 62} - labels = [ - ("parcel_" + str(x)) for x in np.arange(1, scale7Trois[scale] + 1) - ] + labels = [("parcel_" + str(x)) for x in np.arange(1, scale7Trois[scale] + 1)] parcellation_lname = parcellation_fname_base_7T / ( f"Tian_Subcortex_S{scale}_7T_labelnumbering.txt" ) @@ -592,9 +583,7 @@ def _retrieve_tian( "At least one of the parcellation files are missing, fetching." ) - url_basis = ( - "https://www.nitrc.org/frs/download.php/12012/Tian2020MSA_v1.1.zip" - ) + url_basis = "https://www.nitrc.org/frs/download.php/12012/Tian2020MSA_v1.1.zip" logger.info(f"Downloading TIAN from {url_basis}") with tempfile.TemporaryDirectory() as tmpdir: diff --git a/junifer/data/tests/test_parcellations.py b/junifer/data/tests/test_parcellations.py index f1278f769..faa42fbda 100644 --- a/junifer/data/tests/test_parcellations.py +++ b/junifer/data/tests/test_parcellations.py @@ -47,10 +47,7 @@ def test_register_parcellation_already_registered() -> None: parcellation_path="testparc.nii.gz", parcels_labels=["1", "2", "3"], ) - assert ( - load_parcellation("testparc", path_only=True)[2].name - == "testparc.nii.gz" - ) + assert load_parcellation("testparc", path_only=True)[2].name == "testparc.nii.gz" # Try registering again with pytest.raises(ValueError, match=r"already registered."): @@ -66,10 +63,7 @@ def test_register_parcellation_already_registered() -> None: overwrite=True, ) - assert ( - load_parcellation("testparc", path_only=True)[2].name - == "testparc2.nii.gz" - ) + assert load_parcellation("testparc", path_only=True)[2].name == "testparc2.nii.gz" @pytest.mark.parametrize( @@ -293,9 +287,7 @@ def test_suit(tmp_path: Path) -> None: assert "SUITxMNI" in parcellations # Load parcellation - img, lbl, fname = load_parcellation( - name="SUITxSUIT", parcellations_dir=tmp_path - ) + img, lbl, fname = load_parcellation(name="SUITxSUIT", parcellations_dir=tmp_path) fname1 = "SUIT_SUITSpace_1mm.nii" assert img is not None assert fname.name == fname1 @@ -303,9 +295,7 @@ def test_suit(tmp_path: Path) -> None: assert_array_equal(img.header["pixdim"][1:4], [1, 1, 1]) # type: ignore # Load parcellation - img, lbl, fname = load_parcellation( - name="SUITxSUIT", parcellations_dir=tmp_path - ) + img, lbl, fname = load_parcellation(name="SUITxSUIT", parcellations_dir=tmp_path) fname1 = "SUIT_SUITSpace_1mm.nii" assert img is not None assert fname.name == fname1 @@ -313,9 +303,7 @@ def test_suit(tmp_path: Path) -> None: assert_array_equal(img.header["pixdim"][1:4], [1, 1, 1]) # type: ignore # Load parcellation - img, lbl, fname = load_parcellation( - name="SUITxMNI", parcellations_dir=tmp_path - ) + img, lbl, fname = load_parcellation(name="SUITxMNI", parcellations_dir=tmp_path) fname1 = "SUIT_MNISpace_1mm.nii" assert img is not None assert fname.name == fname1 diff --git a/junifer/datagrabber/aomic/piop1.py b/junifer/datagrabber/aomic/piop1.py index abce1404e..f88a735c4 100644 --- a/junifer/datagrabber/aomic/piop1.py +++ b/junifer/datagrabber/aomic/piop1.py @@ -66,8 +66,7 @@ class DataladAOMICPIOP1(PatternDataladDataGrabber): for t in tasks: if t not in all_tasks: raise_error( - f"{t} is not a valid task in the AOMIC PIOP1" - " dataset!" + f"{t} is not a valid task in the AOMIC PIOP1" " dataset!" ) self.tasks = tasks diff --git a/junifer/datagrabber/aomic/piop2.py b/junifer/datagrabber/aomic/piop2.py index 7fe2212fb..ef5e78612 100644 --- a/junifer/datagrabber/aomic/piop2.py +++ b/junifer/datagrabber/aomic/piop2.py @@ -63,8 +63,7 @@ class DataladAOMICPIOP2(PatternDataladDataGrabber): for t in tasks: if t not in all_tasks: raise_error( - f"{t} is not a valid task in the AOMIC PIOP2" - " dataset!" + f"{t} is not a valid task in the AOMIC PIOP2" " dataset!" ) self.tasks = tasks diff --git a/junifer/datagrabber/aomic/tests/test_id1000.py b/junifer/datagrabber/aomic/tests/test_id1000.py index 532777218..706c24a94 100644 --- a/junifer/datagrabber/aomic/tests/test_id1000.py +++ b/junifer/datagrabber/aomic/tests/test_id1000.py @@ -31,8 +31,7 @@ def test_aomic1000_datagrabber() -> None: assert "BOLD" in out assert ( - out["BOLD"]["path"].name - == f"sub-{test_element}_task-moviewatching_" + out["BOLD"]["path"].name == f"sub-{test_element}_task-moviewatching_" "space-MNI152NLin2009cAsym_desc-preproc_bold.nii.gz" ) @@ -55,8 +54,7 @@ def test_aomic1000_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name - == f"sub-{test_element}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name == f"sub-{test_element}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -102,10 +100,7 @@ def test_aomic1000_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert ( - out["DWI"]["path"].name - == f"sub-{test_element}_desc-preproc_dwi.nii.gz" - ) + assert out["DWI"]["path"].name == f"sub-{test_element}_desc-preproc_dwi.nii.gz" assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() diff --git a/junifer/datagrabber/aomic/tests/test_piop1.py b/junifer/datagrabber/aomic/tests/test_piop1.py index b3f44f542..94e378252 100644 --- a/junifer/datagrabber/aomic/tests/test_piop1.py +++ b/junifer/datagrabber/aomic/tests/test_piop1.py @@ -58,8 +58,7 @@ def test_aomic_piop1_datagrabber() -> None: assert "BOLD_confounds" in out assert ( - out["BOLD_confounds"]["path"].name - == f"sub-{sub}_task-{new_task}_" + out["BOLD_confounds"]["path"].name == f"sub-{sub}_task-{new_task}_" "desc-confounds_regressors.tsv" ) @@ -70,8 +69,7 @@ def test_aomic_piop1_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name - == f"sub-{sub}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name == f"sub-{sub}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -117,9 +115,7 @@ def test_aomic_piop1_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert ( - out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" - ) + assert out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() @@ -136,10 +132,7 @@ def test_piop1_invalid_tasks(): """Test whether invalid task fails.""" with pytest.raises( ValueError, - match=( - "thisisnotarealtask is not a valid task in " - "the AOMIC PIOP1 dataset!" - ), + match=("thisisnotarealtask is not a valid task in " "the AOMIC PIOP1 dataset!"), ): DataladAOMICPIOP1(tasks="thisisnotarealtask") diff --git a/junifer/datagrabber/aomic/tests/test_piop2.py b/junifer/datagrabber/aomic/tests/test_piop2.py index a1b8850d0..6676255b8 100644 --- a/junifer/datagrabber/aomic/tests/test_piop2.py +++ b/junifer/datagrabber/aomic/tests/test_piop2.py @@ -54,8 +54,7 @@ def test_aomic_piop2_datagrabber() -> None: assert "BOLD_confounds" in out assert ( - out["BOLD_confounds"]["path"].name - == f"sub-{sub}_task-{new_task}_" + out["BOLD_confounds"]["path"].name == f"sub-{sub}_task-{new_task}_" "desc-confounds_regressors.tsv" ) @@ -66,8 +65,7 @@ def test_aomic_piop2_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name - == f"sub-{sub}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name == f"sub-{sub}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -113,9 +111,7 @@ def test_aomic_piop2_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert ( - out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" - ) + assert out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() @@ -132,10 +128,7 @@ def test_piop2_invalid_tasks(): """Test whether invalid task fails.""" with pytest.raises( ValueError, - match=( - "thisisnotarealtask is not a valid task in " - "the AOMIC PIOP2 dataset!" - ), + match=("thisisnotarealtask is not a valid task in " "the AOMIC PIOP2 dataset!"), ): DataladAOMICPIOP2(tasks="thisisnotarealtask") diff --git a/junifer/datagrabber/pattern.py b/junifer/datagrabber/pattern.py index f6eed37ed..8568d4edb 100644 --- a/junifer/datagrabber/pattern.py +++ b/junifer/datagrabber/pattern.py @@ -64,9 +64,7 @@ class PatternDataGrabber(BaseDataGrabber): """Skip file check existence.""" return False - def _replace_patterns_regex( - self, pattern: str - ) -> Tuple[str, str, List[str]]: + def _replace_patterns_regex(self, pattern: str) -> Tuple[str, str, List[str]]: """Replace the patterns in `pattern` with the named groups. It allows elements to be obtained from the filesystem. @@ -88,9 +86,7 @@ class PatternDataGrabber(BaseDataGrabber): """ re_pattern = pattern glob_pattern = pattern - t_replacements = [ - x for x in self.replacements if f"{{{x}}}" in pattern - ] + t_replacements = [x for x in self.replacements if f"{{{x}}}" in pattern] for t_r in t_replacements: # Replace the first of each with a named group definition @@ -249,10 +245,7 @@ class PatternDataGrabber(BaseDataGrabber): ] new_elements = set() for t_element in elements: - if ( - tuple(np.array(t_element)[t_repl_idx]) - in types_element - ): + if tuple(np.array(t_element)[t_repl_idx]) in types_element: new_elements.add(t_element) elements = new_elements if elements is None: diff --git a/junifer/datagrabber/tests/test_datalad_base.py b/junifer/datagrabber/tests/test_datalad_base.py index 861241ca0..9e48b9c2b 100644 --- a/junifer/datagrabber/tests/test_datalad_base.py +++ b/junifer/datagrabber/tests/test_datalad_base.py @@ -11,9 +11,8 @@ import pytest from typing import Type -from pathlib import Path - import datalad.api as dl +import pytest from junifer.datagrabber.datalad_base import DataladDataGrabber @@ -58,10 +57,7 @@ def concrete_datagrabber() -> Type[DataladDataGrabber]: def get_item(self, subject): out = { - "T1w": { - "path": self.datadir - / f"{subject}/anat/{subject}_T1w.nii.gz" - }, + "T1w": {"path": self.datadir / f"{subject}/anat/{subject}_T1w.nii.gz"}, "BOLD": { "path": self.datadir / f"{subject}/func/{subject}_task-rest_bold.nii.gz" @@ -78,9 +74,7 @@ def concrete_datagrabber() -> Type[DataladDataGrabber]: return MyDataGrabber -def test_datalad_install_errors( - tmp_path: Path, concrete_datagrabber: Type -) -> None: +def test_datalad_install_errors(tmp_path: Path, concrete_datagrabber: Type) -> None: """Test datalad base install errors / warnings. Parameters @@ -116,9 +110,7 @@ def test_datalad_install_errors( pass -def test_datalad_clone_cleanup( - tmp_path: Path, concrete_datagrabber: Type -) -> None: +def test_datalad_clone_cleanup(tmp_path: Path, concrete_datagrabber: Type) -> None: """Test datalad base clone and remove. Parameters @@ -132,9 +124,7 @@ def test_datalad_clone_cleanup( # Clone whole dataset datadir = tmp_path / "newclone" uri = _testing_dataset["example_bids"]["uri"] - elem1_bold = ( - datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" - ) + elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" assert datadir.exists() is False @@ -163,9 +153,7 @@ def test_datalad_clone_cleanup( assert len(list(datadir.glob("*"))) == 0 -def test_datalad_previously_cloned( - tmp_path: Path, concrete_datagrabber: Type -) -> None: +def test_datalad_previously_cloned(tmp_path: Path, concrete_datagrabber: Type) -> None: """Test datalad base on cloned dataset. Parameters @@ -178,9 +166,7 @@ def test_datalad_previously_cloned( # Dataset cloned outside of datagrabber datadir = tmp_path / "cloned" - elem1_bold = ( - datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" - ) + elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -222,9 +208,7 @@ def test_datalad_previously_cloned( # Datagrabber fetched two files assert len(dg._got_files) == 2 assert any(x.name == "sub-01_T1w.nii.gz" for x in dg._got_files) - assert any( - x.name == "sub-01_task-rest_bold.nii.gz" for x in dg._got_files - ) + assert any(x.name == "sub-01_task-rest_bold.nii.gz" for x in dg._got_files) assert datadir.exists() is True assert len(list(datadir.glob("*"))) > 0 @@ -245,9 +229,7 @@ def test_datalad_previously_cloned_and_get( # Dataset cloned outside of datagrabber with some files present datadir = tmp_path / "cloned_clean" - elem1_bold = ( - datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" - ) + elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -325,9 +307,7 @@ def test_datalad_previously_cloned_and_get_dirty( # Dataset cloned outside of datagrabber with some files present and dirty datadir = tmp_path / "cloned_dirty" - elem1_bold = ( - datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" - ) + elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -411,9 +391,7 @@ def test_datalad_previously_cloned_and_get_dirty( # Datagrabber fetched two files assert len(dg._got_files) == 2 assert any(x.name == "sub-02_T1w.nii.gz" for x in dg._got_files) - assert any( - x.name == "sub-02_task-rest_bold.nii.gz" for x in dg._got_files - ) + assert any(x.name == "sub-02_task-rest_bold.nii.gz" for x in dg._got_files) assert datadir.exists() is True assert len(list(datadir.glob("*"))) > 0 diff --git a/junifer/datagrabber/tests/test_multiple.py b/junifer/datagrabber/tests/test_multiple.py index 47abdd525..a6feb17c4 100644 --- a/junifer/datagrabber/tests/test_multiple.py +++ b/junifer/datagrabber/tests/test_multiple.py @@ -54,9 +54,7 @@ def test_multiple() -> None: assert "BOLD" in types expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") - for j in range(1, 3) - for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 3) for i in range(1, 10) ] with dg: diff --git a/junifer/datagrabber/tests/test_pattern.py b/junifer/datagrabber/tests/test_pattern.py index 71ba60701..49a7e9df0 100644 --- a/junifer/datagrabber/tests/test_pattern.py +++ b/junifer/datagrabber/tests/test_pattern.py @@ -54,9 +54,7 @@ def test_PatternDataGrabber_errors(tmp_path: Path) -> None: replacements="subject", # type: ignore ) - with pytest.raises( - ValueError, match=r"`patterns` must have the same length" - ): + with pytest.raises(ValueError, match=r"`patterns` must have the same length"): PatternDataGrabber( datadir="/tmp", types=["func", "anat"], @@ -64,9 +62,7 @@ def test_PatternDataGrabber_errors(tmp_path: Path) -> None: replacements="subject", # type: ignore ) - with pytest.raises( - ValueError, match=r"`patterns` must contain all `types`" - ): + with pytest.raises(ValueError, match=r"`patterns` must contain all `types`"): PatternDataGrabber( datadir="/tmp", types=["func", "anat"], diff --git a/junifer/datagrabber/tests/test_pattern_datalad.py b/junifer/datagrabber/tests/test_pattern_datalad.py index 2deace6eb..9167f3c00 100644 --- a/junifer/datagrabber/tests/test_pattern_datalad.py +++ b/junifer/datagrabber/tests/test_pattern_datalad.py @@ -173,9 +173,7 @@ def test_bids_PatternDataladDataGrabber_session(): ) as dg: subs = [x for x in dg] expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") - for j in range(1, 3) - for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 3) for i in range(1, 10) ] assert set(subs) == set(expected_subs) @@ -193,8 +191,6 @@ def test_bids_PatternDataladDataGrabber_session(): ) as dg: subs = [x for x in dg] expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") - for j in range(1, 4) - for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 4) for i in range(1, 10) ] assert set(subs) == set(expected_subs) diff --git a/junifer/datagrabber/utils.py b/junifer/datagrabber/utils.py index 60fc4e3ab..2598eb0f2 100644 --- a/junifer/datagrabber/utils.py +++ b/junifer/datagrabber/utils.py @@ -24,9 +24,7 @@ def validate_types(types: List[str]) -> None: raise_error(msg="`types` must be a list of strings", klass=TypeError) -def validate_replacements( - replacements: List[str], patterns: Dict[str, str] -) -> None: +def validate_replacements(replacements: List[str], patterns: Dict[str, str]) -> None: """Validate the replacements. Parameters @@ -44,9 +42,7 @@ def validate_replacements( raise_error(msg="`patterns` must be a dict.", klass=TypeError) if any(not isinstance(x, str) for x in replacements): - raise_error( - msg="`replacements` must be a list of strings.", klass=TypeError - ) + raise_error(msg="`replacements` must be a list of strings.", klass=TypeError) for x in replacements: if all(x not in y for y in patterns.values()): @@ -84,9 +80,7 @@ def validate_patterns(types: List[str], patterns: Dict[str, str]) -> None: ) if any(x not in patterns for x in types): - raise_error( - msg="`patterns` must contain all `types`", klass=ValueError - ) + raise_error(msg="`patterns` must contain all `types`", klass=ValueError) if any("}*" in pattern for pattern in patterns.values()): raise_error( diff --git a/junifer/datareader/default.py b/junifer/datareader/default.py index d1a74a8ef..c9103622e 100644 --- a/junifer/datareader/default.py +++ b/junifer/datareader/default.py @@ -94,9 +94,7 @@ class DefaultDataReader(PipelineStepMixin): out["meta"] = input["meta"] continue if "path" not in input[kind]: - warn_with_log( - f"Input kind {kind} does not provide a path. Skipping." - ) + warn_with_log(f"Input kind {kind} does not provide a path. Skipping.") continue t_path = input[kind]["path"] t_params = params.get(kind, {}) @@ -120,9 +118,7 @@ class DefaultDataReader(PipelineStepMixin): fread = reader_func(t_path, **t_params) break if fread is None: - logger.info( - f"Unknown file type {t_path.as_posix()}, skipping reading" - ) + logger.info(f"Unknown file type {t_path.as_posix()}, skipping reading") out[kind]["data"] = fread if "meta" not in out: out["meta"] = {} diff --git a/junifer/datareader/tests/test_default_reader.py b/junifer/datareader/tests/test_default_reader.py index 4ebf61b34..396bb2e9a 100644 --- a/junifer/datareader/tests/test_default_reader.py +++ b/junifer/datareader/tests/test_default_reader.py @@ -16,9 +16,7 @@ from pandas.testing import assert_frame_equal from junifer.datareader import DefaultDataReader -@pytest.mark.parametrize( - "kind", [["T1w", "BOLD", "T2", "dwi"], [], None, ["whatever"]] -) +@pytest.mark.parametrize("kind", [["T1w", "BOLD", "T2", "dwi"], [], None, ["whatever"]]) def test_validation(kind) -> None: """Test validating input/output. @@ -50,9 +48,7 @@ def test_meta() -> None: assert output["meta"]["datareader"]["class"] == "DefaultDataReader" -@pytest.mark.parametrize( - "fname", ["example4d.nii.gz", "reoriented_anat_moved.nii"] -) +@pytest.mark.parametrize("fname", ["example4d.nii.gz", "reoriented_anat_moved.nii"]) def test_read_nifti(fname: str) -> None: """Test reading NIFTI files. diff --git a/junifer/markers/collection.py b/junifer/markers/collection.py index ab2858bdf..8dde440e5 100644 --- a/junifer/markers/collection.py +++ b/junifer/markers/collection.py @@ -45,8 +45,7 @@ class MarkerCollection: if len(set(marker_names)) != len(marker_names): counts = Counter(marker_names) raise ValueError( - "Markers must have different names. " - f"Current names are: {counts}" + "Markers must have different names. " f"Current names are: {counts}" ) self._markers = markers if datareader is None: diff --git a/junifer/markers/functional_connectivity_parcels.py b/junifer/markers/functional_connectivity_parcels.py index 5c354c76d..cdc14e2e6 100644 --- a/junifer/markers/functional_connectivity_parcels.py +++ b/junifer/markers/functional_connectivity_parcels.py @@ -56,13 +56,9 @@ class FunctionalConnectivityParcels(BaseMarker): ) -> None: self.parcellation = parcellation self.agg_method = agg_method - self.agg_method_params = ( - {} if agg_method_params is None else agg_method_params - ) + self.agg_method_params = {} if agg_method_params is None else agg_method_params self.cor_method = cor_method - self.cor_method_params = ( - {} if cor_method_params is None else cor_method_params - ) + self.cor_method_params = {} if cor_method_params is None else cor_method_params # default to nilearn behavior self.cor_method_params["empirical"] = self.cor_method_params.get( "empirical", False diff --git a/junifer/markers/functional_connectivity_spheres.py b/junifer/markers/functional_connectivity_spheres.py index ba3c6e3e4..aa0473c42 100644 --- a/junifer/markers/functional_connectivity_spheres.py +++ b/junifer/markers/functional_connectivity_spheres.py @@ -65,13 +65,9 @@ class FunctionalConnectivitySpheres(BaseMarker): if radius is None or radius <= 0: raise_error(f"radius should be > 0: provided {radius}") self.agg_method = agg_method - self.agg_method_params = ( - {} if agg_method_params is None else agg_method_params - ) + self.agg_method_params = {} if agg_method_params is None else agg_method_params self.cor_method = cor_method - self.cor_method_params = ( - {} if cor_method_params is None else cor_method_params - ) + self.cor_method_params = {} if cor_method_params is None else cor_method_params # default to nilearn behavior self.cor_method_params["empirical"] = self.cor_method_params.get( "empirical", False diff --git a/junifer/markers/tests/test_functional_connectivity_spheres.py b/junifer/markers/tests/test_functional_connectivity_spheres.py index 964fb9797..014cc96c2 100644 --- a/junifer/markers/tests/test_functional_connectivity_spheres.py +++ b/junifer/markers/tests/test_functional_connectivity_spheres.py @@ -49,9 +49,7 @@ def test_FunctionalConnectivitySpheres(tmp_path: Path) -> None: assert len(set(out["col_names"])) == 6 # get the timeseries using sa - sa = SphereAggregation( - coords="DMNBuckner", radius=5.0, method="mean", on="BOLD" - ) + sa = SphereAggregation(coords="DMNBuckner", radius=5.0, method="mean", on="BOLD") ts = sa.compute({"data": fmri_img}) # Check that FC are almost equal when using nileran @@ -107,9 +105,7 @@ def test_FunctionalConnectivitySpheres_empirical(tmp_path: Path) -> None: assert len(set(out["col_names"])) == 6 # get the timeseries using sa - sa = SphereAggregation( - coords="DMNBuckner", radius=5.0, method="mean", on="BOLD" - ) + sa = SphereAggregation(coords="DMNBuckner", radius=5.0, method="mean", on="BOLD") ts = sa.compute({"data": fmri_img}) # Check that FC are almost equal when using nileran diff --git a/junifer/markers/tests/test_parcel_aggregation.py b/junifer/markers/tests/test_parcel_aggregation.py index 0bbc95109..04950eecf 100644 --- a/junifer/markers/tests/test_parcel_aggregation.py +++ b/junifer/markers/tests/test_parcel_aggregation.py @@ -17,9 +17,7 @@ from junifer.markers.parcel_aggregation import ParcelAggregation def test_ParcelAggregation_input_output() -> None: """Test ParcelAggregation input and output types.""" - marker = ParcelAggregation( - parcellation="Schaefer100x7", method="mean", on="VBM_GM" - ) + marker = ParcelAggregation(parcellation="Schaefer100x7", method="mean", on="VBM_GM") output = marker.get_output_kind(["VBM_GM", "BOLD"]) assert output == ["table", "timeseries"] diff --git a/junifer/pipeline/__init__.py b/junifer/pipeline/__init__.py index 2bbf3f09c..f0ddfb38d 100644 --- a/junifer/pipeline/__init__.py +++ b/junifer/pipeline/__init__.py @@ -5,4 +5,3 @@ from . import registry from .pipeline_step_mixin import PipelineStepMixin -from . import registry \ No newline at end of file diff --git a/junifer/pipeline/tests/test_registry.py b/junifer/pipeline/tests/test_registry.py index a84a9b7ee..9c85fb630 100644 --- a/junifer/pipeline/tests/test_registry.py +++ b/junifer/pipeline/tests/test_registry.py @@ -11,12 +11,7 @@ from typing import Type import pytest from junifer.datagrabber import PatternDataGrabber -from junifer.pipeline.registry import ( - build, - get_class, - get_step_names, - register, -) +from junifer.pipeline.registry import build, get_class, get_step_names, register from junifer.storage import SQLiteFeatureStorage diff --git a/junifer/preprocess/confounds/fmriprep_confound_remover.py b/junifer/preprocess/confounds/fmriprep_confound_remover.py index 2e5be6807..a105fb840 100644 --- a/junifer/preprocess/confounds/fmriprep_confound_remover.py +++ b/junifer/preprocess/confounds/fmriprep_confound_remover.py @@ -452,9 +452,7 @@ class fMRIPrepConfoundRemover(BasePreprocessor): raise_error("No BOLD_confounds data provided", ValueError) # Confounds must be a dataframe if not isinstance(extra_input["BOLD_confounds"]["data"], pd.DataFrame): - raise_error( - "Confounds data must be a pandas dataframe", ValueError - ) + raise_error("Confounds data must be a pandas dataframe", ValueError) confound_df = extra_input["BOLD_confounds"]["data"] bold_img = input["data"] @@ -467,8 +465,7 @@ class fMRIPrepConfoundRemover(BasePreprocessor): if "format" not in extra_input["BOLD_confounds"]: raise_error( - "Confounds format must be specified in " - 'input["BOLD_confounds"]' + "Confounds format must be specified in " 'input["BOLD_confounds"]' ) t_format = extra_input["BOLD_confounds"]["format"] @@ -486,13 +483,9 @@ class fMRIPrepConfoundRemover(BasePreprocessor): "the variables names mappings to fmriprep format in " 'input["BOLD_confounds"]["mappings"]["fmriprep"]' ) - fmriprep_mappings = extra_input["BOLD_confounds"]["mappings"][ - "fmriprep" - ] + fmriprep_mappings = extra_input["BOLD_confounds"]["mappings"]["fmriprep"] wrong_names = [ - x - for x in fmriprep_mappings.values() - if x not in FMRIPREP_VALID_NAMES + x for x in fmriprep_mappings.values() if x not in FMRIPREP_VALID_NAMES ] if len(wrong_names) > 0: raise_error( @@ -501,9 +494,7 @@ class fMRIPrepConfoundRemover(BasePreprocessor): ) # Check that all the required columns are present missing = [ - x - for x in fmriprep_mappings.keys() - if x not in confound_df.columns + x for x in fmriprep_mappings.keys() if x not in confound_df.columns ] if len(missing) > 0: diff --git a/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py b/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py index 98e028d86..81e281c36 100644 --- a/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py +++ b/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py @@ -121,9 +121,7 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: "white_matter_derivative1_power2", ] - confounds_df = pd.DataFrame( - np.random.randn(7, len(var_names)), columns=var_names - ) + confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out @@ -142,9 +140,7 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: all_names = var_names + missing_der_names + missing_sq_names - confounds_df = pd.DataFrame( - np.random.randn(7, len(var_names)), columns=var_names - ) + confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out assert set(to_select) == set(all_names) @@ -153,9 +149,7 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: assert spike_name == "framewise_displacement" # Same strategy, with spike, only basics are present - confound_remover = fMRIPrepConfoundRemover( - strategy={"wm_csf": "full"}, spike=0.2 - ) + confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) var_names = ["csf", "white_matter"] missing_der_names = ["csf_derivative1", "white_matter_derivative1"] @@ -193,9 +187,7 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: all_names = var_names + missing_der_names + missing_sq_names - confounds_df = pd.DataFrame( - np.random.randn(7, len(var_names)), columns=var_names - ) + confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out assert set(to_select) == set(all_names) @@ -205,22 +197,16 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: # Test for wrong columns/strategy pairs - confound_remover = fMRIPrepConfoundRemover( - strategy={"wm_csf": "full"}, spike=0.2 - ) + confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) var_names = ["csf"] - confounds_df = pd.DataFrame( - np.random.randn(7, len(var_names)), columns=var_names - ) + confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) msg = r"Missing basic confounds: \['white_matter'\]" with pytest.raises(ValueError, match=msg): confound_remover._process_fmriprep_spec({"data": confounds_df}) var_names = ["csf", "white_matter"] - confounds_df = pd.DataFrame( - np.random.randn(7, len(var_names)), columns=var_names - ) + confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) msg = r"Missing framewise_displacement" with pytest.raises(ValueError, match=msg): @@ -261,9 +247,7 @@ def test_fMRIPrepConfoundRemover__pick_confounds_adhoc() -> None: def test_FMRIPRepConfoundRemover__pick_confounds_fmriprep() -> None: """Test fMRIPrepConfoundRemover pick confounds on fmriprep confounds.""" - confound_remover = fMRIPrepConfoundRemover( - strategy={"wm_csf": "full"}, spike=0.2 - ) + confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) fmriprep_all_vars = [ "csf", "white_matter", @@ -331,9 +315,7 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: input = dg["sub-01"] input = reader.fit_transform(input) new_input = input["VBM_GM"] - with pytest.raises( - DimensionError, match="incompatible dimensionality" - ): + with pytest.raises(DimensionError, match="incompatible dimensionality"): confound_remover._validate_data(new_input, None) with PartlyCloudyTestingDataGrabber(reduce_confounds=False) as dg: @@ -345,9 +327,7 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: confound_remover._validate_data(new_input, None) with pytest.raises(ValueError, match="No BOLD_confounds provided"): confound_remover._validate_data(new_input, {}) - with pytest.raises( - ValueError, match="No BOLD_confounds data provided" - ): + with pytest.raises(ValueError, match="No BOLD_confounds data provided"): confound_remover._validate_data(new_input, {"BOLD_confounds": {}}) extra_input = { @@ -361,9 +341,7 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: with pytest.raises(ValueError, match="Image time series and"): confound_remover._validate_data(new_input, extra_input) - extra_input = { - "BOLD_confounds": {"data": input["BOLD_confounds"]["data"]} - } + extra_input = {"BOLD_confounds": {"data": input["BOLD_confounds"]["data"]}} with pytest.raises(ValueError, match="format must be specified"): confound_remover._validate_data(new_input, extra_input) @@ -445,9 +423,7 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: def test_fMRIPrepConfoundRemover__remove_confounds() -> None: """Test fMRIPrepConfoundRemover remove confounds.""" - confound_remover = fMRIPrepConfoundRemover( - strategy={"wm_csf": "full"}, spike=0.2 - ) + confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) reader = DefaultDataReader() with PartlyCloudyTestingDataGrabber(reduce_confounds=False) as dg: input = dg["sub-01"] @@ -490,9 +466,7 @@ def test_fMRIPrepConfoundRemover_preprocess() -> None: assert orig_bold.shape == trans_bold.shape # but be different - assert_raises( - AssertionError, assert_array_equal, orig_bold, trans_bold - ) + assert_raises(AssertionError, assert_array_equal, orig_bold, trans_bold) assert key == "BOLD" @@ -517,9 +491,7 @@ def test_fMRIPrepConfoundRemover_fit_transform() -> None: assert orig_bold.shape == trans_bold.shape # but be different - assert_raises( - AssertionError, assert_array_equal, orig_bold, trans_bold - ) + assert_raises(AssertionError, assert_array_equal, orig_bold, trans_bold) assert output["meta"] == input["meta"] # general meta does not change assert "meta" in output["BOLD"] diff --git a/junifer/stats.py b/junifer/stats.py index 7cc369ae4..1173e6f61 100644 --- a/junifer/stats.py +++ b/junifer/stats.py @@ -51,20 +51,16 @@ def get_aggfunc_by_name( limits = func_params.get("limits") if limits is None or not isinstance(limits, list): raise_error( - "func_params must contain a list of limits for " - "winsorized_mean", + "func_params must contain a list of limits for " "winsorized_mean", ValueError, ) if len(limits) != 2: raise_error( - "func_params must contain a list of two limits for " - "winsorized_mean", + "func_params must contain a list of two limits for " "winsorized_mean", ValueError, ) if any((lim < 0.0 or lim > 1) for lim in limits): - raise_error( - "Limits for the winsorized mean must be between 0 and 1." - ) + raise_error("Limits for the winsorized mean must be between 0 and 1.") logger.info(f"Limits for winsorized mean are set to {limits}.") # partially interpret func_params func = partial(winsorized_mean, **func_params) @@ -76,8 +72,7 @@ def get_aggfunc_by_name( func = partial(trim_mean, **func_params) else: raise_error( - f"Function {name} unknown. Please provide any of " - f"{_valid_func_names}" + f"Function {name} unknown. Please provide any of " f"{_valid_func_names}" ) return func diff --git a/junifer/storage/base.py b/junifer/storage/base.py index 624a1bab7..9b900dbfa 100644 --- a/junifer/storage/base.py +++ b/junifer/storage/base.py @@ -249,8 +249,6 @@ class BaseFeatureStorage(ABC): """ single = ( - "(single output)" - if self.single_output is True - else "(multiple output)" + "(single output)" if self.single_output is True else "(multiple output)" ) return f"<{self.__class__.__name__} @ {self.uri} {single}>" diff --git a/junifer/storage/sqlite.py b/junifer/storage/sqlite.py index 8256db1cf..0d29b8c1d 100644 --- a/junifer/storage/sqlite.py +++ b/junifer/storage/sqlite.py @@ -175,8 +175,7 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): con, table_name=name, index_col=index_col ) existing, new = _split_incoming_data( - df, pk_indb, index_col - ) + df, pk_indb, index_col) # Step 2: upsert existing data pandas_sql = pandasSQL_builder(con) pandas_sql.meta.reflect(bind=con, only=[name]) @@ -205,8 +204,7 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): raise_error(msg=f"Table ({name}) already exists.") else: raise_error( - msg=f"Invalid option {if_exists} for if_exists." - ) + msg=f"Invalid option {if_exists} for if_exists.") def _store_2d( self, @@ -234,8 +232,7 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): n_rows = len(data) # Convert element metadata to index idx = element_to_index( - meta=meta, n_rows=n_rows, rows_col_name=rows_col_name - ) + meta=meta, n_rows=n_rows, rows_col_name=rows_col_name) # Prepare new dataframe data_df = pd.DataFrame( # type: ignore data, columns=columns, index=idx # type: ignore @@ -244,8 +241,7 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): self.store_df(df=data_df, meta=meta) def list_features( - self, return_df: bool = False - ) -> Union[Dict, pd.DataFrame]: + self, return_df: bool = False) -> Union[Dict, pd.DataFrame]: """Implement features listing from the storage. Parameters @@ -307,10 +303,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): # Parameter value check if feature_md5 is not None and feature_name is not None: raise_error( - msg=( - "Only one of `feature_name` or `feature_md5` can be " - "specified." - ) + msg=("Only one of `feature_name` or `feature_md5` can be " + "specified.") ) elif feature_md5 is None and feature_name is None: raise_error( @@ -349,9 +343,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): f"WHERE tbl_name='{table_name}' " "ORDER BY cid;" ) - index_names = ( - pd.read_sql(sql=query, con=engine).values.squeeze().tolist() - ) + index_names = pd.read_sql( + sql=query, con=engine).values.squeeze().tolist() # Set index on dataframe df = df.set_index(index_names) return df @@ -613,8 +606,7 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): in_engine = in_storage.get_engine() # Open "meta" table t_meta_df = pd.read_sql( - sql="meta", con=in_engine, index_col="meta_md5" - ) + sql="meta", con=in_engine, index_col="meta_md5") # Save metadata out_storage._save_upsert(t_meta_df, "meta") # Save dataframes diff --git a/junifer/storage/tests/test_sqlite.py b/junifer/storage/tests/test_sqlite.py index 3f428f5f4..6e833c923 100644 --- a/junifer/storage/tests/test_sqlite.py +++ b/junifer/storage/tests/test_sqlite.py @@ -15,11 +15,7 @@ from pandas.testing import assert_frame_equal from sqlalchemy import create_engine from junifer.storage.sqlite import SQLiteFeatureStorage -from junifer.storage.utils import ( - element_to_index, - element_to_prefix, - process_meta, -) +from junifer.storage.utils import element_to_index, element_to_prefix, process_meta df1 = pd.DataFrame( { @@ -111,9 +107,7 @@ def test_get_engine_multi_output(tmp_path: Path) -> None: """ uri = tmp_path / "test_multi_output.db" - storage = SQLiteFeatureStorage( - uri=uri, single_output=False, upsert="ignore" - ) + storage = SQLiteFeatureStorage(uri=uri, single_output=False, upsert="ignore") with pytest.raises(ValueError, match="element must be specified"): storage.get_engine() @@ -198,9 +192,7 @@ def test_upsert_ignore(tmp_path: Path) -> None: with pytest.warns(RuntimeWarning, match="are already present"): storage.store_df(df2, meta) # Read stored table - c_dfignore = _read_sql( - table_name, uri=uri.as_posix(), index_col=["element", "pk2"] - ) + c_dfignore = _read_sql(table_name, uri=uri.as_posix(), index_col=["element", "pk2"]) # Check if dataframes are equal assert_frame_equal(c_dfignore, df_ignore) # Check for error @@ -234,9 +226,7 @@ def test_upsert_update(tmp_path: Path) -> None: # Save to database storage.store_df(df2, meta) # Read stored table - c_dfupdate = _read_sql( - table_name, uri=uri.as_posix(), index_col=["element", "pk2"] - ) + c_dfupdate = _read_sql(table_name, uri=uri.as_posix(), index_col=["element", "pk2"]) # Check if dataframes are equal assert_frame_equal(c_dfupdate, df_update) @@ -382,9 +372,7 @@ def test_store_table(tmp_path: Path) -> None: df_new = pd.DataFrame(data_new, columns=["f1", "f2"], index=idx_new) # Check warning with pytest.warns(RuntimeWarning, match=r"Some rows"): - storage.store_table( - data_new, meta, columns=["f1", "f2"], rows_col_name="scan" - ) + storage.store_table(data_new, meta, columns=["f1", "f2"], rows_col_name="scan") # Read stored table c_df_new = _read_sql( table_name=table_name, @@ -410,9 +398,7 @@ def test_store_matrix(tmp_path: Path) -> None: meta = {"element": "test", "version": "0.0.1", "marker": {"name": "fc"}} # Store 4 x 3 full matrix - data = np.array( - [[1, 2, 3], [11, 22, 33], [111, 222, 333], [1111, 2222, 3333]] - ) + data = np.array([[1, 2, 3], [11, 22, 33], [111, 222, 333], [1111, 2222, 3333]]) row_names = ["row1", "row2", "row3", "row4"] col_names = ["col1", "col2", "col3"] @@ -439,9 +425,7 @@ def test_store_matrix(tmp_path: Path) -> None: storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix(data=data, meta=meta) stored_names = [ - f"r{i}~c{j}" - for i in range(data.shape[0]) - for j in range(data.shape[1]) + f"r{i}~c{j}" for i in range(data.shape[0]) for j in range(data.shape[1]) ] features = storage.list_features() feature_md5 = list(features.keys())[0] @@ -491,9 +475,7 @@ def test_store_matrix(tmp_path: Path) -> None: assert "fc" == features[feature_md5]["name"] read_df = storage.read_df(feature_md5=feature_md5) assert list(read_df.columns) == stored_names - assert_array_equal( - read_df.values, data[np.triu_indices(n=data.shape[0])][None, :] - ) + assert_array_equal(read_df.values, data[np.triu_indices(n=data.shape[0])][None, :]) # Store upper triangular matrix without diagonal uri = tmp_path / "test_store_table_triu_nodiagonal.db" @@ -550,9 +532,7 @@ def test_store_matrix(tmp_path: Path) -> None: assert "fc" == features[feature_md5]["name"] read_df = storage.read_df(feature_md5=feature_md5) assert list(read_df.columns) == stored_names - assert_array_equal( - read_df.values, data[np.tril_indices(n=data.shape[0])][None, :] - ) + assert_array_equal(read_df.values, data[np.tril_indices(n=data.shape[0])][None, :]) # Store lower triangular matrix without diagonal uri = tmp_path / "test_store_table_tril_nodiagonal.db" @@ -644,15 +624,9 @@ def test_store_multiple_output(tmp_path: Path): assert hash1 == hash2 assert hash2 == hash3 # Store tables - storage.store_table( - data1, meta1, columns=["f1", "f2"], rows_col_name="scan" - ) - storage.store_table( - data2, meta2, columns=["f1", "f2"], rows_col_name="scan" - ) - storage.store_table( - data3, meta3, columns=["f1", "f2"], rows_col_name="scan" - ) + storage.store_table(data1, meta1, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table(data2, meta2, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table(data3, meta3, columns=["f1", "f2"], rows_col_name="scan") # Check that URI does not exist yet assert not uri.exists() # Convert element to preifx @@ -722,15 +696,9 @@ def test_collect(tmp_path: Path) -> None: data2 = data1 * 10 data3 = data1 * 20 # Store tables - storage.store_table( - data1, meta1, columns=["f1", "f2"], rows_col_name="scan" - ) - storage.store_table( - data2, meta2, columns=["f1", "f2"], rows_col_name="scan" - ) - storage.store_table( - data3, meta3, columns=["f1", "f2"], rows_col_name="scan" - ) + storage.store_table(data1, meta1, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table(data2, meta2, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table(data3, meta3, columns=["f1", "f2"], rows_col_name="scan") # Convert element to prefix prefix1 = element_to_prefix(meta1["element"]) prefix2 = element_to_prefix(meta2["element"]) diff --git a/junifer/storage/tests/test_utils.py b/junifer/storage/tests/test_utils.py index 271397115..8ab802165 100644 --- a/junifer/storage/tests/test_utils.py +++ b/junifer/storage/tests/test_utils.py @@ -8,11 +8,7 @@ from typing import Dict, List, Tuple, Union import pytest -from junifer.storage.utils import ( - element_to_index, - element_to_prefix, - process_meta, -) +from junifer.storage.utils import element_to_index, element_to_prefix, process_meta def test_process_meta_invalid_metadata_type() -> None: @@ -118,9 +114,7 @@ def test_process_meta_element(meta: Dict, elements: List[str]) -> None: ((1, 2), "element_1_2_"), ], ) -def test_element_to_prefix( - element: Union[str, int, Dict, Tuple], prefix: str -) -> None: +def test_element_to_prefix(element: Union[str, int, Dict, Tuple], prefix: str) -> None: """Test converting element to prefix (for file naming). Parameters diff --git a/junifer/storage/utils.py b/junifer/storage/utils.py index 0a6fa58df..0a6e54d41 100644 --- a/junifer/storage/utils.py +++ b/junifer/storage/utils.py @@ -29,9 +29,7 @@ def _meta_hash(meta: Dict) -> str: """ logger.debug(f"Hashing metadata: {meta}") - meta_md5 = hashlib.md5( - json.dumps(meta, sort_keys=True).encode("utf-8") - ).hexdigest() + meta_md5 = hashlib.md5(json.dumps(meta, sort_keys=True).encode("utf-8")).hexdigest() logger.debug(f"Hash computed: {meta_md5}") return meta_md5 @@ -69,9 +67,7 @@ def process_meta(meta: Dict) -> Tuple[str, Dict]: element = t_meta.pop("element", None) if element is None: if "_element_keys" not in t_meta: - raise_error( - msg="`meta` must contain the key 'element' or '_element_keys'" - ) + raise_error(msg="`meta` must contain the key 'element' or '_element_keys'") else: if isinstance(element, dict): t_meta["_element_keys"] = list(element.keys()) @@ -145,9 +141,7 @@ def element_to_index( """ if "element" not in meta: - raise_error( - msg="To create and index, metadata must contain the key 'element'." - ) + raise_error(msg="To create and index, metadata must contain the key 'element'.") # Get element element = meta["element"] if not isinstance(element, dict): @@ -158,7 +152,5 @@ def element_to_index( elem_idx: Dict[Any, Any] = {k: [v] * n_rows for k, v in element.items()} elem_idx[rows_col_name] = np.arange(n_rows) # Create index - index = pd.MultiIndex.from_frame( - pd.DataFrame(elem_idx, index=range(n_rows)) - ) + index = pd.MultiIndex.from_frame(pd.DataFrame(elem_idx, index=range(n_rows))) return index diff --git a/junifer/testing/datagrabbers.py b/junifer/testing/datagrabbers.py index 7253ab7a7..4b7d87a77 100644 --- a/junifer/testing/datagrabbers.py +++ b/junifer/testing/datagrabbers.py @@ -168,9 +168,7 @@ class PartlyCloudyTestingDataGrabber(BaseDataGrabber): """ - def __init__( - self, reduce_confounds: bool = True, age_group: str = "both" - ) -> None: + def __init__(self, reduce_confounds: bool = True, age_group: str = "both") -> None: """Initialize the class.""" datadir = tempfile.mkdtemp() # Define types diff --git a/junifer/tests/test_stats.py b/junifer/tests/test_stats.py index 71b660698..8c2697032 100644 --- a/junifer/tests/test_stats.py +++ b/junifer/tests/test_stats.py @@ -44,14 +44,10 @@ def test_get_aggfunc_by_name_errors() -> None: get_aggfunc_by_name(name="winsorized_mean", func_params=None) with pytest.raises(ValueError, match="list of limits"): - get_aggfunc_by_name( - name="winsorized_mean", func_params={"limits": 0.1} - ) + get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": 0.1}) with pytest.raises(ValueError, match="list of two limits"): - get_aggfunc_by_name( - name="winsorized_mean", func_params={"limits": [0.2]} - ) + get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [0.2]}) with pytest.raises(ValueError, match="list of two"): get_aggfunc_by_name( @@ -59,14 +55,10 @@ def test_get_aggfunc_by_name_errors() -> None: ) with pytest.raises(ValueError, match="must be between 0 and 1"): - get_aggfunc_by_name( - name="winsorized_mean", func_params={"limits": [-1, 0.7]} - ) + get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [-1, 0.7]}) with pytest.raises(ValueError, match="must be between 0 and 1"): - get_aggfunc_by_name( - name="winsorized_mean", func_params={"limits": [0.1, 2]} - ) + get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [0.1, 2]}) def test_winsorized_mean() -> None: diff --git a/junifer/utils/logging.py b/junifer/utils/logging.py index 1c3db746b..b4ca0d26b 100644 --- a/junifer/utils/logging.py +++ b/junifer/utils/logging.py @@ -277,9 +277,7 @@ def raise_error(msg: str, klass: Type[Exception] = ValueError) -> NoReturn: raise klass(msg) -def warn_with_log( - msg: str, category: Optional[Type[Warning]] = RuntimeWarning -) -> None: +def warn_with_log(msg: str, category: Optional[Type[Warning]] = RuntimeWarning) -> None: """Warn, but first log it. Parameters diff --git a/junifer/utils/tests/test_logging.py b/junifer/utils/tests/test_logging.py index a815f8658..4b28ec095 100644 --- a/junifer/utils/tests/test_logging.py +++ b/junifer/utils/tests/test_logging.py @@ -123,9 +123,7 @@ def test_log_file(tmp_path: Path) -> None: assert any("Warn2 message" in line for line in lines) assert any("Error2 message" in line for line in lines) - configure_logging( - fname=tmp_path / "test4.log", level="WARNING", overwrite=True - ) + configure_logging(fname=tmp_path / "test4.log", level="WARNING", overwrite=True) logger.debug("Debug3 message") logger.info("Info3 message") logger.warning("Warn3 message") -- 2.52.0 From df1575dab113aba629029a9e4df0a94387121a97 Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Thu, 10 Nov 2022 14:39:49 +0100 Subject: [PATCH 13/22] fix isort mess --- junifer/api/cli.py | 12 +++- junifer/api/functions.py | 12 +++- junifer/api/tests/test_api_utils.py | 4 +- junifer/api/tests/test_cli.py | 4 +- junifer/api/tests/test_functions.py | 4 +- junifer/api/tests/test_parser.py | 4 +- .../juseless/datagrabbers/camcan_vbm.py | 5 +- .../configs/juseless/datagrabbers/ixi_vbm.py | 9 ++- .../tests/test_aomic_id1000_vbm.py | 5 +- .../datagrabbers/tests/test_ixi_vbm.py | 4 +- .../juseless/datagrabbers/tests/test_ucla.py | 4 +- junifer/configs/juseless/datagrabbers/ucla.py | 11 +++- junifer/data/coordinates.py | 15 +++-- junifer/data/parcellations.py | 21 +++++-- junifer/data/tests/test_parcellations.py | 22 +++++-- junifer/datagrabber/aomic/piop1.py | 3 +- junifer/datagrabber/aomic/piop2.py | 3 +- .../datagrabber/aomic/tests/test_id1000.py | 11 +++- junifer/datagrabber/aomic/tests/test_piop1.py | 15 +++-- junifer/datagrabber/aomic/tests/test_piop2.py | 15 +++-- junifer/datagrabber/pattern.py | 13 +++- .../datagrabber/tests/test_datalad_base.py | 41 +++++++++--- junifer/datagrabber/tests/test_multiple.py | 4 +- junifer/datagrabber/tests/test_pattern.py | 8 ++- .../datagrabber/tests/test_pattern_datalad.py | 8 ++- junifer/datagrabber/utils.py | 12 +++- junifer/datareader/default.py | 8 ++- .../datareader/tests/test_default_reader.py | 8 ++- junifer/markers/collection.py | 3 +- .../functional_connectivity_parcels.py | 8 ++- .../functional_connectivity_spheres.py | 8 ++- .../test_functional_connectivity_spheres.py | 8 ++- .../markers/tests/test_parcel_aggregation.py | 4 +- junifer/pipeline/tests/test_registry.py | 7 ++- .../confounds/fmriprep_confound_remover.py | 19 ++++-- .../tests/test_fmriprep_confound_remover.py | 56 ++++++++++++----- junifer/stats.py | 13 ++-- junifer/storage/base.py | 4 +- junifer/storage/sqlite.py | 26 +++++--- junifer/storage/tests/test_sqlite.py | 62 ++++++++++++++----- junifer/storage/tests/test_utils.py | 10 ++- junifer/storage/utils.py | 16 +++-- junifer/testing/datagrabbers.py | 4 +- junifer/tests/test_stats.py | 16 +++-- junifer/utils/logging.py | 4 +- junifer/utils/tests/test_logging.py | 4 +- 46 files changed, 416 insertions(+), 141 deletions(-) diff --git a/junifer/api/cli.py b/junifer/api/cli.py index e75e91cc8..9293dcc81 100644 --- a/junifer/api/cli.py +++ b/junifer/api/cli.py @@ -81,7 +81,9 @@ def cli() -> None: # pragma: no cover @cli.command() @click.argument( "filepath", - type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), + type=click.Path( + exists=True, readable=True, dir_okay=False, path_type=pathlib.Path + ), ) @click.option("--element", type=str, multiple=True) @click.option( @@ -124,7 +126,9 @@ def run(filepath: click.Path, element: str, verbose: click.Choice) -> None: @cli.command() @click.argument( "filepath", - type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), + type=click.Path( + exists=True, readable=True, dir_okay=False, path_type=pathlib.Path + ), ) @click.option( "-v", @@ -154,7 +158,9 @@ def collect(filepath: click.Path, verbose: click.Choice) -> None: @cli.command() @click.argument( "filepath", - type=click.Path(exists=True, readable=True, dir_okay=False, path_type=pathlib.Path), + type=click.Path( + exists=True, readable=True, dir_okay=False, path_type=pathlib.Path + ), ) @click.option("--element", type=str, multiple=True) @click.option("--overwrite", is_flag=True) diff --git a/junifer/api/functions.py b/junifer/api/functions.py index 2f58b25e4..91b179c39 100644 --- a/junifer/api/functions.py +++ b/junifer/api/functions.py @@ -199,7 +199,9 @@ def queue( "or set overwrite=True." ) else: - logger.info(f"Deleting existing job directory at {str(jobdir.absolute())}") + logger.info( + f"Deleting existing job directory at {str(jobdir.absolute())}" + ) shutil.rmtree(jobdir) jobdir.mkdir(exist_ok=True, parents=True) @@ -323,7 +325,9 @@ def _queue_condor( f"run {str(yaml_config.absolute())} " f"--verbose {verbose} --element $(element)" ) - collect_junifer_args = f"collect {str(yaml_config.absolute())} --verbose {verbose} " + collect_junifer_args = ( + f"collect {str(yaml_config.absolute())} --verbose {verbose} " + ) # Set up the env_name, executable and arguments according to the # environment type @@ -445,7 +449,9 @@ def _queue_condor( logger.info("HTCondor job submitted") else: cmd = f"condor_submit_dag {str(dag_fname.absolute())}" - logger.info(f"HTCondor job files created, to submit the job, run `{cmd}`") + logger.info( + f"HTCondor job files created, to submit the job, run `{cmd}`" + ) def _queue_slurm( diff --git a/junifer/api/tests/test_api_utils.py b/junifer/api/tests/test_api_utils.py index 4f5716fef..33dd4db42 100644 --- a/junifer/api/tests/test_api_utils.py +++ b/junifer/api/tests/test_api_utils.py @@ -47,7 +47,9 @@ def test_get_dependency_information_short() -> None: def test_get_dependency_information_long() -> None: """Test long version of _get_dependency_information().""" dependency_information = _get_dependency_information(long_=True) - dependency_information_keys = [key for key in dependency_information.keys()] + dependency_information_keys = [ + key for key in dependency_information.keys() + ] for key in [ "click", "numpy", diff --git a/junifer/api/tests/test_cli.py b/junifer/api/tests/test_cli.py index 9686bd86c..27935ad9b 100644 --- a/junifer/api/tests/test_cli.py +++ b/junifer/api/tests/test_cli.py @@ -25,7 +25,9 @@ runner = CliRunner() ("sub-01", "sub-02", "sub-04"), ], ) -def test_run_and_collect_commands(tmp_path: Path, elements: Tuple[str, ...]) -> None: +def test_run_and_collect_commands( + tmp_path: Path, elements: Tuple[str, ...] +) -> None: """Test run and collect commands.""" # Get test config infile = Path(__file__).parent / "data" / "gmd_mean.yaml" diff --git a/junifer/api/tests/test_functions.py b/junifer/api/tests/test_functions.py index 7f05bf60f..79a8de3ff 100644 --- a/junifer/api/tests/test_functions.py +++ b/junifer/api/tests/test_functions.py @@ -167,7 +167,9 @@ def test_run_and_collect(tmp_path: Path) -> None: storage=storage, ) # Get datagrabber - dg = build(step="datagrabber", name=datagrabber["kind"], baseclass=BaseDataGrabber) + dg = build( + step="datagrabber", name=datagrabber["kind"], baseclass=BaseDataGrabber + ) elements = dg.get_elements() # type: ignore # This should create 10 files files = list(outdir.glob("*.db")) diff --git a/junifer/api/tests/test_parser.py b/junifer/api/tests/test_parser.py index 16f2e675e..e61ff9fdb 100644 --- a/junifer/api/tests/test_parser.py +++ b/junifer/api/tests/test_parser.py @@ -69,7 +69,9 @@ def test_parse_yaml_failure_with_multi_module_autoload(tmp_path: Path) -> None: """ # Write test file fname = tmp_path / "test_parse_yaml_with_multi_module_autoload.yaml" - fname.write_text("foo: bar\nwith:\n - numpy\n - junifer.testing.wrong_config") + fname.write_text( + "foo: bar\nwith:\n - numpy\n - junifer.testing.wrong_config" + ) # Check test file with pytest.raises(ImportError, match="wrong_config"): parse_yaml(fname) diff --git a/junifer/configs/juseless/datagrabbers/camcan_vbm.py b/junifer/configs/juseless/datagrabbers/camcan_vbm.py index 713fc0e88..602f90b64 100644 --- a/junifer/configs/juseless/datagrabbers/camcan_vbm.py +++ b/junifer/configs/juseless/datagrabbers/camcan_vbm.py @@ -28,7 +28,10 @@ class JuselessDataladCamCANVBM(PatternDataladDataGrabber): """ def __init__(self, datadir: Union[str, Path, None] = None) -> None: - uri = "ria+http://cat_12.5.ds.inm7.de" "#a139b26a-8406-11ea-8f94-a0369f287950" + uri = ( + "ria+http://cat_12.5.ds.inm7.de" + "#a139b26a-8406-11ea-8f94-a0369f287950" + ) types = ["VBM_GM"] replacements = ["subject"] patterns = {"VBM_GM": "sub-{subject}/mri/m0wp1sub-{subject}.nii.gz"} diff --git a/junifer/configs/juseless/datagrabbers/ixi_vbm.py b/junifer/configs/juseless/datagrabbers/ixi_vbm.py index 9efd3bac8..3b6a69e6a 100644 --- a/junifer/configs/juseless/datagrabbers/ixi_vbm.py +++ b/junifer/configs/juseless/datagrabbers/ixi_vbm.py @@ -35,10 +35,15 @@ class JuselessDataladIXIVBM(PatternDataladDataGrabber): datadir: Union[str, Path, None] = None, sites: Union[str, List[str], None] = None, ) -> None: - uri = "ria+http://cat_12.5.ds.inm7.de" "#b7107c52-8408-11ea-89c6-a0369f287950" + uri = ( + "ria+http://cat_12.5.ds.inm7.de" + "#b7107c52-8408-11ea-89c6-a0369f287950" + ) types = ["VBM_GM"] replacements = ["site", "subject"] - patterns = {"VBM_GM": "{site}/sub-{subject}/mri/m0wp1sub-{subject}.nii.gz"} + patterns = { + "VBM_GM": "{site}/sub-{subject}/mri/m0wp1sub-{subject}.nii.gz" + } # validate and/or transform 'site' input all_sites = ["HH", "Guys", "IOP"] diff --git a/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py b/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py index 6063f867e..fa3279e1c 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_aomic_id1000_vbm.py @@ -25,5 +25,8 @@ def test_juselessdataladaomicid1000vbm_datagrabber() -> None: test_element = all_elements[0] out = dg[test_element] assert "VBM_GM" in out - assert out["VBM_GM"]["path"].name == f"mwp1sub-{test_element}_run-2_T1w.nii.gz" + assert ( + out["VBM_GM"]["path"].name + == f"mwp1sub-{test_element}_run-2_T1w.nii.gz" + ) assert out["VBM_GM"]["path"].exists() diff --git a/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py b/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py index daf3ddcca..73d655307 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_ixi_vbm.py @@ -26,7 +26,9 @@ def test_juselessdataladixivbm_datagrabber() -> None: test_element = all_elements[0] out = dg[test_element] assert "VBM_GM" in out - assert out["VBM_GM"]["path"].name == f"m0wp1sub-{test_element[1]}.nii.gz" + assert ( + out["VBM_GM"]["path"].name == f"m0wp1sub-{test_element[1]}.nii.gz" + ) assert out["VBM_GM"]["path"].exists() diff --git a/junifer/configs/juseless/datagrabbers/tests/test_ucla.py b/junifer/configs/juseless/datagrabbers/tests/test_ucla.py index 32887ffe9..eb7f224ff 100644 --- a/junifer/configs/juseless/datagrabbers/tests/test_ucla.py +++ b/junifer/configs/juseless/datagrabbers/tests/test_ucla.py @@ -82,5 +82,7 @@ def test_juseless_ucla_datagrabber_task_params(tasks: Optional[str]) -> None: def test_juseless_ucla_datagrabber_invalid_tasks() -> None: """Test juseless ucla datagrabber with invalid task parameters.""" - with pytest.raises(ValueError, match="invalid is not a valid task in the UCLA"): + with pytest.raises( + ValueError, match="invalid is not a valid task in the UCLA" + ): JuselessUCLA(tasks="invalid") diff --git a/junifer/configs/juseless/datagrabbers/ucla.py b/junifer/configs/juseless/datagrabbers/ucla.py index 377038341..443a1257f 100644 --- a/junifer/configs/juseless/datagrabbers/ucla.py +++ b/junifer/configs/juseless/datagrabbers/ucla.py @@ -31,7 +31,9 @@ class JuselessUCLA(PatternDataGrabber): def __init__( self, - datadir: Union[str, Path] = "/data/project/psychosis_thalamus/data/fmriprep", + datadir: Union[ + str, Path + ] = "/data/project/psychosis_thalamus/data/fmriprep", tasks: Union[str, List[str], None] = None, ) -> None: types = [ @@ -62,7 +64,9 @@ class JuselessUCLA(PatternDataGrabber): else: for t in tasks: if t not in all_tasks: - raise_error(f"{t} is not a valid task in the UCLA dataset!") + raise_error( + f"{t} is not a valid task in the UCLA dataset!" + ) self.tasks = tasks @@ -72,7 +76,8 @@ class JuselessUCLA(PatternDataGrabber): "MNI152NLin2009cAsym_preproc.nii.gz" ), "BOLD_confounds": ( - "sub-{subject}/func/sub-{subject}_" "task-{task}_bold_confounds.tsv" + "sub-{subject}/func/sub-{subject}_" + "task-{task}_bold_confounds.tsv" ), "T1w": ( "sub-{subject}/anat/sub-{subject}_" diff --git a/junifer/data/coordinates.py b/junifer/data/coordinates.py index 4d96fb6d0..bb1500161 100644 --- a/junifer/data/coordinates.py +++ b/junifer/data/coordinates.py @@ -71,7 +71,8 @@ def register_coordinates( if name in _available_coordinates: if isinstance(_available_coordinates[name], Path): raise_error( - f"Coordinates {name} already registered as built-in " "coordinates." + f"Coordinates {name} already registered as built-in " + "coordinates." ) if overwrite is True: logger.info(f"Overwriting coordinates {name}") @@ -82,11 +83,17 @@ def register_coordinates( ) if not isinstance(coordinates, np.ndarray): - raise_error(f"Coordinates must be a numpy.ndarray, not {type(coordinates)}.") + raise_error( + f"Coordinates must be a numpy.ndarray, not {type(coordinates)}." + ) if coordinates.ndim != 2: - raise_error(f"Coordinates must be a 2D array, not {coordinates.ndim}D.") + raise_error( + f"Coordinates must be a 2D array, not {coordinates.ndim}D." + ) if coordinates.shape[1] != 3: - raise_error(f"Each coordinate must have 3 values, not {coordinates.shape[1]} ") + raise_error( + f"Each coordinate must have 3 values, not {coordinates.shape[1]} " + ) if len(voi_names) != coordinates.shape[0]: raise_error( f"Length of voi_names ({len(voi_names)}) does not match the " diff --git a/junifer/data/parcellations.py b/junifer/data/parcellations.py index 24bf6d996..e1ac64498 100644 --- a/junifer/data/parcellations.py +++ b/junifer/data/parcellations.py @@ -106,7 +106,10 @@ def register_parcellation( if name in _available_parcellations: if overwrite is True: logger.info(f"Overwriting {name} parcellation") - if _available_parcellations[name]["family"] != "CustomUserParcellation": + if ( + _available_parcellations[name]["family"] + != "CustomUserParcellation" + ): raise_error( f"Cannot overwrite {name} parcellation. " "It is a built-in parcellation." @@ -274,7 +277,9 @@ def _retrieve_parcellation( If the parcellation's name is invalid. """ if parcellations_dir is None: - parcellations_dir = Path().home() / "junifer" / "data" / "parcellations" + parcellations_dir = ( + Path().home() / "junifer" / "data" / "parcellations" + ) # Create default junifer data directory if not present parcellations_dir.mkdir(exist_ok=True, parents=True) # Convert str to Path @@ -303,7 +308,9 @@ def _retrieve_parcellation( **kwargs, ) else: - raise_error(f"The provided parcellation name {family} cannot be retrieved.") + raise_error( + f"The provided parcellation name {family} cannot be retrieved." + ) return parcellation_fname, parcellation_labesl @@ -562,7 +569,9 @@ def _retrieve_tian( ) # define 7T labels (b/c currently no labels file available for 7T) scale7Trois = {1: 16, 2: 34, 3: 54, 4: 62} - labels = [("parcel_" + str(x)) for x in np.arange(1, scale7Trois[scale] + 1)] + labels = [ + ("parcel_" + str(x)) for x in np.arange(1, scale7Trois[scale] + 1) + ] parcellation_lname = parcellation_fname_base_7T / ( f"Tian_Subcortex_S{scale}_7T_labelnumbering.txt" ) @@ -583,7 +592,9 @@ def _retrieve_tian( "At least one of the parcellation files are missing, fetching." ) - url_basis = "https://www.nitrc.org/frs/download.php/12012/Tian2020MSA_v1.1.zip" + url_basis = ( + "https://www.nitrc.org/frs/download.php/12012/Tian2020MSA_v1.1.zip" + ) logger.info(f"Downloading TIAN from {url_basis}") with tempfile.TemporaryDirectory() as tmpdir: diff --git a/junifer/data/tests/test_parcellations.py b/junifer/data/tests/test_parcellations.py index faa42fbda..f1278f769 100644 --- a/junifer/data/tests/test_parcellations.py +++ b/junifer/data/tests/test_parcellations.py @@ -47,7 +47,10 @@ def test_register_parcellation_already_registered() -> None: parcellation_path="testparc.nii.gz", parcels_labels=["1", "2", "3"], ) - assert load_parcellation("testparc", path_only=True)[2].name == "testparc.nii.gz" + assert ( + load_parcellation("testparc", path_only=True)[2].name + == "testparc.nii.gz" + ) # Try registering again with pytest.raises(ValueError, match=r"already registered."): @@ -63,7 +66,10 @@ def test_register_parcellation_already_registered() -> None: overwrite=True, ) - assert load_parcellation("testparc", path_only=True)[2].name == "testparc2.nii.gz" + assert ( + load_parcellation("testparc", path_only=True)[2].name + == "testparc2.nii.gz" + ) @pytest.mark.parametrize( @@ -287,7 +293,9 @@ def test_suit(tmp_path: Path) -> None: assert "SUITxMNI" in parcellations # Load parcellation - img, lbl, fname = load_parcellation(name="SUITxSUIT", parcellations_dir=tmp_path) + img, lbl, fname = load_parcellation( + name="SUITxSUIT", parcellations_dir=tmp_path + ) fname1 = "SUIT_SUITSpace_1mm.nii" assert img is not None assert fname.name == fname1 @@ -295,7 +303,9 @@ def test_suit(tmp_path: Path) -> None: assert_array_equal(img.header["pixdim"][1:4], [1, 1, 1]) # type: ignore # Load parcellation - img, lbl, fname = load_parcellation(name="SUITxSUIT", parcellations_dir=tmp_path) + img, lbl, fname = load_parcellation( + name="SUITxSUIT", parcellations_dir=tmp_path + ) fname1 = "SUIT_SUITSpace_1mm.nii" assert img is not None assert fname.name == fname1 @@ -303,7 +313,9 @@ def test_suit(tmp_path: Path) -> None: assert_array_equal(img.header["pixdim"][1:4], [1, 1, 1]) # type: ignore # Load parcellation - img, lbl, fname = load_parcellation(name="SUITxMNI", parcellations_dir=tmp_path) + img, lbl, fname = load_parcellation( + name="SUITxMNI", parcellations_dir=tmp_path + ) fname1 = "SUIT_MNISpace_1mm.nii" assert img is not None assert fname.name == fname1 diff --git a/junifer/datagrabber/aomic/piop1.py b/junifer/datagrabber/aomic/piop1.py index f88a735c4..abce1404e 100644 --- a/junifer/datagrabber/aomic/piop1.py +++ b/junifer/datagrabber/aomic/piop1.py @@ -66,7 +66,8 @@ class DataladAOMICPIOP1(PatternDataladDataGrabber): for t in tasks: if t not in all_tasks: raise_error( - f"{t} is not a valid task in the AOMIC PIOP1" " dataset!" + f"{t} is not a valid task in the AOMIC PIOP1" + " dataset!" ) self.tasks = tasks diff --git a/junifer/datagrabber/aomic/piop2.py b/junifer/datagrabber/aomic/piop2.py index ef5e78612..7fe2212fb 100644 --- a/junifer/datagrabber/aomic/piop2.py +++ b/junifer/datagrabber/aomic/piop2.py @@ -63,7 +63,8 @@ class DataladAOMICPIOP2(PatternDataladDataGrabber): for t in tasks: if t not in all_tasks: raise_error( - f"{t} is not a valid task in the AOMIC PIOP2" " dataset!" + f"{t} is not a valid task in the AOMIC PIOP2" + " dataset!" ) self.tasks = tasks diff --git a/junifer/datagrabber/aomic/tests/test_id1000.py b/junifer/datagrabber/aomic/tests/test_id1000.py index 706c24a94..532777218 100644 --- a/junifer/datagrabber/aomic/tests/test_id1000.py +++ b/junifer/datagrabber/aomic/tests/test_id1000.py @@ -31,7 +31,8 @@ def test_aomic1000_datagrabber() -> None: assert "BOLD" in out assert ( - out["BOLD"]["path"].name == f"sub-{test_element}_task-moviewatching_" + out["BOLD"]["path"].name + == f"sub-{test_element}_task-moviewatching_" "space-MNI152NLin2009cAsym_desc-preproc_bold.nii.gz" ) @@ -54,7 +55,8 @@ def test_aomic1000_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name == f"sub-{test_element}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name + == f"sub-{test_element}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -100,7 +102,10 @@ def test_aomic1000_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert out["DWI"]["path"].name == f"sub-{test_element}_desc-preproc_dwi.nii.gz" + assert ( + out["DWI"]["path"].name + == f"sub-{test_element}_desc-preproc_dwi.nii.gz" + ) assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() diff --git a/junifer/datagrabber/aomic/tests/test_piop1.py b/junifer/datagrabber/aomic/tests/test_piop1.py index 94e378252..b3f44f542 100644 --- a/junifer/datagrabber/aomic/tests/test_piop1.py +++ b/junifer/datagrabber/aomic/tests/test_piop1.py @@ -58,7 +58,8 @@ def test_aomic_piop1_datagrabber() -> None: assert "BOLD_confounds" in out assert ( - out["BOLD_confounds"]["path"].name == f"sub-{sub}_task-{new_task}_" + out["BOLD_confounds"]["path"].name + == f"sub-{sub}_task-{new_task}_" "desc-confounds_regressors.tsv" ) @@ -69,7 +70,8 @@ def test_aomic_piop1_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name == f"sub-{sub}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name + == f"sub-{sub}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -115,7 +117,9 @@ def test_aomic_piop1_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" + assert ( + out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" + ) assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() @@ -132,7 +136,10 @@ def test_piop1_invalid_tasks(): """Test whether invalid task fails.""" with pytest.raises( ValueError, - match=("thisisnotarealtask is not a valid task in " "the AOMIC PIOP1 dataset!"), + match=( + "thisisnotarealtask is not a valid task in " + "the AOMIC PIOP1 dataset!" + ), ): DataladAOMICPIOP1(tasks="thisisnotarealtask") diff --git a/junifer/datagrabber/aomic/tests/test_piop2.py b/junifer/datagrabber/aomic/tests/test_piop2.py index 6676255b8..a1b8850d0 100644 --- a/junifer/datagrabber/aomic/tests/test_piop2.py +++ b/junifer/datagrabber/aomic/tests/test_piop2.py @@ -54,7 +54,8 @@ def test_aomic_piop2_datagrabber() -> None: assert "BOLD_confounds" in out assert ( - out["BOLD_confounds"]["path"].name == f"sub-{sub}_task-{new_task}_" + out["BOLD_confounds"]["path"].name + == f"sub-{sub}_task-{new_task}_" "desc-confounds_regressors.tsv" ) @@ -65,7 +66,8 @@ def test_aomic_piop2_datagrabber() -> None: assert "T1w" in out assert ( - out["T1w"]["path"].name == f"sub-{sub}_space-MNI152NLin2009cAsym_" + out["T1w"]["path"].name + == f"sub-{sub}_space-MNI152NLin2009cAsym_" "desc-preproc_T1w.nii.gz" ) @@ -111,7 +113,9 @@ def test_aomic_piop2_datagrabber() -> None: # asserts type "DWI" assert "DWI" in out - assert out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" + assert ( + out["DWI"]["path"].name == f"sub-{sub}_desc-preproc_dwi.nii.gz" + ) assert out["DWI"]["path"].exists() assert out["DWI"]["path"].is_file() @@ -128,7 +132,10 @@ def test_piop2_invalid_tasks(): """Test whether invalid task fails.""" with pytest.raises( ValueError, - match=("thisisnotarealtask is not a valid task in " "the AOMIC PIOP2 dataset!"), + match=( + "thisisnotarealtask is not a valid task in " + "the AOMIC PIOP2 dataset!" + ), ): DataladAOMICPIOP2(tasks="thisisnotarealtask") diff --git a/junifer/datagrabber/pattern.py b/junifer/datagrabber/pattern.py index 8568d4edb..f6eed37ed 100644 --- a/junifer/datagrabber/pattern.py +++ b/junifer/datagrabber/pattern.py @@ -64,7 +64,9 @@ class PatternDataGrabber(BaseDataGrabber): """Skip file check existence.""" return False - def _replace_patterns_regex(self, pattern: str) -> Tuple[str, str, List[str]]: + def _replace_patterns_regex( + self, pattern: str + ) -> Tuple[str, str, List[str]]: """Replace the patterns in `pattern` with the named groups. It allows elements to be obtained from the filesystem. @@ -86,7 +88,9 @@ class PatternDataGrabber(BaseDataGrabber): """ re_pattern = pattern glob_pattern = pattern - t_replacements = [x for x in self.replacements if f"{{{x}}}" in pattern] + t_replacements = [ + x for x in self.replacements if f"{{{x}}}" in pattern + ] for t_r in t_replacements: # Replace the first of each with a named group definition @@ -245,7 +249,10 @@ class PatternDataGrabber(BaseDataGrabber): ] new_elements = set() for t_element in elements: - if tuple(np.array(t_element)[t_repl_idx]) in types_element: + if ( + tuple(np.array(t_element)[t_repl_idx]) + in types_element + ): new_elements.add(t_element) elements = new_elements if elements is None: diff --git a/junifer/datagrabber/tests/test_datalad_base.py b/junifer/datagrabber/tests/test_datalad_base.py index 9e48b9c2b..3db5989bd 100644 --- a/junifer/datagrabber/tests/test_datalad_base.py +++ b/junifer/datagrabber/tests/test_datalad_base.py @@ -57,7 +57,10 @@ def concrete_datagrabber() -> Type[DataladDataGrabber]: def get_item(self, subject): out = { - "T1w": {"path": self.datadir / f"{subject}/anat/{subject}_T1w.nii.gz"}, + "T1w": { + "path": self.datadir + / f"{subject}/anat/{subject}_T1w.nii.gz" + }, "BOLD": { "path": self.datadir / f"{subject}/func/{subject}_task-rest_bold.nii.gz" @@ -74,7 +77,9 @@ def concrete_datagrabber() -> Type[DataladDataGrabber]: return MyDataGrabber -def test_datalad_install_errors(tmp_path: Path, concrete_datagrabber: Type) -> None: +def test_datalad_install_errors( + tmp_path: Path, concrete_datagrabber: Type +) -> None: """Test datalad base install errors / warnings. Parameters @@ -110,7 +115,9 @@ def test_datalad_install_errors(tmp_path: Path, concrete_datagrabber: Type) -> N pass -def test_datalad_clone_cleanup(tmp_path: Path, concrete_datagrabber: Type) -> None: +def test_datalad_clone_cleanup( + tmp_path: Path, concrete_datagrabber: Type +) -> None: """Test datalad base clone and remove. Parameters @@ -124,7 +131,9 @@ def test_datalad_clone_cleanup(tmp_path: Path, concrete_datagrabber: Type) -> No # Clone whole dataset datadir = tmp_path / "newclone" uri = _testing_dataset["example_bids"]["uri"] - elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + elem1_bold = ( + datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + ) elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" assert datadir.exists() is False @@ -153,7 +162,9 @@ def test_datalad_clone_cleanup(tmp_path: Path, concrete_datagrabber: Type) -> No assert len(list(datadir.glob("*"))) == 0 -def test_datalad_previously_cloned(tmp_path: Path, concrete_datagrabber: Type) -> None: +def test_datalad_previously_cloned( + tmp_path: Path, concrete_datagrabber: Type +) -> None: """Test datalad base on cloned dataset. Parameters @@ -166,7 +177,9 @@ def test_datalad_previously_cloned(tmp_path: Path, concrete_datagrabber: Type) - # Dataset cloned outside of datagrabber datadir = tmp_path / "cloned" - elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + elem1_bold = ( + datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + ) elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -208,7 +221,9 @@ def test_datalad_previously_cloned(tmp_path: Path, concrete_datagrabber: Type) - # Datagrabber fetched two files assert len(dg._got_files) == 2 assert any(x.name == "sub-01_T1w.nii.gz" for x in dg._got_files) - assert any(x.name == "sub-01_task-rest_bold.nii.gz" for x in dg._got_files) + assert any( + x.name == "sub-01_task-rest_bold.nii.gz" for x in dg._got_files + ) assert datadir.exists() is True assert len(list(datadir.glob("*"))) > 0 @@ -229,7 +244,9 @@ def test_datalad_previously_cloned_and_get( # Dataset cloned outside of datagrabber with some files present datadir = tmp_path / "cloned_clean" - elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + elem1_bold = ( + datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + ) elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -307,7 +324,9 @@ def test_datalad_previously_cloned_and_get_dirty( # Dataset cloned outside of datagrabber with some files present and dirty datadir = tmp_path / "cloned_dirty" - elem1_bold = datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + elem1_bold = ( + datadir / "example_bids/sub-01/func/sub-01_task-rest_bold.nii.gz" + ) elem1_t1w = datadir / "example_bids/sub-01/anat/sub-01_T1w.nii.gz" uri = _testing_dataset["example_bids"]["uri"] commit = _testing_dataset["example_bids"]["commit"] @@ -391,7 +410,9 @@ def test_datalad_previously_cloned_and_get_dirty( # Datagrabber fetched two files assert len(dg._got_files) == 2 assert any(x.name == "sub-02_T1w.nii.gz" for x in dg._got_files) - assert any(x.name == "sub-02_task-rest_bold.nii.gz" for x in dg._got_files) + assert any( + x.name == "sub-02_task-rest_bold.nii.gz" for x in dg._got_files + ) assert datadir.exists() is True assert len(list(datadir.glob("*"))) > 0 diff --git a/junifer/datagrabber/tests/test_multiple.py b/junifer/datagrabber/tests/test_multiple.py index a6feb17c4..47abdd525 100644 --- a/junifer/datagrabber/tests/test_multiple.py +++ b/junifer/datagrabber/tests/test_multiple.py @@ -54,7 +54,9 @@ def test_multiple() -> None: assert "BOLD" in types expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 3) for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") + for j in range(1, 3) + for i in range(1, 10) ] with dg: diff --git a/junifer/datagrabber/tests/test_pattern.py b/junifer/datagrabber/tests/test_pattern.py index 49a7e9df0..71ba60701 100644 --- a/junifer/datagrabber/tests/test_pattern.py +++ b/junifer/datagrabber/tests/test_pattern.py @@ -54,7 +54,9 @@ def test_PatternDataGrabber_errors(tmp_path: Path) -> None: replacements="subject", # type: ignore ) - with pytest.raises(ValueError, match=r"`patterns` must have the same length"): + with pytest.raises( + ValueError, match=r"`patterns` must have the same length" + ): PatternDataGrabber( datadir="/tmp", types=["func", "anat"], @@ -62,7 +64,9 @@ def test_PatternDataGrabber_errors(tmp_path: Path) -> None: replacements="subject", # type: ignore ) - with pytest.raises(ValueError, match=r"`patterns` must contain all `types`"): + with pytest.raises( + ValueError, match=r"`patterns` must contain all `types`" + ): PatternDataGrabber( datadir="/tmp", types=["func", "anat"], diff --git a/junifer/datagrabber/tests/test_pattern_datalad.py b/junifer/datagrabber/tests/test_pattern_datalad.py index 9167f3c00..2deace6eb 100644 --- a/junifer/datagrabber/tests/test_pattern_datalad.py +++ b/junifer/datagrabber/tests/test_pattern_datalad.py @@ -173,7 +173,9 @@ def test_bids_PatternDataladDataGrabber_session(): ) as dg: subs = [x for x in dg] expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 3) for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") + for j in range(1, 3) + for i in range(1, 10) ] assert set(subs) == set(expected_subs) @@ -191,6 +193,8 @@ def test_bids_PatternDataladDataGrabber_session(): ) as dg: subs = [x for x in dg] expected_subs = [ - (f"sub-{i:02d}", f"ses-{j:02d}") for j in range(1, 4) for i in range(1, 10) + (f"sub-{i:02d}", f"ses-{j:02d}") + for j in range(1, 4) + for i in range(1, 10) ] assert set(subs) == set(expected_subs) diff --git a/junifer/datagrabber/utils.py b/junifer/datagrabber/utils.py index 2598eb0f2..60fc4e3ab 100644 --- a/junifer/datagrabber/utils.py +++ b/junifer/datagrabber/utils.py @@ -24,7 +24,9 @@ def validate_types(types: List[str]) -> None: raise_error(msg="`types` must be a list of strings", klass=TypeError) -def validate_replacements(replacements: List[str], patterns: Dict[str, str]) -> None: +def validate_replacements( + replacements: List[str], patterns: Dict[str, str] +) -> None: """Validate the replacements. Parameters @@ -42,7 +44,9 @@ def validate_replacements(replacements: List[str], patterns: Dict[str, str]) -> raise_error(msg="`patterns` must be a dict.", klass=TypeError) if any(not isinstance(x, str) for x in replacements): - raise_error(msg="`replacements` must be a list of strings.", klass=TypeError) + raise_error( + msg="`replacements` must be a list of strings.", klass=TypeError + ) for x in replacements: if all(x not in y for y in patterns.values()): @@ -80,7 +84,9 @@ def validate_patterns(types: List[str], patterns: Dict[str, str]) -> None: ) if any(x not in patterns for x in types): - raise_error(msg="`patterns` must contain all `types`", klass=ValueError) + raise_error( + msg="`patterns` must contain all `types`", klass=ValueError + ) if any("}*" in pattern for pattern in patterns.values()): raise_error( diff --git a/junifer/datareader/default.py b/junifer/datareader/default.py index c9103622e..d1a74a8ef 100644 --- a/junifer/datareader/default.py +++ b/junifer/datareader/default.py @@ -94,7 +94,9 @@ class DefaultDataReader(PipelineStepMixin): out["meta"] = input["meta"] continue if "path" not in input[kind]: - warn_with_log(f"Input kind {kind} does not provide a path. Skipping.") + warn_with_log( + f"Input kind {kind} does not provide a path. Skipping." + ) continue t_path = input[kind]["path"] t_params = params.get(kind, {}) @@ -118,7 +120,9 @@ class DefaultDataReader(PipelineStepMixin): fread = reader_func(t_path, **t_params) break if fread is None: - logger.info(f"Unknown file type {t_path.as_posix()}, skipping reading") + logger.info( + f"Unknown file type {t_path.as_posix()}, skipping reading" + ) out[kind]["data"] = fread if "meta" not in out: out["meta"] = {} diff --git a/junifer/datareader/tests/test_default_reader.py b/junifer/datareader/tests/test_default_reader.py index 396bb2e9a..4ebf61b34 100644 --- a/junifer/datareader/tests/test_default_reader.py +++ b/junifer/datareader/tests/test_default_reader.py @@ -16,7 +16,9 @@ from pandas.testing import assert_frame_equal from junifer.datareader import DefaultDataReader -@pytest.mark.parametrize("kind", [["T1w", "BOLD", "T2", "dwi"], [], None, ["whatever"]]) +@pytest.mark.parametrize( + "kind", [["T1w", "BOLD", "T2", "dwi"], [], None, ["whatever"]] +) def test_validation(kind) -> None: """Test validating input/output. @@ -48,7 +50,9 @@ def test_meta() -> None: assert output["meta"]["datareader"]["class"] == "DefaultDataReader" -@pytest.mark.parametrize("fname", ["example4d.nii.gz", "reoriented_anat_moved.nii"]) +@pytest.mark.parametrize( + "fname", ["example4d.nii.gz", "reoriented_anat_moved.nii"] +) def test_read_nifti(fname: str) -> None: """Test reading NIFTI files. diff --git a/junifer/markers/collection.py b/junifer/markers/collection.py index 8dde440e5..ab2858bdf 100644 --- a/junifer/markers/collection.py +++ b/junifer/markers/collection.py @@ -45,7 +45,8 @@ class MarkerCollection: if len(set(marker_names)) != len(marker_names): counts = Counter(marker_names) raise ValueError( - "Markers must have different names. " f"Current names are: {counts}" + "Markers must have different names. " + f"Current names are: {counts}" ) self._markers = markers if datareader is None: diff --git a/junifer/markers/functional_connectivity_parcels.py b/junifer/markers/functional_connectivity_parcels.py index cdc14e2e6..5c354c76d 100644 --- a/junifer/markers/functional_connectivity_parcels.py +++ b/junifer/markers/functional_connectivity_parcels.py @@ -56,9 +56,13 @@ class FunctionalConnectivityParcels(BaseMarker): ) -> None: self.parcellation = parcellation self.agg_method = agg_method - self.agg_method_params = {} if agg_method_params is None else agg_method_params + self.agg_method_params = ( + {} if agg_method_params is None else agg_method_params + ) self.cor_method = cor_method - self.cor_method_params = {} if cor_method_params is None else cor_method_params + self.cor_method_params = ( + {} if cor_method_params is None else cor_method_params + ) # default to nilearn behavior self.cor_method_params["empirical"] = self.cor_method_params.get( "empirical", False diff --git a/junifer/markers/functional_connectivity_spheres.py b/junifer/markers/functional_connectivity_spheres.py index aa0473c42..ba3c6e3e4 100644 --- a/junifer/markers/functional_connectivity_spheres.py +++ b/junifer/markers/functional_connectivity_spheres.py @@ -65,9 +65,13 @@ class FunctionalConnectivitySpheres(BaseMarker): if radius is None or radius <= 0: raise_error(f"radius should be > 0: provided {radius}") self.agg_method = agg_method - self.agg_method_params = {} if agg_method_params is None else agg_method_params + self.agg_method_params = ( + {} if agg_method_params is None else agg_method_params + ) self.cor_method = cor_method - self.cor_method_params = {} if cor_method_params is None else cor_method_params + self.cor_method_params = ( + {} if cor_method_params is None else cor_method_params + ) # default to nilearn behavior self.cor_method_params["empirical"] = self.cor_method_params.get( "empirical", False diff --git a/junifer/markers/tests/test_functional_connectivity_spheres.py b/junifer/markers/tests/test_functional_connectivity_spheres.py index 014cc96c2..964fb9797 100644 --- a/junifer/markers/tests/test_functional_connectivity_spheres.py +++ b/junifer/markers/tests/test_functional_connectivity_spheres.py @@ -49,7 +49,9 @@ def test_FunctionalConnectivitySpheres(tmp_path: Path) -> None: assert len(set(out["col_names"])) == 6 # get the timeseries using sa - sa = SphereAggregation(coords="DMNBuckner", radius=5.0, method="mean", on="BOLD") + sa = SphereAggregation( + coords="DMNBuckner", radius=5.0, method="mean", on="BOLD" + ) ts = sa.compute({"data": fmri_img}) # Check that FC are almost equal when using nileran @@ -105,7 +107,9 @@ def test_FunctionalConnectivitySpheres_empirical(tmp_path: Path) -> None: assert len(set(out["col_names"])) == 6 # get the timeseries using sa - sa = SphereAggregation(coords="DMNBuckner", radius=5.0, method="mean", on="BOLD") + sa = SphereAggregation( + coords="DMNBuckner", radius=5.0, method="mean", on="BOLD" + ) ts = sa.compute({"data": fmri_img}) # Check that FC are almost equal when using nileran diff --git a/junifer/markers/tests/test_parcel_aggregation.py b/junifer/markers/tests/test_parcel_aggregation.py index 04950eecf..0bbc95109 100644 --- a/junifer/markers/tests/test_parcel_aggregation.py +++ b/junifer/markers/tests/test_parcel_aggregation.py @@ -17,7 +17,9 @@ from junifer.markers.parcel_aggregation import ParcelAggregation def test_ParcelAggregation_input_output() -> None: """Test ParcelAggregation input and output types.""" - marker = ParcelAggregation(parcellation="Schaefer100x7", method="mean", on="VBM_GM") + marker = ParcelAggregation( + parcellation="Schaefer100x7", method="mean", on="VBM_GM" + ) output = marker.get_output_kind(["VBM_GM", "BOLD"]) assert output == ["table", "timeseries"] diff --git a/junifer/pipeline/tests/test_registry.py b/junifer/pipeline/tests/test_registry.py index 9c85fb630..a84a9b7ee 100644 --- a/junifer/pipeline/tests/test_registry.py +++ b/junifer/pipeline/tests/test_registry.py @@ -11,7 +11,12 @@ from typing import Type import pytest from junifer.datagrabber import PatternDataGrabber -from junifer.pipeline.registry import build, get_class, get_step_names, register +from junifer.pipeline.registry import ( + build, + get_class, + get_step_names, + register, +) from junifer.storage import SQLiteFeatureStorage diff --git a/junifer/preprocess/confounds/fmriprep_confound_remover.py b/junifer/preprocess/confounds/fmriprep_confound_remover.py index a105fb840..2e5be6807 100644 --- a/junifer/preprocess/confounds/fmriprep_confound_remover.py +++ b/junifer/preprocess/confounds/fmriprep_confound_remover.py @@ -452,7 +452,9 @@ class fMRIPrepConfoundRemover(BasePreprocessor): raise_error("No BOLD_confounds data provided", ValueError) # Confounds must be a dataframe if not isinstance(extra_input["BOLD_confounds"]["data"], pd.DataFrame): - raise_error("Confounds data must be a pandas dataframe", ValueError) + raise_error( + "Confounds data must be a pandas dataframe", ValueError + ) confound_df = extra_input["BOLD_confounds"]["data"] bold_img = input["data"] @@ -465,7 +467,8 @@ class fMRIPrepConfoundRemover(BasePreprocessor): if "format" not in extra_input["BOLD_confounds"]: raise_error( - "Confounds format must be specified in " 'input["BOLD_confounds"]' + "Confounds format must be specified in " + 'input["BOLD_confounds"]' ) t_format = extra_input["BOLD_confounds"]["format"] @@ -483,9 +486,13 @@ class fMRIPrepConfoundRemover(BasePreprocessor): "the variables names mappings to fmriprep format in " 'input["BOLD_confounds"]["mappings"]["fmriprep"]' ) - fmriprep_mappings = extra_input["BOLD_confounds"]["mappings"]["fmriprep"] + fmriprep_mappings = extra_input["BOLD_confounds"]["mappings"][ + "fmriprep" + ] wrong_names = [ - x for x in fmriprep_mappings.values() if x not in FMRIPREP_VALID_NAMES + x + for x in fmriprep_mappings.values() + if x not in FMRIPREP_VALID_NAMES ] if len(wrong_names) > 0: raise_error( @@ -494,7 +501,9 @@ class fMRIPrepConfoundRemover(BasePreprocessor): ) # Check that all the required columns are present missing = [ - x for x in fmriprep_mappings.keys() if x not in confound_df.columns + x + for x in fmriprep_mappings.keys() + if x not in confound_df.columns ] if len(missing) > 0: diff --git a/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py b/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py index 81e281c36..98e028d86 100644 --- a/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py +++ b/junifer/preprocess/confounds/tests/test_fmriprep_confound_remover.py @@ -121,7 +121,9 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: "white_matter_derivative1_power2", ] - confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) + confounds_df = pd.DataFrame( + np.random.randn(7, len(var_names)), columns=var_names + ) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out @@ -140,7 +142,9 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: all_names = var_names + missing_der_names + missing_sq_names - confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) + confounds_df = pd.DataFrame( + np.random.randn(7, len(var_names)), columns=var_names + ) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out assert set(to_select) == set(all_names) @@ -149,7 +153,9 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: assert spike_name == "framewise_displacement" # Same strategy, with spike, only basics are present - confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) + confound_remover = fMRIPrepConfoundRemover( + strategy={"wm_csf": "full"}, spike=0.2 + ) var_names = ["csf", "white_matter"] missing_der_names = ["csf_derivative1", "white_matter_derivative1"] @@ -187,7 +193,9 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: all_names = var_names + missing_der_names + missing_sq_names - confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) + confounds_df = pd.DataFrame( + np.random.randn(7, len(var_names)), columns=var_names + ) out = confound_remover._process_fmriprep_spec({"data": confounds_df}) to_select, sq_to_compute, der_to_compute, spike_name = out assert set(to_select) == set(all_names) @@ -197,16 +205,22 @@ def test_fMRIPrepConfoundRemover__process_fmriprep_spec() -> None: # Test for wrong columns/strategy pairs - confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) + confound_remover = fMRIPrepConfoundRemover( + strategy={"wm_csf": "full"}, spike=0.2 + ) var_names = ["csf"] - confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) + confounds_df = pd.DataFrame( + np.random.randn(7, len(var_names)), columns=var_names + ) msg = r"Missing basic confounds: \['white_matter'\]" with pytest.raises(ValueError, match=msg): confound_remover._process_fmriprep_spec({"data": confounds_df}) var_names = ["csf", "white_matter"] - confounds_df = pd.DataFrame(np.random.randn(7, len(var_names)), columns=var_names) + confounds_df = pd.DataFrame( + np.random.randn(7, len(var_names)), columns=var_names + ) msg = r"Missing framewise_displacement" with pytest.raises(ValueError, match=msg): @@ -247,7 +261,9 @@ def test_fMRIPrepConfoundRemover__pick_confounds_adhoc() -> None: def test_FMRIPRepConfoundRemover__pick_confounds_fmriprep() -> None: """Test fMRIPrepConfoundRemover pick confounds on fmriprep confounds.""" - confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) + confound_remover = fMRIPrepConfoundRemover( + strategy={"wm_csf": "full"}, spike=0.2 + ) fmriprep_all_vars = [ "csf", "white_matter", @@ -315,7 +331,9 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: input = dg["sub-01"] input = reader.fit_transform(input) new_input = input["VBM_GM"] - with pytest.raises(DimensionError, match="incompatible dimensionality"): + with pytest.raises( + DimensionError, match="incompatible dimensionality" + ): confound_remover._validate_data(new_input, None) with PartlyCloudyTestingDataGrabber(reduce_confounds=False) as dg: @@ -327,7 +345,9 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: confound_remover._validate_data(new_input, None) with pytest.raises(ValueError, match="No BOLD_confounds provided"): confound_remover._validate_data(new_input, {}) - with pytest.raises(ValueError, match="No BOLD_confounds data provided"): + with pytest.raises( + ValueError, match="No BOLD_confounds data provided" + ): confound_remover._validate_data(new_input, {"BOLD_confounds": {}}) extra_input = { @@ -341,7 +361,9 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: with pytest.raises(ValueError, match="Image time series and"): confound_remover._validate_data(new_input, extra_input) - extra_input = {"BOLD_confounds": {"data": input["BOLD_confounds"]["data"]}} + extra_input = { + "BOLD_confounds": {"data": input["BOLD_confounds"]["data"]} + } with pytest.raises(ValueError, match="format must be specified"): confound_remover._validate_data(new_input, extra_input) @@ -423,7 +445,9 @@ def test_fMRIPrepConfoundRemover__validate_data() -> None: def test_fMRIPrepConfoundRemover__remove_confounds() -> None: """Test fMRIPrepConfoundRemover remove confounds.""" - confound_remover = fMRIPrepConfoundRemover(strategy={"wm_csf": "full"}, spike=0.2) + confound_remover = fMRIPrepConfoundRemover( + strategy={"wm_csf": "full"}, spike=0.2 + ) reader = DefaultDataReader() with PartlyCloudyTestingDataGrabber(reduce_confounds=False) as dg: input = dg["sub-01"] @@ -466,7 +490,9 @@ def test_fMRIPrepConfoundRemover_preprocess() -> None: assert orig_bold.shape == trans_bold.shape # but be different - assert_raises(AssertionError, assert_array_equal, orig_bold, trans_bold) + assert_raises( + AssertionError, assert_array_equal, orig_bold, trans_bold + ) assert key == "BOLD" @@ -491,7 +517,9 @@ def test_fMRIPrepConfoundRemover_fit_transform() -> None: assert orig_bold.shape == trans_bold.shape # but be different - assert_raises(AssertionError, assert_array_equal, orig_bold, trans_bold) + assert_raises( + AssertionError, assert_array_equal, orig_bold, trans_bold + ) assert output["meta"] == input["meta"] # general meta does not change assert "meta" in output["BOLD"] diff --git a/junifer/stats.py b/junifer/stats.py index 1173e6f61..7cc369ae4 100644 --- a/junifer/stats.py +++ b/junifer/stats.py @@ -51,16 +51,20 @@ def get_aggfunc_by_name( limits = func_params.get("limits") if limits is None or not isinstance(limits, list): raise_error( - "func_params must contain a list of limits for " "winsorized_mean", + "func_params must contain a list of limits for " + "winsorized_mean", ValueError, ) if len(limits) != 2: raise_error( - "func_params must contain a list of two limits for " "winsorized_mean", + "func_params must contain a list of two limits for " + "winsorized_mean", ValueError, ) if any((lim < 0.0 or lim > 1) for lim in limits): - raise_error("Limits for the winsorized mean must be between 0 and 1.") + raise_error( + "Limits for the winsorized mean must be between 0 and 1." + ) logger.info(f"Limits for winsorized mean are set to {limits}.") # partially interpret func_params func = partial(winsorized_mean, **func_params) @@ -72,7 +76,8 @@ def get_aggfunc_by_name( func = partial(trim_mean, **func_params) else: raise_error( - f"Function {name} unknown. Please provide any of " f"{_valid_func_names}" + f"Function {name} unknown. Please provide any of " + f"{_valid_func_names}" ) return func diff --git a/junifer/storage/base.py b/junifer/storage/base.py index 9b900dbfa..624a1bab7 100644 --- a/junifer/storage/base.py +++ b/junifer/storage/base.py @@ -249,6 +249,8 @@ class BaseFeatureStorage(ABC): """ single = ( - "(single output)" if self.single_output is True else "(multiple output)" + "(single output)" + if self.single_output is True + else "(multiple output)" ) return f"<{self.__class__.__name__} @ {self.uri} {single}>" diff --git a/junifer/storage/sqlite.py b/junifer/storage/sqlite.py index 0d29b8c1d..8256db1cf 100644 --- a/junifer/storage/sqlite.py +++ b/junifer/storage/sqlite.py @@ -175,7 +175,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): con, table_name=name, index_col=index_col ) existing, new = _split_incoming_data( - df, pk_indb, index_col) + df, pk_indb, index_col + ) # Step 2: upsert existing data pandas_sql = pandasSQL_builder(con) pandas_sql.meta.reflect(bind=con, only=[name]) @@ -204,7 +205,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): raise_error(msg=f"Table ({name}) already exists.") else: raise_error( - msg=f"Invalid option {if_exists} for if_exists.") + msg=f"Invalid option {if_exists} for if_exists." + ) def _store_2d( self, @@ -232,7 +234,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): n_rows = len(data) # Convert element metadata to index idx = element_to_index( - meta=meta, n_rows=n_rows, rows_col_name=rows_col_name) + meta=meta, n_rows=n_rows, rows_col_name=rows_col_name + ) # Prepare new dataframe data_df = pd.DataFrame( # type: ignore data, columns=columns, index=idx # type: ignore @@ -241,7 +244,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): self.store_df(df=data_df, meta=meta) def list_features( - self, return_df: bool = False) -> Union[Dict, pd.DataFrame]: + self, return_df: bool = False + ) -> Union[Dict, pd.DataFrame]: """Implement features listing from the storage. Parameters @@ -303,8 +307,10 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): # Parameter value check if feature_md5 is not None and feature_name is not None: raise_error( - msg=("Only one of `feature_name` or `feature_md5` can be " - "specified.") + msg=( + "Only one of `feature_name` or `feature_md5` can be " + "specified." + ) ) elif feature_md5 is None and feature_name is None: raise_error( @@ -343,8 +349,9 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): f"WHERE tbl_name='{table_name}' " "ORDER BY cid;" ) - index_names = pd.read_sql( - sql=query, con=engine).values.squeeze().tolist() + index_names = ( + pd.read_sql(sql=query, con=engine).values.squeeze().tolist() + ) # Set index on dataframe df = df.set_index(index_names) return df @@ -606,7 +613,8 @@ class SQLiteFeatureStorage(PandasBaseFeatureStorage): in_engine = in_storage.get_engine() # Open "meta" table t_meta_df = pd.read_sql( - sql="meta", con=in_engine, index_col="meta_md5") + sql="meta", con=in_engine, index_col="meta_md5" + ) # Save metadata out_storage._save_upsert(t_meta_df, "meta") # Save dataframes diff --git a/junifer/storage/tests/test_sqlite.py b/junifer/storage/tests/test_sqlite.py index 6e833c923..3f428f5f4 100644 --- a/junifer/storage/tests/test_sqlite.py +++ b/junifer/storage/tests/test_sqlite.py @@ -15,7 +15,11 @@ from pandas.testing import assert_frame_equal from sqlalchemy import create_engine from junifer.storage.sqlite import SQLiteFeatureStorage -from junifer.storage.utils import element_to_index, element_to_prefix, process_meta +from junifer.storage.utils import ( + element_to_index, + element_to_prefix, + process_meta, +) df1 = pd.DataFrame( { @@ -107,7 +111,9 @@ def test_get_engine_multi_output(tmp_path: Path) -> None: """ uri = tmp_path / "test_multi_output.db" - storage = SQLiteFeatureStorage(uri=uri, single_output=False, upsert="ignore") + storage = SQLiteFeatureStorage( + uri=uri, single_output=False, upsert="ignore" + ) with pytest.raises(ValueError, match="element must be specified"): storage.get_engine() @@ -192,7 +198,9 @@ def test_upsert_ignore(tmp_path: Path) -> None: with pytest.warns(RuntimeWarning, match="are already present"): storage.store_df(df2, meta) # Read stored table - c_dfignore = _read_sql(table_name, uri=uri.as_posix(), index_col=["element", "pk2"]) + c_dfignore = _read_sql( + table_name, uri=uri.as_posix(), index_col=["element", "pk2"] + ) # Check if dataframes are equal assert_frame_equal(c_dfignore, df_ignore) # Check for error @@ -226,7 +234,9 @@ def test_upsert_update(tmp_path: Path) -> None: # Save to database storage.store_df(df2, meta) # Read stored table - c_dfupdate = _read_sql(table_name, uri=uri.as_posix(), index_col=["element", "pk2"]) + c_dfupdate = _read_sql( + table_name, uri=uri.as_posix(), index_col=["element", "pk2"] + ) # Check if dataframes are equal assert_frame_equal(c_dfupdate, df_update) @@ -372,7 +382,9 @@ def test_store_table(tmp_path: Path) -> None: df_new = pd.DataFrame(data_new, columns=["f1", "f2"], index=idx_new) # Check warning with pytest.warns(RuntimeWarning, match=r"Some rows"): - storage.store_table(data_new, meta, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table( + data_new, meta, columns=["f1", "f2"], rows_col_name="scan" + ) # Read stored table c_df_new = _read_sql( table_name=table_name, @@ -398,7 +410,9 @@ def test_store_matrix(tmp_path: Path) -> None: meta = {"element": "test", "version": "0.0.1", "marker": {"name": "fc"}} # Store 4 x 3 full matrix - data = np.array([[1, 2, 3], [11, 22, 33], [111, 222, 333], [1111, 2222, 3333]]) + data = np.array( + [[1, 2, 3], [11, 22, 33], [111, 222, 333], [1111, 2222, 3333]] + ) row_names = ["row1", "row2", "row3", "row4"] col_names = ["col1", "col2", "col3"] @@ -425,7 +439,9 @@ def test_store_matrix(tmp_path: Path) -> None: storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix(data=data, meta=meta) stored_names = [ - f"r{i}~c{j}" for i in range(data.shape[0]) for j in range(data.shape[1]) + f"r{i}~c{j}" + for i in range(data.shape[0]) + for j in range(data.shape[1]) ] features = storage.list_features() feature_md5 = list(features.keys())[0] @@ -475,7 +491,9 @@ def test_store_matrix(tmp_path: Path) -> None: assert "fc" == features[feature_md5]["name"] read_df = storage.read_df(feature_md5=feature_md5) assert list(read_df.columns) == stored_names - assert_array_equal(read_df.values, data[np.triu_indices(n=data.shape[0])][None, :]) + assert_array_equal( + read_df.values, data[np.triu_indices(n=data.shape[0])][None, :] + ) # Store upper triangular matrix without diagonal uri = tmp_path / "test_store_table_triu_nodiagonal.db" @@ -532,7 +550,9 @@ def test_store_matrix(tmp_path: Path) -> None: assert "fc" == features[feature_md5]["name"] read_df = storage.read_df(feature_md5=feature_md5) assert list(read_df.columns) == stored_names - assert_array_equal(read_df.values, data[np.tril_indices(n=data.shape[0])][None, :]) + assert_array_equal( + read_df.values, data[np.tril_indices(n=data.shape[0])][None, :] + ) # Store lower triangular matrix without diagonal uri = tmp_path / "test_store_table_tril_nodiagonal.db" @@ -624,9 +644,15 @@ def test_store_multiple_output(tmp_path: Path): assert hash1 == hash2 assert hash2 == hash3 # Store tables - storage.store_table(data1, meta1, columns=["f1", "f2"], rows_col_name="scan") - storage.store_table(data2, meta2, columns=["f1", "f2"], rows_col_name="scan") - storage.store_table(data3, meta3, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table( + data1, meta1, columns=["f1", "f2"], rows_col_name="scan" + ) + storage.store_table( + data2, meta2, columns=["f1", "f2"], rows_col_name="scan" + ) + storage.store_table( + data3, meta3, columns=["f1", "f2"], rows_col_name="scan" + ) # Check that URI does not exist yet assert not uri.exists() # Convert element to preifx @@ -696,9 +722,15 @@ def test_collect(tmp_path: Path) -> None: data2 = data1 * 10 data3 = data1 * 20 # Store tables - storage.store_table(data1, meta1, columns=["f1", "f2"], rows_col_name="scan") - storage.store_table(data2, meta2, columns=["f1", "f2"], rows_col_name="scan") - storage.store_table(data3, meta3, columns=["f1", "f2"], rows_col_name="scan") + storage.store_table( + data1, meta1, columns=["f1", "f2"], rows_col_name="scan" + ) + storage.store_table( + data2, meta2, columns=["f1", "f2"], rows_col_name="scan" + ) + storage.store_table( + data3, meta3, columns=["f1", "f2"], rows_col_name="scan" + ) # Convert element to prefix prefix1 = element_to_prefix(meta1["element"]) prefix2 = element_to_prefix(meta2["element"]) diff --git a/junifer/storage/tests/test_utils.py b/junifer/storage/tests/test_utils.py index 8ab802165..271397115 100644 --- a/junifer/storage/tests/test_utils.py +++ b/junifer/storage/tests/test_utils.py @@ -8,7 +8,11 @@ from typing import Dict, List, Tuple, Union import pytest -from junifer.storage.utils import element_to_index, element_to_prefix, process_meta +from junifer.storage.utils import ( + element_to_index, + element_to_prefix, + process_meta, +) def test_process_meta_invalid_metadata_type() -> None: @@ -114,7 +118,9 @@ def test_process_meta_element(meta: Dict, elements: List[str]) -> None: ((1, 2), "element_1_2_"), ], ) -def test_element_to_prefix(element: Union[str, int, Dict, Tuple], prefix: str) -> None: +def test_element_to_prefix( + element: Union[str, int, Dict, Tuple], prefix: str +) -> None: """Test converting element to prefix (for file naming). Parameters diff --git a/junifer/storage/utils.py b/junifer/storage/utils.py index 0a6e54d41..0a6fa58df 100644 --- a/junifer/storage/utils.py +++ b/junifer/storage/utils.py @@ -29,7 +29,9 @@ def _meta_hash(meta: Dict) -> str: """ logger.debug(f"Hashing metadata: {meta}") - meta_md5 = hashlib.md5(json.dumps(meta, sort_keys=True).encode("utf-8")).hexdigest() + meta_md5 = hashlib.md5( + json.dumps(meta, sort_keys=True).encode("utf-8") + ).hexdigest() logger.debug(f"Hash computed: {meta_md5}") return meta_md5 @@ -67,7 +69,9 @@ def process_meta(meta: Dict) -> Tuple[str, Dict]: element = t_meta.pop("element", None) if element is None: if "_element_keys" not in t_meta: - raise_error(msg="`meta` must contain the key 'element' or '_element_keys'") + raise_error( + msg="`meta` must contain the key 'element' or '_element_keys'" + ) else: if isinstance(element, dict): t_meta["_element_keys"] = list(element.keys()) @@ -141,7 +145,9 @@ def element_to_index( """ if "element" not in meta: - raise_error(msg="To create and index, metadata must contain the key 'element'.") + raise_error( + msg="To create and index, metadata must contain the key 'element'." + ) # Get element element = meta["element"] if not isinstance(element, dict): @@ -152,5 +158,7 @@ def element_to_index( elem_idx: Dict[Any, Any] = {k: [v] * n_rows for k, v in element.items()} elem_idx[rows_col_name] = np.arange(n_rows) # Create index - index = pd.MultiIndex.from_frame(pd.DataFrame(elem_idx, index=range(n_rows))) + index = pd.MultiIndex.from_frame( + pd.DataFrame(elem_idx, index=range(n_rows)) + ) return index diff --git a/junifer/testing/datagrabbers.py b/junifer/testing/datagrabbers.py index 4b7d87a77..7253ab7a7 100644 --- a/junifer/testing/datagrabbers.py +++ b/junifer/testing/datagrabbers.py @@ -168,7 +168,9 @@ class PartlyCloudyTestingDataGrabber(BaseDataGrabber): """ - def __init__(self, reduce_confounds: bool = True, age_group: str = "both") -> None: + def __init__( + self, reduce_confounds: bool = True, age_group: str = "both" + ) -> None: """Initialize the class.""" datadir = tempfile.mkdtemp() # Define types diff --git a/junifer/tests/test_stats.py b/junifer/tests/test_stats.py index 8c2697032..71b660698 100644 --- a/junifer/tests/test_stats.py +++ b/junifer/tests/test_stats.py @@ -44,10 +44,14 @@ def test_get_aggfunc_by_name_errors() -> None: get_aggfunc_by_name(name="winsorized_mean", func_params=None) with pytest.raises(ValueError, match="list of limits"): - get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": 0.1}) + get_aggfunc_by_name( + name="winsorized_mean", func_params={"limits": 0.1} + ) with pytest.raises(ValueError, match="list of two limits"): - get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [0.2]}) + get_aggfunc_by_name( + name="winsorized_mean", func_params={"limits": [0.2]} + ) with pytest.raises(ValueError, match="list of two"): get_aggfunc_by_name( @@ -55,10 +59,14 @@ def test_get_aggfunc_by_name_errors() -> None: ) with pytest.raises(ValueError, match="must be between 0 and 1"): - get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [-1, 0.7]}) + get_aggfunc_by_name( + name="winsorized_mean", func_params={"limits": [-1, 0.7]} + ) with pytest.raises(ValueError, match="must be between 0 and 1"): - get_aggfunc_by_name(name="winsorized_mean", func_params={"limits": [0.1, 2]}) + get_aggfunc_by_name( + name="winsorized_mean", func_params={"limits": [0.1, 2]} + ) def test_winsorized_mean() -> None: diff --git a/junifer/utils/logging.py b/junifer/utils/logging.py index b4ca0d26b..1c3db746b 100644 --- a/junifer/utils/logging.py +++ b/junifer/utils/logging.py @@ -277,7 +277,9 @@ def raise_error(msg: str, klass: Type[Exception] = ValueError) -> NoReturn: raise klass(msg) -def warn_with_log(msg: str, category: Optional[Type[Warning]] = RuntimeWarning) -> None: +def warn_with_log( + msg: str, category: Optional[Type[Warning]] = RuntimeWarning +) -> None: """Warn, but first log it. Parameters diff --git a/junifer/utils/tests/test_logging.py b/junifer/utils/tests/test_logging.py index 4b28ec095..a815f8658 100644 --- a/junifer/utils/tests/test_logging.py +++ b/junifer/utils/tests/test_logging.py @@ -123,7 +123,9 @@ def test_log_file(tmp_path: Path) -> None: assert any("Warn2 message" in line for line in lines) assert any("Error2 message" in line for line in lines) - configure_logging(fname=tmp_path / "test4.log", level="WARNING", overwrite=True) + configure_logging( + fname=tmp_path / "test4.log", level="WARNING", overwrite=True + ) logger.debug("Debug3 message") logger.info("Info3 message") logger.warning("Warn3 message") -- 2.52.0 From 009edbba8703d3f9b8fa1dfb8d42d2463805c282 Mon Sep 17 00:00:00 2001 From: Synchon Mandal Date: Fri, 11 Nov 2022 07:29:23 +0100 Subject: [PATCH 14/22] chore: update latest.inc --- docs/changes/latest.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/latest.inc b/docs/changes/latest.inc index baa2ce7fb..c4e611caa 100644 --- a/docs/changes/latest.inc +++ b/docs/changes/latest.inc @@ -102,4 +102,4 @@ Bugs API changes ~~~~~~~~~~~ -- Change the ``single_output`` default parameter in storage classes from ``False`` to ``True`` (:gh:`134` by `Fede Raimondo`_). \ No newline at end of file +- Change the ``single_output`` default parameter in storage classes from ``False`` to ``True`` (:gh:`134` by `Fede Raimondo`_). -- 2.52.0 From a002e95b29d54a6e97fef78d8823688e4f222f36 Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 15 Nov 2022 16:43:45 +0100 Subject: [PATCH 15/22] Doc WIP --- docs/extending/extension.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/extending/extension.rst b/docs/extending/extension.rst index f37f7e690..0fd5ccc08 100644 --- a/docs/extending/extension.rst +++ b/docs/extending/extension.rst @@ -25,4 +25,4 @@ extensions. .. important:: Some junifer commands will not consider files imported from files included in the ``with`` statement. That is, if ``my_file.py`` imports ``my_other_file.py``, some of the junifer commands will not consider - ``my_other_file.py``. Either place all the code in one file or add multiple files to the ``with`` statement. \ No newline at end of file + ``my_other_file.py``. Either place all the code in one file or add multiple files to the ``with`` statement. -- 2.52.0 From b21eb609d9013e52aee5f5528aa6974d820e0a9d Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 22 Nov 2022 15:51:57 +0100 Subject: [PATCH 16/22] Fix this linter --- junifer/datagrabber/tests/test_datalad_base.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/junifer/datagrabber/tests/test_datalad_base.py b/junifer/datagrabber/tests/test_datalad_base.py index 3db5989bd..50ebcbe76 100644 --- a/junifer/datagrabber/tests/test_datalad_base.py +++ b/junifer/datagrabber/tests/test_datalad_base.py @@ -9,11 +9,6 @@ from typing import Type import datalad.api as dl import pytest -from typing import Type - -import datalad.api as dl -import pytest - from junifer.datagrabber.datalad_base import DataladDataGrabber _testing_dataset = { @@ -29,6 +24,7 @@ _testing_dataset = { }, } + def test_datalad_base_abstractness() -> None: """Test datalad base is abstract.""" with pytest.raises(TypeError, match=r"abstract"): -- 2.52.0 From fa2222744c430afc2145f2a86a2294120e5ddb44 Mon Sep 17 00:00:00 2001 From: Fede Date: Tue, 22 Nov 2022 15:53:07 +0100 Subject: [PATCH 17/22] Remove this useless change --- junifer/api/tests/test_functions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/junifer/api/tests/test_functions.py b/junifer/api/tests/test_functions.py index 79a8de3ff..c931bd07d 100644 --- a/junifer/api/tests/test_functions.py +++ b/junifer/api/tests/test_functions.py @@ -17,6 +17,7 @@ from junifer.api.functions import collect, queue, run from junifer.datagrabber.base import BaseDataGrabber from junifer.pipeline.registry import build + # Define datagrabber datagrabber = { "kind": "OasisVBMTestingDatagrabber", -- 2.52.0 From f9e7bac6c727d94a9639383387f59fbe58ec2a19 Mon Sep 17 00:00:00 2001 From: Fede Date: Wed, 23 Nov 2022 15:33:33 +0100 Subject: [PATCH 18/22] addressed comments --- docs/contribution.rst | 2 +- docs/extending/datagrabber.rst | 37 +++++++------- docs/extending/extension.rst | 2 +- docs/extending/index.rst | 2 +- docs/extending/marker.rst | 50 +++++++++++-------- docs/links.inc | 1 + docs/understanding/data.rst | 4 +- docs/understanding/pipeline.rst | 2 +- docs/understanding/storage.rst | 2 +- docs/using/codeless.rst | 10 ++-- docs/using/queueing.rst | 14 +++--- docs/using/running.rst | 12 ++--- examples/run_ets_rss_marker.py | 2 +- examples/run_junifer_julearn.py | 2 +- examples/run_run_gmd_mean.py | 2 +- examples/yamls/gmd_mean.yaml | 2 +- examples/yamls/gmd_mean_htcondor.yaml | 2 +- examples/yamls/ukb_gmd_mean.yaml | 2 +- junifer/api/decorators.py | 2 +- junifer/api/tests/data/gmd_mean.yaml | 2 +- junifer/api/tests/data/gmd_mean_htcondor.yaml | 2 +- junifer/api/tests/test_functions.py | 20 ++++---- junifer/markers/tests/test_collection.py | 2 +- ...ossparcellation_functional_connectivity.py | 2 +- junifer/markers/tests/test_ets_rss.py | 2 +- .../test_functional_connectivity_parcels.py | 2 +- .../test_functional_connectivity_spheres.py | 2 +- .../markers/tests/test_sphere_aggregation.py | 2 +- junifer/storage/tests/test_sqlite.py | 36 ++++++------- 29 files changed, 117 insertions(+), 107 deletions(-) diff --git a/docs/contribution.rst b/docs/contribution.rst index 83359729e..6c947e26c 100644 --- a/docs/contribution.rst +++ b/docs/contribution.rst @@ -173,7 +173,7 @@ texts. ############################################################################### # The BIDS datagrabber requires three parameters: the types of data we want, # the specific pattern that matches each type, and the variables that will be - # replaced int he patterns. + # replaced in the patterns. types = ["T1w", "BOLD"] patterns = { "T1w": "{subject}/anat/{subject}_T1w.nii.gz", diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst index 1927202e3..1d8a88cab 100644 --- a/docs/extending/datagrabber.rst +++ b/docs/extending/datagrabber.rst @@ -5,7 +5,7 @@ Creating DataGrabbers ===================== -DataGrabbers are the first step of the pipeline. It's purpose is to interpret +DataGrabbers are the first step of the pipeline. Its purpose is to interpret the structure of a dataset and provide two specific functionalities: 1) Given an *element*, provide the path to each kind of data available for this @@ -13,7 +13,7 @@ the structure of a dataset and provide two specific functionalities: 2) Provide the list of *elements* available in the dataset. In this section, we will see how to create a datagrabber for a dataset. Basic -aspects of datagrabbers are covedered in the +aspects of datagrabbers are covered in the :ref:`Understanding DataGrabbers ` section. .. _extending_datagrabbers_think: @@ -30,7 +30,7 @@ only one of each *data type* (see :ref:`data_types`). For example, if we have a dataset from an fMRI study in which: a) both T1w and fMRI was acquired -b) 20 subjects went through a experiment twice +b) 20 subjects went through an experiment twice c) the experiment included resting-stage fMRI and a task named *stroop* then the *element* should be composed of 3 items: @@ -39,7 +39,7 @@ then the *element* should be composed of 3 items: * ``session``: The sesion number, e.g. `ses1`, `ses2` * ``task``: The task performed, e.g. `rest`, `stroop` -If any of this items were not part of the element, then we will have more than +If any of these items were not part of the element, then we will have more than one ``T1w`` and/or ``BOLD`` image for each subject, which is not allowed. Importantly, nothing prevents that one image is part of two different elements. @@ -49,7 +49,7 @@ for the element (``sub001``, ``ses1``, ``rest``) will be the same as the ``T1w`` image for the element (``sub001``, ``ses1``, ``stroop``). We will now continue this section using as an example, a dataset in BIDS format -in which each 9 subjects (`sub-01` to `sub-09`) were scanned each in 3 +in which 9 subjects (`sub-01` to `sub-09`) were scanned each during 3 sessions (`ses-01`, `ses-02`, `ses-03`) and each session included a `T1w` and a `BOLD` image (resting-state), except for `ses-03` which was only anatomical. @@ -71,7 +71,7 @@ expressed as a pattern: ``{subject}/{session}/anat/{subject}_{session}_T1w.nii.gz`` -where ``{subject}`` is the replacement for the subject id and ``{session}}`` +where ``{subject}`` is the replacement for the subject id and ``{session}`` is the replacement for the session id. Since it is a BIDS dataaset, the same happens with the BOLD images. The path to @@ -149,7 +149,8 @@ With this defined, we can now create our datagrabber, we will name it datadir=datadir, types=types, patterns=patterns, - replacements=replacements) + replacements=replacements, + ) Our datagrabber is ready to be used by junifer. However, it is still unknown to the library. We need to register it in the library. To do so, we need to @@ -176,7 +177,8 @@ use the :py:func:`~junifer.api.decorators.register_datagrabber` decorator. datadir=datadir, types=types, patterns=patterns, - replacements=replacements) + replacements=replacements, + ) Now, we can use our datagrabber in junifer, by setting the ``datagrabber`` kind @@ -193,9 +195,9 @@ set the ``datadir``. Optional: Using datalad """"""""""""""""""""""" -If you are using datalad, you can use the +If you are using `datalad`_, you can use the :py:class:`~junifer.datagrabber.PatternDataladDataGrabber` instead of the -:py:class:`~junifer.datagrabber.PatternDataGrabber`. This class will also +:py:class:`~junifer.datagrabber.PatternDataGrabber`. This class will not only interpret patterns, but will also use datalad to `clone` and `get` the data. The main difference between the two is that the ``datadir`` is not the actual @@ -204,7 +206,7 @@ can now be ``None``, which means that the data will be downloaded to a temporary directory. To set the location of the dataset, you can use the ``uri`` argument in the constructor. Additionally, a ``rootdir`` argument can be used to specify the path to the root directory of the dataset after doing -``datalad clone`` +``datalad clone``. In the example, the dataset is hosted in gin (``https://gin.g-node.org/juaml/datalad-example-bids``). @@ -262,7 +264,8 @@ And we can create our datagrabber: rootdir=rootdir, types=types, patterns=patterns, - replacements=replacements) + replacements=replacements, + ) .. _extending_datagrabbers_base: @@ -277,7 +280,7 @@ In order to create a datagrabber extending from :py:class:`~junifer.datagrabber. implement the following methods: - ``get_item``: to get a single item from the dataset. -- ``get_elements``: to get the list fo all elements present in the dataset +- ``get_elements``: to get the list of all elements present in the dataset - ``get_element_keys``: to get the keys of the elements in the dataset. .. note:: @@ -302,7 +305,7 @@ as parameters of ``get_item``: The second method, ``get_elements``, needs to return a list of all the elements in the dataset. In this case, we know that the dataset contains 3 subjects and 3 sessions, so we can create a list of all the possible combinations. -However, we need to remember that for session *ses-03* there is no bold. +However, we need to remember that for session *ses-03* there is no BOLD data. .. code-block:: python @@ -320,8 +323,8 @@ However, we need to remember that for session *ses-03* there is no bold. return elements -And finally, we cna implement the ``get_element_keys`` method. This method needs to return a list of the keys that -are represent each of the items in the element tuple. As a rule of thumb, they should be the parameters of the +And finally, we can implement the ``get_element_keys`` method. This method needs to return a list of the keys that +represent each of the items in the element tuple. As a rule of thumb, they should be the parameters of the ``get_item`` method, in the same order. .. code-block:: python @@ -392,7 +395,7 @@ The ``fmriprep`` format corresponds to the format of the confounds files generat Currently, Junifer provides only one confound remover step (:class:`junifer.preprocess.fMRIPrepConfoundRemover`), which relies entirely on the ``fmriprep`` confound variable names. Thus, if the confounds are not in ``fmriprep`` format, the user will need to provide the mappings -between the *ad-hoc* variable names and the the ``fmriprep`` variable names. +between the *ad-hoc* variable names and the ``fmriprep`` variable names. This is done by specifying the ``adhoc`` format and providing the mappings as a dictionary in the ``mappings`` key. In the following example, the confounds file has 3 variables that are not in the ``fmriprep`` format. Thus, we will diff --git a/docs/extending/extension.rst b/docs/extending/extension.rst index 0fd5ccc08..2d553b80f 100644 --- a/docs/extending/extension.rst +++ b/docs/extending/extension.rst @@ -6,7 +6,7 @@ Creating a Junifer extension ============================ Junifer is designed to be easily extensible. Through the use of a registry and decorators, we can easily add new -functinality to junifer on runtime. This is done by creating a new python module and importing it before running +functionality to junifer on runtime. This is done by creating a new python module and importing it before running junifer. A special consideration has to be made when using the :ref:`code-less configuration`. In this case, the diff --git a/docs/extending/index.rst b/docs/extending/index.rst index 0d5907e08..3be646c3e 100644 --- a/docs/extending/index.rst +++ b/docs/extending/index.rst @@ -9,7 +9,7 @@ While we aim to provide as many datasets and markers as possible, we are also interested in allowing users to extend the functionality with their own datagrabbers, preprocessing, markers, etc. -This does not mean that the new functinality will have to be included in +This does not mean that the new functionality will have to be included in junifer before the user can use them. Instead, the user can simply create a new python file, code the desired functionality and use it with junifer. This is the first step towards including the new functionality in diff --git a/docs/extending/marker.rst b/docs/extending/marker.rst index e93850682..85c084e35 100644 --- a/docs/extending/marker.rst +++ b/docs/extending/marker.rst @@ -22,7 +22,7 @@ Thus, only a few methods are required: 4. ``store``: the method that stores the computed marker. 5. ``__init__``: the initialization method, where the marker is configured. -As an example, we will develop a Parcel Mean marker, that is, a marker that first applies a parecellation and +As an example, we will develop a Parcel Mean marker, that is, a marker that first applies a parcellation and then computes the mean of the data in each parcel. This is a very simple example, but it will show you how to create a new marker. @@ -62,19 +62,19 @@ The parameters of the marker are defined in the ``__init__`` method. The :class: requires two optional parameters: 1. ``name``: the name of the marker. This is used to identify the marker in the configuration file. -2. ``on``: a list or string with the names of the inputs that the marker will be applied to. +2. ``on``: a list or string with the data types that the marker will be applied to. .. attention:: Only basic types (*int*, *bool* and *str*) as well as Lists, Tuples and Dictionaries are allowed as parameters. This is because the parameters are stored in a JSON file, and JSON only supports these types. -In this example, the is only paramater required for the computation is the name of the parcelation to use. Thus, we can +In this example, the is only paramater required for the computation is the name of the parcellation to use. Thus, we can define the ``__init__`` method as follows: .. code-block:: python - def __init__(self, parcelation_name, on=None, name=None): - self.parcelation_name = parcelation_name + def __init__(self, parcellation_name, on=None, name=None): + self.parcellation_name = parcellation_name super().__init__(on=on, name=name) .. caution:: Parameters of the marker must be stored as object attributes without using ``_`` as prefix. This is @@ -91,11 +91,11 @@ In this step, we will define the method that computes the marker. This method wi using the data provided by the datagrabber, as configured by the user. The function ``compute`` has two arguments: * ``input``: a dictionary with the data to be used to compute the marker. This will be the corresponding element in the - :ref:`Data Object` alredy indexing. Thus, the dictionary has at least two keys: ``data`` and ``path``. - The first one contains the data, while the second one contains the path to the data. The dictionary can also contain - other keys, depending on the data type. -* ``extra_input``: the rest of the :ref:`Data Object`. This is usefull if you want to use other data to - compute the marker (e.g.: ``BOLD_confounds`` can be used to de-confound the ``BOLD`` data). + :ref:`Data Object` alredy indexing. Thus, the dictionary has at least two keys: ``data`` and ``path``. + The first one contains the data, while the second one contains the path to the data. The dictionary can also contain + other keys, depending on the data type. +* ``extra_input``: the rest of the :ref:`Data Object`. This is useful if you want to use other data to + compute the marker (e.g.: ``BOLD_confounds`` can be used to de-confound the ``BOLD`` data). Following the example, we will compute the mean of the data in each parcel using :class:`nilearn.maskers.NiftiLabelsMasker`. Importantly, the output of the compute function must be a dictionary. @@ -119,14 +119,17 @@ This dictionary will later be passed onto the ``store`` method. # Load the parcellation t_parcellation, t_labels, _ = load_parcellation( - name=self.parcelation_name, + name=self.parcellation_name, resolution=resolution, ) # Create a masker masker = NiftiLabelsMasker( - labels_img=t_parcellation, standardize=True, - memory='nilearn_cache', verbose=5) + labels_img=t_parcellation, + standardize=True, + memory='nilearn_cache', + verbose=5, + ) # mask the data out_values = masker.fit_transform([data]) @@ -146,9 +149,9 @@ Step 4: Store the marker ------------------------ In this step, we will define the method that stores the marker. This method will be called by junifer when needed, -using the data provided by the ``compute`` method. The function ``store`` has three arguments: +using the data provided by the ``compute`` method. The method ``store`` has three arguments: -* ``kind``: A string indicating the :ref:`type of data ` that was used to compute the marker. +* ``kind``: A string indicating the :ref:`data type ` that was used to compute the marker. * ``out``: The output of the ``compute`` method. * ``storage``: The storage object, that will be used to store the marker. @@ -161,7 +164,7 @@ using the data provided by the ``compute`` method. The function ``store`` has th storage.store(kind="timeseries", **out) .. hint:: Check the hint on :ref:`extending_markers_compute`. If the output of the ``compute`` method is a dictionary - with keys based on the :ref:`storage types `, the ``store`` method can simple call the right + with keys based on the :ref:`storage types `, the ``store`` method can simply call the right storage function, based on the ``kind`` parameter, with ``**out``. @@ -170,7 +173,7 @@ using the data provided by the ``compute`` method. The function ``store`` has th Step 5: Finalize the marker --------------------------- -One all of the above-mentioned methods are defined, we just need to give our marker a name an register it using the +Once all of the above steps are done, we just need to give our marker a name an register it using the ``@register_marker`` decorator: .. code-block:: python @@ -183,8 +186,8 @@ One all of the above-mentioned methods are defined, we just need to give our mar @register_marker class ParcelMean(BaseMarker): - def __init__(self, parcelation_name, on=None, name=None): - self.parcelation_name = parcelation_name + def __init__(self, parcellation_name, on=None, name=None): + self.parcellation_name = parcellation_name super().__init__(on=on, name=name) def get_valid_inputs(self): @@ -205,14 +208,17 @@ One all of the above-mentioned methods are defined, we just need to give our mar # Load the parcellation t_parcellation, t_labels, _ = load_parcellation( - name=self.parcelation_name, + name=self.parcellation_name, resolution=resolution, ) # Create a masker masker = NiftiLabelsMasker( - labels_img=t_parcellation, standardize=True, - memory='nilearn_cache', verbose=5) + labels_img=t_parcellation, + standardize=True, + memory='nilearn_cache', + verbose=5, + ) # mask the data out_values = masker.fit_transform([data]) diff --git a/docs/links.inc b/docs/links.inc index 30ea8b9f2..791b99395 100644 --- a/docs/links.inc +++ b/docs/links.inc @@ -22,6 +22,7 @@ .. _`fMRIPrep`: https://fmriprep.org .. _`nilearn`: https://nilearn.github.io .. _`nipype`: https://nipype.readthedocs.io +.. _`datalad`: https://datalad.org .. _`venv`: https://docs.python.org/3/tutorial/venv.html .. _`conda env`: https://docs.conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html diff --git a/docs/understanding/data.rst b/docs/understanding/data.rst index 0794bb310..72fb251d9 100644 --- a/docs/understanding/data.rst +++ b/docs/understanding/data.rst @@ -56,7 +56,7 @@ Data types - fALFF computed with CONN toolbox * - ``GCOR`` - Global Correlation image (3D) - - GCOR computed with conn-toolbox + - GCOR computed with CONN toolbox * - ``LCOR`` - Local Correlation image (3D) - - LCOR computed with conn-toolbox \ No newline at end of file + - LCOR computed with CONN toolbox \ No newline at end of file diff --git a/docs/understanding/pipeline.rst b/docs/understanding/pipeline.rst index 7aa19c639..a19e94fe8 100644 --- a/docs/understanding/pipeline.rst +++ b/docs/understanding/pipeline.rst @@ -7,7 +7,7 @@ The Junifer Pipeline The junifer pipeline is the main execution path of junifer. It consists of five steps: -1. :ref:`Data Grabber `: Interpret the database and provide a list of files. +1. :ref:`Data Grabber `: Interpret the dataset and provide a list of files. 2. :ref:`Data Reader `: Read the files. 3. :ref:`Pre-processing `: Prepare the images for marker computation. 4. :ref:`Marker Computation `: Compute the marker. diff --git a/docs/understanding/storage.rst b/docs/understanding/storage.rst index 6d3ca9abe..0547b8bee 100644 --- a/docs/understanding/storage.rst +++ b/docs/understanding/storage.rst @@ -65,6 +65,6 @@ Currently supported storage interfaces - File type - Storage kinds * - :class:`junifer.storage.SQLiteFeatureStorage` - - ``.db`` + - ``.sqlite`` - SQLite - ``matrix``, ``table``, ``timeseries`` diff --git a/docs/using/codeless.rst b/docs/using/codeless.rst index c0b169d9f..d2b2d9698 100644 --- a/docs/using/codeless.rst +++ b/docs/using/codeless.rst @@ -44,7 +44,7 @@ In order to configure the pipeline, we need to configure each step: * ``datagrabber`` * ``datareader`` -* ``preproces`` +* ``preprocess`` * ``markers`` * ``storage`` @@ -98,7 +98,7 @@ specify the data reader to use, and additional keys to pass parameters to the da kind: DefaultDataReader -For the *Oasis VBM Testing dataset* example, we will not specify a data reader step. +For the *Oasis VBM Testing dataset* example, we will not specify a ``datareader`` step. Preprocessing ^^^^^^^^^^^^^ @@ -120,8 +120,8 @@ name as the ``kind`` key, as well as its parameters. wm_csf: full global_signal: basic spike: 0.2 - detrend: False - standardize: True + detrend: false + standardize: true For the *Oasis VBM Testing dataset* example, we will not specify a preprocessing step. @@ -133,7 +133,7 @@ Markers The ``markers`` section diverges from the previous ones, as we need to specify a list of markers. Each marker has a name that we can use to refer to it later, and a set of parameters that will be passed to the marker. -For the *Oasis VBM Testing dataset* example, we want to comute the mean ``VBM_GM`` value for each parcel using the +For the *Oasis VBM Testing dataset* example, we want to compute the mean ``VBM_GM`` value for each parcel using the Schaefer parcellation (100 parcels, 7 networks), Schaefer parcellation (200 parcels, 7 networks), and the *DMNBuckner* network, using 5mm spheres. Thus, we will configure the ``markers`` section as follows: diff --git a/docs/using/queueing.rst b/docs/using/queueing.rst index 15b6c54b1..2dc28023b 100644 --- a/docs/using/queueing.rst +++ b/docs/using/queueing.rst @@ -9,15 +9,15 @@ Yet another interesting feature of junifer is the ability to queue jobs on compu adding the ``queue`` section in the :ref:`codeless` file and executing the ``junifer queue`` command. While junifer is meant to support `HTCondor`_, `SLURM`_ and local queueing using `GNU Parallel`_, only HTCondor is -currently supported. This will be implemented in future relases of junifer. If you are in immediate need of any of this +currently supported. This will be implemented in future relases of junifer. If you are in immediate need of any of these schedulers, please create an issue on the `junifer github`_ repository. The ``queue`` section of the :ref:`codeless` must start by defining the following general parameters: * ``jobname``: name of the job to be queued. This will be used to name the folder where the job files will be created, - as well as any relevant file. Depending on the schduler, it will also be listed in the queueing system with this + as well as any relevant file. Depending on the scheduler, it will also be listed in the queueing system with this name. -* ``kind``: The kind of schduler to be used. Currently, only ``htcondor`` is supported. +* ``kind``: The kind of scheduler to be used. Currently, only ``HTCondor`` is supported. Example: @@ -40,7 +40,7 @@ include a final job (``junifer collect``) to collect the results once all of the The following parameters are avilable for HTCondor: -* ``env``: Definition of the python enviroment. It must provide two variables: ``kind`` and ``name``. The ``kind`` +* ``env``: Definition of the Python enviroment. It must provide two variables: ``kind`` and ``name``. The ``kind`` corresponds to the kind of virtual environment to use: ``conda``, ``virtualenv`` (not yet supported) or ``local`` (no virtual enviroment). The ``name`` is the name of the enviroment to use in case a virtual environment is used. @@ -51,9 +51,9 @@ The following parameters are avilable for HTCondor: directory. * ``extra_preamble``: Extra lines to be added to the HTCondor submit file. This can be used to add extra parameters to the job, such as ``requirements``. -* ``collect``: If ``True``, a final job will be added to the DAG to collect the results once all of the individual +* ``collect``: If ``true``, a final job will be added to the DAG to collect the results once all of the individual element jobs are finished. This is useful if you want to run a ``junifer collect`` job only once all of the - individual element jobs are finished. If not specified, it will default to ``True``. + individual element jobs are finished. If not specified, it will default to ``true``. Example: @@ -68,7 +68,7 @@ Example: name: junifer mem: 8G disk: 2GB - collect: True + collect: true Once the :ref:`codeless` file is ready, including the ``queue`` section, you can queue the jobs by executing diff --git a/docs/using/running.rst b/docs/using/running.rst index 06147f2d0..9bd1cfad9 100644 --- a/docs/using/running.rst +++ b/docs/using/running.rst @@ -14,7 +14,7 @@ individual results into a single file. Assuming that we have a configuration file named ``config.yaml``, the following commands will extract the features: -.. code-block:: bash +.. code-block:: console junifer run config.yaml @@ -23,19 +23,19 @@ The ``run`` command accepts the following additional arguments: * ``--help``: Show a help message. * ``--verbose`` Set the verbosity level. Options are ``warning``, ``info``, ``debug``. * ``--element``: The *element* to run. If not specified, all elements will be run. This parameter can be specified - multiple times to run multiple elements. If the *element* requires several parameters, they can be specified - by separating them with ``,``. + multiple times to run multiple elements. If the *element* requires several parameters, they can be specified + by separating them with ``,``. Example on running two elements: -.. code-block:: bash +.. code-block:: console junifer run config.yaml --element sub-01 --element sub-02 Example on elements with multiple parameters and verbose output: -.. code-block:: bash +.. code-block:: console junifer run --verbose info config.yaml --element sub-01,ses-01 @@ -50,7 +50,7 @@ individual results into a single file. Assuming that we have a configuration file named ``config.yaml``, the following commands will collect the results: -.. code-block:: bash +.. code-block:: console junifer collect config.yaml diff --git a/examples/run_ets_rss_marker.py b/examples/run_ets_rss_marker.py index d7549f1f4..ff6b5e86b 100644 --- a/examples/run_ets_rss_marker.py +++ b/examples/run_ets_rss_marker.py @@ -52,7 +52,7 @@ with tempfile.TemporaryDirectory() as tmpdir: # Define the storage interface storage = { "kind": "SQLiteFeatureStorage", - "uri": f"{tmpdir}/test.db", + "uri": f"{tmpdir}/test.sqlite", } # Run the defined junifer feature extraction pipeline run( diff --git a/examples/run_junifer_julearn.py b/examples/run_junifer_julearn.py index 981a0e76c..c2437ccf2 100644 --- a/examples/run_junifer_julearn.py +++ b/examples/run_junifer_julearn.py @@ -71,7 +71,7 @@ sex = ( # Create a temporary directory for junifer feature extraction: with tempfile.TemporaryDirectory() as tmpdir: - storage = {"kind": "SQLiteFeatureStorage", "uri": f"{tmpdir}/test.db"} + storage = {"kind": "SQLiteFeatureStorage", "uri": f"{tmpdir}/test.sqlite"} # run the defined junifer feature extraction pipeline run( workdir="/tmp", diff --git a/examples/run_run_gmd_mean.py b/examples/run_run_gmd_mean.py index 1f58ce0a4..16b56e6b6 100644 --- a/examples/run_run_gmd_mean.py +++ b/examples/run_run_gmd_mean.py @@ -43,7 +43,7 @@ storage = { } with tempfile.TemporaryDirectory() as tmpdir: - uri = f"{tmpdir}/test.db" + uri = f"{tmpdir}/test.sqlite" storage["uri"] = uri run( workdir="/tmp", diff --git a/examples/yamls/gmd_mean.yaml b/examples/yamls/gmd_mean.yaml index 028b9a6b8..447a2fc47 100644 --- a/examples/yamls/gmd_mean.yaml +++ b/examples/yamls/gmd_mean.yaml @@ -21,5 +21,5 @@ markers: method: std storage: kind: SQLiteFeatureStorage - uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.db + uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.sqlite diff --git a/examples/yamls/gmd_mean_htcondor.yaml b/examples/yamls/gmd_mean_htcondor.yaml index f753f3f35..62ad14329 100644 --- a/examples/yamls/gmd_mean_htcondor.yaml +++ b/examples/yamls/gmd_mean_htcondor.yaml @@ -10,7 +10,7 @@ markers: method: mean storage: kind: SQLiteFeatureStorage - uri: /data/group/appliedml/fraimondo/junifer_test/test.db + uri: /data/group/appliedml/fraimondo/junifer_test/test.sqlite queue: jobname: TestHTCondorQueue kind: HTCondor diff --git a/examples/yamls/ukb_gmd_mean.yaml b/examples/yamls/ukb_gmd_mean.yaml index 6a72d5561..1a4f5963f 100644 --- a/examples/yamls/ukb_gmd_mean.yaml +++ b/examples/yamls/ukb_gmd_mean.yaml @@ -21,5 +21,5 @@ markers: method: std storage: kind: SQLiteFeatureStorage - uri: /data/project/ukb_motor/junifer_test/test.db + uri: /data/project/ukb_motor/junifer_test/test.sqlite diff --git a/junifer/api/decorators.py b/junifer/api/decorators.py index b8317bf2a..eb2f0bda1 100644 --- a/junifer/api/decorators.py +++ b/junifer/api/decorators.py @@ -41,7 +41,7 @@ def register_datareader(klass: Type) -> Type: Parameters ---------- klass: class - The class of the datagrabber to register. + The class of the datareader to register. Returns ------- diff --git a/junifer/api/tests/data/gmd_mean.yaml b/junifer/api/tests/data/gmd_mean.yaml index c824ed0b8..c5e954c7e 100644 --- a/junifer/api/tests/data/gmd_mean.yaml +++ b/junifer/api/tests/data/gmd_mean.yaml @@ -11,5 +11,5 @@ markers: method: mean storage: kind: SQLiteFeatureStorage - uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.db + uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.sqlite diff --git a/junifer/api/tests/data/gmd_mean_htcondor.yaml b/junifer/api/tests/data/gmd_mean_htcondor.yaml index 0deb6ed90..1b5db62b8 100644 --- a/junifer/api/tests/data/gmd_mean_htcondor.yaml +++ b/junifer/api/tests/data/gmd_mean_htcondor.yaml @@ -10,7 +10,7 @@ markers: method: mean storage: kind: SQLiteFeatureStorage - uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.db + uri: /Users/fraimondo/dev/tbox/junifer/scratch/db/test.sqlite queue: jobname: TestHTCondorQueue kind: HTCondor diff --git a/junifer/api/tests/test_functions.py b/junifer/api/tests/test_functions.py index c931bd07d..626918e57 100644 --- a/junifer/api/tests/test_functions.py +++ b/junifer/api/tests/test_functions.py @@ -61,7 +61,7 @@ def test_run_single_element(tmp_path: Path) -> None: outdir = tmp_path / "out" outdir.mkdir() # Create storage - uri = outdir / "test.db" + uri = outdir / "test.sqlite" storage["uri"] = uri # type: ignore # Run operations run( @@ -72,7 +72,7 @@ def test_run_single_element(tmp_path: Path) -> None: elements=["sub-01"], ) # Check files - files = list(outdir.glob("*.db")) + files = list(outdir.glob("*.sqlite")) assert len(files) == 1 @@ -92,7 +92,7 @@ def test_run_multi_element(tmp_path: Path) -> None: outdir = tmp_path / "out" outdir.mkdir() # Create storage - uri = outdir / "test.db" + uri = outdir / "test.sqlite" storage["uri"] = uri # type: ignore storage["single_output"] = False # type: ignore # Run operations @@ -104,7 +104,7 @@ def test_run_multi_element(tmp_path: Path) -> None: elements=["sub-01", "sub-03"], ) # Check files - files = list(outdir.glob("*.db")) + files = list(outdir.glob("*.sqlite")) assert len(files) == 2 @@ -124,7 +124,7 @@ def test_run_multi_element_single_output(tmp_path: Path) -> None: outdir = tmp_path / "out" outdir.mkdir() # Create storage - uri = outdir / "test.db" + uri = outdir / "test.sqlite" storage["uri"] = uri # type: ignore storage["single_output"] = True # type: ignore # Run operations @@ -136,9 +136,9 @@ def test_run_multi_element_single_output(tmp_path: Path) -> None: elements=["sub-01", "sub-03"], ) # Check files - files = list(outdir.glob("*.db")) + files = list(outdir.glob("*.sqlite")) assert len(files) == 1 - assert files[0].name == "test.db" + assert files[0].name == "test.sqlite" def test_run_and_collect(tmp_path: Path) -> None: @@ -157,7 +157,7 @@ def test_run_and_collect(tmp_path: Path) -> None: outdir = tmp_path / "out" outdir.mkdir() # Create storage - uri = outdir / "test.db" + uri = outdir / "test.sqlite" storage["uri"] = uri # type: ignore storage["single_output"] = False # type: ignore # Run operations @@ -173,9 +173,9 @@ def test_run_and_collect(tmp_path: Path) -> None: ) elements = dg.get_elements() # type: ignore # This should create 10 files - files = list(outdir.glob("*.db")) + files = list(outdir.glob("*.sqlite")) assert len(files) == len(elements) - # But the test.db file should not exist + # But the test.sqlite file should not exist assert not uri.exists() # Collect in storage collect(storage) diff --git a/junifer/markers/tests/test_collection.py b/junifer/markers/tests/test_collection.py index b6a9c93b9..82df6dc12 100644 --- a/junifer/markers/tests/test_collection.py +++ b/junifer/markers/tests/test_collection.py @@ -134,7 +134,7 @@ def test_marker_collection_storage(tmp_path: Path) -> None: # Test storage dg = OasisVBMTestingDatagrabber() - uri = tmp_path / "test_marker_collection_storage.db" + uri = tmp_path / "test_marker_collection_storage.sqlite" storage = SQLiteFeatureStorage(uri=uri) mc = MarkerCollection( markers=markers, diff --git a/junifer/markers/tests/test_crossparcellation_functional_connectivity.py b/junifer/markers/tests/test_crossparcellation_functional_connectivity.py index 7328efdf4..5a6dee709 100644 --- a/junifer/markers/tests/test_crossparcellation_functional_connectivity.py +++ b/junifer/markers/tests/test_crossparcellation_functional_connectivity.py @@ -78,7 +78,7 @@ def test_store(tmp_path: Path) -> None: parcellation_two=parcellation_TWO, correlation_method="spearman", ) - uri = tmp_path / "test_crossparcellation.db" + uri = tmp_path / "test_crossparcellation.sqlite" storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") out = crossparcellation.fit_transform(input_dict, storage=storage) diff --git a/junifer/markers/tests/test_ets_rss.py b/junifer/markers/tests/test_ets_rss.py index ed6fd484e..a23aa5b64 100644 --- a/junifer/markers/tests/test_ets_rss.py +++ b/junifer/markers/tests/test_ets_rss.py @@ -77,6 +77,6 @@ def test_store(tmp_path: Path) -> None: ets_rss_marker = RSSETSMarker(parcellation=PARCELLATION) # Create storage storage = SQLiteFeatureStorage( - uri=str((tmp_path / "test.db").absolute())) + uri=str((tmp_path / "test.sqlite").absolute())) # Store ets_rss_marker.fit_transform(input=input_dict, storage=storage) diff --git a/junifer/markers/tests/test_functional_connectivity_parcels.py b/junifer/markers/tests/test_functional_connectivity_parcels.py index a857467b6..667c8e246 100644 --- a/junifer/markers/tests/test_functional_connectivity_parcels.py +++ b/junifer/markers/tests/test_functional_connectivity_parcels.py @@ -78,7 +78,7 @@ def test_FunctionalConnectivityParcels(tmp_path: Path) -> None: all_out = fc.fit_transform({"BOLD": {"data": fmri_img}}) - uri = tmp_path / "test_fc_parcellation.db" + uri = tmp_path / "test_fc_parcellation.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") meta = { diff --git a/junifer/markers/tests/test_functional_connectivity_spheres.py b/junifer/markers/tests/test_functional_connectivity_spheres.py index 964fb9797..c4eaf86fe 100644 --- a/junifer/markers/tests/test_functional_connectivity_spheres.py +++ b/junifer/markers/tests/test_functional_connectivity_spheres.py @@ -62,7 +62,7 @@ def test_FunctionalConnectivitySpheres(tmp_path: Path) -> None: # check correct output assert fc.get_output_kind(["BOLD"]) == ["matrix"] - uri = tmp_path / "test_fc_parcel.db" + uri = tmp_path / "test_fc_parcel.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") meta = { diff --git a/junifer/markers/tests/test_sphere_aggregation.py b/junifer/markers/tests/test_sphere_aggregation.py index 08ea00066..e6e22f2ca 100644 --- a/junifer/markers/tests/test_sphere_aggregation.py +++ b/junifer/markers/tests/test_sphere_aggregation.py @@ -116,7 +116,7 @@ def test_SphereAggregation_storage(tmp_path: Path) -> None: oasis_dataset = datasets.fetch_oasis_vbm(n_subjects=1) vbm = oasis_dataset.gray_matter_maps[0] img = nib.load(vbm) - uri = tmp_path / "test_sphere_storage_3D.db" + uri = tmp_path / "test_sphere_storage_3D.sqlite" storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") meta = { diff --git a/junifer/storage/tests/test_sqlite.py b/junifer/storage/tests/test_sqlite.py index 3f428f5f4..8124aa987 100644 --- a/junifer/storage/tests/test_sqlite.py +++ b/junifer/storage/tests/test_sqlite.py @@ -92,7 +92,7 @@ def test_get_engine_single_output(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_single_output.db" + uri = tmp_path / "test_single_output.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") assert storage.single_output is True @@ -110,7 +110,7 @@ def test_get_engine_multi_output(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_multi_output.db" + uri = tmp_path / "test_multi_output.sqlite" storage = SQLiteFeatureStorage( uri=uri, single_output=False, upsert="ignore" ) @@ -130,7 +130,7 @@ def test_get_engine_single_output_creation(tmp_path: Path) -> None: tocreate = tmp_path / "tocreate" # Path does not exist yet assert not tocreate.exists() - uri = tocreate.absolute() / "test_single_output.db" + uri = tocreate.absolute() / "test_single_output.sqlite" _ = SQLiteFeatureStorage(uri=uri, upsert="ignore") # Path exists now assert tocreate.exists() @@ -145,7 +145,7 @@ def test_upsert_replace(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_upsert_replace.db" + uri = tmp_path / "test_upsert_replace.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") # Metadata to store @@ -179,7 +179,7 @@ def test_upsert_ignore(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_upsert_ignore.db" + uri = tmp_path / "test_upsert_ignore.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") # Metadata to store @@ -217,7 +217,7 @@ def test_upsert_update(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_upsert_delete.db" + uri = tmp_path / "test_upsert_delete.sqlite" storage = SQLiteFeatureStorage(uri=uri) # Metadata to store meta = {"element": "test", "version": "0.0.1"} @@ -250,7 +250,7 @@ def test_upsert_invalid_option(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_upsert_invalid.db" + uri = tmp_path / "test_upsert_invalid.sqlite" with pytest.raises(ValueError): SQLiteFeatureStorage(uri=uri, upsert="wrong") @@ -265,7 +265,7 @@ def test_store_df_and_read_df(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_store_df_and_read_df.db" + uri = tmp_path / "test_store_df_and_read_df.sqlite" storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") # Metadata to store meta = { @@ -326,7 +326,7 @@ def test_store_metadata(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_metadata_store.db" + uri = tmp_path / "test_metadata_store.sqlite" # Single storage, must be the uri storage = SQLiteFeatureStorage(uri=uri, upsert="ignore") # Metadata to store @@ -345,7 +345,7 @@ def test_store_table(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_store_table.db" + uri = tmp_path / "test_store_table.sqlite" storage = SQLiteFeatureStorage(uri=uri) # Metadata to store meta = {"element": "test", "version": "0.0.1", "marker": {"name": "fc"}} @@ -404,7 +404,7 @@ def test_store_matrix(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_store_table.db" + uri = tmp_path / "test_store_table.sqlite" storage = SQLiteFeatureStorage(uri=uri) # Metadata to store meta = {"element": "test", "version": "0.0.1", "marker": {"name": "fc"}} @@ -435,7 +435,7 @@ def test_store_matrix(tmp_path: Path) -> None: assert_array_equal(read_df.values[0], data.flatten()) assert list(read_df.columns) == stored_names # Store without row and column names - uri = tmp_path / "test_store_table_nonames.db" + uri = tmp_path / "test_store_table_nonames.sqlite" storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix(data=data, meta=meta) stored_names = [ @@ -467,7 +467,7 @@ def test_store_matrix(tmp_path: Path) -> None: data = np.array([[1, 2, 3], [11, 22, 33], [111, 222, 333]]) row_names = ["row1", "row2", "row3"] col_names = ["col1", "col2", "col3"] - uri = tmp_path / "test_store_table_triu.db" + uri = tmp_path / "test_store_table_triu.sqlite" storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix( data=data, @@ -496,7 +496,7 @@ def test_store_matrix(tmp_path: Path) -> None: ) # Store upper triangular matrix without diagonal - uri = tmp_path / "test_store_table_triu_nodiagonal.db" + uri = tmp_path / "test_store_table_triu_nodiagonal.sqlite" storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix( data=data, @@ -526,7 +526,7 @@ def test_store_matrix(tmp_path: Path) -> None: data = np.array([[1, 2, 3], [11, 22, 33], [111, 222, 333]]) row_names = ["row1", "row2", "row3"] col_names = ["col1", "col2", "col3"] - uri = tmp_path / "test_store_table_tril.db" + uri = tmp_path / "test_store_table_tril.sqlite" storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix( data=data, @@ -555,7 +555,7 @@ def test_store_matrix(tmp_path: Path) -> None: ) # Store lower triangular matrix without diagonal - uri = tmp_path / "test_store_table_tril_nodiagonal.db" + uri = tmp_path / "test_store_table_tril_nodiagonal.sqlite" storage = SQLiteFeatureStorage(uri=uri) storage.store_matrix( data, @@ -592,7 +592,7 @@ def test_store_multiple_output(tmp_path: Path): The path to the test directory. """ - uri = tmp_path / "test_store_multiple_output.db" + uri = tmp_path / "test_store_multiple_output.sqlite" storage = SQLiteFeatureStorage(uri=uri, single_output=False) # Metadata to store meta1 = { @@ -691,7 +691,7 @@ def test_collect(tmp_path: Path) -> None: The path to the test directory. """ - uri = tmp_path / "test_collect.db" + uri = tmp_path / "test_collect.sqlite" storage = SQLiteFeatureStorage(uri=uri, single_output=False) # Metadata for storage meta1 = { -- 2.52.0 From cb122565460ab5d37e86b8d6c1e97d29b0fc05a6 Mon Sep 17 00:00:00 2001 From: Fede Date: Wed, 23 Nov 2022 15:56:33 +0100 Subject: [PATCH 19/22] consistency with datagrabber --- docs/api/datagrabbers.rst | 4 ++-- docs/builtin.rst | 4 ++-- docs/extending/datagrabber.rst | 18 +++++++++--------- docs/understanding/data.rst | 2 +- docs/understanding/datagrabber.rst | 4 ++-- docs/understanding/preprocess.rst | 2 +- .../juseless/datagrabbers/aomic_id1000_vbm.py | 4 ++-- .../juseless/datagrabbers/camcan_vbm.py | 4 ++-- .../configs/juseless/datagrabbers/ixi_vbm.py | 4 ++-- .../configs/juseless/datagrabbers/ukb_vbm.py | 4 ++-- junifer/datagrabber/datalad_base.py | 2 +- junifer/datagrabber/multiple.py | 4 ++-- junifer/datagrabber/pattern.py | 4 ++-- junifer/markers/collection.py | 2 +- junifer/testing/datagrabbers.py | 6 +++--- 15 files changed, 34 insertions(+), 34 deletions(-) diff --git a/docs/api/datagrabbers.rst b/docs/api/datagrabbers.rst index 8556ab5f0..ad7496ce6 100644 --- a/docs/api/datagrabbers.rst +++ b/docs/api/datagrabbers.rst @@ -1,5 +1,5 @@ -DataGrabbers -============ +Data Grabbers +============= .. automodule:: junifer.datagrabber :members: diff --git a/docs/builtin.rst b/docs/builtin.rst index b0a79244f..2a336e916 100644 --- a/docs/builtin.rst +++ b/docs/builtin.rst @@ -4,8 +4,8 @@ Built-in Pipeline steps and data ================================ -DataGrabbers ------------- +Data Grabbers +------------- .. Provide a list of the DataGrabbers that are implemented or planned. diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst index 1d8a88cab..fc1bc15d6 100644 --- a/docs/extending/datagrabber.rst +++ b/docs/extending/datagrabber.rst @@ -2,10 +2,10 @@ .. _extending_datagrabbers: -Creating DataGrabbers -===================== +Creating Data Grabbers +====================== -DataGrabbers are the first step of the pipeline. Its purpose is to interpret +Data Grabbers are the first step of the pipeline. Its purpose is to interpret the structure of a dataset and provide two specific functionalities: 1) Given an *element*, provide the path to each kind of data available for this @@ -14,7 +14,7 @@ the structure of a dataset and provide two specific functionalities: In this section, we will see how to create a datagrabber for a dataset. Basic aspects of datagrabbers are covered in the -:ref:`Understanding DataGrabbers ` section. +:ref:`Understanding Data Grabbers ` section. .. _extending_datagrabbers_think: @@ -22,7 +22,7 @@ Step 1: Think about the element ------------------------------- Like with any programming-related task, the first step is to think. When -creating a DataGrabber, we need to first define what an *element* is. +creating a Data Grabber, we need to first define what an *element* is. The *element* should be the smallest unit of data that can be processed. That is, for each element, there should be a set of data that can be processed, but only one of each *data type* (see :ref:`data_types`). @@ -58,7 +58,7 @@ Step 2: Think about the dataset's structure Now that we have our element defined, we need to think about the structure of the dataset. Mainly, because the structure of the dataset will determine how -the DataGrabber needs to be implemented. +the Data Grabber needs to be implemented. Junifer provides an abstract class to deal with datasets that can be thought in terms of *patterns*. A *pattern* is a string that contains placeholders that are @@ -88,13 +88,13 @@ discussion in the `junifer Discussions`_ page. Most probably we can help you get your dataset in order. If there is no other way, then you can follow :ref:`extending_datagrabbers_base` -to create a DataGrabber from scratch. +to create a Data Grabber from scratch. .. _extending_datagrabbers_pattern: -Step 3: Create a DataGrabber ----------------------------- +Step 3: Create a Data Grabber +----------------------------- Option A: Extending from PatternDataGrabber ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/understanding/data.rst b/docs/understanding/data.rst index 72fb251d9..ff3718e78 100644 --- a/docs/understanding/data.rst +++ b/docs/understanding/data.rst @@ -18,7 +18,7 @@ The second level of keys are the actual data. So far, there are two keys used: - ``path``: path to the file containing the data. - ``data``: the data loaded in memory. -The :ref:`DataGrabber ` step will only fill the ``path`` value. +The :ref:`Data Grabber ` step will only fill the ``path`` value. The ``data`` value will be filled by the :ref:`DataReader ` step, if it is one of the possible file types that the datareader can read. diff --git a/docs/understanding/datagrabber.rst b/docs/understanding/datagrabber.rst index 5b10d7506..394e64144 100644 --- a/docs/understanding/datagrabber.rst +++ b/docs/understanding/datagrabber.rst @@ -2,13 +2,13 @@ .. _datagrabber: -DataGrabber +Data Grabber =========== Description ----------- -The ``DataGrabber`` is an object that can provide an interface to datasets you want to work with in junifer. +The *Data Grabber* is an object that can provide an interface to datasets you want to work with in junifer. Every concrete implementation of a datagrabber is aware of a particular dataset's structure and thus allows you to fetch specific elements of interest from the dataset. It adds the ``path`` key to each :ref:`data type ` in the :ref:`Data object `. diff --git a/docs/understanding/preprocess.rst b/docs/understanding/preprocess.rst index e3241ee9c..99f397dc0 100644 --- a/docs/understanding/preprocess.rst +++ b/docs/understanding/preprocess.rst @@ -20,7 +20,7 @@ Confound Removal ---------------- The *Confound Removal* step is meant to remove *confounds* from the ``BOLD`` data. The confounds are -extracted from the ``BOLD_confounds`` data (must be provided by the :ref:`DataGrabber `). +extracted from the ``BOLD_confounds`` data (must be provided by the :ref:`Data Grabber `). The confounds are then regressed out from the ``BOLD`` data using :func:`nilearn.image.clean_img`. Currently, junifer supports only one confound removal class: diff --git a/junifer/configs/juseless/datagrabbers/aomic_id1000_vbm.py b/junifer/configs/juseless/datagrabbers/aomic_id1000_vbm.py index 0de6fe1f3..80d6c4aef 100644 --- a/junifer/configs/juseless/datagrabbers/aomic_id1000_vbm.py +++ b/junifer/configs/juseless/datagrabbers/aomic_id1000_vbm.py @@ -13,9 +13,9 @@ from ....datagrabber import PatternDataladDataGrabber @register_datagrabber class JuselessDataladAOMICID1000VBM(PatternDataladDataGrabber): - """Juseless AOMICID1000 VBM DataGrabber class. + """Juseless AOMICID1000 VBM Data Grabber class. - Implements a DataGrabber to access the AOMICID1000 VBM data in Juseless. + Implements a Data Grabber to access the AOMICID1000 VBM data in Juseless. Parameters ---------- diff --git a/junifer/configs/juseless/datagrabbers/camcan_vbm.py b/junifer/configs/juseless/datagrabbers/camcan_vbm.py index 602f90b64..14327c85a 100644 --- a/junifer/configs/juseless/datagrabbers/camcan_vbm.py +++ b/junifer/configs/juseless/datagrabbers/camcan_vbm.py @@ -14,9 +14,9 @@ from ....datagrabber import PatternDataladDataGrabber @register_datagrabber class JuselessDataladCamCANVBM(PatternDataladDataGrabber): - """Juseless CamCAN VBM DataGrabber class. + """Juseless CamCAN VBM Data Grabber class. - Implements a DataGrabber to access the CamCAN VBM data in Juseless. + Implements a Data Grabber to access the CamCAN VBM data in Juseless. Parameters ---------- diff --git a/junifer/configs/juseless/datagrabbers/ixi_vbm.py b/junifer/configs/juseless/datagrabbers/ixi_vbm.py index 3b6a69e6a..0cadd6aa5 100644 --- a/junifer/configs/juseless/datagrabbers/ixi_vbm.py +++ b/junifer/configs/juseless/datagrabbers/ixi_vbm.py @@ -15,9 +15,9 @@ from ....utils import raise_error @register_datagrabber class JuselessDataladIXIVBM(PatternDataladDataGrabber): - """Juseless IXI VBM DataGrabber class. + """Juseless IXI VBM Data Grabber class. - Implements a DataGrabber to access the IXI VBM data in Juseless. + Implements a Data Grabber to access the IXI VBM data in Juseless. Parameters ---------- diff --git a/junifer/configs/juseless/datagrabbers/ukb_vbm.py b/junifer/configs/juseless/datagrabbers/ukb_vbm.py index 24a1fbd61..72d2f8b62 100644 --- a/junifer/configs/juseless/datagrabbers/ukb_vbm.py +++ b/junifer/configs/juseless/datagrabbers/ukb_vbm.py @@ -14,9 +14,9 @@ from ....datagrabber import PatternDataladDataGrabber @register_datagrabber class JuselessDataladUKBVBM(PatternDataladDataGrabber): - """Juseless UKB VBM DataGrabber class. + """Juseless UKB VBM Data Grabber class. - Implements a DataGrabber to access the UKB VBM data in Juseless. + Implements a Data Grabber to access the UKB VBM data in Juseless. Parameters ---------- diff --git a/junifer/datagrabber/datalad_base.py b/junifer/datagrabber/datalad_base.py index 7693bc6d7..3237f33a3 100644 --- a/junifer/datagrabber/datalad_base.py +++ b/junifer/datagrabber/datalad_base.py @@ -21,7 +21,7 @@ from .base import BaseDataGrabber class DataladDataGrabber(BaseDataGrabber): """Abstract base class for data fetching via Datalad. - Defines a DataGrabber that gets data from a datalad sibling. + Defines a Data Grabber that gets data from a datalad sibling. Parameters ---------- diff --git a/junifer/datagrabber/multiple.py b/junifer/datagrabber/multiple.py index 3862f1596..512765c72 100644 --- a/junifer/datagrabber/multiple.py +++ b/junifer/datagrabber/multiple.py @@ -12,9 +12,9 @@ from .base import BaseDataGrabber class MultipleDataGrabber(BaseDataGrabber): - """Datagrabber class for data fetching from multiple sources. + """Data Grabber class for data fetching from multiple sources. - Defines a DataGrabber which can be used to fetch data from multiple + Defines a Data Grabber which can be used to fetch data from multiple datagrabbers. Parameters diff --git a/junifer/datagrabber/pattern.py b/junifer/datagrabber/pattern.py index f6eed37ed..48a452d29 100644 --- a/junifer/datagrabber/pattern.py +++ b/junifer/datagrabber/pattern.py @@ -19,9 +19,9 @@ from .utils import validate_patterns, validate_replacements @register_datagrabber class PatternDataGrabber(BaseDataGrabber): - """Concrete implementation for data fetching using patterns. + """Concrete implementation for data grabbing using patterns. - Implements a DataGrabber that understands patterns to grab data. + Implements a Data Grabber that understands patterns to grab data. Parameters ---------- diff --git a/junifer/markers/collection.py b/junifer/markers/collection.py index ab2858bdf..a2d6ad5ee 100644 --- a/junifer/markers/collection.py +++ b/junifer/markers/collection.py @@ -62,7 +62,7 @@ class MarkerCollection: ---------- input : dict The input data to fit the pipeline on. Should be the output of - indexing the DataGrabber with one element. + indexing the DataG rabber with one element. Returns ------- diff --git a/junifer/testing/datagrabbers.py b/junifer/testing/datagrabbers.py index 7253ab7a7..bdf4730d3 100644 --- a/junifer/testing/datagrabbers.py +++ b/junifer/testing/datagrabbers.py @@ -15,7 +15,7 @@ from ..datagrabber.base import BaseDataGrabber class OasisVBMTestingDatagrabber(BaseDataGrabber): - """DataGrabber for Oasis VBM testing data.""" + """Data Grabber for Oasis VBM testing data.""" def __init__(self) -> None: # Create temporary directory @@ -79,7 +79,7 @@ class OasisVBMTestingDatagrabber(BaseDataGrabber): class SPMAuditoryTestingDatagrabber(BaseDataGrabber): - """DataGrabber for SPM Auditory dataset. + """Data Grabber for SPM Auditory dataset. Wrapper for :func:`nilearn.datasets.fetch_spm_auditory`. @@ -143,7 +143,7 @@ class SPMAuditoryTestingDatagrabber(BaseDataGrabber): class PartlyCloudyTestingDataGrabber(BaseDataGrabber): - """DataGrabber for Partly Cloudy dataset. + """Data Grabber for Partly Cloudy dataset. Wrapper for :func:`nilearn.datasets.fetch_development_fmri` -- 2.52.0 From 3cd047e8ce1ecc703cb99f64cdafb34d3bcef1dc Mon Sep 17 00:00:00 2001 From: Fede Date: Wed, 23 Nov 2022 15:57:46 +0100 Subject: [PATCH 20/22] consistency with datareader --- docs/understanding/datareader.rst | 8 ++++---- docs/understanding/marker.rst | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/understanding/datareader.rst b/docs/understanding/datareader.rst index 4fffd0d7b..f4717ed4b 100644 --- a/docs/understanding/datareader.rst +++ b/docs/understanding/datareader.rst @@ -2,13 +2,13 @@ .. _datareader: -DataReader -========== +Data Reader +=========== Description ----------- -The ``DataReader`` is an object that is responsible for actually reading data files in junifer. +The *Data Reader* is an object that is responsible for actually reading data files in junifer. It reads the value of the key ``path`` for each :ref:`data type ` in the :ref:`Data object ` and loads them to memory. After reading the data into memory, it adds the key ``data`` to the same level as ``path`` and the value is the actual data in the memory. @@ -16,7 +16,7 @@ and the value is the actual data in the memory. Datareaders are meant to be used inside the datagrabber context but you can operate on them outside the context as long as the actual data is in the memory and the Python runtime has not garbage-collected it. -For data formats not supported by junifer yet, you can either make your own ``DataReader`` or open an issue on +For data formats not supported by junifer yet, you can either make your own *Data Reader* or open an issue on `junifer Github`_ and we can help you out. Currently supported file-formats diff --git a/docs/understanding/marker.rst b/docs/understanding/marker.rst index 749275f20..e744ccfe3 100644 --- a/docs/understanding/marker.rst +++ b/docs/understanding/marker.rst @@ -9,7 +9,7 @@ Description ----------- The ``Marker`` is an object that is responsible for feature extraction. It primarily operates on data loaded -in memory by :ref:`datareader ` and stored in the ``data`` key of each :ref:`data type ` +in memory by :ref:`Data Reader ` and stored in the ``data`` key of each :ref:`data type ` in the :ref:`Data object `. In some cases, it can also operate on pre-processed data as obtained from the :ref:`Preprocess ` step of the pipeline. It is important to note that this pre-process is not similar to pre-processing done by tools like FSL, SPM, AFNI, etc. . For example, one can perform confound -- 2.52.0 From c4297cac5b09986a5dd673e802dce115dd37d38d Mon Sep 17 00:00:00 2001 From: Fede Raimondo Date: Wed, 23 Nov 2022 16:35:45 +0100 Subject: [PATCH 21/22] Fix typo --- docs/understanding/datagrabber.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/understanding/datagrabber.rst b/docs/understanding/datagrabber.rst index 394e64144..96cd338f6 100644 --- a/docs/understanding/datagrabber.rst +++ b/docs/understanding/datagrabber.rst @@ -3,7 +3,7 @@ .. _datagrabber: Data Grabber -=========== +============ Description ----------- -- 2.52.0 From 3c898d0a671abd1fa8f497ef65873b99382ea863 Mon Sep 17 00:00:00 2001 From: Fede Date: Wed, 23 Nov 2022 17:18:21 +0100 Subject: [PATCH 22/22] last comments --- docs/extending/datagrabber.rst | 2 +- junifer/markers/collection.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/extending/datagrabber.rst b/docs/extending/datagrabber.rst index fc1bc15d6..6231d4e07 100644 --- a/docs/extending/datagrabber.rst +++ b/docs/extending/datagrabber.rst @@ -198,7 +198,7 @@ Optional: Using datalad If you are using `datalad`_, you can use the :py:class:`~junifer.datagrabber.PatternDataladDataGrabber` instead of the :py:class:`~junifer.datagrabber.PatternDataGrabber`. This class will not only -interpret patterns, but will also use datalad to `clone` and `get` the data. +interpret patterns, but also use `datalad`_ to `clone` and `get` the data. The main difference between the two is that the ``datadir`` is not the actual location of the dataset, but the location where the dataset will be cloned. It diff --git a/junifer/markers/collection.py b/junifer/markers/collection.py index a2d6ad5ee..2b94642e9 100644 --- a/junifer/markers/collection.py +++ b/junifer/markers/collection.py @@ -62,7 +62,7 @@ class MarkerCollection: ---------- input : dict The input data to fit the pipeline on. Should be the output of - indexing the DataG rabber with one element. + indexing the Data Grabber with one element. Returns ------- -- 2.52.0