Repository: PennLINC/xcp_d

Issue:
Add `--mode none` option
## Summary
<!--What would you like changed/added and why?-->
Request from Damien Fair: support a "none" mode where lots of things are required and not set by the mode.


Discussion:

Patch:
diff --git a/docs/workflows.rst b/docs/workflows.rst
index 9c7425a..e38e862 100644
--- a/docs/workflows.rst
+++ b/docs/workflows.rst
@@ -35,6 +35,9 @@ based on recommended processing pipelines for different studies.
 XCP-D can be run in one of three modes: ``linc``, ``abcd``, or ``hbcd``.
 Each mode is designed by a different group, and has different requirements.
 
+Users may also run XCP-D in ``none`` mode, in which case almost all of the parameters must be
+defined by the user.
+
 
 linc Mode
 =========
diff --git a/xcp_d/cli/parser.py b/xcp_d/cli/parser.py
index 9136648..9df4c7d 100644
--- a/xcp_d/cli/parser.py
+++ b/xcp_d/cli/parser.py
@@ -70,7 +70,7 @@ def _build_parser():
         "--mode",
         dest="mode",
         action="store",
-        choices=["abcd", "hbcd", "linc"],
+        choices=["abcd", "hbcd", "linc", "none"],
         required=True,
         help=(
             "The mode of operation for XCP-D. "
@@ -272,7 +272,7 @@ def _build_parser():
             # GSR-only for UKB
             "gsr_only",
         ],
