Repository: PennLINC/xcp_d

Issue:
Make coverage threshold configurable
## Summary
<!--What would you like changed/added and why?-->
In #757, we add a 50% coverage threshold and start to write out coverage files for each of the atlases. We want to allow users to set the coverage threshold themselves, so we need to make it a parameter that users can define, with a default of 50%.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) Report
Base: **70.31**% // Head: **70.29**% // Decreases project coverage by **`-0.02%`** :warning:
> Coverage data is based on head [(`35e1577`)](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) compared to base [(`497d690`)](https://codecov.io/gh/PennLINC/xcp_d/commit/497d69079462e17ab4a9864a76dcdf91aec0c326?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).
> Patch coverage: 91.66% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #782      +/-   ##
==========================================
- Coverage   70.31%   70.29%   -0.02%     
==========================================
  Files          71       71              
  Lines        6465     6471       +6     
==========================================
+ Hits         4546     4549       +3     
- Misses       1919     1922       +3     
```


| [Impacted Files](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC) | Coverage Δ | |
|---|---|---|
| [xcp\_d/tests/test\_cli.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvdGVzdHMvdGVzdF9jbGkucHk=) | `100.00% <ø> (ø)` | |
| [xcp\_d/workflows/base.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2Qvd29ya2Zsb3dzL2Jhc2UucHk=) | `97.82% <ø> (ø)` | |
| [xcp\_d/workflows/bold.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2Qvd29ya2Zsb3dzL2JvbGQucHk=) | `93.98% <ø> (ø)` | |
| [xcp\_d/workflows/cifti.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2Qvd29ya2Zsb3dzL2NpZnRpLnB5) | `94.48% <ø> (ø)` | |
| [xcp\_d/cli/parser\_utils.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvY2xpL3BhcnNlcl91dGlscy5weQ==) | `66.66% <62.50%> (-0.61%)` | :arrow_down: |
| [xcp\_d/cli/run.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvY2xpL3J1bi5weQ==) | `51.04% <100.00%> (+0.17%)` | :arrow_up: |
| [xcp\_d/interfaces/connectivity.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvaW50ZXJmYWNlcy9jb25uZWN0aXZpdHkucHk=) | `100.00% <100.00%> (ø)` | |
| [xcp\_d/interfaces/prepostcleaning.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvaW50ZXJmYWNlcy9wcmVwb3N0Y2xlYW5pbmcucHk=) | `95.79% <100.00%> (-0.06%)` | :arrow_down: |
| [xcp\_d/tests/test\_workflows\_connectivity.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2QvdGVzdHMvdGVzdF93b3JrZmxvd3NfY29ubmVjdGl2aXR5LnB5) | `95.45% <100.00%> (ø)` | |
| [xcp\_d/workflows/connectivity.py](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC#diff-eGNwX2Qvd29ya2Zsb3dzL2Nvbm5lY3Rpdml0eS5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/PennLINC/xcp_d/pull/782?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennLINC).


Patch:
diff --git a/xcp_d/cli/parser_utils.py b/xcp_d/cli/parser_utils.py
index 3591aad..04b876e 100644
--- a/xcp_d/cli/parser_utils.py
+++ b/xcp_d/cli/parser_utils.py
@@ -76,6 +76,19 @@ def _float_or_auto(string, is_parser=True):
     return floatarg
 
 
+def _restricted_float(x):
+    """From https://stackoverflow.com/a/12117065/2589328."""
+    try:
+        x = float(x)
+    except ValueError:
+        raise argparse.ArgumentTypeError(f"{x} not a floating-point literal")
+
+    if x < 0.0 or x > 1.0:
+        raise argparse.ArgumentTypeError(f"{x} not in range [0.0, 1.0]")
+
+    return x
+
+
 class _DeprecatedStoreAction(Action):
     """A custom argparse "store" action to raise a DeprecationWarning.
 
diff --git a/xcp_d/cli/run.py b/xcp_d/cli/run.py
index d780784..743c9ab 100644
--- a/xcp_d/cli/run.py
+++ b/xcp_d/cli/run.py
@@ -21,6 +21,7 @@ from xcp_d.cli.parser_utils import (
     _DeprecatedStoreAction040,
     _float_or_auto,
     _int_or_auto,
+    _restricted_float,
     check_deps,
     json_file,
 )
@@ -210,6 +211,19 @@ def get_parser():
             "file will be selected. "
         ),
     )
+    g_param.add_argument(
+        "--min_coverage",
+        "--min-coverage",
+        required=False,
+        default=0.5,
+        type=_restricted_float,
+        help=(
+            "Coverage threshold to apply to parcels in each atlas. "
+            "Any parcels with lower coverage than the threshold will be replaced with NaNs. "
+            "Must be a value between zero and one. "
+            "Default is 0.5."
+        ),
+    )
 
     dummyvols = g_param.add_mutually_exclusive_group()
     dummyvols.add_argument(
@@ -888,6 +902,7 @@ Running xcp_d version {__version__}:
         process_surfaces=opts.process_surfaces,
         dcan_qc=opts.dcan_qc,
         input_type=opts.input_type,
+        min_coverage=opts.min_coverage,
         name="xcpd_wf",
     )
 
diff --git a/xcp_d/interfaces/connectivity.py b/xcp_d/interfaces/connectivity.py
index 1de9b09..0d42c05 100644
--- a/xcp_d/interfaces/connectivity.py
+++ b/xcp_d/interfaces/connectivity.py
@@ -24,13 +24,23 @@ LOGGER = logging.getLogger("nipype.interface")
 
 class _NiftiConnectInputSpec(BaseInterfaceInputSpec):
     filtered_file = File(exists=True, mandatory=True, desc="filtered file")
-    mask = File(exists=True, mandator=True, desc="brain mask file")
+    mask = File(exists=True, mandatory=True, desc="brain mask file")
     atlas = File(exists=True, mandatory=True, desc="atlas file")
+    min_coverage = traits.Float(
+        default=0.5,
+        usedefault=True,
+        desc=(
+            "Coverage threshold to apply to parcels. "
+            "Any parcels with lower coverage than the threshold will be replaced with NaNs. "
+            "Must be a value between zero and one. "
+            "Default is 0.5."
+        ),
+    )
 
 
 class _NiftiConnectOutputSpec(TraitedSpec):
-    time_series_tsv = File(exists=True, mandatory=True, desc=" time series file")
-    fcon_matrix_tsv = File(exists=True, mandatory=True, desc=" time series file")
+    time_series_tsv = File(exists=True, mandatory=True, desc="Parcellated time series file.")
+    fcon_matrix_tsv = File(exists=True, mandatory=True, desc="Correlation matrix file.")
     parcel_coverage_file = File(exists=True, mandatory=True, desc="Parcel-wise coverage file.")
 
 
@@ -49,6 +59,7 @@ class NiftiConnect(SimpleInterface):
         filtered_file = self.inputs.filtered_file
         mask = self.inputs.mask
         atlas = self.inputs.atlas
+        coverage_threshold = self.inputs.min_coverage
 
         self._results["time_series_tsv"] = fname_presuffix(
             filtered_file,
@@ -69,8 +80,6 @@ class NiftiConnect(SimpleInterface):
             use_ext=False,
         )
 
-        coverage_threshold = 0.5
-
         # Before anything, we need to measure coverage
         atlas_img = nb.load(atlas)
         atlas_data = atlas_img.get_fdata()
@@ -96,22 +105,22 @@ class NiftiConnect(SimpleInterface):
         coverage_thresholded = parcel_coverage < coverage_threshold
 
         n_nodes = coverage_thresholded.size
-        n_bad_nodes = np.sum(parcel_coverage == 0)
-        n_poor_nodes = np.sum(np.logical_and(parcel_coverage > 0, parcel_coverage < 0.5))
-        n_partial_nodes = np.sum(np.logical_and(parcel_coverage >= 0.5, parcel_coverage < 1))
+        n_bad_parcels = np.sum(parcel_coverage == 0)
+        n_poor_parcels = np.sum(np.logical_and(parcel_coverage > 0, parcel_coverage < 0.5))
+        n_partial_parcels = np.sum(np.logical_and(parcel_coverage >= 0.5, parcel_coverage < 1))
 
-        if n_bad_nodes:
-            LOGGER.warning(f"{n_bad_nodes}/{n_nodes} of parcels have 0% coverage.")
+        if n_bad_parcels:
+            LOGGER.warning(f"{n_bad_parcels}/{n_nodes} of parcels have 0% coverage.")
 
-        if n_poor_nodes:
+        if n_poor_parcels:
             LOGGER.warning(
-                f"{n_poor_nodes}/{n_nodes} of parcels have <50% coverage. "
+                f"{n_poor_parcels}/{n_nodes} of parcels have <50% coverage. "
                 "These parcels' time series will be replaced with zeros."
             )
 
-        if n_partial_nodes:
+        if n_partial_parcels:
             LOGGER.warning(
-                f"{n_partial_nodes}/{n_nodes} of parcels have at least one uncovered "
+                f"{n_partial_parcels}/{n_nodes} of parcels have at least one uncovered "
                 "voxel, but have enough good voxels to be useable. "
                 "The bad voxels will be ignored and the parcels' time series will be "
                 "calculated from the remaining voxels."
diff --git a/xcp_d/interfaces/prepostcleaning.py b/xcp_d/interfaces/prepostcleaning.py
index 4d32374..128bbf2 100644
--- a/xcp_d/interfaces/prepostcleaning.py
+++ b/xcp_d/interfaces/prepostcleaning.py
@@ -583,21 +583,16 @@ class _CiftiPrepareForParcellationInputSpec(BaseInterfaceInputSpec):
         ),
     )
     TR = traits.Float(mandatory=True, desc="Repetition time, in seconds.")
-    out_file = File(
-        "prepared_timeseries.dtseries.nii",
+    min_coverage = traits.Float(
+        default=0.5,
         usedefault=True,
-        exists=False,
         desc=(
-            "The name of the modified file to write out. "
-            "prepared_timeseries.dtseries.nii by default."
+            "Coverage threshold to apply to parcels. "
+            "Any parcels with lower coverage than the threshold will be replaced with NaNs. "
+            "Must be a value between zero and one. "
+            "Default is 0.5."
         ),
     )
-    parcel_coverage_file = File(
-        "parcel_coverage.dscalar.nii",
-        usedefault=True,
-        exists=False,
-        desc="An atlas-specific file with the coverage for each parcel.",
-    )
 
 
 class _CiftiPrepareForParcellationOutputSpec(TraitedSpec):
@@ -606,7 +601,7 @@ class _CiftiPrepareForParcellationOutputSpec(TraitedSpec):
 
 
 class CiftiPrepareForParcellation(SimpleInterface):
-    """Apply 50% coverage threshold to parcellated data.
+    """Apply coverage threshold to parcellated data.
 
     This interface takes a CIFTI atlas and a CIFTI data file, and ensures that
     missing data in the data file is handled appropriately.
@@ -614,8 +609,8 @@ class CiftiPrepareForParcellation(SimpleInterface):
     (i.e., are time series of all zeros or NaNs)
     are replaced with time series of all NaNs,
     so that the CIFTI parcellation step will ignore those vertices.
-    Next, any parcels with <50% coverage will have all associated vertices in the data file
-    replaced with *zeros* (overwriting any NaNs).
+    Next, any parcels with coverage less than the threshold will have all associated vertices in
+    the data file replaced with *zeros* (overwriting any NaNs).
     This way, the parcellation will return a summary time series of zeros.
     Any parcels with all NaNs will raise an error in the parcellation step, so we must use zeros
     for these vertices instead.
@@ -628,7 +623,7 @@ class CiftiPrepareForParcellation(SimpleInterface):
         data_file = self.inputs.data_file
         atlas_file = self.inputs.atlas_file
         TR = self.inputs.TR
-        coverage_threshold = 0.5
+        min_coverage = self.inputs.min_coverage
 
         assert data_file.endswith(".dtseries.nii")
         assert atlas_file.endswith(".dlabel.nii"), atlas_file
@@ -648,7 +643,7 @@ class CiftiPrepareForParcellation(SimpleInterface):
 
         # The problem is that wb_command -cifti-parcellate will raise an error if a whole parcel is
         # NaNs, so we must replace entirely-bad parcels with zeros instead.
-        n_partially_covered_parcels, n_poorly_covered_parcels, n_uncovered_parcels = 0, 0, 0
+        n_partial_parcels, n_poor_parcels, n_bad_parcels = 0, 0, 0
         parcel_ids = np.unique(atlas_data)[1:]
         n_parcels = parcel_ids.size
         parcel_coverages = np.zeros_like(atlas_data, dtype=np.float32)
@@ -667,51 +662,59 @@ class CiftiPrepareForParcellation(SimpleInterface):
                 # If the whole parcel is bad, replace all of the values with zeros.
                 data[parcel_idx, :] = 0
 
-                n_uncovered_parcels += 1
+                n_bad_parcels += 1
 
-            elif parcel_coverage < coverage_threshold:
-                # If the parcel has >=50% bad data, replace all of the values with zeros.
+            elif parcel_coverage < min_coverage:
+                # If the parcel has >=threshold% bad data, replace all of the values with zeros.
                 data[parcel_idx, :] = 0
 
-                n_poorly_covered_parcels += 1
+                n_poor_parcels += 1
 
             elif parcel_coverage < 1:
                 # If the parcel has < 50% bad data, keep the parcel, but make a note.
-                n_partially_covered_parcels += 1
+                n_partial_parcels += 1
 
-        if n_uncovered_parcels:
-            LOGGER.warning(f"{n_uncovered_parcels}/{n_parcels} of parcels have 0% coverage.")
+        if n_bad_parcels:
+            LOGGER.warning(f"{n_bad_parcels}/{n_parcels} of parcels have 0% coverage.")
 
-        if n_poorly_covered_parcels:
+        if n_poor_parcels:
             LOGGER.warning(
-                f"{n_poorly_covered_parcels}/{n_parcels} of parcels have <50% coverage. "
+                f"{n_poor_parcels}/{n_parcels} of parcels have <50% coverage. "
                 "These parcels' time series will be replaced with zeros."
             )
 
-        if n_partially_covered_parcels:
+        if n_partial_parcels:
             LOGGER.warning(
-                f"{n_partially_covered_parcels}/{n_parcels} of parcels have at least one "
+                f"{n_partial_parcels}/{n_parcels} of parcels have at least one "
                 "uncovered vertex, but have enough good vertices to be useable. "
                 "The bad vertices will be ignored and the parcels' time series will be "
                 "calculated from the remaining vertices."
             )
 
-        out_file = os.path.abspath(self.inputs.out_file)
+        self._results["out_file"] = fname_presuffix(
+            data_file,
+            suffix="_timeseries.dtseries.nii",
+            newpath=runtime.cwd,
+            use_ext=False,
+        )
         write_ndata(
             data_matrix=data,
             template=data_file,
-            filename=out_file,
+            filename=self._results["out_file"],
             TR=TR,
         )
-        self._results["out_file"] = out_file
 
-        parcel_coverage_file = os.path.abspath(self.inputs.parcel_coverage_file)
+        self._results["parcel_coverage_file"] = fname_presuffix(
+            data_file,
+            suffix="_coverage.dscalar.nii",
+            newpath=runtime.cwd,
+            use_ext=False,
+        )
         write_ndata(
             data_matrix=parcel_coverages,
             template=atlas_file,
-            filename=parcel_coverage_file,
+            filename=self._results["parcel_coverage_file"],
             TR=TR,
         )
-        self._results["parcel_coverage_file"] = parcel_coverage_file
 
         return runtime
diff --git a/xcp_d/workflows/base.py b/xcp_d/workflows/base.py
index 3afaa4e..5cfea6b 100644
--- a/xcp_d/workflows/base.py
+++ b/xcp_d/workflows/base.py
@@ -68,6 +68,7 @@ def init_xcpd_wf(
     process_surfaces=False,
     dcan_qc=False,
     input_type="fmriprep",
+    min_coverage=0.5,
     name="xcpd_wf",
 ):
     """Build and organize execution of xcp_d pipeline.
@@ -121,6 +122,7 @@ def init_xcpd_wf(
                 process_surfaces=False,
                 dcan_qc=False,
                 input_type="fmriprep",
+                min_coverage=0.5,
                 name="xcpd_wf",
             )
 
@@ -168,6 +170,7 @@ def init_xcpd_wf(
     dcan_qc : bool
         Whether to run DCAN QC or not.
     %(input_type)s
+    min_coverage
     %(name)s
 
     References
@@ -209,6 +212,7 @@ def init_xcpd_wf(
             process_surfaces=process_surfaces,
             dcan_qc=dcan_qc,
             input_type=input_type,
+            min_coverage=min_coverage,
             name=f"single_subject_{subject_id}_wf",
         )
 
@@ -252,6 +256,7 @@ def init_subject_wf(
     dcan_qc,
     output_dir,
     input_type,
+    min_coverage,
     name,
 ):
     """Organize the postprocessing pipeline for a single subject.
@@ -294,6 +299,7 @@ def init_subject_wf(
                 layout=None,
                 dcan_qc=False,
                 input_type="fmriprep",
+                min_coverage=0.5,
                 name="single_subject_sub-01_wf",
             )
 
@@ -335,6 +341,7 @@ def init_subject_wf(
         Whether to run DCAN QC or not.
     %(subject_id)s
     %(input_type)s
+    min_coverage
     %(name)s
 
     References
@@ -595,6 +602,7 @@ It is released under the [CC0](https://creativecommons.org/publicdomain/zero/1.0
             fd_thresh=fd_thresh,
             dcan_qc=dcan_qc,
             output_dir=output_dir,
+            min_coverage=min_coverage,
             name=f"{'cifti' if cifti else 'nifti'}_postprocess_{i_run}_wf",
         )
 
diff --git a/xcp_d/workflows/bold.py b/xcp_d/workflows/bold.py
index 97b2b04..50fe747 100644
--- a/xcp_d/workflows/bold.py
+++ b/xcp_d/workflows/bold.py
@@ -65,6 +65,7 @@ def init_boldpostprocess_wf(
     n_runs,
     despike,
     dcan_qc,
+    min_coverage,
     layout=None,
     name="bold_postprocess_wf",
 ):
@@ -117,6 +118,7 @@ def init_boldpostprocess_wf(
                 despike=True,
                 dcan_qc=True,
                 n_runs=1,
+                min_coverage=0.5,
                 omp_nthreads=1,
                 layout=layout,
                 name="nifti_postprocess_wf",
@@ -155,6 +157,7 @@ def init_boldpostprocess_wf(
         If True, run 3dDespike from AFNI
     dcan_qc : bool
         Whether to run DCAN QC or not.
+    min_coverage
     layout : BIDSLayout object
         BIDS dataset layout
     %(name)s
@@ -353,6 +356,7 @@ produced by the regression.
 
     fcon_ts_wf = init_nifti_functional_connectivity_wf(
         output_dir=output_dir,
+        min_coverage=min_coverage,
         mem_gb=mem_gbx["timeseries"],
         name="fcons_ts_wf",
         omp_nthreads=omp_nthreads,
diff --git a/xcp_d/workflows/cifti.py b/xcp_d/workflows/cifti.py
index d4d7a9d..1c471bc 100644
--- a/xcp_d/workflows/cifti.py
+++ b/xcp_d/workflows/cifti.py
@@ -64,6 +64,7 @@ def init_ciftipostprocess_wf(
     despike,
     dcan_qc,
     n_runs,
+    min_coverage,
     omp_nthreads,
     layout=None,
     name="cifti_process_wf",
@@ -117,6 +118,7 @@ def init_ciftipostprocess_wf(
                 despike=True,
                 dcan_qc=True,
                 n_runs=1,
+                min_coverage=0.5,
                 omp_nthreads=1,
                 layout=layout,
                 name="cifti_postprocess_wf",
@@ -151,6 +153,7 @@ def init_ciftipostprocess_wf(
     dcan_qc : bool
         Whether to run DCAN QC or not.
     n_runs
+    min_coverage
     layout : BIDSLayout object
         BIDS dataset layout
     %(name)s
@@ -324,6 +327,7 @@ produced by the regression.
 
     fcon_ts_wf = init_cifti_functional_connectivity_wf(
         TR=TR,
+        min_coverage=min_coverage,
         output_dir=output_dir,
         mem_gb=mem_gbx["timeseries"],
         name="cifti_ts_con_wf",
diff --git a/xcp_d/workflows/connectivity.py b/xcp_d/workflows/connectivity.py
index e4dbad8..610a4d5 100644
--- a/xcp_d/workflows/connectivity.py
+++ b/xcp_d/workflows/connectivity.py
@@ -25,6 +25,7 @@ from xcp_d.utils.utils import get_std2bold_xforms
 @fill_doc
 def init_nifti_functional_connectivity_wf(
     output_dir,
+    min_coverage,
     mem_gb,
     omp_nthreads,
     name="nifti_fcon_wf",
@@ -39,6 +40,7 @@ def init_nifti_functional_connectivity_wf(
             from xcp_d.workflows.connectivity import init_nifti_functional_connectivity_wf
             wf = init_nifti_functional_connectivity_wf(
                 output_dir=".",
+                min_coverage=0.5,
                 mem_gb=0.1,
                 omp_nthreads=1,
                 name="nifti_fcon_wf",
@@ -85,8 +87,9 @@ atlas [@Schaefer_2017], the Glasser atlas [@Glasser_2016],
 the Gordon atlas [@Gordon_2014], and the Tian subcortical artlas [@tian2020topographic].
 Corresponding pair-wise functional connectivity between all regions was computed for each atlas,
 which was operationalized as the Pearson's correlation of each parcel's unsmoothed timeseries.
-In cases of partial coverage, uncovered voxels (values of all zeros or NaNs) were either ignored,
-when the parcel had >50% coverage, or were set to zero, when the parcel had <50% coverage.
+In cases of partial coverage, uncovered voxels (values of all zeros or NaNs) were either
+ignored, when the parcel had >{min_coverage * 100}% coverage,
+or were set to zero,  when the parcel had <{min_coverage * 100}% coverage.
 """
 
     inputnode = pe.Node(
@@ -159,7 +162,7 @@ when the parcel had >50% coverage, or were set to zero, when the parcel had <50%
     )
 
     nifti_connect = pe.MapNode(
-        NiftiConnect(),
+        NiftiConnect(min_coverage=min_coverage),
         name="nifti_connect",
         iterfield=["atlas"],
         mem_gb=mem_gb,
@@ -235,6 +238,7 @@ when the parcel had >50% coverage, or were set to zero, when the parcel had <50%
 def init_cifti_functional_connectivity_wf(
     TR,
     output_dir,
+    min_coverage,
     mem_gb,
     omp_nthreads,
     name="cifti_fcon_wf",
@@ -250,6 +254,7 @@ def init_cifti_functional_connectivity_wf(
             wf = init_cifti_functional_connectivity_wf(
                 TR=1.,
                 output_dir=".",
+                min_coverage=0.5,
                 mem_gb=0.1,
                 omp_nthreads=1,
                 name="cifti_fcon_wf",
@@ -286,7 +291,7 @@ def init_cifti_functional_connectivity_wf(
         This figure contains four ROI-to-ROI correlation heat maps from four of the atlases.
     """
     workflow = Workflow(name=name)
-    workflow.__desc__ = """
+    workflow.__desc__ = f"""
 Processed functional timeseries were extracted from residual BOLD using
 Connectome Workbench [@hcppipelines] for the following atlases:
 the Schaefer 17-network 100, 200, 300, 400, 500, 600, 700, 800, 900, and 1000 parcel
@@ -295,8 +300,9 @@ the Gordon atlas [@Gordon_2014], and the Tian subcortical artlas [@tian2020topog
 Corresponding pair-wise functional connectivity between all regions was computed for each atlas,
 which was operationalized as the Pearson's correlation of each parcel's unsmoothed timeseries with
 the Connectome Workbench.
-In cases of partial coverage, uncovered vertices (values of all zeros or NaNs) were either ignored,
-when the parcel had >50% coverage, or were set to zero, when the parcel had <50% coverage.
+In cases of partial coverage, uncovered vertices (values of all zeros or NaNs) were either
+ignored, when the parcel had >{min_coverage * 100}% coverage,
+or were set to zero, when the parcel had <{min_coverage * 100}% coverage.
 """
 
     inputnode = pe.Node(
@@ -347,13 +353,12 @@ when the parcel had >50% coverage, or were set to zero, when the parcel had <50%
     # fmt:on
 
     prepare_data_for_parcellation = pe.MapNode(
-        CiftiPrepareForParcellation(),
+        CiftiPrepareForParcellation(min_coverage=min_coverage, TR=TR),
         name="prepare_data_for_parcellation",
         mem_gb=mem_gb,
         n_procs=omp_nthreads,
         iterfield=["atlas_file"],
     )
-    prepare_data_for_parcellation.inputs.TR = TR
 
     # fmt:off
     workflow.connect([


Test patch:
diff --git a/xcp_d/tests/test_cli.py b/xcp_d/tests/test_cli.py
index fb2cdf0..2eb50ca 100644
--- a/xcp_d/tests/test_cli.py
+++ b/xcp_d/tests/test_cli.py
@@ -48,6 +48,7 @@ def test_ds001419_nifti(datasets, output_dir, working_dir):
         "--motion-filter-type=notch",
         "--band-stop-min=12",
         "--band-stop-max=18",
+        "--min-coverage=1",
     ]
     opts = get_parser().parse_args(parameters)
 
diff --git a/xcp_d/tests/test_cli_utils.py b/xcp_d/tests/test_cli_utils.py
new file mode 100644
index 0000000..d8d584d
--- /dev/null
+++ b/xcp_d/tests/test_cli_utils.py
@@ -0,0 +1,63 @@
+"""Tests for the xcp_d.cli.parser_utils module."""
+from argparse import ArgumentTypeError
+
+import pytest
+
+from xcp_d.cli import parser_utils
+
+
+def test_int_or_auto():
+    """Test parser_utils._int_or_auto."""
+    with pytest.raises(
+        ArgumentTypeError,
+        match="Argument must be a nonnegative integer or 'auto'.",
+    ):
+        parser_utils._int_or_auto("hello")
+
+    with pytest.raises(ArgumentTypeError, match="Int argument must be nonnegative."):
+        parser_utils._int_or_auto(-2)
+
+    out = parser_utils._int_or_auto("auto")
+    assert out == "auto"
+
+    out = parser_utils._int_or_auto("3")
+    assert out == 3
+
+    out = parser_utils._int_or_auto(3)
+    assert out == 3
+
+
+def test_float_or_auto():
+    """Test parser_utils._float_or_auto."""
+    with pytest.raises(
+        ArgumentTypeError,
+        match="Argument must be a nonnegative float or 'auto'.",
+    ):
+        parser_utils._float_or_auto("hello")
+
+    with pytest.raises(ArgumentTypeError, match="Float argument must be nonnegative."):
+        parser_utils._float_or_auto(-2)
+
+    out = parser_utils._float_or_auto("auto")
+    assert out == "auto"
+
+    out = parser_utils._float_or_auto("3")
+    assert out == 3.0
+
+    out = parser_utils._float_or_auto(3)
+    assert out == 3.0
+
+
+def test_restricted_float():
+    """Test parser_utils._restricted_float."""
+    with pytest.raises(ArgumentTypeError, match="not a floating-point literal"):
+        parser_utils._restricted_float("hello")
+
+    with pytest.raises(ArgumentTypeError, match="not in range"):
+        parser_utils._restricted_float(1.5)
+
+    out = parser_utils._restricted_float("0.5")
+    assert out == 0.5
+
+    out = parser_utils._restricted_float(0.5)
+    assert out == 0.5
diff --git a/xcp_d/tests/test_workflows_connectivity.py b/xcp_d/tests/test_workflows_connectivity.py
index da44581..f6fce7a 100644
--- a/xcp_d/tests/test_workflows_connectivity.py
+++ b/xcp_d/tests/test_workflows_connectivity.py
@@ -43,6 +43,7 @@ def test_nifti_conn(fmriprep_with_freesurfer_data, tmp_path_factory):
     # Let's define the inputs and create the node
     connectivity_wf = init_nifti_functional_connectivity_wf(
         output_dir=tmpdir,
+        min_coverage=0.5,
         mem_gb=4,
         name="connectivity_wf",
         omp_nthreads=2,
@@ -136,6 +137,7 @@ def test_cifti_conn(fmriprep_with_freesurfer_data, tmp_path_factory):
     connectivity_wf = init_cifti_functional_connectivity_wf(
         TR=TR,
         output_dir=tmpdir,
+        min_coverage=0.5,
         mem_gb=4,
         omp_nthreads=2,
         name="connectivity_wf",
@@ -152,7 +154,7 @@ def test_cifti_conn(fmriprep_with_freesurfer_data, tmp_path_factory):
     )
     parc_file = os.path.join(
         parc_dir,
-        "parcellated_prepared_timeseries.dtseries.ptseries.nii",
+        "parcellated_fake_signal_file_timeseries.dtseries.ptseries.nii",
     )
     assert os.path.isfile(parc_file), os.listdir(parc_dir)
 
@@ -171,9 +173,9 @@ def test_cifti_conn(fmriprep_with_freesurfer_data, tmp_path_factory):
     )
     pconn_file = os.path.join(
         corr_dir,
-        "correlation_matrix_parcellated_prepared_timeseries.dtseries.ptseries.pconn.nii",
+        "correlation_matrix_parcellated_fake_signal_file_timeseries.dtseries.ptseries.pconn.nii",
     )
-    assert os.path.isfile(pconn_file), os.lsitdir(corr_dir)
+    assert os.path.isfile(pconn_file), os.listdir(corr_dir)
 
     # Read it out
     xcp_array = nb.load(pconn_file).get_fdata().T