Repository: PennLINC/xcp_d

Issue:
Support session-wise post-processing
## Summary
<!--What would you like changed/added and why?-->
Anatomical processing is done across sessions. Power users work around this by using the `--bids-filter-file` to run each session separately, but we should natively support session-wise processing.

Hints from issue comments:
@cindyhfls: The link to the example JSON file for this flag is broken. Can you fix that? https://xcp-d.readthedocs.io/en/latest/usage.html. What should be the argument used to run each session separately@tsalo: I've opened #1323 to fix the link.

I don't think we'll have an argument on XCP-D's end to enable session-wise processing. It should depend on the organization of the input data.@cindyhfls: Thanks.

So if I do:
```
{
   "bold": {
      "session": ["01","02"],
      "task": ["rest"]
   }
}
```

It would try to use the same anatomical file, but if I do:
```
{
   "bold": {
      "session": ["01"],
      "task": ["rest"]
   }
}
```
and run the pipeline again on
```
{
   "bold": {
      "session": ["02"],
      "task": ["rest"]
   }
}
```

Then it's okay?@tsalo: You should probably include `"session": ["01"]` for all of the entries in https://github.com/PennLINC/xcp_d/blob/7694faa6fcbe0f5ed3d9ab0bf272f7ecedaf5a0d/xcp_d/data/io_spec.yaml.@cindyhfls: I don't quite follow that, can you provide a toy example of how this --bids-filter-file is used?@tsalo: Something like this
```json
{
    "anat_brainmask": {
        "session": "01"
    },
    "anat_to_template_xfm": {
        "session": "01"
    },
    "bold": {
        "session": "01"
    },
    "cortical_thickness": {
        "session": "01"
    },
    "cortical_thickness_corr": {
        "session": "01"
    },
    "lh_pial_surf": {
        "session": "01"
    },
    "lh_subject_sphere": {
        "session": "01"
    },
    "lh_wm_surf": {
        "session": "01"
    },
    "myelin": {
        "session": "01"
    },
    "myelin_smoothed": {
        "session": "01"
    },
    "rh_pial_surf": {
        "session": "01"
    },
    "rh_subject_sphere": {
        "session": "01"
    },
    "rh_wm_surf": {
        "session": "01"
    },
    "sulcal_curv": {
        "session": "01"
    },
    "sulcal_depth": {
        "session": "01"
    },
    "t1w": {
        "session": "01"
    },
    "t2w": {
        "session": "01"
    },
    "template_to_anat_xfm": {
        "session": "01"
    }
}
```@mattcieslak: @tsalo wdyt about adding --session-id for session filtering?@tsalo: That should definitely be part of this.

Solution patch:
diff --git a/docs/outputs.rst b/docs/outputs.rst
index c896f7e..3d1f7e6 100644
--- a/docs/outputs.rst
+++ b/docs/outputs.rst
@@ -39,6 +39,9 @@ session.
 The executive summary is based on the DCAN lab's
 `ExecutiveSummary tool <https://github.com/DCAN-Labs/ExecutiveSummary>`_.
 
+The report output level can be set to ``root`` (shown below), ``subject``, or ``session`` using the
+``--report-output-level`` parameter.
+
 .. code-block::
 
    xcp_d/
diff --git a/xcp_d/cli/parser.py b/xcp_d/cli/parser.py
index ee7ad33..e578cbf 100644
--- a/xcp_d/cli/parser.py
+++ b/xcp_d/cli/parser.py
@@ -621,6 +621,20 @@ anatomical tissue segmentation, and an HDF5 file containing motion levels at dif
     )
 
     g_other = parser.add_argument_group('Other options')
+    g_other.add_argument(
+        '--report-output-level',
+        action='store',
+        choices=['root', 'subject', 'session'],
+        default='auto',
+        help=(
+            'Where should the html reports be written? '
+            '"root" will write them to the --output-dir. '
+            '"subject" will write them into each subject\'s directory. '
+            '"session" will write them into each session\'s directory. '
+            'The default is "auto", which will default to "root" for "none" mode, '
+            '"session" for "abcd" and "hbcd" modes, and "root" for "linc" and "nichart" modes.'
+        ),
+    )
     g_other.add_argument(
         '--aggregate-session-reports',
         dest='aggr_ses_reports',
@@ -996,6 +1010,9 @@ def _validate_parameters(opts, build_log, parser):
             error_messages.append(f"'--output-type' cannot be 'censored' for '{opts.mode}' mode.")
         opts.output_type = 'interpolated'
         opts.process_surfaces = True if opts.process_surfaces == 'auto' else opts.process_surfaces
+        opts.report_output_level = (
+            'session' if opts.report_output_level == 'auto' else opts.report_output_level
+        )
         opts.smoothing = 6 if opts.smoothing == 'auto' else opts.smoothing
     elif opts.mode == 'hbcd':
         opts.abcc_qc = True if (opts.abcc_qc == 'auto') else opts.abcc_qc
@@ -1016,6 +1033,9 @@ def _validate_parameters(opts, build_log, parser):
             error_messages.append(f"'--output-type' cannot be 'censored' for '{opts.mode}' mode.")
         opts.output_type = 'interpolated'
         opts.process_surfaces = True if opts.process_surfaces == 'auto' else opts.process_surfaces
+        opts.report_output_level = (
+            'session' if opts.report_output_level == 'auto' else opts.report_output_level
+        )
         opts.smoothing = 6 if opts.smoothing == 'auto' else opts.smoothing
     elif opts.mode == 'linc':
         opts.abcc_qc = False if (opts.abcc_qc == 'auto') else opts.abcc_qc
@@ -1035,6 +1055,9 @@ def _validate_parameters(opts, build_log, parser):
             )
         opts.output_type = 'censored'
         opts.process_surfaces = False if opts.process_surfaces == 'auto' else opts.process_surfaces
