diff --git a/fmriprep/workflows/bold/base.py b/fmriprep/workflows/bold/base.py index 98ec2c6d5..950c0b029 100644 --- a/fmriprep/workflows/bold/base.py +++ b/fmriprep/workflows/bold/base.py @@ -192,21 +192,6 @@ def init_bold_wf( mem_gb["largemem"], ) - functional_cache = {} - if config.execution.derivatives: - from fmriprep.utils.bids import collect_derivatives, extract_entities - - entities = extract_entities(bold_series) - - for deriv_dir in config.execution.derivatives: - functional_cache.update( - collect_derivatives( - derivatives_dir=deriv_dir, - entities=entities, - fieldmap_id=fieldmap_id, - ) - ) - workflow = Workflow(name=_get_wf_name(bold_file, "bold")) workflow.__postdesc__ = """\ All resamplings can be performed with *a single interpolation @@ -266,7 +251,7 @@ def init_bold_wf( bold_fit_wf = init_bold_fit_wf( bold_series=bold_series, - precomputed=functional_cache, + precomputed=precomputed, fieldmap_id=fieldmap_id, omp_nthreads=omp_nthreads, ) diff --git a/fmriprep/workflows/bold/fit.py b/fmriprep/workflows/bold/fit.py index a32fa712c..0a53e2a23 100644 --- a/fmriprep/workflows/bold/fit.py +++ b/fmriprep/workflows/bold/fit.py @@ -287,7 +287,9 @@ def init_bold_fit_wf( name="hmcref_buffer", ) fmapref_buffer = pe.Node(niu.Function(function=_select_ref), name="fmapref_buffer") - hmc_buffer = pe.Node(niu.IdentityInterface(fields=["hmc_xforms"]), name="hmc_buffer") + hmc_buffer = pe.Node( + niu.IdentityInterface(fields=["hmc_xforms", "movpar_file", "rmsd_file"]), name="hmc_buffer" + ) fmapreg_buffer = pe.Node( niu.IdentityInterface(fields=["boldref2fmap_xfm"]), name="fmapreg_buffer" ) diff --git a/fmriprep/workflows/bold/registration.py b/fmriprep/workflows/bold/registration.py index a2730f339..bde2423f2 100644 --- a/fmriprep/workflows/bold/registration.py +++ b/fmriprep/workflows/bold/registration.py @@ -582,8 +582,8 @@ def init_fsl_bbr_wf( ) FSLDIR = os.getenv('FSLDIR') - if FSLDIR: - flt_bbr.inputs.schedule = op.join(FSLDIR, 'etc/flirtsch/bbr.sch') + if FSLDIR and os.path.exists(schedule := op.join(FSLDIR, 'etc/flirtsch/bbr.sch')): + flt_bbr.inputs.schedule = schedule else: # Should mostly be hit while building docs LOGGER.warning("FSLDIR unset - using packaged BBR schedule") diff --git a/fmriprep/workflows/bold/tests/test_base.py b/fmriprep/workflows/bold/tests/test_base.py new file mode 100644 index 000000000..93035c640 --- /dev/null +++ b/fmriprep/workflows/bold/tests/test_base.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import nibabel as nb +import numpy as np +import pytest +from nipype.pipeline.engine.utils import generate_expanded_graph +from niworkflows.utils.testing import generate_bids_skeleton + +from .... import config +from ...tests import mock_config +from ...tests.test_base import BASE_LAYOUT +from ..base import init_bold_wf + + +@pytest.fixture(scope="module", autouse=True) +def _quiet_logger(): + import logging + + logger = logging.getLogger("nipype.workflow") + old_level = logger.getEffectiveLevel() + logger.setLevel(logging.ERROR) + yield + logger.setLevel(old_level) + + +@pytest.fixture(scope="module") +def bids_root(tmp_path_factory): + base = tmp_path_factory.mktemp("boldbase") + bids_dir = base / "bids" + generate_bids_skeleton(bids_dir, BASE_LAYOUT) + yield bids_dir + + +@pytest.mark.parametrize("task", ["rest", "nback"]) +@pytest.mark.parametrize("fieldmap_id", ["phasediff", None]) +@pytest.mark.parametrize("freesurfer", [False, True]) +@pytest.mark.parametrize("level", ["minimal", "resampling", "full"]) +def test_bold_wf( + bids_root: Path, + tmp_path: Path, + task: str, + fieldmap_id: str | None, + freesurfer: bool, + level: str, +): + """Test as many combinations of precomputed files and input + configurations as possible.""" + output_dir = tmp_path / 'output' + output_dir.mkdir() + + img = nb.Nifti1Image(np.zeros((10, 10, 10, 10)), np.eye(4)) + + if task == 'rest': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / 'sub-01_task-rest_run-1_bold.nii.gz'), + ] + elif task == 'nback': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / f'sub-01_task-nback_echo-{i}_bold.nii.gz') + for i in range(1, 4) + ] + + # The workflow will attempt to read file headers + for path in bold_series: + img.to_filename(path) + + with mock_config(bids_dir=bids_root): + config.workflow.level = level + config.workflow.run_reconall = freesurfer + wf = init_bold_wf( + bold_series=bold_series, + fieldmap_id=fieldmap_id, + precomputed={}, + ) + + flatgraph = wf._create_flat_graph() + generate_expanded_graph(flatgraph) diff --git a/fmriprep/workflows/bold/tests/test_fit.py b/fmriprep/workflows/bold/tests/test_fit.py new file mode 100644 index 000000000..382c48e7a --- /dev/null +++ b/fmriprep/workflows/bold/tests/test_fit.py @@ -0,0 +1,172 @@ +from pathlib import Path + +import nibabel as nb +import numpy as np +import pytest +from nipype.pipeline.engine.utils import generate_expanded_graph +from niworkflows.utils.testing import generate_bids_skeleton + +from .... import config +from ...tests import mock_config +from ...tests.test_base import BASE_LAYOUT +from ..fit import init_bold_fit_wf, init_bold_native_wf + + +@pytest.fixture(scope="module", autouse=True) +def _quiet_logger(): + import logging + + logger = logging.getLogger("nipype.workflow") + old_level = logger.getEffectiveLevel() + logger.setLevel(logging.ERROR) + yield + logger.setLevel(old_level) + + +@pytest.fixture(scope="module") +def bids_root(tmp_path_factory): + base = tmp_path_factory.mktemp("boldfit") + bids_dir = base / "bids" + generate_bids_skeleton(bids_dir, BASE_LAYOUT) + yield bids_dir + + +def _make_params( + have_hmcref: bool = True, + have_coregref: bool = True, + have_hmc_xfms: bool = True, + have_boldref2fmap_xfm: bool = True, + have_boldref2anat_xfm: bool = True, +): + return ( + have_hmcref, + have_coregref, + have_hmc_xfms, + have_boldref2anat_xfm, + have_boldref2fmap_xfm, + ) + + +@pytest.mark.parametrize("task", ["rest", "nback"]) +@pytest.mark.parametrize("fieldmap_id", ["phasediff", None]) +@pytest.mark.parametrize( + ( + 'have_hmcref', + 'have_coregref', + 'have_hmc_xfms', + 'have_boldref2fmap_xfm', + 'have_boldref2anat_xfm', + ), + [ + (True, True, True, True, True), + (False, False, False, False, False), + _make_params(have_hmcref=False), + _make_params(have_hmc_xfms=False), + _make_params(have_coregref=False), + _make_params(have_coregref=False, have_boldref2fmap_xfm=False), + _make_params(have_boldref2anat_xfm=False), + ], +) +def test_bold_fit_precomputes( + bids_root: Path, + tmp_path: Path, + task: str, + fieldmap_id: str | None, + have_hmcref: bool, + have_coregref: bool, + have_hmc_xfms: bool, + have_boldref2fmap_xfm: bool, + have_boldref2anat_xfm: bool, +): + """Test as many combinations of precomputed files and input + configurations as possible.""" + output_dir = tmp_path / 'output' + output_dir.mkdir() + + img = nb.Nifti1Image(np.zeros((10, 10, 10, 10)), np.eye(4)) + + if task == 'rest': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / 'sub-01_task-rest_run-1_bold.nii.gz'), + ] + elif task == 'nback': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / f'sub-01_task-nback_echo-{i}_bold.nii.gz') + for i in range(1, 4) + ] + + # The workflow will attempt to read file headers + for path in bold_series: + img.to_filename(path) + + dummy_nifti = str(tmp_path / 'dummy.nii') + dummy_affine = str(tmp_path / 'dummy.txt') + img.to_filename(dummy_nifti) + np.savetxt(dummy_affine, np.eye(4)) + + # Construct precomputed files + precomputed = {'transforms': {}} + if have_hmcref: + precomputed['hmc_boldref'] = dummy_nifti + if have_coregref: + precomputed['coreg_boldref'] = dummy_nifti + if have_hmc_xfms: + precomputed['transforms']['hmc'] = dummy_affine + if have_boldref2anat_xfm: + precomputed['transforms']['boldref2anat'] = dummy_affine + if have_boldref2fmap_xfm: + precomputed['transforms']['boldref2fmap'] = dummy_affine + + with mock_config(bids_dir=bids_root): + wf = init_bold_fit_wf( + bold_series=bold_series, + precomputed=precomputed, + fieldmap_id=fieldmap_id, + omp_nthreads=1, + ) + + flatgraph = wf._create_flat_graph() + generate_expanded_graph(flatgraph) + + +@pytest.mark.parametrize("task", ["rest", "nback"]) +@pytest.mark.parametrize("fieldmap_id", ["phasediff", None]) +@pytest.mark.parametrize("run_stc", [True, False]) +def test_bold_native_precomputes( + bids_root: Path, + tmp_path: Path, + task: str, + fieldmap_id: str | None, + run_stc: bool, +): + """Test as many combinations of precomputed files and input + configurations as possible.""" + output_dir = tmp_path / 'output' + output_dir.mkdir() + + img = nb.Nifti1Image(np.zeros((10, 10, 10, 10)), np.eye(4)) + + if task == 'rest': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / 'sub-01_task-rest_run-1_bold.nii.gz'), + ] + elif task == 'nback': + bold_series = [ + str(bids_root / 'sub-01' / 'func' / f'sub-01_task-nback_echo-{i}_bold.nii.gz') + for i in range(1, 4) + ] + + # The workflow will attempt to read file headers + for path in bold_series: + img.to_filename(path) + + with mock_config(bids_dir=bids_root): + config.workflow.ignore = ['slicetiming'] if not run_stc else [] + wf = init_bold_native_wf( + bold_series=bold_series, + fieldmap_id=fieldmap_id, + omp_nthreads=1, + ) + + flatgraph = wf._create_flat_graph() + generate_expanded_graph(flatgraph) diff --git a/fmriprep/workflows/tests/__init__.py b/fmriprep/workflows/tests/__init__.py index 3b8bfc49c..c246ccd13 100644 --- a/fmriprep/workflows/tests/__init__.py +++ b/fmriprep/workflows/tests/__init__.py @@ -33,7 +33,7 @@ @contextmanager -def mock_config(): +def mock_config(bids_dir=None): """Create a mock config for documentation and testing purposes.""" from ... import config @@ -51,9 +51,13 @@ def mock_config(): config.loggers.init() config.init_spaces() + bids_dir = bids_dir or data.load('tests/ds000005').absolute() + config.execution.work_dir = Path(mkdtemp()) - config.execution.bids_dir = data.load('tests/ds000005').absolute() + config.execution.bids_dir = bids_dir config.execution.fmriprep_dir = Path(mkdtemp()) + config.execution.bids_database_dir = None + config.execution._layout = None config.execution.init() yield diff --git a/fmriprep/workflows/tests/test_base.py b/fmriprep/workflows/tests/test_base.py index 70dcd5590..66544cb73 100644 --- a/fmriprep/workflows/tests/test_base.py +++ b/fmriprep/workflows/tests/test_base.py @@ -9,15 +9,42 @@ BASE_LAYOUT = { "01": { - "anat": [{"suffix": "T1w"}], + "anat": [ + {"run": 1, "suffix": "T1w"}, + {"run": 2, "suffix": "T1w"}, + {"suffix": "T2w"}, + ], "func": [ - { - "task": "rest", - "run": i, - "suffix": "bold", - "metadata": {"PhaseEncodingDirection": "j", "TotalReadoutTime": 0.6}, - } - for i in range(1, 3) + *( + { + "task": "rest", + "run": i, + "suffix": "bold", + "metadata": { + "RepetitionTime": 2.0, + "PhaseEncodingDirection": "j", + "TotalReadoutTime": 0.6, + "EchoTime": 0.03, + "SliceTiming": [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8], + }, + } + for i in range(1, 3) + ), + *( + { + "task": "nback", + "echo": i, + "suffix": "bold", + "metadata": { + "RepetitionTime": 2.0, + "PhaseEncodingDirection": "j", + "TotalReadoutTime": 0.6, + "EchoTime": 0.015 * i, + "SliceTiming": [0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8], + }, + } + for i in range(1, 4) + ), ], "fmap": [ {"suffix": "phasediff", "metadata": {"EchoTime1": 0.005, "EchoTime2": 0.007}}, @@ -43,7 +70,9 @@ def test_get_estimator_none(tmp_path): # No IntendedFors/B0Fields generate_bids_skeleton(bids_dir, BASE_LAYOUT) layout = bids.BIDSLayout(bids_dir) - bold_files = sorted(layout.get(suffix='bold', extension='.nii.gz', return_type='file')) + bold_files = sorted( + layout.get(suffix='bold', task='rest', extension='.nii.gz', return_type='file') + ) assert get_estimator(layout, bold_files[0]) == () assert get_estimator(layout, bold_files[1]) == () @@ -65,7 +94,9 @@ def test_get_estimator_b0field_and_intendedfor(tmp_path): layout = bids.BIDSLayout(bids_dir) _ = find_estimators(layout=layout, subject='01') - bold_files = sorted(layout.get(suffix='bold', extension='.nii.gz', return_type='file')) + bold_files = sorted( + layout.get(suffix='bold', task='rest', extension='.nii.gz', return_type='file') + ) assert get_estimator(layout, bold_files[0]) == ('epi',) assert get_estimator(layout, bold_files[1]) == ('auto_00000',) @@ -92,7 +123,9 @@ def test_get_estimator_overlapping_specs(tmp_path): layout = bids.BIDSLayout(bids_dir) _ = find_estimators(layout=layout, subject='01') - bold_files = sorted(layout.get(suffix='bold', extension='.nii.gz', return_type='file')) + bold_files = sorted( + layout.get(suffix='bold', task='rest', extension='.nii.gz', return_type='file') + ) # B0Fields take precedence assert get_estimator(layout, bold_files[0]) == ('epi',) @@ -116,7 +149,9 @@ def test_get_estimator_multiple_b0fields(tmp_path): layout = bids.BIDSLayout(bids_dir) _ = find_estimators(layout=layout, subject='01') - bold_files = sorted(layout.get(suffix='bold', extension='.nii.gz', return_type='file')) + bold_files = sorted( + layout.get(suffix='bold', task='rest', extension='.nii.gz', return_type='file') + ) # Always get an iterable; don't care if it's a list or tuple assert get_estimator(layout, bold_files[0]) == ['epi', 'phasediff']