-        default="36P",
+        default="auto",
         type=str,
         help=(
             "Nuisance parameters to be selected. "
@@ -435,6 +435,21 @@ This parameter can be disabled by providing a zero or a negative value.
 The default is 240 (4 minutes).
 """,
     )
+    g_censor.add_argument(
+        "--output-type",
+        dest="output_type",
+        default="auto",
+        action="store",
+        choices=["auto", "censored", "interpolated"],
+        help=(
+            "The type of output to generate. "
+            "If 'auto', the output type will be inferred from the processing mode. "
+            "If 'censored', the BOLD outputs (dense and parcellated time series) will be "
+            "censored. "
+            "If 'interpolated', the BOLD outputs (dense and parcellated time series) will be "
+            "interpolated."
+        ),
+    )
 
     g_temporal_filter = parser.add_argument_group(
         title="Data filtering parameters",
@@ -927,13 +942,14 @@ def _validate_parameters(opts, build_log, parser):
         opts.custom_confounds = str(opts.custom_confounds.resolve())
 
     # Check parameter value types/valid values
-    assert opts.mode in ("abcd", "hbcd", "linc"), f"Unsupported mode '{opts.mode}'."
-    assert opts.despike in (True, False, "auto")
-    assert opts.process_surfaces in (True, False, "auto")
+    assert opts.abcc_qc in (True, False, "auto")
     assert opts.combine_runs in (True, False, "auto")
+    assert opts.despike in (True, False, "auto")
     assert opts.file_format in ("nifti", "cifti", "auto")
-    assert opts.abcc_qc in (True, False, "auto")
     assert opts.linc_qc in (True, False, "auto")
+    assert opts.mode in ("abcd", "hbcd", "linc", "none"), f"Unsupported mode '{opts.mode}'."
+    assert opts.output_type in ("censored", "interpolated", "auto")
+    assert opts.process_surfaces in (True, False, "auto")
 
     # Check parameters based on the mode
     if opts.mode == "abcd":
@@ -950,7 +966,10 @@ def _validate_parameters(opts, build_log, parser):
         if opts.motion_filter_type is None:
             error_messages.append(f"'--motion-filter-type' is required for '{opts.mode}' mode.")
         opts.output_correlations = True if "all" in opts.dcan_correlation_lengths else False
-        opts.output_interpolated = True
+        if opts.output_type == "censored":
+            error_messages.append(f"'--output-type' cannot be 'censored' for '{opts.mode}' mode.")
+        opts.output_type = "interpolated"
+        opts.params = "36P" if opts.params == "auto" else opts.params
         opts.process_surfaces = (
             True if (opts.process_surfaces == "auto") else opts.process_surfaces
         )
@@ -970,7 +989,10 @@ def _validate_parameters(opts, build_log, parser):
         if opts.motion_filter_type is None:
             error_messages.append(f"'--motion-filter-type' is required for '{opts.mode}' mode.")
         opts.output_correlations = True if "all" in opts.dcan_correlation_lengths else False
-        opts.output_interpolated = True
+        if opts.output_type == "censored":
+            error_messages.append(f"'--output-type' cannot be 'censored' for '{opts.mode}' mode.")
+        opts.output_type = "interpolated"
+        opts.params = "36P" if opts.params == "auto" else opts.params
         opts.process_surfaces = (
             True if (opts.process_surfaces == "auto") else opts.process_surfaces
         )
@@ -985,10 +1007,60 @@ def _validate_parameters(opts, build_log, parser):
         opts.input_type = "fmriprep" if opts.input_type == "auto" else opts.input_type
         opts.linc_qc = True if (opts.linc_qc == "auto") else opts.linc_qc
         opts.output_correlations = True
-        opts.output_interpolated = False
+        if opts.output_type == "interpolated":
+            error_messages.append(
+                f"'--output-type' cannot be 'interpolated' for '{opts.mode}' mode."
+            )
+        opts.output_type = "censored"
+        opts.params = "36P" if opts.params == "auto" else opts.params
         opts.process_surfaces = False if opts.process_surfaces == "auto" else opts.process_surfaces
         if opts.dcan_correlation_lengths is not None:
             error_messages.append(f"'--create-matrices' is not supported for '{opts.mode}' mode.")
+    elif opts.mode == "none":
+        if opts.abcc_qc == "auto":
+            error_messages.append("'--abcc-qc' (y or n) is required for 'none' mode.")
+
+        if opts.combine_runs == "auto":
+            error_messages.append("'--combine-runs' (y or n) is required for 'none' mode.")
+
+        opts.dcan_correlation_lengths = (
+            [] if opts.dcan_correlation_lengths is None else opts.dcan_correlation_lengths
+        )
+
+        if opts.despike == "auto":
+            error_messages.append("'--despike' (y or n) is required for 'none' mode.")
+
+        if opts.fd_thresh == "auto":
+            error_messages.append("'--fd-thresh' is required for 'none' mode.")
+            opts.fd_thresh = 0  # just to satisfy later checks, not to actually use
+
+        if opts.file_format == "auto":
+            error_messages.append("'--file-format' is required for 'none' mode.")
+
+        if opts.input_type == "auto":
+            error_messages.append("'--input-type' is required for 'none' mode.")
+
+        if opts.linc_qc == "auto":
+            error_messages.append("'--linc-qc' (y or n) is required for 'none' mode.")
+
+        if opts.motion_filter_type is None:
+            error_messages.append("'--motion-filter-type' is required for 'none' mode.")
+
+        opts.output_correlations = True if "all" in opts.dcan_correlation_lengths else False
+
+        if opts.output_type == "auto":
+            error_messages.append("'--output-type' is required for 'none' mode.")
+
+        if opts.params == "auto":
+            error_messages.append("'--nuisance-regressors' is required for 'none' mode.")
+
+        if opts.process_surfaces == "auto":
+            error_messages.append(
+                "'--warp-surfaces-native2std' (y or n) is required for 'none' mode."
+            )
+
+        # Remove "all" from the list of correlation lengths
+        opts.dcan_correlation_lengths = [c for c in opts.dcan_correlation_lengths if c != "all"]
 
     # Bandpass filter parameters
     if opts.high_pass <= 0 and opts.low_pass <= 0:
@@ -1015,6 +1087,8 @@ def _validate_parameters(opts, build_log, parser):
         )
         opts.min_time = 0
 
+    opts.output_interpolated = True if opts.output_type == "interpolated" else False
+
     # Motion filtering parameters
     if opts.motion_filter_type == "none":
         opts.motion_filter_type = None
diff --git a/xcp_d/utils/restingstate.py b/xcp_d/utils/restingstate.py
index 7b1c887..3c11198 100644
--- a/xcp_d/utils/restingstate.py
+++ b/xcp_d/utils/restingstate.py
@@ -162,7 +162,7 @@ def compute_alff(data_matrix, low_pass, high_pass, TR, sample_mask=None):
             voxel_data_censored -= np.mean(voxel_data_censored)
             voxel_data_censored /= np.std(voxel_data_censored)
 
-            time_arr = np.arange(0, n_volumes * TR, TR)
+            time_arr = np.arange(n_volumes) * TR
             assert sample_mask.size == time_arr.size, f"{sample_mask.size} != {time_arr.size}"
             time_arr = time_arr[sample_mask]
             frequencies_hz = np.linspace(0, 0.5 * fs, (n_volumes // 2) + 1)[1:]


Test patch:
diff --git a/xcp_d/tests/test_cli.py b/xcp_d/tests/test_cli.py
index dde9df8..1e964fc 100644
--- a/xcp_d/tests/test_cli.py
+++ b/xcp_d/tests/test_cli.py
@@ -43,7 +43,7 @@ def test_ds001419_nifti(data_dir, output_dir, working_dir):
         dataset_dir,
         out_dir,
         "participant",
-        "--mode=linc",
+        "--mode=none",
         f"-w={work_dir}",
         f"--bids-filter-file={filter_file}",
         "--nuisance-regressors=aroma_gsr",
@@ -55,6 +55,14 @@ def test_ds001419_nifti(data_dir, output_dir, working_dir):
         "--skip-parcellation",
         "--min-time=100",
         "--combine-runs",
+        "--output-type=censored",
+        "--combine-runs=y",
+        "--linc-qc=y",
+        "--abcc-qc=n",
+        "--despike=n",
+        "--file-format=nifti",
+        "--input-type=fmriprep",
+        "--warp-surfaces-native2std=n",
     ]
     _run_and_generate(
         test_name=test_name,
diff --git a/xcp_d/tests/test_cli_run.py b/xcp_d/tests/test_cli_run.py
index 21a6c99..1531472 100644
--- a/xcp_d/tests/test_cli_run.py
+++ b/xcp_d/tests/test_cli_run.py
@@ -32,7 +32,7 @@ def base_opts():
         "mode": "linc",
         "file_format": "auto",
         "input_type": "auto",
-        "params": "36P",
+        "params": "auto",
         "high_pass": 0.01,
         "low_pass": 0.1,
         "bandpass_filter": True,
@@ -51,6 +51,7 @@ def base_opts():
         "abcc_qc": "auto",
         "linc_qc": "auto",
         "combine_runs": "auto",
+        "output_type": "auto",
     }
     opts = FakeOptions(**opts_dict)
     return opts
@@ -367,6 +368,41 @@ def test_validate_parameters_hbcd_mode(base_opts, base_parser, capsys):
     assert "'--motion-filter-type' is required for" in stderr
 
 
+def test_validate_parameters_none_mode(base_opts, base_parser, capsys):
+    """Test parser._validate_parameters with none mode."""
+    opts = deepcopy(base_opts)
+    opts.mode = "none"
+
+    with pytest.raises(SystemExit, match="2"):
+        parser._validate_parameters(deepcopy(opts), build_log, parser=base_parser)
+
+    stderr = capsys.readouterr().err
+    assert "'--abcc-qc' (y or n) is required for 'none' mode." in stderr
+    assert "'--combine-runs' (y or n) is required for 'none' mode." in stderr
+    assert "'--despike' (y or n) is required for 'none' mode." in stderr
+    assert "'--fd-thresh' is required for 'none' mode." in stderr
+    assert "'--file-format' is required for 'none' mode." in stderr
+    assert "'--input-type' is required for 'none' mode." in stderr
+    assert "'--linc-qc' (y or n) is required for 'none' mode." in stderr
+    assert "'--motion-filter-type' is required for 'none' mode." in stderr
+    assert "'--nuisance-regressors' is required for 'none' mode." in stderr
+    assert "'--output-type' is required for 'none' mode." in stderr
+    assert "'--warp-surfaces-native2std' (y or n) is required for 'none' mode." in stderr
+
+    opts.abcc_qc = False
+    opts.combine_runs = False
+    opts.despike = False
+    opts.fd_thresh = 0
+    opts.file_format = "nifti"
+    opts.input_type = "fmriprep"
+    opts.linc_qc = False
+    opts.motion_filter_type = "none"
+    opts.output_type = "censored"
+    opts.params = "36P"
+    opts.process_surfaces = False
+    opts = parser._validate_parameters(deepcopy(opts), build_log, parser=base_parser)
+
+
 def test_validate_parameters_other_mode(base_opts, base_parser, capsys):
     """Test parser._validate_parameters with 'other' mode."""
     opts = deepcopy(base_opts)