+        opts.report_output_level = (
+            'root' if opts.report_output_level == 'auto' else opts.report_output_level
+        )
         opts.smoothing = 6 if opts.smoothing == 'auto' else opts.smoothing
         if opts.correlation_lengths is not None:
             error_messages.append(f"'--create-matrices' is not supported for '{opts.mode}' mode.")
@@ -1055,6 +1078,9 @@ def _validate_parameters(opts, build_log, parser):
         opts.min_coverage = 0.4 if opts.min_coverage == 'auto' else opts.min_coverage
         opts.output_type = 'censored' if opts.output_type == 'auto' else opts.output_type
         opts.process_surfaces = False if opts.process_surfaces == 'auto' else opts.process_surfaces
+        opts.report_output_level = (
+            'root' if opts.report_output_level == 'auto' else opts.report_output_level
+        )
         opts.smoothing = 0 if opts.smoothing == 'auto' else opts.smoothing
     elif opts.mode == 'none':
         if opts.abcc_qc == 'auto':
@@ -1098,6 +1124,11 @@ def _validate_parameters(opts, build_log, parser):
                 "'--warp-surfaces-native2std' (y or n) is required for 'none' mode."
             )
 
+        # Default to root for none mode, since that was the previous behavior
+        opts.report_output_level = (
+            'root' if opts.report_output_level == 'auto' else opts.report_output_level
+        )
+
         if opts.smoothing == 'auto':
             error_messages.append("'--smoothing' is required for 'none' mode.")
 
diff --git a/xcp_d/cli/run.py b/xcp_d/cli/run.py
index 5e6eed0..03cc8f7 100644
--- a/xcp_d/cli/run.py
+++ b/xcp_d/cli/run.py
@@ -168,18 +168,12 @@ def main():
         if config.execution.atlases:
             write_atlas_dataset_description(config.execution.output_dir / 'atlases')
 
-        # Generate reports phase
-        session_list = (
-            config.execution.get().get('bids_filters', {}).get('bold', {}).get('session')
-        )
-
         # Generate reports phase
         failed_reports = generate_reports(
-            subject_list=config.execution.participant_label,
-            output_dir=config.execution.output_dir,
+            output_level=config.execution.report_output_level,
+            dataset_dir=config.execution.output_dir,
             abcc_qc=config.workflow.abcc_qc,
             run_uuid=config.execution.run_uuid,
-            session_list=session_list,
         )
 
         if failed_reports:
diff --git a/xcp_d/cli/workflow.py b/xcp_d/cli/workflow.py
index dabbe51..58548e4 100644
--- a/xcp_d/cli/workflow.py
+++ b/xcp_d/cli/workflow.py
@@ -62,18 +62,11 @@ def build_workflow(config_file, retval):
     # Called with reports only
     if config.execution.reports_only:
         build_log.log(25, 'Running --reports-only on participants %s', ', '.join(subject_list))
