Skip to content

Commit 0c438f3

Browse files
authored
Add metadata loading stage with TR validation (#257)
* Add metadata loading stage with TR validation Introduce FunctionalMetadata frozen dataclass that loads, validates, and logs TR from BIDS sidecar and NIfTI header before any processing begins. Add --tr CLI override to functional, all, and metrics commands. Closes #225, closes #224 * Add plausibility checks for TR and SliceTiming Warn on implausible TR values (outside 0.1-20s range) and raise on SliceTiming values outside [0, TR) per the BIDS spec. * Fix full_pipeline tests for new metadata/TR signatures * Remove compat alias * All the renames * Ruff format * Duplicate import * Remove useless test
1 parent 15bde22 commit 0c438f3

23 files changed

Lines changed: 656 additions & 83 deletions

scripts/generate_bids_tools.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def w(s: str = "") -> None:
255255
w()
256256

257257
# Keys excluded from entity kwargs TypedDicts because they are handled
258-
# as explicit parameters (sub/ses from PipelineContext, desc/space/atlas
258+
# as explicit parameters (sub/ses from RunContext, desc/space/atlas
259259
# vary per-call rather than per-session).
260260
excluded_entity_keys = {"sub", "ses", "desc", "space", "atlas"}
261261

src/rbc/cli/all.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from rbc.cli.base import BaseArgs, _validate_atlas, _validate_positive, _validate_task
2424
from rbc.cli.query import iter_session_files, load_session
25-
from rbc.context import PipelineContext
25+
from rbc.context import RunContext
2626
from rbc.core.bids import (
2727
Datatype,
2828
Suffix,
@@ -32,6 +32,7 @@
3232
)
3333
from rbc.core.bids2table import load_table
3434
from rbc.core.niwrap import setup_runner
35+
from rbc.metadata import FunctionalMetadata
3536
from rbc.workflows.anatomical import single_session_preprocess as anatomical_preprocess
3637
from rbc.workflows.functional import single_session_preprocess as functional_preprocess
3738
from rbc.workflows.metrics import single_session_metrics as metrics_pipeline
@@ -53,6 +54,7 @@ class AllArgs(BaseArgs):
5354
atlas: Sequence[AtlasName]
5455
fwhm: float
5556
start_tr: int
57+
tr: float | None
5658

5759
@classmethod
5860
def validate_namespace(cls, ns: argparse.Namespace) -> AllArgs:
@@ -62,13 +64,15 @@ def validate_namespace(cls, ns: argparse.Namespace) -> AllArgs:
6264
_validate_atlas(atlas)
6365
_validate_positive(ns.fwhm, "FWHM")
6466
_validate_positive(ns.start_tr, "Start TR")
67+
_validate_positive(ns.tr, "TR")
6568
return cls(
6669
**BaseArgs.validate_namespace(ns).__dict__,
6770
regressor=ns.regressor,
6871
task=ns.task,
6972
atlas=ns.atlas,
7073
fwhm=ns.fwhm,
7174
start_tr=ns.start_tr,
75+
tr=ns.tr,
7276
)
7377

7478

@@ -98,7 +102,7 @@ def main(args: AllArgs) -> int: # noqa: C901
98102
for _, sub_ses_group in tqdm(
99103
df.group_by(_SUB_SES_QUERY, maintain_order=True), disable=not ctx.verbose
100104
):
101-
pipe_ctx = PipelineContext(
105+
pipe_ctx = RunContext(
102106
sub=sub_ses_group["sub"][0],
103107
ses=sub_ses_group["ses"][0] or None,
104108
output_dir=args.output_dir,
@@ -151,6 +155,8 @@ def main(args: AllArgs) -> int: # noqa: C901
151155
ents = extract_entities(row, ["task", "run", "acq", "rec", "dir", "echo"])
152156
ctx.logger.info(f"Functional: {bold_fpath}")
153157

158+
func_metadata = FunctionalMetadata.load(bold_fpath, tr_override=args.tr)
159+
154160
func_outputs = functional_preprocess(
155161
in_bold=bold_fpath,
156162
t1w_brain=anat_outputs.brain,
@@ -159,6 +165,7 @@ def main(args: AllArgs) -> int: # noqa: C901
159165
csf_mask=anat_outputs.csf_mask,
160166
wm_mask=anat_outputs.wm_mask,
161167
anat_to_template=anat_outputs.inverse_xfm,
168+
metadata=func_metadata,
162169
start_tr=args.start_tr,
163170
regressor_set=args.regressor,
164171
)
@@ -236,6 +243,7 @@ def main(args: AllArgs) -> int: # noqa: C901
236243
regressed_bold=func_outputs.regressed_bold[regressor],
237244
cleaned_bold=func_outputs.cleaned_bold[regressor],
238245
template_brain_mask=func_outputs.template_brain_mask,
246+
tr=func_metadata.tr,
239247
atlas=args.atlas,
240248
fwhm=args.fwhm,
241249
)
@@ -358,5 +366,11 @@ def register_command(
358366
default=2,
359367
help="Number of initial TRs to discard.",
360368
)
369+
parser.add_argument(
370+
"--tr",
371+
type=float,
372+
default=None,
373+
help="Repetition time in seconds. Overrides BIDS sidecar and NIfTI header.",
374+
)
361375

362376
parser.set_defaults(func=lambda args: main(AllArgs.validate_namespace(args)))

src/rbc/cli/anatomical.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from rbc.cli import _ANAT_GROUP_ENTITIES, _DEFAULT_ENV_VARS, _SUB_SES_QUERY
2020
from rbc.cli.base import BaseArgs
21-
from rbc.context import PipelineContext
21+
from rbc.context import RunContext
2222
from rbc.core.bids import Datatype, Suffix, TemplateSpace, extract_entities
2323
from rbc.core.bids2table import load_table
2424
from rbc.core.niwrap import setup_runner
@@ -60,7 +60,7 @@ def main(args: AnatomicalArgs) -> int:
6060
for _, sub_ses_group in tqdm(
6161
df.group_by(_SUB_SES_QUERY, maintain_order=True), disable=not ctx.verbose
6262
):
63-
pipe_ctx = PipelineContext(
63+
pipe_ctx = RunContext(
6464
sub=sub_ses_group["sub"][0],
6565
ses=sub_ses_group["ses"][0] or None,
6666
output_dir=args.output_dir,

src/rbc/cli/functional.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
from tqdm import tqdm
1818

1919
from rbc.cli import _DEFAULT_ENV_VARS, _FUNC_GROUP_ENTITIES, _SUB_SES_QUERY
20-
from rbc.cli.base import BaseArgs, _validate_task
20+
from rbc.cli.base import BaseArgs, _validate_positive, _validate_task
2121
from rbc.cli.query import iter_session_files, load_session
22-
from rbc.context import PipelineContext
22+
from rbc.context import RunContext
2323
from rbc.core.bids import (
2424
Datatype,
2525
Suffix,
@@ -29,6 +29,7 @@
2929
)
3030
from rbc.core.bids2table import load_table
3131
from rbc.core.niwrap import setup_runner
32+
from rbc.metadata import FunctionalMetadata
3233
from rbc.workflows.functional import single_session_preprocess
3334

3435
if TYPE_CHECKING:
@@ -42,15 +43,18 @@ class FunctionalArgs(BaseArgs):
4243

4344
regressor: Sequence[Literal["36-parameter", "aCompCor"]]
4445
task: str | None
46+
tr: float | None
4547

4648
@classmethod
4749
def validate_namespace(cls, ns: argparse.Namespace) -> FunctionalArgs:
4850
"""Validation of functional workflow specific arguments to NamedTuple."""
4951
_validate_task(ns.task)
52+
_validate_positive(ns.tr, "TR")
5053
return cls(
5154
**BaseArgs.validate_namespace(ns).__dict__,
5255
regressor=ns.regressor, # Validated by argparse choices
5356
task=ns.task,
57+
tr=ns.tr,
5458
)
5559

5660

@@ -77,7 +81,7 @@ def main(args: FunctionalArgs) -> int:
7781
for _, sub_ses_group in tqdm(
7882
df.group_by(_SUB_SES_QUERY, maintain_order=True), disable=not ctx.verbose
7983
):
80-
pipe_ctx = PipelineContext(
84+
pipe_ctx = RunContext(
8185
sub=sub_ses_group["sub"][0],
8286
ses=sub_ses_group["ses"][0] or None,
8387
output_dir=args.output_dir,
@@ -96,6 +100,8 @@ def main(args: FunctionalArgs) -> int:
96100

97101
anat_q = pipe_ctx.bids(datatype=Datatype.ANAT)
98102

103+
func_metadata = FunctionalMetadata.load(bold_fpath, tr_override=args.tr)
104+
99105
outputs = single_session_preprocess(
100106
in_bold=bold_fpath,
101107
t1w_brain=anat_q.expect(anat_df, suffix=Suffix.T1W, desc="brain"),
@@ -112,6 +118,7 @@ def main(args: FunctionalArgs) -> int:
112118
"mode": "image",
113119
},
114120
),
121+
metadata=func_metadata,
115122
regressor_set=args.regressor,
116123
)
117124

@@ -205,5 +212,11 @@ def register_command(
205212
default=None,
206213
help="Task label to filter BOLD runs (without 'task-' prefix).",
207214
)
215+
parser.add_argument(
216+
"--tr",
217+
type=float,
218+
default=None,
219+
help="Repetition time in seconds. Overrides BIDS sidecar and NIfTI header.",
220+
)
208221

209222
parser.set_defaults(func=lambda args: main(FunctionalArgs.validate_namespace(args)))

src/rbc/cli/longitudinal.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from rbc.cli import _DEFAULT_ENV_VARS, _FUNC_GROUP_ENTITIES, _SUB_SES_QUERY
1717
from rbc.cli.base import BaseArgs
1818
from rbc.cli.query import iter_session_files, load_session
19-
from rbc.context import PipelineContext
19+
from rbc.context import RunContext
2020
from rbc.core.bids import Datatype, Extension, Suffix, extract_entities
2121
from rbc.core.bids2table import load_table
2222
from rbc.core.niwrap import setup_runner
@@ -52,7 +52,7 @@ def _require_file(path: Path | None, field: str) -> Path:
5252

5353

5454
def _process_anat(
55-
pipe_ctx: PipelineContext, anat_df: pl.DataFrame, tpl_df: pl.DataFrame
55+
pipe_ctx: RunContext, anat_df: pl.DataFrame, tpl_df: pl.DataFrame
5656
) -> None:
5757
"""Handle anatomical longitudinal processing."""
5858
anat_df = anat_df.filter(pl.col("space").is_null())
@@ -102,7 +102,7 @@ def _process_anat(
102102

103103

104104
def _process_func(
105-
pipe_ctx: PipelineContext, func_df: pl.DataFrame, tpl_df: pl.DataFrame
105+
pipe_ctx: RunContext, func_df: pl.DataFrame, tpl_df: pl.DataFrame
106106
) -> None:
107107
"""Handle functional longitudinal processing."""
108108
row = func_df.filter(suffix=Suffix.BOLD).row(0, named=True)
@@ -174,7 +174,7 @@ def main(args: LongitudinalArgs) -> int:
174174
for _, sub_ses_group in tqdm(
175175
group_df.group_by(_SUB_SES_QUERY, maintain_order=True), disable=not ctx.verbose
176176
):
177-
pipe_ctx = PipelineContext(
177+
pipe_ctx = RunContext(
178178
sub=sub_ses_group["sub"][0],
179179
ses=sub_ses_group["ses"][0],
180180
output_dir=args.output_dir,

src/rbc/cli/metrics.py

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66

77
from __future__ import annotations
88

9+
import logging
910
from dataclasses import dataclass
1011
from typing import TYPE_CHECKING, Literal
1112

13+
import nibabel as nib
1214
import polars as pl
1315
from tqdm import tqdm
1416

1517
from rbc.cli import _DEFAULT_ENV_VARS, _FUNC_GROUP_ENTITIES, _SUB_SES_QUERY
1618
from rbc.cli.base import BaseArgs, _validate_atlas, _validate_positive, _validate_task
17-
from rbc.context import PipelineContext
19+
from rbc.context import RunContext
1820
from rbc.core.bids import Datatype, Suffix, TemplateSpace, extract_entities
1921
from rbc.core.bids2table import load_table
2022
from rbc.core.niwrap import setup_runner
@@ -23,6 +25,7 @@
2325
if TYPE_CHECKING:
2426
import argparse
2527
from collections.abc import Sequence
28+
from pathlib import Path
2629

2730
from rbc_resources import AtlasName
2831

@@ -35,6 +38,7 @@ class MetricsArgs(BaseArgs):
3538
fwhm: float
3639
task: str | None
3740
regressor: Sequence[Literal["36-parameter", "aCompCor"]]
41+
tr: float | None
3842

3943
@classmethod
4044
def validate_namespace(cls, ns: argparse.Namespace) -> MetricsArgs:
@@ -43,21 +47,43 @@ def validate_namespace(cls, ns: argparse.Namespace) -> MetricsArgs:
4347
_validate_atlas(atlas)
4448
_validate_task(ns.task)
4549
_validate_positive(ns.fwhm, "FWHM")
50+
_validate_positive(ns.tr, "TR")
4651
return cls(
4752
**BaseArgs.validate_namespace(ns).__dict__,
4853
atlas=ns.atlas,
4954
fwhm=ns.fwhm,
5055
task=ns.task,
5156
regressor=ns.regressor,
57+
tr=ns.tr,
5258
)
5359

5460

61+
_logger = logging.getLogger(__name__)
62+
63+
64+
def _read_header_tr(nifti_path: Path) -> float:
65+
"""Read TR from a NIfTI header, raising on missing/zero values."""
66+
hdr = nib.nifti1.load(nifti_path).header
67+
tr = float(hdr["pixdim"][4]) # type: ignore[index]
68+
if tr <= 0:
69+
msg = (
70+
f"NIfTI header TR is {tr} for {nifti_path}. Pass --tr to specify manually."
71+
)
72+
raise ValueError(msg)
73+
_logger.info("TR: %.4f s (from NIfTI header)", tr)
74+
return tr
75+
76+
5577
def main(args: MetricsArgs) -> int:
5678
"""Main entrypoint of metrics workflow."""
5779
ctx = setup_runner(runner=args.runner, verbose=args.verbose, tmp_dir=args.tmp_dir)
5880
ctx.runner.environ = _DEFAULT_ENV_VARS
5981

6082
ctx.logger.info("Preparing to run RBC metrics workflow")
83+
if args.tr is not None:
84+
ctx.logger.info("Using CLI-provided TR: %.4f s", args.tr)
85+
else:
86+
ctx.logger.info("TR will be read from NIfTI headers")
6187
df = load_table(
6288
dataset_dir=args.output_dir,
6389
index_fpath=None,
@@ -82,7 +108,7 @@ def main(args: MetricsArgs) -> int:
82108
for _, group in tqdm(df.group_by(_SUB_SES_QUERY), disable=not ctx.verbose):
83109
sub: str = group["sub"][0]
84110
ses: str | None = group["ses"][0] or None
85-
pipe_ctx = PipelineContext(sub=sub, ses=ses, output_dir=args.output_dir)
111+
pipe_ctx = RunContext(sub=sub, ses=ses, output_dir=args.output_dir)
86112

87113
deriv_df = load_table(
88114
dataset_dir=args.output_dir, index_fpath=None, max_workers=0, verbose=False
@@ -115,10 +141,13 @@ def main(args: MetricsArgs) -> int:
115141
extra={"reg": regressor},
116142
)
117143

144+
tr = args.tr if args.tr is not None else _read_header_tr(regressed_bold)
145+
118146
outputs = single_session_metrics(
119147
regressed_bold=regressed_bold,
120148
cleaned_bold=cleaned_bold,
121149
template_brain_mask=template_brain_mask,
150+
tr=tr,
122151
atlas=args.atlas,
123152
fwhm=args.fwhm,
124153
)
@@ -199,5 +228,11 @@ def register_command(
199228
"functional preprocessing."
200229
),
201230
)
231+
parser.add_argument(
232+
"--tr",
233+
type=float,
234+
default=None,
235+
help="Repetition time in seconds. Overrides NIfTI header value for ALFF.",
236+
)
202237

203238
parser.set_defaults(func=lambda args: main(MetricsArgs.validate_namespace(args)))

src/rbc/cli/qc.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from rbc.cli import _DEFAULT_ENV_VARS, _FUNC_GROUP_ENTITIES, _SUB_SES_QUERY
1717
from rbc.cli.base import BaseArgs, _validate_positive, _validate_task
18-
from rbc.context import PipelineContext
18+
from rbc.context import RunContext
1919
from rbc.core.bids import Datatype, Suffix, TemplateSpace, extract_entities
2020
from rbc.core.bids2table import load_table
2121
from rbc.core.niwrap import setup_runner
@@ -78,7 +78,7 @@ def main(args: QCArgs) -> int:
7878
for _, group in tqdm(df.group_by(_SUB_SES_QUERY), disable=not ctx.verbose):
7979
sub: str = group["sub"][0]
8080
ses: str | None = group["ses"][0] or None
81-
pipe_ctx = PipelineContext(sub=sub, ses=ses, output_dir=args.output_dir)
81+
pipe_ctx = RunContext(sub=sub, ses=ses, output_dir=args.output_dir)
8282

8383
deriv_df = load_table(
8484
dataset_dir=args.output_dir, index_fpath=None, max_workers=0, verbose=False

src/rbc/context.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Holds subject identity and output directory, providing a :meth:`bids` factory
44
that returns a :class:`~rbc.core.bids.Bids` builder for composing exports
55
and queries.
6+
67
"""
78

89
from __future__ import annotations
@@ -20,8 +21,8 @@
2021
_RBC_VERSION = version("rbc")
2122

2223

23-
@dataclass
24-
class PipelineContext:
24+
@dataclass(frozen=True)
25+
class RunContext:
2526
"""Minimal context for a single pipeline run.
2627
2728
Attributes:

0 commit comments

Comments
 (0)