From 7e72f405405ff527435d04576f091d9ba759f188 Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 12:21:50 -0400 Subject: [PATCH 01/11] Split FunctionalOutputs.regressor_file into raw + bandpass-filtered regressor_file now carries the raw (unfiltered) regressor .1D files as computed from native-space BOLD. The new bpf_regressor_file field holds the bandpass-filtered version that 3dTproject actually applied, exported with desc-Filtered for provenance. This lets the longitudinal pipeline reuse the raw regressors without recomputation, matching the cross-sectional principle: one regressor computation per run, applied in each target space. Port of the regressor-split idea from #282. Co-authored-by: Janhavi Pillai --- docs/data_dictionary.md | 9 ++++++++- src/rbc/bids/functional.py | 6 ++++++ src/rbc/workflows/functional.py | 19 +++++++++++++++---- tests/unit/bids/test_exports.py | 12 +++++++----- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/data_dictionary.md b/docs/data_dictionary.md index 684e3d01..080764f1 100644 --- a/docs/data_dictionary.md +++ b/docs/data_dictionary.md @@ -56,7 +56,8 @@ Produced by `rbc functional`. These are functional MRI (BOLD) processing results | `*_space-MNI152NLin6Asym_desc-preproc_bold.nii.gz` | `bold` | BOLD timeseries resampled to MNI152NLin6Asym template space in a single interpolation step (before denoising) | ANTs resampling | 4D NIfTI | | `*_space-MNI152NLin6Asym_desc-bold_mask.nii.gz` | `mask` | Brain mask warped to template space at the BOLD resolution | ANTs resampling | 3D NIfTI, binary mask | | `*_space-MNI152NLin6Asym_reg-{regressor}_desc-preproc_bold.nii.gz` | `bold` | Denoised BOLD timeseries in template space after nuisance regression and bandpass filtering. `{regressor}` is `36parameter` or `aCompCor` | Nuisance regression | 4D NIfTI | -| `*_desc-{regressor}_regressors.1D` | `regressors` | Nuisance regressor matrix used for denoising. `{regressor}` is `36parameter` or `aCompCor` | Computed from motion parameters and tissue masks | Text, multi-column 1D file | +| `*_desc-{regressor}_regressors.1D` | `regressors` | Raw (unfiltered) nuisance regressor matrix. `{regressor}` is `36parameter` or `aCompCor`. Carried forward for longitudinal regression reuse | Computed from motion parameters and tissue masks | Text, multi-column 1D file | +| `*_desc-{regressor}Filtered_regressors.1D` | `regressors` | Bandpass-filtered nuisance regressor matrix matching what `3dTproject -bandpass` applied. For provenance only | FFT-based bandpass filter | Text, multi-column 1D file | --- @@ -133,3 +134,9 @@ Produced by the `rbc longitudinal` subcommand group (`template`, `anatomical`, ` | `*_space-longitudinal_desc-T1w_mask.nii.gz` | `mask` | Brain mask in longitudinal template space | ANTs registration to longitudinal template | 3D NIfTI, binary mask | | `*_from-T1w_to-longitudinal_mode-image_xfm.nii.gz` | `xfm` | Warp field mapping subject anatomy to the longitudinal template | ANTs registration | 3D NIfTI, displacement field | | `*_from-longitudinal_to-T1w_mode-image_xfm.nii.gz` | `xfm` | Inverse warp field mapping longitudinal template back to subject anatomy | ANTs registration | 3D NIfTI, displacement field | +| `*_space-longitudinal_sbref.nii.gz` | `sbref` | Motion reference volume warped to longitudinal template space | ANTs warping (composed BOLD-to-longitudinal) | 3D NIfTI | +| `*_space-longitudinal_desc-preproc_bold.nii.gz` | `bold` | Preprocessed BOLD warped to longitudinal template space | ANTs warping (composed BOLD-to-longitudinal) | 4D NIfTI | +| `*_space-longitudinal_desc-brain_mask.nii.gz` | `mask` | BOLD brain mask in longitudinal template space | ANTs warping (nearest-neighbor) | 3D NIfTI, binary mask | +| `*_from-bold_to-longitudinal_...xfm.nii.gz` | `xfm` | Composite BOLD-to-longitudinal-template warp field | ANTs compose transforms | 3D NIfTI, displacement field | +| `*_space-longitudinal_desc-regressed_reg-_bold.nii.gz` | `bold` | Nuisance-regressed BOLD (no bandpass) in longitudinal space, per regressor strategy | AFNI 3dTproject | 4D NIfTI | +| `*_space-longitudinal_desc-preproc_reg-_bold.nii.gz` | `bold` | Nuisance-regressed + bandpass-filtered BOLD in longitudinal space, per regressor strategy | AFNI 3dTproject -bandpass | 4D NIfTI | diff --git a/src/rbc/bids/functional.py b/src/rbc/bids/functional.py index 1991fd18..17ece5b2 100644 --- a/src/rbc/bids/functional.py +++ b/src/rbc/bids/functional.py @@ -158,6 +158,12 @@ def export_functional( desc=bids_safe_label(reg), extension=".1D", ) + func.save( + outputs.bpf_regressor_file[reg], + suffix="regressors", + desc=f"{bids_safe_label(reg)}Filtered", + extension=".1D", + ) mni = func.derive(space=TemplateSpace.MNI152NLIN6ASYM) for reg in regressors: diff --git a/src/rbc/workflows/functional.py b/src/rbc/workflows/functional.py index 21b779c8..11f44655 100644 --- a/src/rbc/workflows/functional.py +++ b/src/rbc/workflows/functional.py @@ -76,7 +76,12 @@ class FunctionalOutputs(NamedTuple): template_bold: BOLD resampled to template space. regressed_bold: Nuisance-regressed & non-bandpassed BOLD. cleaned_bold: Nuisance-regressed & bandpass-filtered BOLD. - regressor_file: Bandpass-filtered nuisance regressor ``.1D`` file. + regressor_file: Raw (unfiltered) nuisance regressor ``.1D`` file, + as computed from native-space BOLD. Carried forward so + longitudinal regression can reuse it without recomputation. + bpf_regressor_file: Bandpass-filtered nuisance regressor ``.1D`` + file, matching what ``3dTproject -bandpass`` actually applied. + For BIDS export only. template_brain_mask: Brain mask warped to template space. """ @@ -100,6 +105,7 @@ class FunctionalOutputs(NamedTuple): regressed_bold: dict[str, Path] cleaned_bold: dict[str, Path] regressor_file: dict[str, Path] + bpf_regressor_file: dict[str, Path] template_brain_mask: Path @@ -329,6 +335,7 @@ def single_session_preprocess( regression: dict[str, ApplyRegressionOutputs] = {} cleaned: dict[str, ApplyRegressionOutputs] = {} + raw_regressors: dict[str, Path] = {} filtered_regressors: dict[str, Path] = {} for regressor in regressor_set: # 15. Nuisance regression without bandpass (pre-bandpass residuals @@ -350,8 +357,11 @@ def single_session_preprocess( regressor_file=regressors[regressor].regressor_file, ) - # 17. Export bandpass-filtered regressors (matches what 3dTproject - # actually applied; raw regressors still in compute_regressors output) + # 17a. Carry raw (unfiltered) regressors forward for longitudinal reuse + raw_regressors[regressor] = regressors[regressor].regressor_file + + # 17b. Export bandpass-filtered regressors (matches what 3dTproject + # actually applied) filtered_regressors[regressor] = bandpass_regressor_file( regressors[regressor].regressor_file, tr=metadata.tr, @@ -379,6 +389,7 @@ def single_session_preprocess( template_bold=template_bold, regressed_bold={r: regression[r].regressed_bold for r in regressor_set}, cleaned_bold={r: cleaned[r].regressed_bold for r in regressor_set}, - regressor_file=filtered_regressors, + regressor_file=raw_regressors, + bpf_regressor_file=filtered_regressors, template_brain_mask=tmpl_brain, ) diff --git a/tests/unit/bids/test_exports.py b/tests/unit/bids/test_exports.py index 1c96faab..33ec4852 100644 --- a/tests/unit/bids/test_exports.py +++ b/tests/unit/bids/test_exports.py @@ -74,6 +74,7 @@ def _make_func_outputs(w: Path, regressors: list[str]) -> FunctionalOutputs: regressed_bold={r: _dummy(w, f"regressed_{r}.nii.gz") for r in regressors}, cleaned_bold={r: _dummy(w, f"cleaned_{r}.nii.gz") for r in regressors}, regressor_file={r: _dummy(w, f"regressors_{r}.1D") for r in regressors}, + bpf_regressor_file={r: _dummy(w, f"regressors_bpf_{r}.1D") for r in regressors}, template_brain_mask=_dummy(w, "template_mask.nii.gz"), ) @@ -195,26 +196,27 @@ def test_file_count_single_regressor( ) -> None: """Correct file count with one regressor. - 8 native-space fixed + 1 regressor file + 2 per-regressor MNI - + 2 fixed MNI = 13. + 8 native-space fixed + 2 regressor files (raw + filtered) + + 2 per-regressor MNI + 2 fixed MNI = 14. """ outputs = _make_func_outputs(workdir, ["36-parameter"]) export_functional(func_bids, outputs, regressors=["36-parameter"]) saved = list(pipe_ctx.output_dir.rglob("*.*")) - assert len(saved) == 13 + assert len(saved) == 14 def test_file_count_two_regressors( self, func_bids: Bids, workdir: Path, pipe_ctx: RunContext ) -> None: """Correct file count with two regressors. - 8 fixed + 2 regressor files + 4 per-regressor MNI + 2 fixed MNI = 16. + 8 fixed + 4 regressor files (2x raw + 2x filtered) + + 4 per-regressor MNI + 2 fixed MNI = 18. """ regs = ["36-parameter", "aCompCor"] outputs = _make_func_outputs(workdir, regs) export_functional(func_bids, outputs, regressors=regs) saved = list(pipe_ctx.output_dir.rglob("*.*")) - assert len(saved) == 16 + assert len(saved) == 18 # --------------------------------------------------------------------------- From 31195a822994ee891b4be24b0e4c451c48523fbf Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 12:22:01 -0400 Subject: [PATCH 02/11] Add per-regressor regression to longitudinal functional workflow FunctionalLongOutputs gains regressed_bold and cleaned_bold dicts keyed by regressor strategy. longitudinal_process now accepts regressor_files (raw .1D from cross-sectional) and re-runs apply_regression + apply_regression_bandpass on the warped BOLD. No regressor recomputation -- same matrix, different target space. bold_mask is now mandatory (was Path | None). BIDS export writes per-regressor desc-regressed and desc-preproc BOLD with reg- entity. resolve_longitudinal_func resolves the raw regressor files per strategy. CLI gains --regressor with the same choices as cross-sectional (36-parameter, aCompCor). Port of the longitudinal regression logic from #282. Co-authored-by: Janhavi Pillai --- src/rbc/bids/longitudinal/functional.py | 47 +++++++++-- src/rbc/cli/longitudinal/functional.py | 14 +++- .../orchestration/longitudinal/functional.py | 21 ++++- src/rbc/workflows/longitudinal/functional.py | 81 ++++++++++++++----- tests/unit/cli/test_longitudinal.py | 9 +++ tests/unit/orchestration/test_longitudinal.py | 11 +-- 6 files changed, 147 insertions(+), 36 deletions(-) diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index b02c4247..0ac9d457 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -7,6 +7,7 @@ from rbc.bids import Suffix, bids_safe_label if TYPE_CHECKING: + from collections.abc import Sequence from pathlib import Path import polars as pl @@ -22,7 +23,8 @@ def resolve_longitudinal_func( tpl_df: pl.DataFrame, *, ses: str, -) -> dict[str, Path | None]: + regressors: Sequence[str] = ("36-parameter",), +) -> dict[str, Path | dict[str, Path]]: """Resolve inputs for longitudinal functional processing. Args: @@ -31,10 +33,23 @@ def resolve_longitudinal_func( func_df: DataFrame of functional derivatives. tpl_df: DataFrame of longitudinal template files. ses: Session label (used for template xfm lookup). + regressors: Regressor strategy names to resolve raw regressor + files for. Returns: - Dict with keys matching ``longitudinal_process`` parameters. + Dict with keys matching ``longitudinal_process`` parameters, + including ``regressor_files`` keyed by strategy name. """ + regressor_files: dict[str, Path] = {} + for reg in regressors: + regressor_files[reg] = func_q.expect( + func_df, + suffix="regressors", + desc=bids_safe_label(reg), + extension=".1D", + without=["space"], + ) + return { "template": tpl_q.expect(tpl_df, suffix="T1w"), "anat_to_template_xfm": tpl_q.expect( @@ -54,18 +69,25 @@ def resolve_longitudinal_func( "bold": func_q.expect( func_df, suffix=Suffix.BOLD, desc="preproc", without=["space"] ), - "bold_mask": func_q.find( + "bold_mask": func_q.expect( func_df, suffix=Suffix.MASK, desc="brain", without=["space"] ), + "regressor_files": regressor_files, } -def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None: +def export_longitudinal_func( + fex: Bids, + outputs: FunctionalLongOutputs, + *, + regressors: Sequence[str], +) -> None: """Export longitudinal functional outputs. Args: fex: Bids builder with ``space="longitudinal"`` and identity entities. outputs: Results from the longitudinal functional workflow. + regressors: Regressor strategy names that were applied. """ fex.save(outputs.sbref, suffix=Suffix.SBREF) fex.save(outputs.bold, suffix=Suffix.BOLD, desc="preproc") @@ -75,5 +97,18 @@ def export_longitudinal_func(fex: Bids, outputs: FunctionalLongOutputs) -> None: desc="composite", extra={"from": "bold", "to": "longitudinal", "mode": "image"}, ) - if outputs.bold_mask: - fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain") + fex.save(outputs.bold_mask, suffix=Suffix.MASK, desc="brain") + + for reg in regressors: + fex.save( + outputs.regressed_bold[reg], + suffix=Suffix.BOLD, + desc="regressed", + extra={"reg": bids_safe_label(reg)}, + ) + fex.save( + outputs.cleaned_bold[reg], + suffix=Suffix.BOLD, + desc="preproc", + extra={"reg": bids_safe_label(reg)}, + ) diff --git a/src/rbc/cli/longitudinal/functional.py b/src/rbc/cli/longitudinal/functional.py index 5eea0cce..c1214557 100644 --- a/src/rbc/cli/longitudinal/functional.py +++ b/src/rbc/cli/longitudinal/functional.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from rbc.cli.base import _validate_task from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument @@ -20,6 +20,7 @@ class FunctionalLongArgs(LongitudinalBaseArgs): """Arguments for ``rbc longitudinal functional``.""" task: str | None + regressor: Sequence[Literal["36-parameter", "aCompCor"]] @classmethod def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs: @@ -28,6 +29,7 @@ def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalLongArgs: return cls( **LongitudinalBaseArgs.validate_namespace(ns).__dict__, task=ns.task, + regressor=ns.regressor, ) @@ -41,6 +43,7 @@ def main(args: FunctionalLongArgs) -> int: session_label=args.session_label, task=args.task, ), + regressors=args.regressor, runner_config=RunnerConfig( runner=args.runner, verbose=bool(args.verbose), @@ -61,7 +64,7 @@ def register_command( parents=parents, description=( "Warp preprocessed BOLD derivatives into each subject's " - "longitudinal template space." + "longitudinal template space and re-run nuisance regression." ), help="Longitudinal functional stage", usage=( @@ -75,6 +78,13 @@ def register_command( default=None, help="Task label to filter BOLD runs (without 'task-' prefix).", ) + parser.add_argument( + "--regressor", + nargs="+", + choices=["36-parameter", "aCompCor"], + default=["36-parameter"], + help="Space-delimited nuisance regression method(s) to apply.", + ) parser.set_defaults( func=lambda args: main(FunctionalLongArgs.validate_namespace(args)) diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index 724634f9..1c1e4365 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path + from typing import Literal import polars as pl @@ -34,6 +35,8 @@ def process_func( pipe_ctx: RunContext, func_df: pl.DataFrame, tpl_df: pl.DataFrame, + *, + regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), ) -> None: """Handle functional longitudinal processing for one BOLD run. @@ -41,6 +44,7 @@ def process_func( pipe_ctx: RunContext bound to this subject/session. func_df: Functional derivative DataFrame for this run. tpl_df: Longitudinal template DataFrame. + regressors: Regressor strategies to apply in longitudinal space. """ row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) ents = extract_entities(row, ["task", "run"]) @@ -54,10 +58,14 @@ def process_func( func_df, tpl_df, ses=pipe_ctx.ses, # type: ignore[arg-type] + regressors=regressors, + ) + func_outputs = functional_longitudinal( + **resolved, # type: ignore[arg-type] + regressor_set=regressors, ) - func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type] fex = func_q.derive(space="longitudinal") - export_longitudinal_func(fex, func_outputs) + export_longitudinal_func(fex, func_outputs, regressors=regressors) def run( @@ -65,6 +73,7 @@ def run( output_dir: Path, *, filters: Filters, + regressors: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), runner_config: RunnerConfig | None = None, ) -> None: """Run longitudinal functional processing for all matching subjects/sessions. @@ -75,6 +84,7 @@ def run( and longitudinal templates). output_dir: Output directory for derivatives. filters: Participant/session/task filters applied before grouping. + regressors: Regressor strategies to apply in longitudinal space. runner_config: Execution backend configuration. """ config = runner_config or RunnerConfig() @@ -91,7 +101,12 @@ def run( input_dirs, output_dir, filters=filters, verbose=verbose ): for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES): - process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df) + process_func( + pipe_ctx=pipe_ctx, + func_df=func_df, + tpl_df=tpl_df, + regressors=regressors, + ) pipe_ctx.ensure_dataset_description() _logger.info("RBC longitudinal functional workflow complete") diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index da8dc0ce..0ff35840 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -1,14 +1,17 @@ """Longitudinal functional processing workflow. Transforms preprocessed functional outputs to a pre-computed longitudinal -template space and returns all output paths as a +template space, then re-runs nuisance regression on the warped BOLD using +raw regressors from the cross-sectional run. Returns all output paths as a :class:`FunctionalLongOutputs` named tuple. """ from __future__ import annotations +import logging from typing import TYPE_CHECKING, NamedTuple +from rbc.core.functional import apply_regression, apply_regression_bandpass from rbc.core.longitudinal.transform import ( compose_transform, func_transform, @@ -16,7 +19,11 @@ ) if TYPE_CHECKING: + from collections.abc import Sequence from pathlib import Path + from typing import Literal + +_logger = logging.getLogger("rbc") class FunctionalLongOutputs(NamedTuple): @@ -26,14 +33,19 @@ class FunctionalLongOutputs(NamedTuple): bold_to_long_xfm: BOLD-to-longitudinal-template composite warp. sbref: Motion reference volume warped to longitudinal template space. bold: Preprocessed BOLD warped to longitudinal template space. - bold_mask: Brain mask warped to longitudinal template space, - or *None* if no mask was provided. + bold_mask: Brain mask warped to longitudinal template space. + regressed_bold: Per-regressor nuisance-regressed BOLD (no bandpass) + in longitudinal template space, keyed by strategy name. + cleaned_bold: Per-regressor nuisance-regressed + bandpass-filtered + BOLD in longitudinal template space, keyed by strategy name. """ bold_to_long_xfm: Path sbref: Path bold: Path - bold_mask: Path | None = None + bold_mask: Path + regressed_bold: dict[str, Path] + cleaned_bold: dict[str, Path] def longitudinal_process( @@ -43,13 +55,16 @@ def longitudinal_process( bold_to_anat_itk: Path, sbref: Path, bold: Path, - bold_mask: Path | None, + bold_mask: Path, + regressor_files: dict[str, Path], + regressor_set: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), ) -> FunctionalLongOutputs: """Transform preprocessed functional outputs to longitudinal template space. - Assumes a longitudinal template has been generated, the subject-to-template - composite warp is available, and anatomical data has already been processed - to longitudinal template space. + After warping the BOLD timeseries, re-runs nuisance regression using the + raw (unfiltered) regressors produced by the cross-sectional pipeline. + No regressor recomputation is performed: the same regressor matrix is + applied in the new target space. Args: template: Longitudinal template image. @@ -57,11 +72,15 @@ def longitudinal_process( bold_to_anat_itk: BOLD-to-T1w affine in ITK format. sbref: Motion reference (single-band reference) volume. bold: Preprocessed bold image. - bold_mask: Bold brain mask, if available. + bold_mask: Bold brain mask. + regressor_files: Raw (unfiltered) regressor ``.1D`` files from + the cross-sectional run, keyed by strategy name. + regressor_set: Which regressor strategies to apply. Must be a + subset of the keys in *regressor_files*. Returns: - :class:`FunctionalLongOutputs` with all non-null inputs transformed to template - space. + :class:`FunctionalLongOutputs` with all inputs transformed to + longitudinal template space and per-regressor regression outputs. """ bold_to_tpl_xfm = compose_transform( ref=template, @@ -69,15 +88,37 @@ def longitudinal_process( anat_to_tpl_xfm=anat_to_template_xfm, ) + long_sbref = func_transform( + in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single" + ) + long_bold = func_transform( + in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked" + ) + long_mask = mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm) + + regressed: dict[str, Path] = {} + cleaned: dict[str, Path] = {} + for reg in regressor_set: + reg_file = regressor_files[reg] + _logger.info("Longitudinal %s nuisance regression (no bandpass)", reg) + regressed[reg] = apply_regression( + bold_file=long_bold, + brain_mask_file=long_mask, + regressor_file=reg_file, + ).regressed_bold + + _logger.info("Longitudinal %s nuisance regression + bandpass filtering", reg) + cleaned[reg] = apply_regression_bandpass( + bold_file=long_bold, + brain_mask_file=long_mask, + regressor_file=reg_file, + ).regressed_bold + return FunctionalLongOutputs( - sbref=func_transform( # 3D volume - in_file=sbref, template=template, xfm=bold_to_tpl_xfm, strategy="single" - ), - bold=func_transform( - in_file=bold, template=template, xfm=bold_to_tpl_xfm, strategy="chunked" - ), - bold_mask=mask_transform(mask=bold_mask, template=template, xfm=bold_to_tpl_xfm) - if bold_mask - else None, bold_to_long_xfm=bold_to_tpl_xfm, + sbref=long_sbref, + bold=long_bold, + bold_mask=long_mask, + regressed_bold=regressed, + cleaned_bold=cleaned, ) diff --git a/tests/unit/cli/test_longitudinal.py b/tests/unit/cli/test_longitudinal.py index 4608cd01..66f97918 100644 --- a/tests/unit/cli/test_longitudinal.py +++ b/tests/unit/cli/test_longitudinal.py @@ -80,15 +80,24 @@ class TestFunctionalLongArgs: def test_valid_task(self, base_ns: argparse.Namespace) -> None: """Alphanumeric task labels pass validation.""" base_ns.task = "rest" + base_ns.regressor = ["36-parameter"] args = FunctionalLongArgs.validate_namespace(base_ns) assert args.task == "rest" def test_invalid_task_rejected(self, base_ns: argparse.Namespace) -> None: """Task labels with special characters are rejected.""" base_ns.task = "rest/invalid" + base_ns.regressor = ["36-parameter"] with pytest.raises(ValueError, match="Task"): FunctionalLongArgs.validate_namespace(base_ns) + def test_regressor_preserved(self, base_ns: argparse.Namespace) -> None: + """Regressor choices round-trip through validation.""" + base_ns.task = None + base_ns.regressor = ["36-parameter", "aCompCor"] + args = FunctionalLongArgs.validate_namespace(base_ns) + assert list(args.regressor) == ["36-parameter", "aCompCor"] + class TestMetricsLongArgs: """Tests for the metrics longitudinal subcommand validator.""" diff --git a/tests/unit/orchestration/test_longitudinal.py b/tests/unit/orchestration/test_longitudinal.py index 43b321f8..4195992f 100644 --- a/tests/unit/orchestration/test_longitudinal.py +++ b/tests/unit/orchestration/test_longitudinal.py @@ -67,6 +67,8 @@ def _mock_func_outputs(*, with_bold_mask: bool = True) -> Mock: m.bold = fake / "bold.nii.gz" m.bold_to_long_xfm = fake / "bold_to_long_xfm.nii.gz" m.bold_mask = (fake / "bold_mask.nii.gz") if with_bold_mask else None + m.regressed_bold = {"36-parameter": fake / "regressed_36.nii.gz"} + m.cleaned_bold = {"36-parameter": fake / "cleaned_36.nii.gz"} return m @@ -382,24 +384,23 @@ def test_missing_required_file_raises( ): process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df) - def test_optional_bold_mask_file_not_found( + def test_missing_bold_mask_raises( self, func_df: pl.DataFrame, tpl_df: pl.DataFrame, tmp_path: Path ) -> None: - """Optional bold_mask not found is caught; 3 exports emitted.""" + """bold_mask is now mandatory; missing file raises FileNotFoundError.""" pipe_ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path) with ( patch( "rbc.orchestration.longitudinal.functional.functional_longitudinal", - return_value=_mock_func_outputs(with_bold_mask=False), + return_value=_mock_func_outputs(), ), patch( "rbc.bids.query.find_file", side_effect=_none_for(suffix="mask", desc="brain"), ), - patch("rbc.bids.builder.shutil.copy2") as mock_copy, + pytest.raises(FileNotFoundError), ): process_func(pipe_ctx=pipe_ctx, func_df=func_df, tpl_df=tpl_df) - assert mock_copy.call_count == 3 class TestLongitudinalAnatomicalRun: From b487e358c7ff804dfe36291fd62bd9976295b29f Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 12:22:09 -0400 Subject: [PATCH 03/11] Add tests for longitudinal functional regression (Stage 5 of #301) Unit tests: resolve_longitudinal_func with single/multiple regressors, missing regressor raises, mandatory bold_mask; export file counts and reg- entity validation. Tier-2 integration: apply_regression and apply_regression_bandpass on warped BOLD with raw regressors produce non-degenerate outputs. Tier-4 full_pipeline: longitudinal functional outputs exist, regressed and cleaned BOLD have non-zero variance, bold mask is binary. --- tests/full_pipeline/longitudinal/__init__.py | 1 + tests/full_pipeline/longitudinal/conftest.py | 110 +++++ .../longitudinal/test_functional.py | 64 +++ .../longitudinal/test_regression_reuse.py | 146 ++++++ .../unit/bids/test_longitudinal_functional.py | 417 ++++++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 tests/full_pipeline/longitudinal/__init__.py create mode 100644 tests/full_pipeline/longitudinal/conftest.py create mode 100644 tests/full_pipeline/longitudinal/test_functional.py create mode 100644 tests/integration/longitudinal/test_regression_reuse.py create mode 100644 tests/unit/bids/test_longitudinal_functional.py diff --git a/tests/full_pipeline/longitudinal/__init__.py b/tests/full_pipeline/longitudinal/__init__.py new file mode 100644 index 00000000..138248c6 --- /dev/null +++ b/tests/full_pipeline/longitudinal/__init__.py @@ -0,0 +1 @@ +"""Longitudinal full-pipeline e2e tests.""" diff --git a/tests/full_pipeline/longitudinal/conftest.py b/tests/full_pipeline/longitudinal/conftest.py new file mode 100644 index 00000000..4f167d6f --- /dev/null +++ b/tests/full_pipeline/longitudinal/conftest.py @@ -0,0 +1,110 @@ +"""Shared fixtures for longitudinal full-pipeline e2e tests. + +Runs the longitudinal template + anatomical + functional stages once +per session, caching outputs for all tests in this directory. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +import pytest + +from rbc.metadata import FunctionalMetadata +from rbc.workflows import anatomical_preprocess, functional_preprocess +from rbc.workflows.longitudinal.functional import ( + FunctionalLongOutputs, +) +from rbc.workflows.longitudinal.functional import ( + longitudinal_process as functional_longitudinal, +) + +if TYPE_CHECKING: + from collections.abc import Generator + + import niwrap + from conftest import TestSubjectData + + from rbc.workflows import AnatomicalOutputs, FunctionalOutputs + +MANIFEST_PATH = Path(__file__).parent / ".last_run_longitudinal.json" + + +class LongitudinalPipelineData(NamedTuple): + """Shared outputs from the longitudinal functional pipeline.""" + + anat: AnatomicalOutputs + func: FunctionalOutputs + long_func: FunctionalLongOutputs + + +def _to_dict(obj: object) -> dict | str | None: + """Convert workflow NamedTuple object to dict recursively.""" + if hasattr(obj, "_asdict"): + return {k: _to_dict(v) for k, v in obj._asdict().items()} + if isinstance(obj, dict): + return {k: _to_dict(v) for k, v in obj.items()} + return str(obj) if obj is not None else None + + +@pytest.fixture(scope="session") +def longitudinal_manifest() -> Generator[dict[str, object], None, None]: + """Shared manifest written to disk at session end.""" + data: dict[str, object] = {} + yield data + MANIFEST_PATH.write_text(json.dumps(data, indent=2)) + + +@pytest.fixture(scope="session") +def longitudinal_pipeline_data( + test_subject: TestSubjectData, + niwrap_runner: niwrap.Runner, # noqa: ARG001 + longitudinal_manifest: dict[str, object], +) -> LongitudinalPipelineData: + """Run cross-sectional + longitudinal functional pipeline once. + + Since the full_pipeline test dataset (ds000001) is single-session, + we simulate longitudinal processing by treating the cross-sectional + outputs as if they came from a multi-session subject. The longitudinal + workflow only needs a template, xfm, and the cross-sectional BOLD + + regressors, so we can construct a synthetic longitudinal template from + the anatomical outputs. + """ + # Cross-sectional anat + anat = anatomical_preprocess(test_subject.t1w) + + # Cross-sectional func + func_metadata = FunctionalMetadata.load(test_subject.bold) + func = functional_preprocess( + in_bold=test_subject.bold, + t1w_brain=anat.brain, + wm_bbr_mask=anat.wm_bbr_mask, + brain_mask=anat.brain_mask, + csf_mask=anat.csf_mask, + wm_mask=anat.wm_mask, + anat_to_template=anat.anat_to_template_xfm, + metadata=func_metadata, + ) + + # Longitudinal functional: use anat-to-template xfm as the + # "longitudinal template" xfm and the brain as the "template" image. + # This is not anatomically meaningful but exercises the full pipeline + # chain through regression reuse. + long_func = functional_longitudinal( + template=anat.brain, + anat_to_template_xfm=anat.anat_to_template_xfm, + bold_to_anat_itk=func.bold_to_anat_itk, + sbref=func.sbref, + bold=func.preproc_bold, + bold_mask=func.bold_mask, + regressor_files=func.regressor_file, + regressor_set=["36-parameter"], + ) + + longitudinal_manifest["anat"] = _to_dict(anat) + longitudinal_manifest["func"] = _to_dict(func) + longitudinal_manifest["long_func"] = _to_dict(long_func) + + return LongitudinalPipelineData(anat=anat, func=func, long_func=long_func) diff --git a/tests/full_pipeline/longitudinal/test_functional.py b/tests/full_pipeline/longitudinal/test_functional.py new file mode 100644 index 00000000..744386e5 --- /dev/null +++ b/tests/full_pipeline/longitudinal/test_functional.py @@ -0,0 +1,64 @@ +"""Full e2e test for the longitudinal functional preprocessing workflow. + +Tier-4 test for Stage 5 of the longitudinal refactor (tracker: #301). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import nibabel as nib +import numpy as np + +if TYPE_CHECKING: + from full_pipeline.longitudinal.conftest import LongitudinalPipelineData + + +def test_longitudinal_func_outputs_exist( + longitudinal_pipeline_data: LongitudinalPipelineData, +) -> None: + """All FunctionalLongOutputs paths exist on disk.""" + outputs = longitudinal_pipeline_data.long_func + + for field_name, value in outputs._asdict().items(): + if isinstance(value, dict): + for key, path in value.items(): + assert Path(path).exists(), ( + f"{field_name}[{key!r}] does not exist: {path}" + ) + elif value is not None: + assert Path(value).exists(), f"{field_name} does not exist: {value}" + + +def test_regressed_bold_non_degenerate( + longitudinal_pipeline_data: LongitudinalPipelineData, +) -> None: + """Regressed BOLD in longitudinal space has non-zero variance.""" + outputs = longitudinal_pipeline_data.long_func + for reg, path in outputs.regressed_bold.items(): + img = nib.nifti1.load(path) + data = img.get_fdata() + assert data.var() > 0, f"Regressed BOLD for {reg!r} has zero variance" + + +def test_cleaned_bold_non_degenerate( + longitudinal_pipeline_data: LongitudinalPipelineData, +) -> None: + """Cleaned (bandpassed) BOLD in longitudinal space has non-zero variance.""" + outputs = longitudinal_pipeline_data.long_func + for reg, path in outputs.cleaned_bold.items(): + img = nib.nifti1.load(path) + data = img.get_fdata() + assert data.var() > 0, f"Cleaned BOLD for {reg!r} has zero variance" + + +def test_bold_mask_is_binary( + longitudinal_pipeline_data: LongitudinalPipelineData, +) -> None: + """Warped bold mask in longitudinal space is binary.""" + mask_path = longitudinal_pipeline_data.long_func.bold_mask + img = nib.nifti1.load(mask_path) + data = img.get_fdata() + unique = np.unique(data) + assert set(unique).issubset({0, 1}), f"Mask has non-binary values: {unique}" diff --git a/tests/integration/longitudinal/test_regression_reuse.py b/tests/integration/longitudinal/test_regression_reuse.py new file mode 100644 index 00000000..5803dbd9 --- /dev/null +++ b/tests/integration/longitudinal/test_regression_reuse.py @@ -0,0 +1,146 @@ +"""Integration test for longitudinal regression reuse. + +Given cross-sectional outputs and raw regressor ``.1D`` files, +``apply_regression`` and ``apply_regression_bandpass`` on warped BOLD +produce non-degenerate outputs (non-zero variance, correct timepoints). + +Tier-2 integration test for Stage 5 of the longitudinal refactor +(tracker: #301). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import nibabel as nib +import numpy as np +import pytest + +from rbc.core.functional import apply_regression, apply_regression_bandpass +from rbc.core.nifti import nifti_num_volumes + +if TYPE_CHECKING: + from pathlib import Path + + from conftest import TestSubjectData + + +def _prepare_short_bold_and_mask( + test_subject: TestSubjectData, + n_vols: int = 50, +) -> tuple[Path, Path, Path]: + """Prepare a short BOLD series with brain mask and dummy regressor. + + Returns (bold_path, mask_path, regressor_1d_path). + """ + from niwrap import afni + + from rbc.core.common import deoblique_and_reorient + from rbc.core.functional import ( + extract_motion_reference, + fsl_motion_correction, + nuisance_regression, + ) + from rbc.core.niwrap import generate_exec_folder + + reoriented = deoblique_and_reorient(in_file=test_subject.bold) + truncated = afni.v_3dcalc( + dataset_a=afni.v_3dcalc_dataset_a_file( + file=reoriented.out_file, selectors_=f"[0..{n_vols - 1}]" + ), + expression="a", + prefix="test_bold.nii.gz", + ) + assert truncated.output_file is not None + motion_ref = extract_motion_reference(in_file=truncated.output_file) + mc = fsl_motion_correction(in_file=truncated.output_file, ref_file=motion_ref) + + automask = afni.v_3d_automask( + in_file=mc.bold, + prefix="brain_mask.nii.gz", + ) + assert automask.mask_file is not None + + # Create synthetic tissue masks for regressor computation + from scipy.ndimage import binary_erosion + + out_dir = generate_exec_folder("regression_reuse_masks") + brain_img = nib.nifti1.load(automask.mask_file) + brain_data = brain_img.get_fdata() > 0 + eroded_1 = binary_erosion(brain_data, iterations=1) + csf_data = brain_data & ~eroded_1 + wm_data = binary_erosion(brain_data, iterations=3) + + csf_file = out_dir / "csf_mask.nii.gz" + wm_file = out_dir / "wm_mask.nii.gz" + nib.nifti1.Nifti1Image( + csf_data.astype(np.uint8), brain_img.affine, brain_img.header + ).to_filename(str(csf_file)) + nib.nifti1.Nifti1Image( + wm_data.astype(np.uint8), brain_img.affine, brain_img.header + ).to_filename(str(wm_file)) + + # Compute raw regressors + reg_result = nuisance_regression( + bold_file=mc.bold, + brain_mask_file=automask.mask_file, + csf_mask_file=csf_file, + wm_mask_file=wm_file, + motion_params=mc.motion_params, + regressor_set="36-parameter", + ) + + return mc.bold, automask.mask_file, reg_result.regressor_file + + +@pytest.mark.slow +def test_apply_regression_on_warped_bold(test_subject: TestSubjectData) -> None: + """Raw regressors from cross-sectional run produce valid regression output. + + Simulates the longitudinal case: regression is applied to a BOLD + timeseries using regressors that were computed in a different space. + The regressor matrix has the right number of timepoints (matched by + the cross-sectional pipeline), so regression should succeed. + """ + bold, mask, regressor_1d = _prepare_short_bold_and_mask(test_subject) + + result = apply_regression( + bold_file=bold, + brain_mask_file=mask, + regressor_file=regressor_1d, + ) + + assert result.regressed_bold.exists() + assert nifti_num_volumes(result.regressed_bold) == nifti_num_volumes(bold) + + # Non-degenerate: regressed BOLD should have non-zero variance + img = nib.nifti1.load(result.regressed_bold) + data = img.get_fdata() + mask_img = nib.nifti1.load(mask) + mask_data = mask_img.get_fdata() > 0 + brain_ts = data[mask_data] + assert brain_ts.var(axis=-1).mean() > 0, "Regressed BOLD has zero variance" + + +@pytest.mark.slow +def test_apply_regression_bandpass_on_warped_bold( + test_subject: TestSubjectData, +) -> None: + """Bandpass regression on warped BOLD produces non-degenerate output.""" + bold, mask, regressor_1d = _prepare_short_bold_and_mask(test_subject) + + result = apply_regression_bandpass( + bold_file=bold, + brain_mask_file=mask, + regressor_file=regressor_1d, + ) + + assert result.regressed_bold.exists() + assert nifti_num_volumes(result.regressed_bold) == nifti_num_volumes(bold) + + img = nib.nifti1.load(result.regressed_bold) + data = img.get_fdata() + mask_img = nib.nifti1.load(mask) + mask_data = mask_img.get_fdata() > 0 + brain_ts = data[mask_data] + assert brain_ts.var(axis=-1).mean() > 0, "Cleaned BOLD has zero variance" diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py new file mode 100644 index 00000000..98a17d41 --- /dev/null +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -0,0 +1,417 @@ +"""Unit tests for ``rbc.bids.longitudinal.functional``.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import polars as pl +import pytest + +from rbc.bids.longitudinal.functional import ( + export_longitudinal_func, + resolve_longitudinal_func, +) +from rbc.context import RunContext +from rbc.workflows.longitudinal.functional import FunctionalLongOutputs + +if TYPE_CHECKING: + from pathlib import Path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _func_row( + *, + sub: str, + ses: str, + suffix: str, + desc: str | None = None, + ext: str = ".nii.gz", + space: str | None = None, + extra: list[dict[str, str]] | None = None, +) -> dict[str, object]: + """Build a single BIDS-like row for functional derivatives.""" + desc_part = f"_desc-{desc}" if desc else "" + space_part = f"_space-{space}" if space else "" + path = ( + f"sub-{sub}/ses-{ses}/func/" + f"sub-{sub}_ses-{ses}{space_part}{desc_part}_{suffix}{ext}" + ) + return { + "datatype": "func", + "suffix": suffix, + "ext": ext, + "sub": sub, + "ses": ses, + "space": space, + "desc": desc, + "root": "/data", + "path": path, + "extra_entities": extra or [], + } + + +def _anat_row( + *, + sub: str, + ses: str, + suffix: str, + desc: str | None = None, + ext: str = ".nii.gz", + extra: list[dict[str, str]] | None = None, +) -> dict[str, object]: + """Build a single BIDS-like row for anatomical/template derivatives.""" + desc_part = f"_desc-{desc}" if desc else "" + path = f"sub-{sub}/ses-{ses}/anat/sub-{sub}_ses-{ses}{desc_part}_{suffix}{ext}" + return { + "datatype": "anat", + "suffix": suffix, + "ext": ext, + "sub": sub, + "ses": ses, + "space": None, + "desc": desc, + "root": "/data", + "path": path, + "extra_entities": extra or [], + } + + +def _df(*rows: dict[str, object]) -> pl.DataFrame: + return pl.DataFrame(list(rows)) + + +def _make_long_outputs(workdir: Path) -> FunctionalLongOutputs: + """Build a populated FunctionalLongOutputs pointing at dummy files.""" + + def _dummy(name: str) -> Path: + p = workdir / name + p.write_bytes(b"\x00") + return p + + return FunctionalLongOutputs( + bold_to_long_xfm=_dummy("bold_to_long_xfm.nii.gz"), + sbref=_dummy("sbref.nii.gz"), + bold=_dummy("bold.nii.gz"), + bold_mask=_dummy("bold_mask.nii.gz"), + regressed_bold={ + "36-parameter": _dummy("regressed_36p.nii.gz"), + "aCompCor": _dummy("regressed_acompcor.nii.gz"), + }, + cleaned_bold={ + "36-parameter": _dummy("cleaned_36p.nii.gz"), + "aCompCor": _dummy("cleaned_acompcor.nii.gz"), + }, + ) + + +# --------------------------------------------------------------------------- +# resolve_longitudinal_func +# --------------------------------------------------------------------------- + + +class TestResolveLongitudinalFunc: + """Tests for :func:`resolve_longitudinal_func`.""" + + def test_resolves_single_regressor(self, tmp_path: Path) -> None: + """Single regressor resolves raw regressor file from derivatives.""" + func_df = _df( + _func_row(sub="01", ses="baseline", suffix="sbref"), + _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), + _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row( + sub="01", + ses="baseline", + suffix="xfm", + desc="linearITK", + ext=".txt", + extra=[ + {"key": "from", "value": "bold"}, + {"key": "to", "value": "T1w"}, + {"key": "mode", "value": "image"}, + ], + ), + _func_row( + sub="01", + ses="baseline", + suffix="regressors", + desc="36parameter", + ext=".1D", + ), + ) + tpl_df = _df( + _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row( + sub="01", + ses="longitudinal", + suffix="xfm", + ext=".txt", + extra=[ + {"key": "from", "value": "baseline"}, + {"key": "to", "value": "longitudinal"}, + ], + ), + ) + + ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path) + func_q = ctx.bids(datatype="func") + tpl_q = ctx.bids(datatype="anat").derive(ses="longitudinal") + + resolved = resolve_longitudinal_func( + func_q, + tpl_q, + func_df, + tpl_df, + ses="baseline", + regressors=["36-parameter"], + ) + + assert set(resolved) == { + "template", + "anat_to_template_xfm", + "bold_to_anat_itk", + "sbref", + "bold", + "bold_mask", + "regressor_files", + } + assert isinstance(resolved["regressor_files"], dict) + assert "36-parameter" in resolved["regressor_files"] + assert str(resolved["regressor_files"]["36-parameter"]).endswith( + "regressors.1D" + ) + + def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: + """Multiple regressors each get their own raw regressor file resolved.""" + func_df = _df( + _func_row(sub="01", ses="baseline", suffix="sbref"), + _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), + _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row( + sub="01", + ses="baseline", + suffix="xfm", + desc="linearITK", + ext=".txt", + extra=[ + {"key": "from", "value": "bold"}, + {"key": "to", "value": "T1w"}, + {"key": "mode", "value": "image"}, + ], + ), + _func_row( + sub="01", + ses="baseline", + suffix="regressors", + desc="36parameter", + ext=".1D", + ), + _func_row( + sub="01", + ses="baseline", + suffix="regressors", + desc="aCompCor", + ext=".1D", + ), + ) + tpl_df = _df( + _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row( + sub="01", + ses="longitudinal", + suffix="xfm", + ext=".txt", + extra=[ + {"key": "from", "value": "baseline"}, + {"key": "to", "value": "longitudinal"}, + ], + ), + ) + + ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path) + func_q = ctx.bids(datatype="func") + tpl_q = ctx.bids(datatype="anat").derive(ses="longitudinal") + + resolved = resolve_longitudinal_func( + func_q, + tpl_q, + func_df, + tpl_df, + ses="baseline", + regressors=["36-parameter", "aCompCor"], + ) + + reg_files = resolved["regressor_files"] + assert isinstance(reg_files, dict) + assert set(reg_files) == {"36-parameter", "aCompCor"} + + def test_missing_regressor_raises(self, tmp_path: Path) -> None: + """Requesting a regressor not present in derivatives raises.""" + func_df = _df( + _func_row(sub="01", ses="baseline", suffix="sbref"), + _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), + _func_row(sub="01", ses="baseline", suffix="mask", desc="brain"), + _func_row( + sub="01", + ses="baseline", + suffix="xfm", + desc="linearITK", + ext=".txt", + extra=[ + {"key": "from", "value": "bold"}, + {"key": "to", "value": "T1w"}, + {"key": "mode", "value": "image"}, + ], + ), + _func_row( + sub="01", + ses="baseline", + suffix="regressors", + desc="36parameter", + ext=".1D", + ), + ) + tpl_df = _df( + _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row( + sub="01", + ses="longitudinal", + suffix="xfm", + ext=".txt", + extra=[ + {"key": "from", "value": "baseline"}, + {"key": "to", "value": "longitudinal"}, + ], + ), + ) + + ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path) + func_q = ctx.bids(datatype="func") + tpl_q = ctx.bids(datatype="anat").derive(ses="longitudinal") + + with pytest.raises(FileNotFoundError): + resolve_longitudinal_func( + func_q, + tpl_q, + func_df, + tpl_df, + ses="baseline", + regressors=["aCompCor"], + ) + + def test_bold_mask_mandatory(self, tmp_path: Path) -> None: + """bold_mask is now resolved with expect(), so missing raises.""" + func_df = _df( + _func_row(sub="01", ses="baseline", suffix="sbref"), + _func_row(sub="01", ses="baseline", suffix="bold", desc="preproc"), + _func_row( + sub="01", + ses="baseline", + suffix="xfm", + desc="linearITK", + ext=".txt", + extra=[ + {"key": "from", "value": "bold"}, + {"key": "to", "value": "T1w"}, + {"key": "mode", "value": "image"}, + ], + ), + _func_row( + sub="01", + ses="baseline", + suffix="regressors", + desc="36parameter", + ext=".1D", + ), + ) + tpl_df = _df( + _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row( + sub="01", + ses="longitudinal", + suffix="xfm", + ext=".txt", + extra=[ + {"key": "from", "value": "baseline"}, + {"key": "to", "value": "longitudinal"}, + ], + ), + ) + + ctx = RunContext(sub="01", ses="baseline", output_dir=tmp_path) + func_q = ctx.bids(datatype="func") + tpl_q = ctx.bids(datatype="anat").derive(ses="longitudinal") + + with pytest.raises(FileNotFoundError): + resolve_longitudinal_func( + func_q, + tpl_q, + func_df, + tpl_df, + ses="baseline", + regressors=["36-parameter"], + ) + + +# --------------------------------------------------------------------------- +# export_longitudinal_func +# --------------------------------------------------------------------------- + + +class TestExportLongitudinalFunc: + """Tests for :func:`export_longitudinal_func`.""" + + def test_writes_expected_files(self, tmp_path: Path) -> None: + """Exports BOLD, sbref, mask, xfm, plus per-regressor regressed/cleaned.""" + workdir = tmp_path / "work" + workdir.mkdir() + out_dir = tmp_path / "out" + + ctx = RunContext(sub="01", ses="baseline", output_dir=out_dir) + fex = ctx.bids(datatype="func").derive(space="longitudinal") + + outputs = _make_long_outputs(workdir) + export_longitudinal_func(fex, outputs, regressors=["36-parameter", "aCompCor"]) + + saved = sorted(p.name for p in out_dir.rglob("*.*")) + # 4 fixed (sbref, bold, xfm, mask) + # + 2 regressors x 2 (regressed + cleaned) = 4 per-regressor + # = 8 total + assert len(saved) == 8 + + def test_regressor_entity_in_filenames(self, tmp_path: Path) -> None: + """Per-regressor outputs carry the reg- entity.""" + workdir = tmp_path / "work" + workdir.mkdir() + out_dir = tmp_path / "out" + + ctx = RunContext(sub="01", ses="baseline", output_dir=out_dir) + fex = ctx.bids(datatype="func").derive(space="longitudinal") + + outputs = _make_long_outputs(workdir) + export_longitudinal_func(fex, outputs, regressors=["36-parameter", "aCompCor"]) + + names = [p.name for p in out_dir.rglob("*.*")] + reg_files = [n for n in names if "reg-" in n] + assert len(reg_files) == 4 + + assert any("reg-36parameter" in n for n in reg_files) + assert any("reg-aCompCor" in n for n in reg_files) + + def test_single_regressor_file_count(self, tmp_path: Path) -> None: + """Single regressor produces 6 files: 4 fixed + 2 per-regressor.""" + workdir = tmp_path / "work" + workdir.mkdir() + out_dir = tmp_path / "out" + + ctx = RunContext(sub="01", ses="baseline", output_dir=out_dir) + fex = ctx.bids(datatype="func").derive(space="longitudinal") + + outputs = _make_long_outputs(workdir) + export_longitudinal_func(fex, outputs, regressors=["36-parameter"]) + + saved = list(out_dir.rglob("*.*")) + assert len(saved) == 6 From a3e77071394ffbcf0303ee390352fc18ff5681d1 Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 12:28:45 -0400 Subject: [PATCH 04/11] Remove redundant regressor_set, replace fake tests with real ds000114 integration - Drop regressor_set param from longitudinal_process; iterate regressor_files.keys() instead. The resolve layer already filters to the requested strategies, so the dict keys ARE the set. Eliminates the caller sync burden. - Delete tests/full_pipeline/longitudinal/ -- the conftest hacked the cross-sectional anat brain as a "longitudinal template", which exercises the code path but not the actual transform chain. - Rewrite tests/integration/longitudinal/test_regression_reuse.py as a proper end-to-end CLI test: rbc functional -> rbc longitudinal functional on ds000114 sub-01 ses-test via subprocess. Asserts expected BIDS tree, non-degenerate variance, and binary mask. - Add ds000114_func_derivatives and longitudinal_func_output fixtures to the integration conftest so the full cross-sectional -> longitudinal chain runs on real multi-session data with docker. --- .../orchestration/longitudinal/functional.py | 5 +- src/rbc/workflows/longitudinal/functional.py | 15 +- tests/full_pipeline/longitudinal/__init__.py | 1 - tests/full_pipeline/longitudinal/conftest.py | 110 ----------- .../longitudinal/test_functional.py | 64 ------ tests/integration/longitudinal/conftest.py | 68 +++++++ .../longitudinal/test_regression_reuse.py | 184 +++++++----------- 7 files changed, 148 insertions(+), 299 deletions(-) delete mode 100644 tests/full_pipeline/longitudinal/__init__.py delete mode 100644 tests/full_pipeline/longitudinal/conftest.py delete mode 100644 tests/full_pipeline/longitudinal/test_functional.py diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index 1c1e4365..d5145be1 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -60,10 +60,7 @@ def process_func( ses=pipe_ctx.ses, # type: ignore[arg-type] regressors=regressors, ) - func_outputs = functional_longitudinal( - **resolved, # type: ignore[arg-type] - regressor_set=regressors, - ) + func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type] fex = func_q.derive(space="longitudinal") export_longitudinal_func(fex, func_outputs, regressors=regressors) diff --git a/src/rbc/workflows/longitudinal/functional.py b/src/rbc/workflows/longitudinal/functional.py index 0ff35840..fdf7b669 100644 --- a/src/rbc/workflows/longitudinal/functional.py +++ b/src/rbc/workflows/longitudinal/functional.py @@ -19,9 +19,7 @@ ) if TYPE_CHECKING: - from collections.abc import Sequence from pathlib import Path - from typing import Literal _logger = logging.getLogger("rbc") @@ -57,7 +55,6 @@ def longitudinal_process( bold: Path, bold_mask: Path, regressor_files: dict[str, Path], - regressor_set: Sequence[Literal["36-parameter", "aCompCor"]] = ("36-parameter",), ) -> FunctionalLongOutputs: """Transform preprocessed functional outputs to longitudinal template space. @@ -66,6 +63,10 @@ def longitudinal_process( No regressor recomputation is performed: the same regressor matrix is applied in the new target space. + Regression is applied for every strategy present in *regressor_files*. + The caller controls which strategies to run by passing only the desired + keys (the resolve layer filters to the requested ``--regressor`` set). + Args: template: Longitudinal template image. anat_to_template_xfm: T1w-to-longitudinal-template composite warp. @@ -74,9 +75,8 @@ def longitudinal_process( bold: Preprocessed bold image. bold_mask: Bold brain mask. regressor_files: Raw (unfiltered) regressor ``.1D`` files from - the cross-sectional run, keyed by strategy name. - regressor_set: Which regressor strategies to apply. Must be a - subset of the keys in *regressor_files*. + the cross-sectional run, keyed by strategy name. Regression + is applied for every key in this dict. Returns: :class:`FunctionalLongOutputs` with all inputs transformed to @@ -98,8 +98,7 @@ def longitudinal_process( regressed: dict[str, Path] = {} cleaned: dict[str, Path] = {} - for reg in regressor_set: - reg_file = regressor_files[reg] + for reg, reg_file in regressor_files.items(): _logger.info("Longitudinal %s nuisance regression (no bandpass)", reg) regressed[reg] = apply_regression( bold_file=long_bold, diff --git a/tests/full_pipeline/longitudinal/__init__.py b/tests/full_pipeline/longitudinal/__init__.py deleted file mode 100644 index 138248c6..00000000 --- a/tests/full_pipeline/longitudinal/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Longitudinal full-pipeline e2e tests.""" diff --git a/tests/full_pipeline/longitudinal/conftest.py b/tests/full_pipeline/longitudinal/conftest.py deleted file mode 100644 index 4f167d6f..00000000 --- a/tests/full_pipeline/longitudinal/conftest.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Shared fixtures for longitudinal full-pipeline e2e tests. - -Runs the longitudinal template + anatomical + functional stages once -per session, caching outputs for all tests in this directory. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING, NamedTuple - -import pytest - -from rbc.metadata import FunctionalMetadata -from rbc.workflows import anatomical_preprocess, functional_preprocess -from rbc.workflows.longitudinal.functional import ( - FunctionalLongOutputs, -) -from rbc.workflows.longitudinal.functional import ( - longitudinal_process as functional_longitudinal, -) - -if TYPE_CHECKING: - from collections.abc import Generator - - import niwrap - from conftest import TestSubjectData - - from rbc.workflows import AnatomicalOutputs, FunctionalOutputs - -MANIFEST_PATH = Path(__file__).parent / ".last_run_longitudinal.json" - - -class LongitudinalPipelineData(NamedTuple): - """Shared outputs from the longitudinal functional pipeline.""" - - anat: AnatomicalOutputs - func: FunctionalOutputs - long_func: FunctionalLongOutputs - - -def _to_dict(obj: object) -> dict | str | None: - """Convert workflow NamedTuple object to dict recursively.""" - if hasattr(obj, "_asdict"): - return {k: _to_dict(v) for k, v in obj._asdict().items()} - if isinstance(obj, dict): - return {k: _to_dict(v) for k, v in obj.items()} - return str(obj) if obj is not None else None - - -@pytest.fixture(scope="session") -def longitudinal_manifest() -> Generator[dict[str, object], None, None]: - """Shared manifest written to disk at session end.""" - data: dict[str, object] = {} - yield data - MANIFEST_PATH.write_text(json.dumps(data, indent=2)) - - -@pytest.fixture(scope="session") -def longitudinal_pipeline_data( - test_subject: TestSubjectData, - niwrap_runner: niwrap.Runner, # noqa: ARG001 - longitudinal_manifest: dict[str, object], -) -> LongitudinalPipelineData: - """Run cross-sectional + longitudinal functional pipeline once. - - Since the full_pipeline test dataset (ds000001) is single-session, - we simulate longitudinal processing by treating the cross-sectional - outputs as if they came from a multi-session subject. The longitudinal - workflow only needs a template, xfm, and the cross-sectional BOLD + - regressors, so we can construct a synthetic longitudinal template from - the anatomical outputs. - """ - # Cross-sectional anat - anat = anatomical_preprocess(test_subject.t1w) - - # Cross-sectional func - func_metadata = FunctionalMetadata.load(test_subject.bold) - func = functional_preprocess( - in_bold=test_subject.bold, - t1w_brain=anat.brain, - wm_bbr_mask=anat.wm_bbr_mask, - brain_mask=anat.brain_mask, - csf_mask=anat.csf_mask, - wm_mask=anat.wm_mask, - anat_to_template=anat.anat_to_template_xfm, - metadata=func_metadata, - ) - - # Longitudinal functional: use anat-to-template xfm as the - # "longitudinal template" xfm and the brain as the "template" image. - # This is not anatomically meaningful but exercises the full pipeline - # chain through regression reuse. - long_func = functional_longitudinal( - template=anat.brain, - anat_to_template_xfm=anat.anat_to_template_xfm, - bold_to_anat_itk=func.bold_to_anat_itk, - sbref=func.sbref, - bold=func.preproc_bold, - bold_mask=func.bold_mask, - regressor_files=func.regressor_file, - regressor_set=["36-parameter"], - ) - - longitudinal_manifest["anat"] = _to_dict(anat) - longitudinal_manifest["func"] = _to_dict(func) - longitudinal_manifest["long_func"] = _to_dict(long_func) - - return LongitudinalPipelineData(anat=anat, func=func, long_func=long_func) diff --git a/tests/full_pipeline/longitudinal/test_functional.py b/tests/full_pipeline/longitudinal/test_functional.py deleted file mode 100644 index 744386e5..00000000 --- a/tests/full_pipeline/longitudinal/test_functional.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Full e2e test for the longitudinal functional preprocessing workflow. - -Tier-4 test for Stage 5 of the longitudinal refactor (tracker: #301). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import nibabel as nib -import numpy as np - -if TYPE_CHECKING: - from full_pipeline.longitudinal.conftest import LongitudinalPipelineData - - -def test_longitudinal_func_outputs_exist( - longitudinal_pipeline_data: LongitudinalPipelineData, -) -> None: - """All FunctionalLongOutputs paths exist on disk.""" - outputs = longitudinal_pipeline_data.long_func - - for field_name, value in outputs._asdict().items(): - if isinstance(value, dict): - for key, path in value.items(): - assert Path(path).exists(), ( - f"{field_name}[{key!r}] does not exist: {path}" - ) - elif value is not None: - assert Path(value).exists(), f"{field_name} does not exist: {value}" - - -def test_regressed_bold_non_degenerate( - longitudinal_pipeline_data: LongitudinalPipelineData, -) -> None: - """Regressed BOLD in longitudinal space has non-zero variance.""" - outputs = longitudinal_pipeline_data.long_func - for reg, path in outputs.regressed_bold.items(): - img = nib.nifti1.load(path) - data = img.get_fdata() - assert data.var() > 0, f"Regressed BOLD for {reg!r} has zero variance" - - -def test_cleaned_bold_non_degenerate( - longitudinal_pipeline_data: LongitudinalPipelineData, -) -> None: - """Cleaned (bandpassed) BOLD in longitudinal space has non-zero variance.""" - outputs = longitudinal_pipeline_data.long_func - for reg, path in outputs.cleaned_bold.items(): - img = nib.nifti1.load(path) - data = img.get_fdata() - assert data.var() > 0, f"Cleaned BOLD for {reg!r} has zero variance" - - -def test_bold_mask_is_binary( - longitudinal_pipeline_data: LongitudinalPipelineData, -) -> None: - """Warped bold mask in longitudinal space is binary.""" - mask_path = longitudinal_pipeline_data.long_func.bold_mask - img = nib.nifti1.load(mask_path) - data = img.get_fdata() - unique = np.unique(data) - assert set(unique).issubset({0, 1}), f"Mask has non-binary values: {unique}" diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index daaf1bc4..2f126d2b 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -25,6 +25,7 @@ ) _SUB = "01" +_TASK = "fingerfootlips" def _rbc_exe() -> str: @@ -143,3 +144,70 @@ def longitudinal_template_output( ], ) return ds000114_anat_derivatives + + +@pytest.fixture(scope="session") +def ds000114_func_derivatives( + ds000114_dataset: Path, + longitudinal_template_output: Path, + _runner: str, +) -> Path: + """Run ``rbc functional`` on ds000114 sub-01 ses-test. + + Produces cross-sectional functional derivatives (including raw + regressor ``.1D`` files) that the longitudinal functional stage + consumes. Writes into the same derivatives tree as the template + stage so all outputs are visible to downstream fixtures. + + Only ses-test is processed (one session is sufficient to exercise + the longitudinal functional chain). + """ + _run_rbc( + [ + "functional", + str(ds000114_dataset), + str(longitudinal_template_output), + "-o", + str(longitudinal_template_output), + "--runner", + _runner, + "--participant-label", + _SUB, + "--session-label", + "test", + "--task", + _TASK, + ], + ) + return longitudinal_template_output + + +@pytest.fixture(scope="session") +def longitudinal_func_output( + ds000114_func_derivatives: Path, + _runner: str, +) -> Path: + """Run ``rbc longitudinal functional`` on ds000114 sub-01 ses-test. + + Produces longitudinal functional derivatives (warped BOLD, + per-regressor regressed/cleaned BOLD) by consuming the + cross-sectional functional outputs and the longitudinal template. + """ + _run_rbc( + [ + "longitudinal", + "functional", + str(ds000114_func_derivatives), + "-o", + str(ds000114_func_derivatives), + "--runner", + _runner, + "--participant-label", + _SUB, + "--session-label", + "test", + "--task", + _TASK, + ], + ) + return ds000114_func_derivatives diff --git a/tests/integration/longitudinal/test_regression_reuse.py b/tests/integration/longitudinal/test_regression_reuse.py index 5803dbd9..6dc41c52 100644 --- a/tests/integration/longitudinal/test_regression_reuse.py +++ b/tests/integration/longitudinal/test_regression_reuse.py @@ -1,8 +1,8 @@ -"""Integration test for longitudinal regression reuse. +"""Integration test for longitudinal functional regression reuse. -Given cross-sectional outputs and raw regressor ``.1D`` files, -``apply_regression`` and ``apply_regression_bandpass`` on warped BOLD -produce non-degenerate outputs (non-zero variance, correct timepoints). +Exercises the full ``rbc longitudinal functional`` CLI on ds000114, +which chains: composed BOLD-to-longitudinal warp, BOLD resampling, +and re-application of cross-sectional regressors in longitudinal space. Tier-2 integration test for Stage 5 of the longitudinal refactor (tracker: #301). @@ -16,131 +16,91 @@ import numpy as np import pytest -from rbc.core.functional import apply_regression, apply_regression_bandpass -from rbc.core.nifti import nifti_num_volumes - if TYPE_CHECKING: from pathlib import Path - from conftest import TestSubjectData - -def _prepare_short_bold_and_mask( - test_subject: TestSubjectData, - n_vols: int = 50, -) -> tuple[Path, Path, Path]: - """Prepare a short BOLD series with brain mask and dummy regressor. +@pytest.mark.slow +def test_longitudinal_functional_produces_expected_tree( + longitudinal_func_output: Path, +) -> None: + """``rbc longitudinal functional`` writes per-regressor BOLD derivatives. - Returns (bold_path, mask_path, regressor_1d_path). + Checks that the ``ses-test`` func directory under ``space-longitudinal`` + contains the expected files: warped BOLD, sbref, mask, composite xfm, + and per-regressor regressed + cleaned BOLD. """ - from niwrap import afni - - from rbc.core.common import deoblique_and_reorient - from rbc.core.functional import ( - extract_motion_reference, - fsl_motion_correction, - nuisance_regression, - ) - from rbc.core.niwrap import generate_exec_folder - - reoriented = deoblique_and_reorient(in_file=test_subject.bold) - truncated = afni.v_3dcalc( - dataset_a=afni.v_3dcalc_dataset_a_file( - file=reoriented.out_file, selectors_=f"[0..{n_vols - 1}]" - ), - expression="a", - prefix="test_bold.nii.gz", + func_dir = longitudinal_func_output / "sub-01" / "ses-test" / "func" + stem = "sub-01_ses-test_task-fingerfootlips" + + expected_fragments = [ + f"{stem}_space-longitudinal_sbref.nii.gz", + f"{stem}_space-longitudinal_desc-preproc_bold.nii.gz", + f"{stem}_space-longitudinal_desc-brain_mask.nii.gz", + f"{stem}_space-longitudinal_desc-regressed_reg-36parameter_bold.nii.gz", + f"{stem}_space-longitudinal_desc-preproc_reg-36parameter_bold.nii.gz", + ] + tree = sorted( + str(p.relative_to(longitudinal_func_output)) + for p in longitudinal_func_output.rglob("*") + if p.is_file() ) - assert truncated.output_file is not None - motion_ref = extract_motion_reference(in_file=truncated.output_file) - mc = fsl_motion_correction(in_file=truncated.output_file, ref_file=motion_ref) - - automask = afni.v_3d_automask( - in_file=mc.bold, - prefix="brain_mask.nii.gz", - ) - assert automask.mask_file is not None - - # Create synthetic tissue masks for regressor computation - from scipy.ndimage import binary_erosion - - out_dir = generate_exec_folder("regression_reuse_masks") - brain_img = nib.nifti1.load(automask.mask_file) - brain_data = brain_img.get_fdata() > 0 - eroded_1 = binary_erosion(brain_data, iterations=1) - csf_data = brain_data & ~eroded_1 - wm_data = binary_erosion(brain_data, iterations=3) - - csf_file = out_dir / "csf_mask.nii.gz" - wm_file = out_dir / "wm_mask.nii.gz" - nib.nifti1.Nifti1Image( - csf_data.astype(np.uint8), brain_img.affine, brain_img.header - ).to_filename(str(csf_file)) - nib.nifti1.Nifti1Image( - wm_data.astype(np.uint8), brain_img.affine, brain_img.header - ).to_filename(str(wm_file)) - - # Compute raw regressors - reg_result = nuisance_regression( - bold_file=mc.bold, - brain_mask_file=automask.mask_file, - csf_mask_file=csf_file, - wm_mask_file=wm_file, - motion_params=mc.motion_params, - regressor_set="36-parameter", - ) - - return mc.bold, automask.mask_file, reg_result.regressor_file + for name in expected_fragments: + assert (func_dir / name).is_file(), ( + f"Missing: {name}\n--- file tree ---\n" + "\n".join(tree) + ) @pytest.mark.slow -def test_apply_regression_on_warped_bold(test_subject: TestSubjectData) -> None: - """Raw regressors from cross-sectional run produce valid regression output. - - Simulates the longitudinal case: regression is applied to a BOLD - timeseries using regressors that were computed in a different space. - The regressor matrix has the right number of timepoints (matched by - the cross-sectional pipeline), so regression should succeed. - """ - bold, mask, regressor_1d = _prepare_short_bold_and_mask(test_subject) - - result = apply_regression( - bold_file=bold, - brain_mask_file=mask, - regressor_file=regressor_1d, +def test_regressed_bold_non_degenerate( + longitudinal_func_output: Path, +) -> None: + """Regressed BOLD in longitudinal space has non-zero variance.""" + path = ( + longitudinal_func_output + / "sub-01" + / "ses-test" + / "func" + / "sub-01_ses-test_task-fingerfootlips" + "_space-longitudinal_desc-regressed_reg-36parameter_bold.nii.gz" ) - - assert result.regressed_bold.exists() - assert nifti_num_volumes(result.regressed_bold) == nifti_num_volumes(bold) - - # Non-degenerate: regressed BOLD should have non-zero variance - img = nib.nifti1.load(result.regressed_bold) + img = nib.nifti1.load(path) data = img.get_fdata() - mask_img = nib.nifti1.load(mask) - mask_data = mask_img.get_fdata() > 0 - brain_ts = data[mask_data] - assert brain_ts.var(axis=-1).mean() > 0, "Regressed BOLD has zero variance" + assert data.var() > 0, "Regressed BOLD has zero variance" @pytest.mark.slow -def test_apply_regression_bandpass_on_warped_bold( - test_subject: TestSubjectData, +def test_cleaned_bold_non_degenerate( + longitudinal_func_output: Path, ) -> None: - """Bandpass regression on warped BOLD produces non-degenerate output.""" - bold, mask, regressor_1d = _prepare_short_bold_and_mask(test_subject) - - result = apply_regression_bandpass( - bold_file=bold, - brain_mask_file=mask, - regressor_file=regressor_1d, + """Cleaned (bandpassed) BOLD in longitudinal space has non-zero variance.""" + path = ( + longitudinal_func_output + / "sub-01" + / "ses-test" + / "func" + / "sub-01_ses-test_task-fingerfootlips" + "_space-longitudinal_desc-preproc_reg-36parameter_bold.nii.gz" ) + img = nib.nifti1.load(path) + data = img.get_fdata() + assert data.var() > 0, "Cleaned BOLD has zero variance" - assert result.regressed_bold.exists() - assert nifti_num_volumes(result.regressed_bold) == nifti_num_volumes(bold) - img = nib.nifti1.load(result.regressed_bold) +@pytest.mark.slow +def test_bold_mask_is_binary( + longitudinal_func_output: Path, +) -> None: + """Warped bold mask in longitudinal space is binary.""" + path = ( + longitudinal_func_output + / "sub-01" + / "ses-test" + / "func" + / "sub-01_ses-test_task-fingerfootlips" + "_space-longitudinal_desc-brain_mask.nii.gz" + ) + img = nib.nifti1.load(path) data = img.get_fdata() - mask_img = nib.nifti1.load(mask) - mask_data = mask_img.get_fdata() > 0 - brain_ts = data[mask_data] - assert brain_ts.var(axis=-1).mean() > 0, "Cleaned BOLD has zero variance" + unique = np.unique(data) + assert set(unique).issubset({0, 1}), f"Mask has non-binary values: {unique}" From 08530bcef687f206bb42d11f51ef85953b3da7f0 Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 14:25:21 -0400 Subject: [PATCH 05/11] Fix integration test fixture ordering to avoid bids2table discovery issue ds000114_func_derivatives now depends on ds000114_anat_derivatives directly instead of longitudinal_template_output. This ensures rbc functional runs before the template step writes ses-longitudinal files into the derivatives dir, avoiding a potential bids2table discovery issue where the ses-longitudinal anat files interfere with the functional pipeline's anat resolution. longitudinal_func_output depends on both ds000114_func_derivatives and longitudinal_template_output to ensure both cross-sectional functional outputs and the longitudinal template are present before rbc longitudinal functional runs. --- tests/integration/longitudinal/conftest.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index 2f126d2b..89b53759 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -149,14 +149,14 @@ def longitudinal_template_output( @pytest.fixture(scope="session") def ds000114_func_derivatives( ds000114_dataset: Path, - longitudinal_template_output: Path, + ds000114_anat_derivatives: Path, _runner: str, ) -> Path: """Run ``rbc functional`` on ds000114 sub-01 ses-test. Produces cross-sectional functional derivatives (including raw regressor ``.1D`` files) that the longitudinal functional stage - consumes. Writes into the same derivatives tree as the template + consumes. Writes into the same derivatives tree as the anatomical stage so all outputs are visible to downstream fixtures. Only ses-test is processed (one session is sufficient to exercise @@ -166,9 +166,9 @@ def ds000114_func_derivatives( [ "functional", str(ds000114_dataset), - str(longitudinal_template_output), + str(ds000114_anat_derivatives), "-o", - str(longitudinal_template_output), + str(ds000114_anat_derivatives), "--runner", _runner, "--participant-label", @@ -179,12 +179,13 @@ def ds000114_func_derivatives( _TASK, ], ) - return longitudinal_template_output + return ds000114_anat_derivatives @pytest.fixture(scope="session") def longitudinal_func_output( ds000114_func_derivatives: Path, + longitudinal_template_output: Path, # noqa: ARG001 — fixture dep for ordering _runner: str, ) -> Path: """Run ``rbc longitudinal functional`` on ds000114 sub-01 ses-test. @@ -192,6 +193,8 @@ def longitudinal_func_output( Produces longitudinal functional derivatives (warped BOLD, per-regressor regressed/cleaned BOLD) by consuming the cross-sectional functional outputs and the longitudinal template. + Both ``ds000114_func_derivatives`` and ``longitudinal_template_output`` + write into the same derivatives directory, so all files are visible. """ _run_rbc( [ From 8f6e1a6b650ff52c1f6b5594bfac2909c8c2b77a Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 14:35:55 -0400 Subject: [PATCH 06/11] Debug: log derivatives tree before rbc functional in integration fixture --- tests/integration/longitudinal/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index 89b53759..18fe634c 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -162,6 +162,18 @@ def ds000114_func_derivatives( Only ses-test is processed (one session is sufficient to exercise the longitudinal functional chain). """ + # Debug: list derivatives tree before running rbc functional + _tree = sorted( + str(p.relative_to(ds000114_anat_derivatives)) + for p in ds000114_anat_derivatives.rglob("*") + if p.is_file() + ) + print( # noqa: T201 + f"\n--- derivatives tree before rbc functional " + f"({ds000114_anat_derivatives}) ---\n" + + "\n".join(_tree) + + "\n--- end tree ---\n" + ) _run_rbc( [ "functional", From d5a86a17ce10e9ff7a014a239a932e8f6fafe864 Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 14:53:48 -0400 Subject: [PATCH 07/11] Debug: dump bids2table output for derivatives dir before rbc functional --- tests/integration/longitudinal/conftest.py | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index 18fe634c..2460ea16 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -162,18 +162,21 @@ def ds000114_func_derivatives( Only ses-test is processed (one session is sufficient to exercise the longitudinal functional chain). """ - # Debug: list derivatives tree before running rbc functional - _tree = sorted( - str(p.relative_to(ds000114_anat_derivatives)) - for p in ds000114_anat_derivatives.rglob("*") - if p.is_file() - ) - print( # noqa: T201 - f"\n--- derivatives tree before rbc functional " - f"({ds000114_anat_derivatives}) ---\n" - + "\n".join(_tree) - + "\n--- end tree ---\n" - ) + # Debug: dump bids2table output for derivatives dir + import bids2table as b2t + import polars as pl + + _datasets = list(b2t.find_bids_datasets(ds000114_anat_derivatives)) + print(f"\n--- b2t datasets in {ds000114_anat_derivatives}: {_datasets} ---") # noqa: T201 + _tables = list(b2t.batch_index_dataset(_datasets, max_workers=0)) + for _t in _tables: + _df = pl.DataFrame(pl.from_arrow(_t)) + _anat = _df.filter(pl.col("datatype") == "anat") + print( # noqa: T201 + "b2t anat rows:", + _anat.select(["sub", "ses", "suffix", "desc", "space"]).to_dicts(), + ) + print("--- end b2t debug ---\n") # noqa: T201 _run_rbc( [ "functional", From 0a0bf34a6b64021768d822c827d32703857098bf Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 15:18:53 -0400 Subject: [PATCH 08/11] Debug: inline rbc functional call with verbose + tree dump on failure --- tests/integration/longitudinal/conftest.py | 38 +++++++++++++--------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index 2460ea16..fbd94c3b 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -162,23 +162,10 @@ def ds000114_func_derivatives( Only ses-test is processed (one session is sufficient to exercise the longitudinal functional chain). """ - # Debug: dump bids2table output for derivatives dir - import bids2table as b2t - import polars as pl - - _datasets = list(b2t.find_bids_datasets(ds000114_anat_derivatives)) - print(f"\n--- b2t datasets in {ds000114_anat_derivatives}: {_datasets} ---") # noqa: T201 - _tables = list(b2t.batch_index_dataset(_datasets, max_workers=0)) - for _t in _tables: - _df = pl.DataFrame(pl.from_arrow(_t)) - _anat = _df.filter(pl.col("datatype") == "anat") - print( # noqa: T201 - "b2t anat rows:", - _anat.select(["sub", "ses", "suffix", "desc", "space"]).to_dicts(), - ) - print("--- end b2t debug ---\n") # noqa: T201 - _run_rbc( + # Run rbc functional with verbose to get load_table debug output + result = subprocess.run( # noqa: S603 [ + _rbc_exe(), "functional", str(ds000114_dataset), str(ds000114_anat_derivatives), @@ -192,8 +179,27 @@ def ds000114_func_derivatives( "test", "--task", _TASK, + "-v", ], + capture_output=True, + text=True, + timeout=7200, ) + if result.returncode != 0: + # Dump the derivatives tree for diagnosis + _tree = sorted( + str(p.relative_to(ds000114_anat_derivatives)) + for p in ds000114_anat_derivatives.rglob("*") + if p.is_file() + ) + msg = ( + f"rbc functional exited with code {result.returncode}\n" + f"--- derivatives dir: {ds000114_anat_derivatives} ---\n" + + "\n".join(_tree) + + f"\n--- stdout ---\n{result.stdout[-2000:]}\n" + f"--- stderr ---\n{result.stderr[-2000:]}" + ) + raise AssertionError(msg) return ds000114_anat_derivatives From ec57a5efcc674ba826f82fbbd6d192145eeafc5d Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 15:30:37 -0400 Subject: [PATCH 09/11] Fix integration fixture: --task filter drops anat rows Filters.apply() applies the --task filter to ALL rows including anat. Anat files have task=null, so --task fingerfootlips drops them, causing resolve_functional to fail with FileNotFoundError on desc-brain_T1w. Drop --task from both rbc functional and rbc longitudinal functional fixture calls. ds000114 only has one task per session so the filter isn't needed. Remove debug logging from previous commits. --- tests/integration/longitudinal/conftest.py | 31 +++------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/tests/integration/longitudinal/conftest.py b/tests/integration/longitudinal/conftest.py index fbd94c3b..a71a659a 100644 --- a/tests/integration/longitudinal/conftest.py +++ b/tests/integration/longitudinal/conftest.py @@ -25,7 +25,6 @@ ) _SUB = "01" -_TASK = "fingerfootlips" def _rbc_exe() -> str: @@ -162,10 +161,11 @@ def ds000114_func_derivatives( Only ses-test is processed (one session is sufficient to exercise the longitudinal functional chain). """ - # Run rbc functional with verbose to get load_table debug output - result = subprocess.run( # noqa: S603 + # Note: do NOT pass --task here. The Filters.apply() task filter + # applies to ALL rows including anat, and anat rows have task=null, + # so --task would drop all anat rows and break resolve_functional. + _run_rbc( [ - _rbc_exe(), "functional", str(ds000114_dataset), str(ds000114_anat_derivatives), @@ -177,29 +177,8 @@ def ds000114_func_derivatives( _SUB, "--session-label", "test", - "--task", - _TASK, - "-v", ], - capture_output=True, - text=True, - timeout=7200, ) - if result.returncode != 0: - # Dump the derivatives tree for diagnosis - _tree = sorted( - str(p.relative_to(ds000114_anat_derivatives)) - for p in ds000114_anat_derivatives.rglob("*") - if p.is_file() - ) - msg = ( - f"rbc functional exited with code {result.returncode}\n" - f"--- derivatives dir: {ds000114_anat_derivatives} ---\n" - + "\n".join(_tree) - + f"\n--- stdout ---\n{result.stdout[-2000:]}\n" - f"--- stderr ---\n{result.stderr[-2000:]}" - ) - raise AssertionError(msg) return ds000114_anat_derivatives @@ -230,8 +209,6 @@ def longitudinal_func_output( _SUB, "--session-label", "test", - "--task", - _TASK, ], ) return ds000114_func_derivatives From 264c33cb6cc95c2cc292f99f3324b60d1963888a Mon Sep 17 00:00:00 2001 From: Florian Rupprecht Date: Thu, 16 Apr 2026 15:35:55 -0400 Subject: [PATCH 10/11] Fix Filters.apply() task filter dropping anat rows The --task filter applied pl.col("task") == value to all rows, but anat/dwi files have task=null, so they were silently dropped. This caused rbc functional --task