-        session_list = (
-            config.execution.bids_filters.get('bold', {}).get('session')
-            if config.execution.bids_filters
-            else None
-        )
-
         failed_reports = generate_reports(
-            subject_list=config.execution.participant_label,
-            output_dir=config.execution.output_dir,
+            output_level=config.execution.report_output_level,
+            dataset_dir=config.execution.output_dir,
             abcc_qc=config.workflow.abcc_qc,
             run_uuid=config.execution.run_uuid,
-            session_list=session_list,
         )
         if failed_reports:
             config.loggers.cli.error(
diff --git a/xcp_d/config.py b/xcp_d/config.py
index 66f19fa..8d8d0fb 100644
--- a/xcp_d/config.py
+++ b/xcp_d/config.py
@@ -413,6 +413,8 @@ class execution(_Config):
     """Do not convert boilerplate from MarkDown to LaTex and HTML."""
     notrack = None
     """Do not collect telemetry information for *XCP-D*."""
+    report_output_level = None
+    """Directory level at which the html reports should be written."""
     reports_only = None
     """Only build the reports, based on the reportlets found in a cached working directory."""
     output_dir = None
diff --git a/xcp_d/data/reports-spec.yml b/xcp_d/data/reports-spec.yml
index 70a09d1..7996b14 100755
--- a/xcp_d/data/reports-spec.yml
+++ b/xcp_d/data/reports-spec.yml
@@ -149,7 +149,7 @@ sections:
   reportlets:
   - bids: {datatype: figures, desc: about, suffix: bold}
   - custom: boilerplate
-    path: '{out_dir}/logs'
+    path: '{reportlets_dir}/logs'
     bibfile: ['xcp_d', 'data/boilerplate.bib']
     caption: |
       <p>We kindly ask to report results postprocessed with this tool using the following boilerplate.</p>
@@ -162,6 +162,6 @@ sections:
       </p>
     title: Methods
   - custom: errors
-    path: '{out_dir}/sub-{subject}/log/{run_uuid}'
+    path: '{reportlets_dir}/sub-{subject}/log/{run_uuid}'
     captions: <em>NiReports</em> may have recorded failure conditions.
     title: Errors
diff --git a/xcp_d/data/xcp_d_bids_config.json b/xcp_d/data/xcp_d_bids_config.json
index 06cc4ce..e845397 100755
--- a/xcp_d/data/xcp_d_bids_config.json
+++ b/xcp_d/data/xcp_d_bids_config.json
@@ -45,9 +45,9 @@
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<timeseries>}{extension<.ptseries.nii|.json>}",
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}]_seg-{segmentation}[_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<boldmap>}{extension<.pscalar.nii|.json>}",
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}]_seg-{segmentation}[_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<boldmap>}{extension<.pconn.nii|.json>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<T1w|T2w|T1rho|T1map|T2map|T2star|FLAIR|FLASH|PDmap|PD|PDT2|inplaneT[12]|angio|dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_space-{space}][_cohort-{cohort}][_desc-{desc}]_{suffix<bold|motion>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_desc-{desc}]_{suffix<design>|design}{extension<.html|.svg|.png>}"
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<T1w|T2w|T1rho|T1map|T2map|T2star|FLAIR|FLASH|PDmap|PD|PDT2|inplaneT[12]|angio|dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_space-{space}][_cohort-{cohort}][_desc-{desc}]_{suffix<bold|motion>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_desc-{desc}]_{suffix<design>|design}{extension<.html|.svg|.png>}"
   ]
 }
diff --git a/xcp_d/data/xcp_d_bids_config2.json b/xcp_d/data/xcp_d_bids_config2.json
index 3992708..11ac28f 100755
--- a/xcp_d/data/xcp_d_bids_config2.json
+++ b/xcp_d/data/xcp_d_bids_config2.json
@@ -41,9 +41,9 @@
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<timeseries>}{extension<.ptseries.nii|.json>}",
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}]_seg-{segmentation}[_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<boldmap>}{extension<.pscalar.nii|.json>}",
     "sub-{subject}[/ses-{session}]/{datatype<func>|func}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_hemi-{hemi<L|R>}][_space-{space}][_cohort-{cohort}]_seg-{segmentation}[_den-{den}]_stat-{statistic}[_desc-{desc}]_{suffix<boldmap>}{extension<.pconn.nii|.json>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<T1w|T2w|T1rho|T1map|T2map|T2star|FLAIR|FLASH|PDmap|PD|PDT2|inplaneT[12]|angio|dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_space-{space}][_cohort-{cohort}][_desc-{desc}]_{suffix<bold|motion>}{extension<.html|.svg|.png>}",
-    "sub-{subject}/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_desc-{desc}]_{suffix<design>|design}{extension<.html|.svg|.png>}"
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<T1w|T2w|T1rho|T1map|T2map|T2star|FLAIR|FLASH|PDmap|PD|PDT2|inplaneT[12]|angio|dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}][_acq-{acquisition}][_ce-{ceagent}][_rec-{reconstruction}][_run-{run}][_space-{space}][_cohort-{cohort}][_seg-{segmentation}][_desc-{desc}]_{suffix<dseg|mask|dwi|epiref|fieldmap>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_space-{space}][_cohort-{cohort}][_desc-{desc}]_{suffix<bold|motion>}{extension<.html|.svg|.png>}",
+    "sub-{subject}[/ses-{session}]/{datatype<figures>}/sub-{subject}[_ses-{session}]_task-{task}[_acq-{acquisition}][_ce-{ceagent}][_dir-{direction}][_rec-{reconstruction}][_run-{run}][_echo-{echo}][_desc-{desc}]_{suffix<design>|design}{extension<.html|.svg|.png>}"
   ]
 }
