diff --git a/src/rbc/bids/longitudinal/anatomical.py b/src/rbc/bids/longitudinal/anatomical.py index 8d8d4472..816b74eb 100644 --- a/src/rbc/bids/longitudinal/anatomical.py +++ b/src/rbc/bids/longitudinal/anatomical.py @@ -43,7 +43,7 @@ def resolve_longitudinal_anat( Dict with keys matching ``longitudinal_process`` parameters. """ return { - "template": tpl_q.expect(tpl_df, suffix=Suffix.T1W), + "template": tpl_q.expect(tpl_df, suffix=Suffix.T1W, without=["res"]), "subj_to_template_xfm": tpl_q.expect( tpl_df, suffix="xfm", diff --git a/src/rbc/bids/longitudinal/functional.py b/src/rbc/bids/longitudinal/functional.py index 0ac9d457..c4cb0eb9 100644 --- a/src/rbc/bids/longitudinal/functional.py +++ b/src/rbc/bids/longitudinal/functional.py @@ -23,6 +23,7 @@ def resolve_longitudinal_func( tpl_df: pl.DataFrame, *, ses: str, + task: str, regressors: Sequence[str] = ("36-parameter",), ) -> dict[str, Path | dict[str, Path]]: """Resolve inputs for longitudinal functional processing. @@ -33,6 +34,7 @@ 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). + task: Task entity value to denote BOLD reference for template resampling. regressors: Regressor strategy names to resolve raw regressor files for. @@ -51,7 +53,7 @@ def resolve_longitudinal_func( ) return { - "template": tpl_q.expect(tpl_df, suffix="T1w"), + "template": tpl_q.expect(tpl_df, suffix="T1w", res=task), "anat_to_template_xfm": tpl_q.expect( tpl_df, suffix="xfm", diff --git a/src/rbc/bids/longitudinal/template.py b/src/rbc/bids/longitudinal/template.py index 8b5784f9..cb8ad387 100644 --- a/src/rbc/bids/longitudinal/template.py +++ b/src/rbc/bids/longitudinal/template.py @@ -7,7 +7,7 @@ import polars as pl -from rbc.bids import Suffix, bids_safe_label +from rbc.bids import FUNC_GROUP_ENTITIES, Suffix, bids_safe_label if TYPE_CHECKING: from rbc.bids import Bids @@ -21,11 +21,36 @@ class TemplateInputs(NamedTuple): sub: Subject label. sessions: Per-input session labels (parallel to ``files``). files: Per-session preprocessed T1w brain volumes. + bold_files: Per-session, unique preprocessed BOLD volumes (all tasks). """ sub: str sessions: list[str] files: list[Path] + bold_files: dict[BoldKey, Path] + + +# Use typing library instead of collections for static type hints +class BoldKey(NamedTuple): + """Key with associated entities to use as dict key to represent bold filepath found. + + Attributes: + task: Task associated with file. + run: Run associated with file. + acq: Acquisition associated with file. + dir: Direction associated with file. + echo: Echo associated with file. + part: Part associated with file. + rec: Recording associated with file. + """ + + task: str + run: str | None = None + acq: str | None = None + dir: str | None = None + echo: str | None = None + part: str | None = None + rec: str | None = None def discover_template_inputs( @@ -56,6 +81,13 @@ def discover_template_inputs( # the mri_robust_template invocation. pl.col("space").is_null(), ) + bold_rows = df.filter( + pl.col("ses") != "longitudinal", + pl.col("datatype") == "func", + pl.col("desc") == "preproc", + pl.col("suffix") == "bold", + pl.col("space").is_null(), + ) inputs: list[TemplateInputs] = [] skipped: list[str] = [] @@ -68,7 +100,19 @@ def discover_template_inputs( files = [ Path(row["root"]) / row["path"] for row in sub_group.iter_rows(named=True) ] - inputs.append(TemplateInputs(sub=sub, sessions=sessions, files=files)) + sub_bold = bold_rows.filter( + (pl.col("sub") == sub) & (pl.col("ses") == sessions[0]) + ).unique(subset=(*FUNC_GROUP_ENTITIES, "root", "path")) + bold_files: dict[BoldKey, Path] = { + BoldKey(**{ent: row[ent] for ent in FUNC_GROUP_ENTITIES}): Path(row["root"]) + / row["path"] + for row in sub_bold.iter_rows(named=True) + } + inputs.append( + TemplateInputs( + sub=sub, sessions=sessions, files=files, bold_files=bold_files + ) + ) return inputs, skipped @@ -81,6 +125,8 @@ def export_template(tpl: Bids, outputs: LongitudinalTemplateOutputs) -> None: outputs: Results from the longitudinal template workflow. """ tpl.save(outputs.template, suffix=Suffix.T1W) + for bold_key, bold_template in outputs.bold_templates.items(): + tpl.save(bold_template, res=bids_safe_label(bold_key.task), suffix=Suffix.T1W) for ses, xfm in zip(outputs.sessions, outputs.transforms, strict=True): ses_label = bids_safe_label(ses) tpl.save( diff --git a/src/rbc/core/longitudinal/resampling.py b/src/rbc/core/longitudinal/resampling.py new file mode 100644 index 00000000..0c1fdde6 --- /dev/null +++ b/src/rbc/core/longitudinal/resampling.py @@ -0,0 +1,45 @@ +"""Resampling utilities for longitudinal templates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import nibabel as nib +import numpy as np +from nibabel.processing import resample_from_to + +if TYPE_CHECKING: + from pathlib import Path + +from rbc.core.niwrap import generate_exec_folder + + +def resample_img_to_bold_grid(bold_ref: Path, img: Path, order: int = 3) -> Path: + """Resample template to BOLD grid if shapes differ. + + Args: + bold_ref: BOLD reference volume. + img: 3D image in target space to resample. + order: Interpolation order used during resampling + + Returns: + Resampled 3D image with BOLD grid + """ + bold_ref_img = nib.nifti1.load(bold_ref) + img_obj = nib.nifti1.load(img) + + # If 4D, extract first volume + if len(bold_ref_img.shape) > 3: + bold_ref_img = bold_ref_img.slicer[..., 0] + # If same shape, no need to resample + if bold_ref_img.shape == img_obj.shape and np.allclose( + bold_ref_img.affine, img_obj.affine + ): + return img + + img_resampled = resample_from_to(img_obj, bold_ref_img, order=order) + img_resampled_path = ( + generate_exec_folder("img_resample_to_bold_grid") / "resampled.nii.gz" + ) + nib.save(img_resampled, img_resampled_path) + return img_resampled_path diff --git a/src/rbc/core/longitudinal/transform.py b/src/rbc/core/longitudinal/transform.py index 7a147271..e6b3947d 100644 --- a/src/rbc/core/longitudinal/transform.py +++ b/src/rbc/core/longitudinal/transform.py @@ -36,6 +36,7 @@ def anat_transform(in_file: Path, template: Path, xfm: Path) -> Path: return ants.ants_apply_transforms( reference_image=template, input_image=in_file, + transform=[ants.ants_apply_transforms_transform_file_name(xfm)], output=ants.ants_apply_transforms_warped_output("subject_to_template.nii.gz"), dimensionality=3, interpolation=ants.ants_apply_transforms_linear(), diff --git a/src/rbc/orchestration/longitudinal/all.py b/src/rbc/orchestration/longitudinal/all.py index bd8902d0..6bc0b1d2 100644 --- a/src/rbc/orchestration/longitudinal/all.py +++ b/src/rbc/orchestration/longitudinal/all.py @@ -18,7 +18,7 @@ from rbc.bids import FUNC_GROUP_ENTITIES, Datatype, Suffix, extract_entities, load_table from rbc.bids.longitudinal.template import discover_template_inputs from rbc.bids.metrics import export_metrics -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files from rbc.context import RunContext from rbc.orchestration import Filters, RunnerConfig, init_runner from rbc.orchestration.longitudinal._iter import iter_sessions_with_template @@ -124,7 +124,7 @@ def run( ) row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, _FUNC_ENTITY_KEYS) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) func_long = func_q.derive(space="longitudinal") diff --git a/src/rbc/orchestration/longitudinal/anatomical.py b/src/rbc/orchestration/longitudinal/anatomical.py index 509116d1..8a5ccfc0 100644 --- a/src/rbc/orchestration/longitudinal/anatomical.py +++ b/src/rbc/orchestration/longitudinal/anatomical.py @@ -51,7 +51,7 @@ def process_anat( Workflow outputs for in-memory handoff to downstream stages. """ anat_df = anat_df.filter(pl.col("space").is_null()) - ents = extract_entities(anat_df.row(0, named=True), ["run"]) + ents = extract_entities(anat_df.row(0, named=True), ["run", "acq", "rec", "echo"]) anat_q = pipe_ctx.bids(datatype=Datatype.ANAT) tpl_q = anat_q.derive(ses="longitudinal") diff --git a/src/rbc/orchestration/longitudinal/functional.py b/src/rbc/orchestration/longitudinal/functional.py index dc971b38..913084da 100644 --- a/src/rbc/orchestration/longitudinal/functional.py +++ b/src/rbc/orchestration/longitudinal/functional.py @@ -10,7 +10,7 @@ export_longitudinal_func, resolve_longitudinal_func, ) -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files from rbc.orchestration import Filters, RunnerConfig, init_runner from rbc.orchestration.longitudinal._iter import iter_sessions_with_template from rbc.workflows.longitudinal.functional import ( @@ -53,7 +53,7 @@ def process_func( Workflow outputs for in-memory handoff to downstream stages. """ row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, list(_FUNC_ENTITY_KEYS)) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) tpl_q = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal") @@ -64,6 +64,7 @@ def process_func( func_df, tpl_df, ses=pipe_ctx.ses, # type: ignore[arg-type] + task=ents["task"], regressors=regressors, ) func_outputs = functional_longitudinal(**resolved) # type: ignore[arg-type] diff --git a/src/rbc/orchestration/longitudinal/qc.py b/src/rbc/orchestration/longitudinal/qc.py index 0fd8b7e0..33e55dab 100644 --- a/src/rbc/orchestration/longitudinal/qc.py +++ b/src/rbc/orchestration/longitudinal/qc.py @@ -20,7 +20,8 @@ resolve_longitudinal_qc, write_longitudinal_qc_tsv, ) -from rbc.bids.session import iter_session_files +from rbc.bids.session import _FUNC_ENTITY_KEYS, iter_session_files +from rbc.core.longitudinal.resampling import resample_img_to_bold_grid from rbc.core.niwrap import generate_exec_folder from rbc.core.qc.registration import registration_qc_metrics from rbc.orchestration import Filters, RunnerConfig, init_runner @@ -67,6 +68,10 @@ def process_qc( Returns: QC outputs with overlap metrics and pass/fail flag. """ + # Resample longitudinal anatomical mask to bold grid for QC purposes. + # Longitudinal processed data are registered to the longitudinal template with + # respective modality's native resolution + anat_brain_mask = resample_img_to_bold_grid(bold_mask, anat_brain_mask, order=0) anat_mask_arr = nib.nifti1.load(anat_brain_mask).get_fdata() bold_mask_arr = nib.nifti1.load(bold_mask).get_fdata() reg_metrics = registration_qc_metrics(anat_mask_arr, bold_mask_arr) @@ -155,7 +160,7 @@ def run( for func_df, _ in iter_session_files(session, groupby=FUNC_GROUP_ENTITIES): row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True) - ents = extract_entities(row, ["task", "run"]) + ents = extract_entities(row, _FUNC_ENTITY_KEYS) func_q = pipe_ctx.bids(datatype=Datatype.FUNC, entities=ents) func_long_q = func_q.derive(space="longitudinal") diff --git a/src/rbc/orchestration/longitudinal/template.py b/src/rbc/orchestration/longitudinal/template.py index 026c3b65..a76b0631 100644 --- a/src/rbc/orchestration/longitudinal/template.py +++ b/src/rbc/orchestration/longitudinal/template.py @@ -49,6 +49,7 @@ def process_subject( sub=inputs.sub, sessions=inputs.sessions, in_files=inputs.files, + bold_files=inputs.bold_files, ) tpl = pipe_ctx.bids(datatype=Datatype.ANAT).derive(ses="longitudinal") export_template(tpl, outputs) diff --git a/src/rbc/workflows/longitudinal/template.py b/src/rbc/workflows/longitudinal/template.py index c76494b7..7225749e 100644 --- a/src/rbc/workflows/longitudinal/template.py +++ b/src/rbc/workflows/longitudinal/template.py @@ -14,11 +14,14 @@ fs_to_itk_xfm, generate_robust_template, ) +from rbc.core.longitudinal.resampling import resample_img_to_bold_grid if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from pathlib import Path + from rbc.bids.longitudinal.template import BoldKey + _logger = logging.getLogger("rbc") @@ -27,11 +30,14 @@ class LongitudinalTemplateOutputs(NamedTuple): Attributes: template: Robust within-subject template volume. + bold_templates: Within-subject template volumes resampled to task-specific BOLD + resolutions. sessions: Session labels in the same order as ``transforms``. transforms: Per-session ITK-format session-to-template transforms. """ template: Path + bold_templates: dict[BoldKey, Path] sessions: list[str] transforms: list[Path] @@ -40,6 +46,7 @@ def generate_subject_template( sub: str, sessions: Sequence[str], in_files: Sequence[Path], + bold_files: Mapping[BoldKey, Path], ) -> LongitudinalTemplateOutputs: """Build a robust template and ITK transforms for one subject. @@ -47,6 +54,7 @@ def generate_subject_template( sub: Subject label (without the ``sub-`` prefix). sessions: Session labels parallel to ``in_files``. in_files: Per-session preprocessed T1w volumes (e.g. brain-extracted). + bold_files: Reference bold volumes to resample template for functional data. Returns: :class:`LongitudinalTemplateOutputs` ready for BIDS export. @@ -65,8 +73,15 @@ def generate_subject_template( in_xfms=robust.transforms, ) + _logger.info("Creating reference volumes for each functional task") + bold_templates = { + btask: resample_img_to_bold_grid(bfile, robust.template) + for btask, bfile in bold_files.items() + } + return LongitudinalTemplateOutputs( template=robust.template, + bold_templates=bold_templates, sessions=list(sessions), transforms=itk_xfms, ) diff --git a/tests/unit/bids/test_longitudinal_anatomical.py b/tests/unit/bids/test_longitudinal_anatomical.py index d80f6c89..59cc5064 100644 --- a/tests/unit/bids/test_longitudinal_anatomical.py +++ b/tests/unit/bids/test_longitudinal_anatomical.py @@ -99,6 +99,7 @@ def _anat_row( "sub": sub, "ses": ses, "desc": desc, + "res": None, "root": "/data", "path": path, "extra_entities": extra or [], diff --git a/tests/unit/bids/test_longitudinal_functional.py b/tests/unit/bids/test_longitudinal_functional.py index 98a17d41..fece77c2 100644 --- a/tests/unit/bids/test_longitudinal_functional.py +++ b/tests/unit/bids/test_longitudinal_functional.py @@ -28,6 +28,7 @@ def _func_row( sub: str, ses: str, suffix: str, + task: str, desc: str | None = None, ext: str = ".nii.gz", space: str | None = None, @@ -38,11 +39,12 @@ def _func_row( 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}" + f"sub-{sub}_ses-{ses}{space_part}_task-{task}{desc_part}_{suffix}{ext}" ) return { "datatype": "func", "suffix": suffix, + "task": task, "ext": ext, "sub": sub, "ses": ses, @@ -59,6 +61,7 @@ def _anat_row( sub: str, ses: str, suffix: str, + res: str | None = None, desc: str | None = None, ext: str = ".nii.gz", extra: list[dict[str, str]] | None = None, @@ -73,6 +76,7 @@ def _anat_row( "sub": sub, "ses": ses, "space": None, + "res": res, "desc": desc, "root": "/data", "path": path, @@ -119,12 +123,17 @@ class TestResolveLongitudinalFunc: 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", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -137,13 +146,14 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -166,6 +176,7 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter"], ) @@ -187,12 +198,17 @@ def test_resolves_single_regressor(self, tmp_path: Path) -> None: 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", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -205,6 +221,7 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", @@ -212,13 +229,14 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="aCompCor", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -241,6 +259,7 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter", "aCompCor"], ) @@ -251,12 +270,17 @@ def test_resolves_multiple_regressors(self, tmp_path: Path) -> None: 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", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), + _func_row( + sub="01", ses="baseline", task="rest", suffix="mask", desc="brain" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -269,13 +293,14 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -299,17 +324,21 @@ def test_missing_regressor_raises(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", 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", task="rest", suffix="sbref"), + _func_row( + sub="01", ses="baseline", task="rest", suffix="bold", desc="preproc" + ), _func_row( sub="01", ses="baseline", + task="rest", suffix="xfm", desc="linearITK", ext=".txt", @@ -322,13 +351,14 @@ def test_bold_mask_mandatory(self, tmp_path: Path) -> None: _func_row( sub="01", ses="baseline", + task="rest", suffix="regressors", desc="36parameter", ext=".1D", ), ) tpl_df = _df( - _anat_row(sub="01", ses="longitudinal", suffix="T1w"), + _anat_row(sub="01", ses="longitudinal", res="rest", suffix="T1w"), _anat_row( sub="01", ses="longitudinal", @@ -352,6 +382,7 @@ def test_bold_mask_mandatory(self, tmp_path: Path) -> None: func_df, tpl_df, ses="baseline", + task="rest", regressors=["36-parameter"], ) diff --git a/tests/unit/bids/test_longitudinal_template.py b/tests/unit/bids/test_longitudinal_template.py index a9c80e63..25f3a4e5 100644 --- a/tests/unit/bids/test_longitudinal_template.py +++ b/tests/unit/bids/test_longitudinal_template.py @@ -7,6 +7,7 @@ import polars as pl from rbc.bids.longitudinal.template import ( + BoldKey, discover_template_inputs, export_template, ) @@ -23,6 +24,11 @@ "sub", "ses", "space", + "acq", + "dir", + "echo", + "part", + "rec", "task", "run", "desc", @@ -35,7 +41,7 @@ def _df(*rows: tuple) -> pl.DataFrame: return pl.DataFrame(dict(zip(_SCHEMA, zip(*rows, strict=True), strict=True))) -def _brain_row(sub: str, ses: str, space: str | None = None) -> tuple: +def _anat_row(sub: str, ses: str, space: str | None = None) -> tuple: space_part = f"_space-{space}" if space else "" path = ( f"sub-{sub}/ses-{ses}/anat/" @@ -50,22 +56,53 @@ def _brain_row(sub: str, ses: str, space: str | None = None) -> tuple: space, None, None, + None, + None, + None, + None, + None, "brain", "/data", path, ) +def _func_row(sub: str, ses: str, task: str = "rest") -> tuple: + path = f"sub-{sub}/ses-{ses}/func/sub-{sub}_ses-{ses}_task-{task}_bold.nii.gz" + return ( + "func", + "bold", + ".nii.gz", + sub, + ses, + None, + None, + None, + None, + None, + None, + task, + None, + None, + "/data", + path, + ) + + class TestDiscoverTemplateInputs: """Tests for :func:`discover_template_inputs`.""" def test_groups_by_subject(self) -> None: """Multi-session subjects yield one TemplateInputs each.""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("02", "baseline"), - _brain_row("02", "vis2"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("02", "baseline"), + _anat_row("02", "vis2"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("02", "baseline"), + _func_row("02", "vis2"), ) inputs, skipped = discover_template_inputs(df) assert {ti.sub for ti in inputs} == {"01", "02"} @@ -77,9 +114,12 @@ def test_groups_by_subject(self) -> None: def test_reports_single_session_subject(self) -> None: """Single-session subjects are reported separately (bug #19).""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("02", "baseline"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("02", "baseline"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("02", "baseline"), ) inputs, skipped = discover_template_inputs(df) assert [ti.sub for ti in inputs] == ["01"] @@ -88,9 +128,12 @@ def test_reports_single_session_subject(self) -> None: def test_excludes_existing_longitudinal(self) -> None: """Pre-existing longitudinal templates are not re-included as inputs.""" df = _df( - _brain_row("01", "baseline"), - _brain_row("01", "vis2"), - _brain_row("01", "longitudinal"), + _anat_row("01", "baseline"), + _anat_row("01", "vis2"), + _anat_row("01", "longitudinal"), + _func_row("01", "baseline"), + _func_row("01", "vis2"), + _func_row("01", "longitudinal"), ) inputs, skipped = discover_template_inputs(df) assert len(inputs) == 1 @@ -111,10 +154,10 @@ def test_excludes_mni_registered_brains(self) -> None: duplicate LTA filenames in the mri_robust_template invocation. """ df = _df( - _brain_row("01", "test"), - _brain_row("01", "test", space="MNI152NLin6Asym"), - _brain_row("01", "retest"), - _brain_row("01", "retest", space="MNI152NLin6Asym"), + _anat_row("01", "test"), + _anat_row("01", "test", space="MNI152NLin6Asym"), + _anat_row("01", "retest"), + _anat_row("01", "retest", space="MNI152NLin6Asym"), ) inputs, skipped = discover_template_inputs(df) assert len(inputs) == 1 @@ -131,6 +174,8 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: """Template + per-session xfms land at the expected BIDS paths.""" template_src = tmp_path / "src_template.nii.gz" template_src.write_bytes(b"\x00") + template_bold_src = tmp_path / "src_bold_template.nii.gz" + template_bold_src.write_bytes(b"\x00") xfm_baseline = tmp_path / "xfm_baseline.txt" xfm_baseline.write_text("baseline") xfm_vis2 = tmp_path / "xfm_vis2.txt" @@ -138,6 +183,7 @@ def test_writes_template_and_xfms(self, tmp_path: Path) -> None: outputs = LongitudinalTemplateOutputs( template=template_src, + bold_templates={BoldKey(task="test"): template_bold_src}, sessions=["baseline", "vis2"], transforms=[xfm_baseline, xfm_vis2], ) diff --git a/tests/unit/orchestration/test_longitudinal_template.py b/tests/unit/orchestration/test_longitudinal_template.py index c2b6af28..5cdf8b2f 100644 --- a/tests/unit/orchestration/test_longitudinal_template.py +++ b/tests/unit/orchestration/test_longitudinal_template.py @@ -23,6 +23,11 @@ "sub", "ses", "space", + "acq", + "dir", + "echo", + "part", + "rec", "task", "run", "desc", @@ -42,6 +47,11 @@ def _brain_row(sub: str, ses: str) -> tuple: None, None, None, + None, + None, + None, + None, + None, "brain", "/data", path,