Skip to content

Commit ff3721d

Browse files
committed
Split longitudinal CLI into nested per-stage subcommands (Stage 3 of #301)
Replace the legacy `rbc longitudinal process --anatomical --functional` flag flow with dedicated `rbc longitudinal {template,anatomical,functional, metrics,qc,all}` subcommands. `rbc long` stays as an alias for the parent group. `metrics`, `qc`, and `all` register for discoverability but raise `NotImplementedError` until Stage 6 lands. CLI args now hang off `LongitudinalBaseArgs` (in `cli/longitudinal/_base.py`), which adds `--fs-license` on top of `BaseArgs`. Orchestration gains per-stage `run()` entrypoints in `orchestration/longitudinal/{anatomical,functional, metrics,qc,all}.py`; the shared subject/session iteration primitive moves to `orchestration/longitudinal.iter_sessions_with_template`. Unit tests now cover each subcommand's `validate_namespace`, the parent subparser + `long` alias help output, and per-stage orchestration dispatch.
1 parent 230c552 commit ff3721d

19 files changed

Lines changed: 1017 additions & 326 deletions

File tree

README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,29 @@ Run any command with `--help` for full options.
4141

4242
## Workflows
4343

44-
| Command | Description |
45-
| ---------------- | ------------------------------------------------------------------------------------------------ |
46-
| `rbc anatomical` | Brain extraction (ANTs), tissue segmentation (FSL FAST), registration to MNI152 |
47-
| `rbc functional` | Motion correction, slice timing, BBR coregistration, single-step resampling, nuisance regression |
48-
| `rbc metrics` | ALFF/fALFF, ReHo, smoothing, z-scoring, atlas-based timeseries and correlation matrices |
49-
| `rbc qc` | XCP-D format quality metrics, framewise displacement, DVARS, RBC pass/fail thresholds |
50-
| `rbc all` | Runs all four stages in sequence, passing results in memory between stages |
44+
| Command | Description |
45+
| -------------------- | ------------------------------------------------------------------------------------------------ |
46+
| `rbc anatomical` | Brain extraction (ANTs), tissue segmentation (FSL FAST), registration to MNI152 |
47+
| `rbc functional` | Motion correction, slice timing, BBR coregistration, single-step resampling, nuisance regression |
48+
| `rbc metrics` | ALFF/fALFF, ReHo, smoothing, z-scoring, atlas-based timeseries and correlation matrices |
49+
| `rbc qc` | XCP-D format quality metrics, framewise displacement, DVARS, RBC pass/fail thresholds |
50+
| `rbc all` | Runs all four stages in sequence, passing results in memory between stages |
51+
| `rbc longitudinal …` | Multi-session workflows: `template`, `anatomical`, `functional`, `metrics`, `qc`, `all` |
5152

5253
Workflows must be run in order: `anatomical``functional``metrics` / `qc`. The `all` command handles this automatically.
5354

55+
Longitudinal workflows consume cross-sectional derivatives. Typical flow:
56+
57+
```bash
58+
rbc all /data -o /data/derivatives --runner docker
59+
rbc longitudinal template /data/derivatives -o /data/derivatives --runner docker
60+
rbc longitudinal anatomical /data/derivatives -o /data/derivatives --runner docker
61+
```
62+
63+
`rbc long` is an alias for `rbc longitudinal`. The `metrics`, `qc`, and `all`
64+
longitudinal stages are registered but raise `NotImplementedError` until
65+
Stage 6 of the longitudinal refactor lands (tracker: #301).
66+
5467
## Outputs
5568

5669
See the [data dictionary](docs/data_dictionary.md) for a complete description of every output file, including format details and the processing step that produces each one.

docs/data_dictionary.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Produced by `rbc qc`. A single summary file per functional run containing qualit
125125

126126
## Longitudinal outputs
127127

128-
Produced by `rbc longitudinal`. Anatomical outputs aligned to a subject-specific longitudinal template built from multiple sessions. Note: longitudinal functional processing is not yet implemented.
128+
Produced by the `rbc longitudinal` subcommand group (`template`, `anatomical`, `functional`, `metrics`, `qc`, `all`). Anatomical outputs aligned to a subject-specific longitudinal template built from multiple sessions. Note: longitudinal metrics, QC, and the combined `all` stage are not yet implemented (tracker: #301).
129129

130130
| File | Suffix | Description | Created by | Format |
131131
| -------------------------------------------------- | ------ | ------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------- |

src/rbc/cli/longitudinal/__init__.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
"""``rbc longitudinal`` parent command and nested subcommand registration.
22
3-
Stage 2 introduces the nested subcommand layout. The pre-existing
4-
``--anatomical --functional`` flow lives under ``rbc longitudinal process``
5-
until Stage 3 splits it into per-stage subcommands.
3+
Stage 3 lands the full nested subcommand layout. Every stage of the
4+
longitudinal pipeline has a dedicated subcommand; ``metrics``, ``qc``, and
5+
``all`` are registered so they surface in ``--help`` but raise
6+
``NotImplementedError`` until Stage 6.
67
"""
78

89
from __future__ import annotations
910

1011
from typing import TYPE_CHECKING
1112

12-
from rbc.cli.longitudinal import process, template
13+
from rbc.cli.longitudinal import (
14+
all as all_,
15+
)
16+
from rbc.cli.longitudinal import (
17+
anatomical,
18+
functional,
19+
metrics,
20+
qc,
21+
template,
22+
)
1323

1424
if TYPE_CHECKING:
1525
import argparse
@@ -26,7 +36,7 @@ def register_command(
2636
aliases=["long"],
2737
description="RBC longitudinal workflows",
2838
help="Longitudinal workflows",
29-
usage="rbc longitudinal {template,process} ...",
39+
usage=("rbc longitudinal {template,anatomical,functional,metrics,qc,all} ..."),
3040
)
3141
nested = parser.add_subparsers(
3242
title="longitudinal stages",
@@ -36,4 +46,8 @@ def register_command(
3646
help="Stage help",
3747
)
3848
template.register_command(nested, parents=parents)
39-
process.register_command(nested, parents=parents)
49+
anatomical.register_command(nested, parents=parents)
50+
functional.register_command(nested, parents=parents)
51+
metrics.register_command(nested, parents=parents)
52+
qc.register_command(nested, parents=parents)
53+
all_.register_command(nested, parents=parents)

src/rbc/cli/longitudinal/_base.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Shared base arguments for ``rbc longitudinal`` subcommands."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
from typing import TYPE_CHECKING
8+
9+
from rbc.cli.base import BaseArgs
10+
11+
if TYPE_CHECKING:
12+
import argparse
13+
14+
15+
@dataclass(frozen=True)
16+
class LongitudinalBaseArgs(BaseArgs):
17+
"""Base args for longitudinal subcommands.
18+
19+
Adds ``--fs-license`` on top of :class:`~rbc.cli.base.BaseArgs`.
20+
Only the template stage currently consumes the license, but the flag is
21+
accepted across all longitudinal subcommands for a consistent surface.
22+
"""
23+
24+
fs_license: Path | None
25+
26+
@classmethod
27+
def validate_namespace(cls, ns: argparse.Namespace) -> LongitudinalBaseArgs:
28+
"""Validate base args plus the optional ``--fs-license`` path."""
29+
fs_license: Path | None = ns.fs_license
30+
if fs_license is not None and not fs_license.exists():
31+
raise ValueError(f"FreeSurfer license not found: {fs_license}")
32+
return cls(**BaseArgs.validate_namespace(ns).__dict__, fs_license=fs_license)
33+
34+
35+
def add_fs_license_argument(parser: argparse.ArgumentParser) -> None:
36+
"""Attach the ``--fs-license`` argument to a subcommand parser."""
37+
parser.add_argument(
38+
"--fs-license",
39+
type=Path,
40+
default=None,
41+
help=(
42+
"Optional path to a FreeSurfer license file. Falls back to the "
43+
"FS_LICENSE environment variable, then to a license-free bypass "
44+
"if neither is set. Only the ``template`` stage consumes it."
45+
),
46+
)

src/rbc/cli/longitudinal/all.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""``rbc longitudinal all`` subcommand (placeholder for Stage 6)."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING
7+
8+
from rbc.cli.base import _or_default, _validate_nifti_path, _validate_positive
9+
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
10+
from rbc.cli.metrics import _resolve_atlas_args
11+
from rbc.orchestration import Filters, RunnerConfig
12+
from rbc.orchestration.longitudinal.all import run
13+
from rbc_resources import ATLAS_REGISTRY, REGISTRATION_TEMPLATES
14+
15+
if TYPE_CHECKING:
16+
import argparse
17+
from collections.abc import Sequence
18+
from pathlib import Path
19+
20+
21+
@dataclass(frozen=True)
22+
class AllLongArgs(LongitudinalBaseArgs):
23+
"""Arguments for ``rbc longitudinal all``."""
24+
25+
registration_template: Path
26+
atlas_files: dict[str, Path]
27+
fwhm: float
28+
29+
@classmethod
30+
def validate_namespace(cls, ns: argparse.Namespace) -> AllLongArgs:
31+
"""Validate namespace for the full longitudinal pipeline subcommand."""
32+
_validate_positive(ns.fwhm, "FWHM")
33+
return cls(
34+
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
35+
registration_template=_or_default(
36+
ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm
37+
),
38+
atlas_files=_resolve_atlas_args(ns.atlas),
39+
fwhm=ns.fwhm,
40+
)
41+
42+
43+
def main(args: AllLongArgs) -> int:
44+
"""Run the combined longitudinal pipeline."""
45+
run(
46+
input_dirs=args.input_dirs,
47+
output_dir=args.output_dir,
48+
filters=Filters(
49+
participant_label=args.participant_label,
50+
session_label=args.session_label,
51+
),
52+
fs_license=args.fs_license,
53+
atlas_files=args.atlas_files,
54+
fwhm=args.fwhm,
55+
runner_config=RunnerConfig(
56+
runner=args.runner,
57+
verbose=bool(args.verbose),
58+
tmp_dir=args.tmp_dir,
59+
ants_threads=args.ants_threads,
60+
),
61+
)
62+
return 0
63+
64+
65+
def register_command(
66+
subparsers: argparse._SubParsersAction,
67+
parents: Sequence[argparse.ArgumentParser],
68+
) -> None:
69+
"""Register ``rbc longitudinal all`` (Stage 6 placeholder)."""
70+
parser = subparsers.add_parser(
71+
"all",
72+
parents=parents,
73+
description=(
74+
"Run the full longitudinal pipeline (template → anat → func → "
75+
"metrics → qc). Placeholder wired up by Stage 3; full "
76+
"implementation ships in Stage 6."
77+
),
78+
help="Full longitudinal pipeline (Stage 6)",
79+
usage=(
80+
"rbc longitudinal all INPUT_DIR [INPUT_DIR ...] -o OUTPUT_DIR [options]"
81+
),
82+
)
83+
add_fs_license_argument(parser)
84+
parser.add_argument(
85+
"--atlas",
86+
nargs="+",
87+
default=["schaefer_200"],
88+
metavar="ATLAS",
89+
help=(
90+
"Atlas(es) for timeseries extraction. Accepts registry names "
91+
f"({', '.join(sorted(ATLAS_REGISTRY))}) or paths to custom NIfTI "
92+
"atlas files."
93+
),
94+
)
95+
parser.add_argument(
96+
"--fwhm",
97+
type=float,
98+
default=6.0,
99+
help="Smoothing kernel FWHM in mm.",
100+
)
101+
102+
templates = parser.add_argument_group("template overrides")
103+
templates.add_argument(
104+
"--anat-template",
105+
type=_validate_nifti_path,
106+
default=None,
107+
help="Custom brain template for anatomical registration.",
108+
)
109+
110+
parser.set_defaults(func=lambda args: main(AllLongArgs.validate_namespace(args)))
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""``rbc longitudinal anatomical`` subcommand."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING
7+
8+
from rbc.cli.base import _or_default, _validate_nifti_path
9+
from rbc.cli.longitudinal._base import LongitudinalBaseArgs, add_fs_license_argument
10+
from rbc.orchestration import Filters, RunnerConfig
11+
from rbc.orchestration.longitudinal.anatomical import run
12+
from rbc_resources import REGISTRATION_TEMPLATES
13+
14+
if TYPE_CHECKING:
15+
import argparse
16+
from collections.abc import Sequence
17+
from pathlib import Path
18+
19+
20+
@dataclass(frozen=True)
21+
class AnatomicalLongArgs(LongitudinalBaseArgs):
22+
"""Arguments for ``rbc longitudinal anatomical``."""
23+
24+
registration_template: Path
25+
26+
@classmethod
27+
def validate_namespace(cls, ns: argparse.Namespace) -> AnatomicalLongArgs:
28+
"""Validate namespace for the longitudinal anatomical subcommand."""
29+
return cls(
30+
**LongitudinalBaseArgs.validate_namespace(ns).__dict__,
31+
registration_template=_or_default(
32+
ns.anat_template, REGISTRATION_TEMPLATES.brain_1mm
33+
),
34+
)
35+
36+
37+
def main(args: AnatomicalLongArgs) -> int:
38+
"""Run the longitudinal anatomical stage."""
39+
run(
40+
input_dirs=args.input_dirs,
41+
output_dir=args.output_dir,
42+
filters=Filters(
43+
participant_label=args.participant_label,
44+
session_label=args.session_label,
45+
),
46+
registration_template=args.registration_template,
47+
runner_config=RunnerConfig(
48+
runner=args.runner,
49+
verbose=bool(args.verbose),
50+
tmp_dir=args.tmp_dir,
51+
ants_threads=args.ants_threads,
52+
),
53+
)
54+
return 0
55+
56+
57+
def register_command(
58+
subparsers: argparse._SubParsersAction,
59+
parents: Sequence[argparse.ArgumentParser],
60+
) -> None:
61+
"""Register ``rbc longitudinal anatomical`` on a longitudinal subparser group."""
62+
parser = subparsers.add_parser(
63+
"anatomical",
64+
parents=parents,
65+
description=(
66+
"Warp preprocessed anatomical derivatives into each subject's "
67+
"longitudinal template space."
68+
),
69+
help="Longitudinal anatomical stage",
70+
usage=(
71+
"rbc longitudinal anatomical INPUT_DIR [INPUT_DIR ...] "
72+
"-o OUTPUT_DIR [options]"
73+
),
74+
)
75+
add_fs_license_argument(parser)
76+
77+
templates = parser.add_argument_group("template overrides")
78+
templates.add_argument(
79+
"--anat-template",
80+
type=_validate_nifti_path,
81+
default=None,
82+
help="Custom brain template for anatomical registration.",
83+
)
84+
85+
parser.set_defaults(
86+
func=lambda args: main(AnatomicalLongArgs.validate_namespace(args))
87+
)

0 commit comments

Comments
 (0)