diff --git a/xcp_d/interfaces/execsummary.py b/xcp_d/interfaces/execsummary.py
index 21ed042..f7dbe9c 100644
--- a/xcp_d/interfaces/execsummary.py
+++ b/xcp_d/interfaces/execsummary.py
@@ -35,14 +35,17 @@ class ExecutiveSummary:
     ----------
     xcpd_path : :obj:`str`
         Path to the XCP-D derivatives.
+    output_dir : :obj:`str`
+        Folder where the executive summary will be written out.
     subject_id : :obj:`str`
         Subject ID.
     session_id : None or :obj:`str`, optional
         Session ID.
     """
 
-    def __init__(self, xcpd_path, subject_id, session_id=None):
+    def __init__(self, xcpd_path, output_dir, subject_id, session_id=None):
         self.xcpd_path = xcpd_path
+        self.output_dir = output_dir
         self.subject_id = subject_id
         if session_id:
             self.session_id = session_id
@@ -57,14 +60,14 @@ class ExecutiveSummary:
         Parameters
         ----------
         document : :obj:`str`
-            html document.
+            HTML contents to write to file.
         filename : :obj:`str`
-            name of html file.
+            Name of HTML file to write.
         """
         soup = BeautifulSoup(document, features='lxml')
         html = soup.prettify()  # prettify the html
 
-        filepath = os.path.join(self.xcpd_path, filename)
+        filepath = os.path.join(self.output_dir, filename)
         with open(filepath, 'w') as fo:
             fo.write(html)
 
@@ -72,7 +75,7 @@ class ExecutiveSummary:
         files = self.layout.get(**query)
         if len(files) == 1:
             found_file = files[0].path
-            found_file = os.path.relpath(found_file, self.xcpd_path)
+            found_file = os.path.relpath(found_file, self.output_dir)
         else:
             found_file = 'None'
 
@@ -302,7 +305,7 @@ class ExecutiveSummary:
             else:
                 out_file = f'sub-{self.subject_id}_executive_summary.html'
 
-            out_file = os.path.join(self.xcpd_path, out_file)
+            out_file = os.path.join(self.output_dir, out_file)
 
         boilerplate = []
         boiler_idx = 0
diff --git a/xcp_d/reports/core.py b/xcp_d/reports/core.py
index 4e88d9b..461d3cc 100644
--- a/xcp_d/reports/core.py
+++ b/xcp_d/reports/core.py
@@ -7,6 +7,7 @@ This is adapted from fMRIPost-AROMA.
 
 from pathlib import Path
 
+from bids.layout import Query
 from nireports.assembler.report import Report
 
 from xcp_d import config, data
@@ -14,25 +15,26 @@ from xcp_d.interfaces.execsummary import ExecutiveSummary
 
 
 def run_reports(
-    output_dir,
+    out_dir,
     subject_label,
     run_uuid,
     bootstrap_file=None,
     out_filename='report.html',
-    reportlets_dir=None,
+    dataset_dir=None,
     errorname='report.err',
+    metadata=None,
     **entities,
 ):
     """Run the reports."""
     robj = Report(
-        output_dir,
+        out_dir,
         run_uuid,
         bootstrap_file=bootstrap_file,
         out_filename=out_filename,
-        reportlets_dir=reportlets_dir,
+        reportlets_dir=dataset_dir,
         plugins=None,
         plugin_meta=None,
-        metadata=None,
+        metadata=metadata,
         **entities,
     )
 
@@ -44,89 +46,120 @@ def run_reports(
         import traceback
 
         # Store the list of subjects for which report generation failed
-        traceback.print_exception(*sys.exc_info(), file=str(Path(output_dir) / 'logs' / errorname))
+        traceback.print_exception(
+            *sys.exc_info(),
+            file=str(Path(out_dir) / 'logs' / errorname),
+        )
         return subject_label
 
     return None
 
 
 def generate_reports(
-    subject_list,
-    output_dir,
+    output_level,
+    dataset_dir,
     abcc_qc,
     run_uuid,
-    session_list=None,
     bootstrap_file=None,
     work_dir=None,
 ):
-    """Generate reports for a list of subjects."""
-    reportlets_dir = None
-    if work_dir is not None:
-        reportlets_dir = Path(work_dir) / 'reportlets'
-
-    if isinstance(subject_list, str):
-        subject_list = [subject_list]
+    """Generate reports for a list of subjects.
 
+    Parameters
+    ----------
+    output_level : {'root', 'subject', 'session'}
+    """
     errors = []
+    bootstrap_file = bootstrap_file or data.load('reports-spec.yml')
+
+    bids_filters = config.execution.bids_filters or {}
+    subject_list = config.execution.participant_label
+    subject_list = [sub[4:] if sub.startswith('sub-') else sub for sub in subject_list]
     for subject_label in subject_list:
-        # The number of sessions is intentionally not based on session_list but
-        # on the total number of sessions, because I want the final derivatives
-        # folder to be the same whether sessions were run one at a time or all-together.
-        n_ses = len(config.execution.layout.get_sessions(subject=subject_label))
-
-        if bootstrap_file is not None:
-            # If a config file is precised, we do not override it
-            html_report = 'report.html'
-        elif n_ses <= config.execution.aggr_ses_reports:
-            # If there are only a few session for this subject,
-            # we aggregate them in a single visual report.
-            bootstrap_file = data.load('reports-spec.yml')
-            html_report = 'report.html'
-        else:
-            # Beyond a threshold, we separate the anatomical report from the functional.
-            bootstrap_file = data.load('reports-spec-anat.yml')
-            html_report = f'sub-{subject_label.lstrip("sub-")}_anat.html'
-
-        report_error = run_reports(
-            output_dir,
-            subject_label,
-            run_uuid,
-            bootstrap_file=bootstrap_file,
-            out_filename=html_report,
-            reportlets_dir=reportlets_dir,
-            errorname=f'report-{run_uuid}-{subject_label}.err',
+        filters = bids_filters.get('bold', {})
+        filters['session'] = config.execution.session_id or Query.OPTIONAL
+        # Extract session IDs from the processed DWIs
+        sessions = config.execution.layout.get_sessions(
             subject=subject_label,
+            suffix='bold',
+            **filters,
         )
-        # If the report generation failed, append the subject label for which it failed
-        if report_error is not None:
-            errors.append(report_error)
-
-        if n_ses > config.execution.aggr_ses_reports:
-            # Beyond a certain number of sessions per subject,
-            # we separate the functional reports per session
-            if session_list is None:
-                all_filters = config.execution.bids_filters or {}
-                filters = all_filters.get('bold', {})
-                session_list = config.execution.layout.get_sessions(
-                    subject=subject_label, **filters
-                )
-
-            # Drop ses- prefixes
-            session_list = [ses for ses in session_list if isinstance(ses, str)]  # drop Queries
-            session_list = [ses[4:] if ses.startswith('ses-') else ses for ses in session_list]
+        if output_level == 'session' and not sessions:
+            report_dir = dataset_dir
+            output_level = 'subject'
+            config.loggers.workflow.warning(
+                'Session-level reports were requested, '
+                'but data was found without a session level. '
+                'Writing out reports to subject level.'
+            )
 
-            for session_label in session_list:
-                bootstrap_file = data.load('reports-spec-func.yml')
-                html_report = f'sub-{subject_label.lstrip("sub-")}_ses-{session_label}_func.html'
+        if not sessions:
+            sessions = [Query.NONE]
+
+        if output_level != 'session' and len(sessions) <= config.execution.aggr_ses_reports:
+            html_report = f'sub-{subject_label}.html'
+
+            if output_level == 'root':
+                report_dir = dataset_dir
+            elif output_level == 'subject':
+                report_dir = Path(dataset_dir) / f'sub-{subject_label}'
+
+            report_error = run_reports(
+                out_dir=report_dir,
+                subject_label=subject_label,
+                run_uuid=run_uuid,
+                bootstrap_file=bootstrap_file,
+                out_filename=html_report,
+                dataset_dir=dataset_dir,
+                errorname=f'report-{run_uuid}-{subject_label}.err',
+                subject=subject_label,
+                session=None,
+            )
+            # If the report generation failed, append the subject label for which it failed
+            if report_error is not None:
+                errors.append(report_error)
+
+            if abcc_qc:
+                for session_label in sessions:
+                    if session_label == Query.NONE:
+                        session_label = None
+
+                    exsumm = ExecutiveSummary(
+                        xcpd_path=dataset_dir,
+                        output_dir=report_dir,
+                        subject_id=subject_label,
+                        session_id=session_label,
+                    )
+                    exsumm.collect_inputs()
+                    exsumm.generate_report()
+        else:
+            for session_label in sessions:
+                if session_label == Query.NONE:
+                    html_report = f'sub-{subject_label}.html'
+                    session_label = None
+                else:
+                    html_report = f'sub-{subject_label}_ses-{session_label}.html'
+
+                if output_level == 'root':
+                    report_dir = dataset_dir
+                elif output_level == 'subject':
+                    report_dir = Path(dataset_dir) / f'sub-{subject_label}'
+                elif output_level == 'session':
+                    report_dir = (
+                        Path(dataset_dir) / f'sub-{subject_label}' / f'ses-{session_label}'
+                    )
 
                 report_error = run_reports(
-                    output_dir,
-                    subject_label,
-                    run_uuid,
+                    out_dir=report_dir,
+                    subject_label=subject_label,
+                    run_uuid=run_uuid,
                     bootstrap_file=bootstrap_file,
                     out_filename=html_report,
-                    reportlets_dir=reportlets_dir,
-                    errorname=f'report-{run_uuid}-{subject_label}-func.err',
+                    dataset_dir=dataset_dir,
+                    errorname=f'report-{run_uuid}-{subject_label}.err',
+                    metadata={
+                        'session_str': f", session '{session_label}'" if session_label else '',
+                    },
                     subject=subject_label,
                     session=session_label,
                 )
@@ -134,6 +167,19 @@ def generate_reports(
                 if report_error is not None:
                     errors.append(report_error)
 
+                if abcc_qc:
+                    exsumm = ExecutiveSummary(
+                        xcpd_path=dataset_dir,
+                        output_dir=report_dir,
+                        subject_id=subject_label,
+                        session_id=session_label,
+                    )
+                    exsumm.collect_inputs()
+                    exsumm.generate_report()
+
+            # Someday, when we have anatomical reports, add a section here that
+            # finds sessions and makes the reports.
+
     if errors:
         error_list = ', '.join(
             f'{subid} ({err})' for subid, err in zip(subject_list, errors, strict=False) if err
@@ -143,29 +189,7 @@ def generate_reports(
             'data from participants: %s. Check the HTML reports for details.',
             error_list,
         )
-    elif abcc_qc:
-        config.loggers.cli.info('Generating executive summary.')
-
-        if session_list is None:
-            all_filters = config.execution.bids_filters or {}
-            filters = all_filters.get('bold', {})
-            session_list = config.execution.layout.get_sessions(subject=subject_label, **filters)
-
-        # Drop ses- prefixes
-        session_list = [ses for ses in session_list if isinstance(ses, str)]  # drop Queries
-        session_list = [ses[4:] if ses.startswith('ses-') else ses for ses in session_list]
-        if not session_list:
-            session_list = [None]
-
-        for session_label in session_list:
-            exsumm = ExecutiveSummary(
-                xcpd_path=output_dir,
-                subject_id=subject_label,
-                session_id=session_label,
-            )
-            exsumm.collect_inputs()
-            exsumm.generate_report()
 
-        config.loggers.cli.info('Reports generated successfully')
+    config.loggers.cli.info('Reports generated successfully')
 
     return errors


Test patch:
diff --git a/xcp_d/tests/data/test_ds001419_cifti_outputs.txt b/xcp_d/tests/data/test_ds001419_cifti_outputs.txt
index 975e831..521a7a1 100644
--- a/xcp_d/tests/data/test_ds001419_cifti_outputs.txt
+++ b/xcp_d/tests/data/test_ds001419_cifti_outputs.txt
@@ -24,7 +24,7 @@ logs/CITATION.html
 logs/CITATION.md
 logs/CITATION.tex
 sub-01
-sub-01.html
+sub-01/sub-01.html
 sub-01/anat
 sub-01/anat/sub-01_space-MNI152NLin6Asym_desc-preproc_T1w.nii.gz
 sub-01/anat/sub-01_space-fsLR_seg-4S156Parcels_stat-mean_desc-curv_morph.tsv
@@ -205,4 +205,4 @@ sub-01/func/sub-01_task-imagery_space-fsLR_seg-4S456Parcels_den-91k_stat-mean_ti
 sub-01/func/sub-01_task-imagery_space-fsLR_seg-4S456Parcels_den-91k_stat-mean_timeseries.ptseries.nii
 sub-01/func/sub-01_task-imagery_space-fsLR_seg-4S456Parcels_stat-mean_timeseries.json
 sub-01/func/sub-01_task-imagery_space-fsLR_seg-4S456Parcels_stat-mean_timeseries.tsv
-sub-01_executive_summary.html
+sub-01/sub-01_executive_summary.html
diff --git a/xcp_d/tests/data/test_fmriprep_without_freesurfer_outputs.txt b/xcp_d/tests/data/test_fmriprep_without_freesurfer_outputs.txt
index 52bf293..cc3c2ed 100644
--- a/xcp_d/tests/data/test_fmriprep_without_freesurfer_outputs.txt
+++ b/xcp_d/tests/data/test_fmriprep_without_freesurfer_outputs.txt
@@ -16,7 +16,7 @@ logs/CITATION.html
 logs/CITATION.md
 logs/CITATION.tex
 sub-01
-sub-01.html
+sub-01/sub-01.html
 sub-01/anat
 sub-01/anat/sub-01_space-MNI152NLin2009cAsym_desc-preproc_T1w.nii.gz
 sub-01/func
@@ -80,4 +80,4 @@ sub-01/func/sub-01_task-mixedgamblestask_run-2_space-MNI152NLin2009cAsym_seg-Sch
 sub-01/func/sub-01_task-mixedgamblestask_run-2_space-MNI152NLin2009cAsym_seg-Schaefer100_stat-reho_bold.tsv
 sub-01/func/sub-01_task-mixedgamblestask_run-2_space-MNI152NLin2009cAsym_stat-reho_boldmap.json
 sub-01/func/sub-01_task-mixedgamblestask_run-2_space-MNI152NLin2009cAsym_stat-reho_boldmap.nii.gz
-sub-01_executive_summary.html
+sub-01/sub-01_executive_summary.html
diff --git a/xcp_d/tests/data/test_nibabies_outputs.txt b/xcp_d/tests/data/test_nibabies_outputs.txt
index eafc8c1..536b515 100644
--- a/xcp_d/tests/data/test_nibabies_outputs.txt
+++ b/xcp_d/tests/data/test_nibabies_outputs.txt
@@ -63,7 +63,7 @@ logs/CITATION.html
 logs/CITATION.md
 logs/CITATION.tex
 sub-01
-sub-01.html
+sub-01/sub-01.html
 sub-01/ses-1mo
 sub-01/ses-1mo/anat
 sub-01/ses-1mo/anat/sub-01_ses-1mo_run-001_space-MNIInfant_cohort-1_desc-preproc_T1w.nii.gz
@@ -219,4 +219,4 @@ sub-01/ses-1mo/func/sub-01_ses-1mo_task-rest_acq-PA_run-001_space-MNIInfant_coho
 sub-01/ses-1mo/func/sub-01_ses-1mo_task-rest_acq-PA_run-001_space-MNIInfant_cohort-1_stat-alff_boldmap.nii.gz
 sub-01/ses-1mo/func/sub-01_ses-1mo_task-rest_acq-PA_run-001_space-MNIInfant_cohort-1_stat-reho_boldmap.json
 sub-01/ses-1mo/func/sub-01_ses-1mo_task-rest_acq-PA_run-001_space-MNIInfant_cohort-1_stat-reho_boldmap.nii.gz
-sub-01_ses-1mo_executive_summary.html
+sub-01/sub-01_ses-1mo_executive_summary.html
diff --git a/xcp_d/tests/data/test_pnc_cifti_outputs.txt b/xcp_d/tests/data/test_pnc_cifti_outputs.txt
index 65ec2ee..4daad5b 100644
--- a/xcp_d/tests/data/test_pnc_cifti_outputs.txt
+++ b/xcp_d/tests/data/test_pnc_cifti_outputs.txt
@@ -19,7 +19,7 @@ logs/CITATION.html
 logs/CITATION.md
 logs/CITATION.tex
 sub-1648798153
-sub-1648798153.html
+sub-1648798153/ses-PNC1/sub-1648798153_ses-PNC1.html
 sub-1648798153/ses-PNC1
 sub-1648798153/ses-PNC1/anat
 sub-1648798153/ses-PNC1/anat/sub-1648798153_ses-PNC1_acq-refaced_space-MNI152NLin6Asym_desc-preproc_T1w.nii.gz
@@ -105,4 +105,4 @@ sub-1648798153/ses-PNC1/func/sub-1648798153_ses-PNC1_task-rest_acq-singleband_sp
 sub-1648798153/ses-PNC1/func/sub-1648798153_ses-PNC1_task-rest_acq-singleband_space-fsLR_seg-Tian_stat-pearsoncorrelation_relmat.tsv
 sub-1648798153/ses-PNC1/func/sub-1648798153_ses-PNC1_task-rest_acq-singleband_space-fsLR_seg-Tian_stat-reho_bold.json
 sub-1648798153/ses-PNC1/func/sub-1648798153_ses-PNC1_task-rest_acq-singleband_space-fsLR_seg-Tian_stat-reho_bold.tsv
-sub-1648798153_ses-PNC1_executive_summary.html
+sub-1648798153/ses-PNC1/sub-1648798153_ses-PNC1_executive_summary.html
diff --git a/xcp_d/tests/test_cli.py b/xcp_d/tests/test_cli.py
index 8198fe2..ab95c2f 100644
--- a/xcp_d/tests/test_cli.py
+++ b/xcp_d/tests/test_cli.py
@@ -72,6 +72,7 @@ def test_ds001419_nifti(data_dir, output_dir, working_dir):
         '--file-format=nifti',
         '--input-type=fmriprep',
         '--warp-surfaces-native2std=n',
+        '--report-output-level=root',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -121,6 +122,7 @@ def test_ds001419_cifti(data_dir, output_dir, working_dir):
         '4S356Parcels',
         '4S456Parcels',
         '--linc-qc',
+        '--report-output-level=subject',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -158,6 +160,7 @@ def test_ukbiobank(data_dir, output_dir, working_dir):
         '--min-coverage=0.1',
         '--random-seed=8675309',
         '--min-time=100',
+        '--report-output-level=root',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -216,6 +219,7 @@ def test_pnc_cifti(data_dir, output_dir, working_dir):
         '480',
         'all',
         '--linc-qc=n',
+        '--report-output-level=session',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -270,6 +274,7 @@ def test_pnc_cifti_t2wonly(data_dir, output_dir, working_dir):
         '--disable-bandpass-filter',
         '--create-matrices=all',
         '--linc-qc=n',
+        '--report-output-level=root',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -319,6 +324,7 @@ def test_fmriprep_without_freesurfer(data_dir, output_dir, working_dir):
         '--min-time=20',
         '--dummy-scans=1',
         '--abcc-qc',
+        '--report-output-level=session',  # will revert to subject
     ]
 
     _run_and_generate(
@@ -371,6 +377,7 @@ def test_fmriprep_without_freesurfer_with_main(data_dir, output_dir, working_dir
         '--min-time=20',
         '--dummy-scans=1',
         '--abcc-qc',
+        '--report-output-level=subject',
     ]
 
     _run_and_generate(
@@ -412,6 +419,7 @@ def test_nibabies(data_dir, output_dir, working_dir):
         '--create-matrices=all',
         '--motion-filter-type=none',
         '--linc-qc=n',
+        '--report-output-level=subject',
     ]
     _run_and_generate(
         test_name=test_name,
@@ -459,17 +467,11 @@ def _run_and_generate(test_name, parameters, input_type, test_main=False):
             write_atlas_dataset_description(config.execution.output_dir / 'atlases')
 
         build_boilerplate(str(config_file), xcpd_wf)
-        session_list = (
-            config.execution.bids_filters.get('bold', {}).get('session')
-            if config.execution.bids_filters
-            else None
-        )
         generate_reports(
-            subject_list=config.execution.participant_label,
-            output_dir=config.execution.output_dir,
+            output_level=config.execution.report_output_level,
+            dataset_dir=config.execution.output_dir,
             abcc_qc=config.workflow.abcc_qc,
             run_uuid=config.execution.run_uuid,
-            session_list=session_list,
         )
 
     output_list_file = os.path.join(get_test_data_path(), f'{test_name}_outputs.txt')
diff --git a/xcp_d/tests/test_cli_run.py b/xcp_d/tests/test_cli_run.py
index 97c9c37..b55e1bc 100644
--- a/xcp_d/tests/test_cli_run.py
+++ b/xcp_d/tests/test_cli_run.py
@@ -33,6 +33,7 @@ def base_opts():
         'mode': 'linc',
         'file_format': 'auto',
         'input_type': 'auto',
+        'report_output_level': 'auto',
         'confounds_config': 'auto',
         'high_pass': 0.01,
         'low_pass': 0.1,
@@ -281,6 +282,7 @@ def test_validate_parameters_linc_mode(base_opts, base_parser, capsys):
     assert opts.min_coverage == 0.5
     assert opts.smoothing == 6.0
     assert opts.correlation_lengths == ['all']
+    assert opts.report_output_level == 'root'
 
     # --create-matrices is not supported
     opts.correlation_lengths = ['300']
@@ -312,7 +314,7 @@ def test_validate_parameters_abcd_mode(base_opts, base_parser, capsys):
     assert opts.min_coverage == 0.5
     assert opts.process_surfaces is True
     assert opts.smoothing == 6.0
-
+    assert opts.report_output_level == 'session'
     opts.correlation_lengths = ['300', 'all']
     opts = parser._validate_parameters(deepcopy(opts), build_log, parser=base_parser)
     assert opts.correlation_lengths == ['300', 'all']
@@ -346,6 +348,7 @@ def test_validate_parameters_hbcd_mode(base_opts, base_parser, capsys):
     assert opts.linc_qc is True
     assert opts.min_coverage == 0.5
     assert opts.process_surfaces is True
+    assert opts.report_output_level == 'session'
     assert opts.smoothing == 6.0
 
     opts.correlation_lengths = ['300', 'all']
@@ -374,6 +377,7 @@ def test_validate_parameters_nichart_mode(base_opts, base_parser, capsys):
     assert opts.file_format == 'nifti'
     assert opts.min_coverage == 0.4
     assert opts.smoothing == 0
+    assert opts.report_output_level == 'root'
 
 
 def test_validate_parameters_none_mode(base_opts, base_parser, capsys):
@@ -414,6 +418,7 @@ def test_validate_parameters_none_mode(base_opts, base_parser, capsys):
     opts.process_surfaces = False
     opts.smoothing = 0
     opts = parser._validate_parameters(deepcopy(opts), build_log, parser=base_parser)
+    assert opts.report_output_level == 'root'
 
 
 def test_validate_parameters_other_mode(base_opts, base_parser, capsys):