Repository: nilearn/nilearn

Issue:
`save_glm_to_bids` should save the output of each participant in a different folder
Currently everything is left in flat structure.

See the example:
https://nilearn.github.io/stable/auto_examples/04_glm_first_level/plot_bids_features.html#saving-model-outputs-to-disk

```
derivatives
└── nilearn_glm
    ├── dataset_description.json
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-effect_statmap.nii.gz
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-p_statmap.nii.gz
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-t_statmap.nii.gz
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-variance_statmap.nii.gz
    ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-z_statmap.nii.gz
    ├── sub-10159_task-stopsignal_design.svg
    ├── sub-10159_task-stopsignal_design.tsv
    ├── sub-10159_task-stopsignal_stat-errorts_statmap.nii.gz
    ├── sub-10159_task-stopsignal_statmap.json
    └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
```

Though it can be hard to decide how to structure several GLMs in a dataset (see this point in BIDS extension https://docs.google.com/document/d/1KHzp-yk8KXvkUIhtN71WU0m4P4kKT9C1yvI-i9_kNeY/edit?disco=AAAAZYC1rtw)
it should be fairly safe to assume that the date of each subject can go in a subfolder


Discussion:
Not fully sure I undertsand: in the above example, there is only one glm fitted, right ? Do you mean that we should systematically have a "glm folder" in the output path ?
Let me give you examples. That may be clearer than my midnight rambling.
IMHO the bare minimum would be to have at least a subject level folder:

```
derivatives
└── nilearn_glm
    ├── dataset_description.json
    └── sub-10159
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-effect_statmap.nii.gz
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-p_statmap.nii.gz
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-t_statmap.nii.gz
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-variance_statmap.nii.gz
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_stat-z_statmap.nii.gz
        ├── sub-10159_task-stopsignal_design.svg
        ├── sub-10159_task-stopsignal_design.tsv
        ├── sub-10159_task-stopsignal_stat-errorts_statmap.nii.gz
        ├── sub-10159_task-stopsignal_statmap.json
        └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
```
But if someone wants tries different GLMs, they could want one dataset per GLM:

```
derivatives
├── nilearn_glm_1
│   ├── dataset_description.json
│   └── sub-10159
│       ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
│       ├── ...
│       └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
└── nilearn_glm_2
    ├── dataset_description.json
    └── sub-10159
        ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
        ├── ...
        └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
```

One dataset to store all GLMs:

```
derivatives
└── nilearn
    ├── dataset_description.json
    └── sub-10159
        ├── glm_1
        │   ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
        │   ├── ...
        │   └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
        └── glm_2
            ├── sub-10159_task-stopsignal_contrast-stopsuccessMinusGo_design.svg
            ├── ...
            └── sub-10159_task-stopsignal_stat-rSquare_statmap.nii.gz
```

I think it is at the moment Nilearn's job to arbitrate on this, but I would default to having at least a subject level folder in the output to prevent from having all files from say a 100 subjects all saved in one single folder.
@tsalo 
Any peculiar reason why this data structure was chosen?
Also noticing that the statsmap metadata looks like this, which is fine except that having a  `RepetitionTime` for a 3D image feels weird to me. Might be better to have this as metadata of the design matrix that without this may be hard to understand.

```
{
    "Description": "A statistical map generated by Nilearn.",
    "ModelParameters": {
        "drift_model": "cosine",
        "drift_order": 1,
        "fir_delays": [
            0
        ],
        "high_pass": 0.01,
        "hrf_model": "glover",
        "scaling_axis": 0,
        "signal_scaling": 0,
        "slice_time_ref": 0.0,
        "smoothing_fwhm": 5.0,
        "standardize": false,
        "target_affine": null,
        "target_shape": null
    },
    "RepetitionTime": 2
}
```
Makes sense to me.
I meant to say.

I think it is NOT, at the moment, Nilearn's job to arbitrate on this, 
@Remi-Gau I believe we went with a flat structure to produce BIDS-like outputs without committing to making Nilearn a BIDS App. The idea was to make it easier to use Nilearn within BIDS Apps, like fitlins. Here's the original description from #2715:

>The goal of the new function(s) is not to implement a BIDS App, but to generate BIDS-compatible outputs that can be collected and integrated automatically within a BIDS App like fitlins. The output files won't use entities from the source files; they'll only have the new entities specific to the model. We also can't define the full derivative directory tree because the function(s) will be tied to an individual model, so we will output any top-level files (like dataset_description.json) within the one output directory.

That said, writing out more complete BIDS derivatives is definitely something I'm in favor of. 
To me just setting a BIDS-compatible folder tree is a good thing. The cost is limited.

> I believe we went with a flat structure to produce BIDS-like outputs without committing to making Nilearn a BIDS App. The idea was to make it easier to use Nilearn within BIDS Apps, like fitlins.

It seems that fitlins handles the saving to file and output file naming itself in any case:

https://github.com/poldracklab/fitlins/blob/35f9a0d440731f3ad5630c56bda2780878885794/fitlins/workflows/base.py#L142-L173
It does, but I figured that following a recognizable filename pattern in Nilearn would make it easier to handle things on fitlins' side. Plus, with BIDS-like outputs, we can include sidecar files with useful metadata.

Resolving patch:
diff --git a/examples/04_glm_first_level/plot_bids_features.py b/examples/04_glm_first_level/plot_bids_features.py
--- a/examples/04_glm_first_level/plot_bids_features.py
+++ b/examples/04_glm_first_level/plot_bids_features.py
@@ -213,33 +213,18 @@
 print(table.to_latex())
 
 # %%
-# Generating a report
-# -------------------
-# Using the computed FirstLevelModel and :term:`contrast` information,
-# we can quickly create a summary report.
-from nilearn.reporting import make_glm_report
-
-report = make_glm_report(
-    model=model,
-    contrasts="StopSuccess - Go",
-)
-
-# %%
-# We have several ways to access the report:
-
-# report  # This report can be viewed in a notebook
-# report.open_in_browser()
+# Saving model outputs to disk
+# ----------------------------
+#
+# We can now easily save the main results,
+# the model metadata and an HTML report to the disk.
+#
 
-# or we can save as an html file
 from pathlib import Path
 
 output_dir = Path.cwd() / "results" / "plot_bids_features"
 output_dir.mkdir(exist_ok=True, parents=True)
-# report.save_as_html(output_dir / 'report.html')
 
-# %%
-# Saving model outputs to disk
-# ----------------------------
 from nilearn.interfaces.bids import save_glm_to_bids
 
 save_glm_to_bids(
@@ -252,5 +237,27 @@
 
 # %%
 # View the generated files
-files = sorted(list((output_dir / "derivatives" / "nilearn_glm").glob("*")))
+files = sorted(list((output_dir / "derivatives" / "nilearn_glm").glob("**/*")))
 print("\n".join([str(x.relative_to(output_dir)) for x in files]))
+
+# %%
+# Only generating the HTML report
+# -------------------------------
+#
+# Using the computed FirstLevelModel and :term:`contrast` information,
+# we can quickly also also only create a summary report.
+from nilearn.reporting import make_glm_report
+
+report = make_glm_report(
+    model=model,
+    contrasts="StopSuccess - Go",
+)
+
+# %%
+# We have several ways to access the report:
+
+# report  # This report can be viewed in a notebook
+# report.open_in_browser()
+
+# or we can save as an html file
+# report.save_as_html(output_dir / 'report.html')
diff --git a/examples/05_glm_second_level/plot_oasis.py b/examples/05_glm_second_level/plot_oasis.py
--- a/examples/05_glm_second_level/plot_oasis.py
+++ b/examples/05_glm_second_level/plot_oasis.py
@@ -105,7 +105,7 @@
 from nilearn.glm.second_level import SecondLevelModel
 
 second_level_model = SecondLevelModel(
-    smoothing_fwhm=2.0, mask_img=mask_img, n_jobs=2
+    smoothing_fwhm=2.0, mask_img=mask_img, n_jobs=2, minimize_memory=False
 )
 second_level_model.fit(
     gray_matter_map_filenames,
@@ -175,14 +175,36 @@
     bg_img=icbm152_2009["t1"],
 )
 
+
 # %%
 # We have several ways to access the report:
-
 # report  # This report can be viewed in a notebook
 # report.open_in_browser()
 
 # or we can save as an html file
-# from pathlib import Path
-# output_dir = Path.cwd() / "results" / "plot_oasis"
-# output_dir.mkdir(exist_ok=True, parents=True)
-# report.save_as_html(output_dir / 'report.html')
+from pathlib import Path
+
+output_dir = Path.cwd() / "results" / "plot_oasis"
+output_dir.mkdir(exist_ok=True, parents=True)
+report.save_as_html(output_dir / "report.html")
+
+
+# %%
+# Saving model outputs to disk
+# ----------------------------
+
+# We can also save the model outputs to disk
+from nilearn.interfaces.bids import save_glm_to_bids
+
+save_glm_to_bids(
+    second_level_model,
+    contrasts=["age", "sex"],
+    out_dir=output_dir / "derivatives" / "nilearn_glm",
+    prefix="ageEffectOnGM",
+    bg_img=icbm152_2009["t1"],
+)
+
+# %%
+# View the generated files
+files = sorted(list((output_dir / "derivatives" / "nilearn_glm").glob("**/*")))
+print("\n".join([str(x.relative_to(output_dir)) for x in files]))
diff --git a/nilearn/interfaces/bids/glm.py b/nilearn/interfaces/bids/glm.py
--- a/nilearn/interfaces/bids/glm.py
+++ b/nilearn/interfaces/bids/glm.py
@@ -1,8 +1,8 @@
 """Functions for generating BIDS-compliant GLM outputs."""
 
 import json
-import os
 import warnings
+from pathlib import Path
 
 import numpy as np
 
@@ -50,7 +50,8 @@ def _clean_contrast_name(contrast_name):
     # Let users know if the name was changed
     if new_name != contrast_name:
         warnings.warn(
-            f'Contrast name "{contrast_name}" changed to "{new_name}"'
+            f'Contrast name "{contrast_name}" changed to "{new_name}"',
+            stacklevel=3,
         )
     return new_name
 
@@ -70,9 +71,6 @@ def _generate_model_metadata(out_file, model):
     """
     # Define which FirstLevelModel attributes are BIDS compliant and which
     # should be bundled in a new "ModelParameters" field.
-    DATA_ATTRIBUTES = [
-        "t_r",
-    ]
     PARAMETER_ATTRIBUTES = [
         "drift_model",
         "hrf_model",
@@ -85,22 +83,10 @@ def _generate_model_metadata(out_file, model):
         "smoothing_fwhm",
         "target_affine",
         "slice_time_ref",
-        "fir_delays",
     ]
-    ATTRIBUTE_RENAMING = {
-        "t_r": "RepetitionTime",
-    }
 
-    # Fields for the top level of the dictionary
-    DATA_ATTRIBUTES.sort()
-    data_attributes = {
-        attr_name: getattr(model, attr_name)
-        for attr_name in DATA_ATTRIBUTES
-        if hasattr(model, attr_name)
-    }
-    data_attributes = {
-        ATTRIBUTE_RENAMING.get(k, k): v for k, v in data_attributes.items()
-    }
+    if hasattr(model, "hrf_model") and getattr(model, "hrf_model") == "fir":
+        PARAMETER_ATTRIBUTES.append("fir_delays")
 
     # Fields for a nested section of the dictionary
     # The ModelParameters field is an ad-hoc way to retain useful info.
@@ -110,9 +96,6 @@ def _generate_model_metadata(out_file, model):
         for attr_name in PARAMETER_ATTRIBUTES
         if hasattr(model, attr_name)
     }
-    model_attributes = {
-        ATTRIBUTE_RENAMING.get(k, k): v for k, v in model_attributes.items()
-    }
 
     for key, value in model_attributes.items():
         if isinstance(value, (np.ndarray)):
@@ -120,7 +103,6 @@ def _generate_model_metadata(out_file, model):
 
     model_metadata = {
         "Description": "A statistical map generated by Nilearn.",
-        **data_attributes,
         "ModelParameters": model_attributes,
     }
 
@@ -133,38 +115,48 @@ def _generate_dataset_description(out_file, model_level):
 
     .. versionadded:: 0.9.2
 
+    If the dataset_description already exists only the GeneratedBy section
+    is extended.
+
     Parameters
     ----------
-    out_file : :obj:`str`
+    out_file : :obj:`pathlib.Path`
         Output JSON filename, to be created by the function.
     model_level : {1, 2}
         The level of the model. 1 means a first-level model.
         2 means a second-level model.
     """
     repo_url = "https://github.com/nilearn/nilearn"
-    dataset_description = {
-        "GeneratedBy": {
-            "Name": "nilearn",
-            "Version": __version__,
-            "Description": (
-                "A Nilearn "
-                f"{'first' if model_level == 1 else 'second'}"
-                "-level GLM."
-            ),
-            "CodeURL": (f"{repo_url}/releases/tag/{__version__}"),
-        }
+
+    GeneratedBy = {
+        "Name": "nilearn",
+        "Version": __version__,
+        "Description": (
+            "A Nilearn "
+            f"{'first' if model_level == 1 else 'second'}"
+            "-level GLM."
+        ),
+        "CodeURL": (f"{repo_url}/releases/tag/{__version__}"),
     }
 
+    if out_file.exists():
+        with open(out_file) as f_obj:
+            dataset_description = json.load(f_obj)
+        if dataset_description.get("GeneratedBy"):
+            dataset_description["GeneratedBy"].append(GeneratedBy)
+    else:
+        dataset_description = {
+            "BIDSVersion": "1.9.0",
+            "DatasetType": "derivative",
+            "GeneratedBy": [GeneratedBy],
+        }
+
     with open(out_file, "w") as f_obj:
         json.dump(dataset_description, f_obj, indent=4, sort_keys=True)
 
 
 def save_glm_to_bids(
-    model,
-    contrasts,
-    contrast_types=None,
-    out_dir=".",
-    prefix=None,
+    model, contrasts, contrast_types=None, out_dir=".", prefix=None, **kwargs
 ):
     """Save :term:`GLM` results to :term:`BIDS`-like files.
 
@@ -200,13 +192,19 @@ def save_glm_to_bids(
         for which :term:`contrast` type must be
         explicitly set need to be included.
 
-    out_dir : :obj:`str`, optional
+    out_dir : :obj:`str` or :obj:`pathlib.Path`, optional
         Output directory for files. Default is current working directory.
 
     prefix : :obj:`str` or None, default=None
         String to prepend to generated filenames.
         If a string is provided, '_' will be added to the end.
 
+    kwargs : extra keywords arguments to pass to ``model.generate_report``
+        See :func:`nilearn.reporting.make_glm_report` for more details.
+        Can be any of the following: ``title``, ``bg_img``, ``threshold``,
+        ``alpha``, ``cluster_threshold``, ``height_control``,
+        ``min_distance``, ``plot_type``, ``display_mode``.
+
     Warnings
     --------
     The files generated by this function are a best approximation of
@@ -223,9 +221,10 @@ def save_glm_to_bids(
         - Modeling software information (``dataset_description.json``)
         - Model-level metadata (``statmap.json``)
         - Model design matrix (``design.tsv``)
+        - Model design metadata (``design.json``)
         - Model design matrix figure (``design.svg``)
         - Model error (``stat-errorts_statmap.nii.gz``)
-        - Model r-squared (``stat-rSquare_statmap.nii.gz``)
+        - Model r-squared (``stat-rsquared_statmap.nii.gz``)
         - Contrast :term:`'parameter estimates'<Parameter Estimate>`
           (``contrast-[name]_stat-effect_statmap.nii.gz``)
         - Variance of the contrast parameter estimates
@@ -238,17 +237,35 @@ def save_glm_to_bids(
 
     """
     # Import here to avoid circular imports
-    from nilearn import glm
     from nilearn.plotting.matrix_plotting import (
         plot_contrast_matrix,
         plot_design_matrix,
     )
     from nilearn.reporting.glm_reporter import _make_stat_maps
 
-    if isinstance(prefix, str) and not prefix.endswith("_"):
-        prefix += "_"
-    elif not isinstance(prefix, str):
+    allowed_extra_kwarg = [
+        "title",
+        "bg_img",
+        "threshold",
+        "alpha",
+        "cluster_threshold",
+        "height_control",
+        "min_distance",
+        "plot_type",
+        "display_mode",
+    ]
+    for key in kwargs:
+        if key not in allowed_extra_kwarg:
+            raise ValueError(
+                f"Extra key-word arguments must be one of: "
+                f"{allowed_extra_kwarg}\n"
+                f"Got: {key}"
+            )
+
+    if not isinstance(prefix, str):
         prefix = ""
+    if prefix and not prefix.endswith("_"):
+        prefix += "_"
 
     if isinstance(contrasts, list):
         contrasts = {c: c for c in contrasts}
@@ -265,12 +282,23 @@ def save_glm_to_bids(
                 f"not {type(v)}"
             )
 
-    out_dir = os.path.abspath(out_dir)
-    os.makedirs(out_dir, exist_ok=True)
+    model_level = _model_level(model)
 
-    model_level = (
-        1 if isinstance(model, glm.first_level.FirstLevelModel) else 2
-    )
+    if model_level == 2:
+        sub_directory = "group"
+    else:
+        sub_directory = (
+            prefix.split("_")[0] if prefix.startswith("sub-") else ""
+        )
+
+    out_dir = Path(out_dir)
+    out_dir.mkdir(exist_ok=True, parents=True)
+
+    dset_desc_file = out_dir / "dataset_description.json"
+    _generate_dataset_description(dset_desc_file, model_level)
+
+    out_dir = out_dir / sub_directory
+    out_dir.mkdir(exist_ok=True, parents=True)
 
     if not isinstance(contrast_types, dict):
         contrast_types = {}
@@ -287,22 +315,23 @@ def save_glm_to_bids(
         run_str = f"run-{i_run + 1}_" if len(design_matrices) > 1 else ""
 
         # Save design matrix and associated figure
-        dm_file = os.path.join(
-            out_dir,
-            f"{prefix}{run_str}design.tsv",
-        )
         design_matrix.to_csv(
-            dm_file,
+            out_dir / f"{prefix}{run_str}design.tsv",
             sep="\t",
             index=False,
         )
 
-        dm_fig_file = os.path.join(
-            out_dir,
-            f"{prefix}{run_str}design.svg",
-        )
         dm_fig = plot_design_matrix(design_matrix)
-        dm_fig.figure.savefig(dm_fig_file)
+        dm_fig.figure.savefig(out_dir / f"{prefix}{run_str}design.svg")
+
+        if model_level == 1:
+            with open(out_dir / f"{prefix}{run_str}design.json", "w") as f_obj:
+                json.dump(
+                    {"RepetitionTime": getattr(model, "t_r")},
+                    f_obj,
+                    indent=4,
+                    sort_keys=True,
+                )
 
         # Save contrast plots as well
         for contrast_name, contrast_data in contrasts.items():
@@ -315,23 +344,19 @@ def save_glm_to_bids(
             contrast_plot.figure.set_figheight(2)
             contrast_plot.figure.set_tight_layout(True)
             contrast_name = _clean_contrast_name(contrast_name)
-            constrast_fig_file = os.path.join(
-                out_dir,
-                f"{prefix}{run_str}contrast-{contrast_name}_design.svg",
+            constrast_fig_file = (
+                out_dir
+                / f"{prefix}{run_str}contrast-{contrast_name}_design.svg"
             )
             contrast_plot.figure.savefig(constrast_fig_file)
 
-    statistical_maps = _make_stat_maps(model, contrasts, output_type="all")
-
     # Model metadata
     # TODO: Determine optimal mapping of model metadata to BIDS fields.
-    metadata_file = os.path.join(out_dir, f"{prefix}statmap.json")
+    metadata_file = out_dir / f"{prefix}statmap.json"
     _generate_model_metadata(metadata_file, model)
 
-    dset_desc_file = os.path.join(out_dir, "dataset_description.json")
-    _generate_dataset_description(dset_desc_file, model_level)
-
     # Write out contrast-level statistical maps
+    statistical_maps = _make_stat_maps(model, contrasts, output_type="all")
     for contrast_name, contrast_maps in statistical_maps.items():
         # Extract stat_type
         contrast_matrix = contrasts[contrast_name]
@@ -374,19 +399,46 @@ def save_glm_to_bids(
         }
 
         for map_name, img in renamed_contrast_maps.items():
-            out_file = os.path.join(out_dir, map_name)
-            img.to_filename(out_file)
+            img.to_filename(out_dir / map_name)
 
-    # Write out model-level statistical maps
-    model_level_mapping = {
-        "residuals": f"{prefix}stat-errorts_statmap.nii.gz",
-        "r_square": f"{prefix}stat-rSquare_statmap.nii.gz",
-    }
-    for attr, map_name in model_level_mapping.items():
-        print(f"Extracting and saving {attr}")
-        img = getattr(model, attr)
-        if isinstance(img, list):
-            img = img[0]
-
-        out_file = os.path.join(out_dir, map_name)
-        img.to_filename(out_file)
+    _write_model_level_statistical_maps(model, prefix, out_dir)
+
+    # Add html report
+    glm_report = model.generate_report(contrasts=contrasts, **kwargs)
+    glm_report.save_as_html(out_dir / f"{prefix}report.html")
+
+
+def _model_level(model):
+    from nilearn.glm.first_level import FirstLevelModel
+
+    return 1 if isinstance(model, FirstLevelModel) else 2
+
+
+def _write_model_level_statistical_maps(model, prefix, out_dir):
+    if _model_level(model) == 2:
+
+        model_level_mapping = {
+            "residuals": f"{prefix}stat-errorts_statmap.nii.gz",
+            "r_square": f"{prefix}stat-rsquared_statmap.nii.gz",
+        }
+        for attr, map_name in model_level_mapping.items():
+            stat_map_to_save = getattr(model, attr)
+            stat_map_to_save.to_filename(out_dir / map_name)
+
+    else:
+
+        if hasattr(model, "design_matrices_"):
+            design_matrices = model.design_matrices_
+        else:
+            design_matrices = [model.design_matrix_]
+
+        for i_run, _ in enumerate(design_matrices):
+            run_str = f"run-{i_run + 1}_" if len(design_matrices) > 1 else ""
+            model_level_mapping = {
+                "residuals": f"{prefix}{run_str}stat-errorts_statmap.nii.gz",
+                "r_square": f"{prefix}{run_str}stat-rsquared_statmap.nii.gz",
+            }
+            for attr, map_name in model_level_mapping.items():
+                img = getattr(model, attr)
+                stat_map_to_save = img[i_run]
+                stat_map_to_save.to_filename(out_dir / map_name)


Test patch:
diff --git a/nilearn/interfaces/tests/test_bids.py b/nilearn/interfaces/tests/test_bids.py
--- a/nilearn/interfaces/tests/test_bids.py
+++ b/nilearn/interfaces/tests/test_bids.py
@@ -303,7 +303,10 @@ def test_parse_bids_filename():
     assert file_dict["file_fields"] == fields
 
 
-def test_save_glm_to_bids(tmp_path_factory):
+@pytest.mark.parametrize(
+    "prefix", ["sub-01_ses-01_task-nback", "sub-01_task-nback", "task-nback"]
+)
+def test_save_glm_to_bids(tmp_path_factory, prefix):
     """Test that save_glm_to_bids saves the appropriate files.
 
     This test reuses code from
@@ -312,33 +315,19 @@ def test_save_glm_to_bids(tmp_path_factory):
     tmpdir = tmp_path_factory.mktemp("test_save_glm_results")
 
     EXPECTED_FILENAMES = [
-        "dataset_description.json",
-        "sub-01_ses-01_task-nback_contrast-effectsOfInterest_design.svg",
-        (
-            "sub-01_ses-01_task-nback_contrast-effectsOfInterest_"
-            "stat-F_statmap.nii.gz"
-        ),
-        (
-            "sub-01_ses-01_task-nback_contrast-effectsOfInterest_"
-            "stat-effect_statmap.nii.gz"
-        ),
-        (
-            "sub-01_ses-01_task-nback_contrast-effectsOfInterest_"
-            "stat-p_statmap.nii.gz"
-        ),
-        (
-            "sub-01_ses-01_task-nback_contrast-effectsOfInterest_"
-            "stat-variance_statmap.nii.gz"
-        ),
-        (
-            "sub-01_ses-01_task-nback_contrast-effectsOfInterest_"
-            "stat-z_statmap.nii.gz"
-        ),
-        "sub-01_ses-01_task-nback_design.svg",
-        "sub-01_ses-01_task-nback_design.tsv",
-        "sub-01_ses-01_task-nback_stat-errorts_statmap.nii.gz",
-        "sub-01_ses-01_task-nback_stat-rSquare_statmap.nii.gz",
-        "sub-01_ses-01_task-nback_statmap.json",
+        "contrast-effectsOfInterest_design.svg",
+        "contrast-effectsOfInterest_stat-F_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-effect_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-p_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-variance_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-z_statmap.nii.gz",
+        "design.svg",
+        "design.tsv",
+        "design.json",
+        "stat-errorts_statmap.nii.gz",
+        "stat-rsquared_statmap.nii.gz",
+        "statmap.json",
+        "report.html",
     ]
 
     shapes, rk = [(7, 8, 9, 15)], 3
@@ -366,12 +355,15 @@ def test_save_glm_to_bids(tmp_path_factory):
         contrasts=contrasts,
         contrast_types=contrast_types,
         out_dir=tmpdir,
-        prefix="sub-01_ses-01_task-nback",
+        prefix=prefix,
     )
 
+    assert (tmpdir / "dataset_description.json").exists()
+
+    sub_prefix = prefix.split("_")[0] if prefix.startswith("sub-") else ""
+
     for fname in EXPECTED_FILENAMES:
-        full_filename = os.path.join(tmpdir, fname)
-        assert os.path.isfile(full_filename)
+        assert (tmpdir / sub_prefix / f"{prefix}_{fname}").exists()
 
 
 def test_save_glm_to_bids_serialize_affine(tmp_path):
@@ -477,6 +469,17 @@ def test_save_glm_to_bids_errors(
             prefix="sub-01_ses-01_task-nback",
         )
 
+    with pytest.raises(
+        ValueError, match="Extra key-word arguments must be one of"
+    ):
+        save_glm_to_bids(
+            model=two_runs_model,
+            contrasts=["AAA - BBB"],
+            out_dir=tmpdir,
+            prefix="sub-01_ses-01_task-nback",
+            foo="bar",
+        )
+
 
 @pytest.mark.parametrize(
     "prefix", ["sub-01_ses-01_task-nback", "sub-01_task-nback_", 1]
@@ -498,21 +501,25 @@ def test_save_glm_to_bids_contrast_definitions(
     )
 
     EXPECTED_FILENAME_ENDINGS = [
-        "dataset_description.json",
-        ("contrast-aaaMinusBbb_stat-effect_statmap.nii.gz"),
+        "contrast-aaaMinusBbb_stat-effect_statmap.nii.gz",
         "contrast-aaaMinusBbb_stat-p_statmap.nii.gz",
         "contrast-aaaMinusBbb_stat-t_statmap.nii.gz",
-        ("contrast-aaaMinusBbb_stat-variance_statmap.nii.gz"),
+        "contrast-aaaMinusBbb_stat-variance_statmap.nii.gz",
         "contrast-aaaMinusBbb_stat-z_statmap.nii.gz",
         "run-1_contrast-aaaMinusBbb_design.svg",
         "run-1_design.svg",
         "run-1_design.tsv",
+        "run-1_design.json",
+        "run-1_stat-errorts_statmap.nii.gz",
+        "run-1_stat-rsquared_statmap.nii.gz",
         "run-2_contrast-aaaMinusBbb_design.svg",
         "run-2_design.svg",
         "run-2_design.tsv",
-        "stat-errorts_statmap.nii.gz",
-        "stat-rSquare_statmap.nii.gz",
+        "run-2_design.json",
+        "run-2_stat-errorts_statmap.nii.gz",
+        "run-2_stat-rsquared_statmap.nii.gz",
         "statmap.json",
+        "report.html",
     ]
 
     save_glm_to_bids(
@@ -523,16 +530,22 @@ def test_save_glm_to_bids_contrast_definitions(
         prefix=prefix,
     )
 
+    assert (tmpdir / "dataset_description.json").exists()
+
+    if not isinstance(prefix, str):
+        prefix = ""
+
+    if prefix and not prefix.endswith("_"):
+        prefix = f"{prefix}_"
+
+    sub_prefix = prefix.split("_")[0] if prefix.startswith("sub-") else ""
+
     for fname in EXPECTED_FILENAME_ENDINGS:
-        if fname != "dataset_description.json" and isinstance(prefix, str):
-            fname = (
-                prefix + fname if prefix.endswith("_") else f"{prefix}_{fname}"
-            )
-        full_filename = os.path.join(tmpdir, fname)
-        assert os.path.isfile(full_filename)
+        assert (tmpdir / sub_prefix / f"{prefix}{fname}").exists()
 
 
-def test_save_glm_to_bids_second_level(tmp_path_factory):
+@pytest.mark.parametrize("prefix", ["task-nback"])
+def test_save_glm_to_bids_second_level(tmp_path_factory, prefix):
     """Test save_glm_to_bids on a SecondLevelModel.
 
     This test reuses code from
@@ -541,18 +554,18 @@ def test_save_glm_to_bids_second_level(tmp_path_factory):
     tmpdir = tmp_path_factory.mktemp("test_save_glm_to_bids_second_level")
 
     EXPECTED_FILENAMES = [
-        "dataset_description.json",
-        "task-nback_contrast-effectsOfInterest_design.svg",
-        "task-nback_contrast-effectsOfInterest_stat-F_statmap.nii.gz",
-        "task-nback_contrast-effectsOfInterest_stat-effect_statmap.nii.gz",
-        "task-nback_contrast-effectsOfInterest_stat-p_statmap.nii.gz",
-        "task-nback_contrast-effectsOfInterest_stat-variance_statmap.nii.gz",
-        "task-nback_contrast-effectsOfInterest_stat-z_statmap.nii.gz",
-        "task-nback_design.svg",
-        "task-nback_design.tsv",
-        "task-nback_stat-errorts_statmap.nii.gz",
-        "task-nback_stat-rSquare_statmap.nii.gz",
-        "task-nback_statmap.json",
+        "contrast-effectsOfInterest_design.svg",
+        "contrast-effectsOfInterest_stat-F_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-effect_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-p_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-variance_statmap.nii.gz",
+        "contrast-effectsOfInterest_stat-z_statmap.nii.gz",
+        "design.svg",
+        "design.tsv",
+        "stat-errorts_statmap.nii.gz",
+        "stat-rsquared_statmap.nii.gz",
+        "statmap.json",
+        "report.html",
     ]
 
     shapes = ((7, 8, 9, 1),)
@@ -581,9 +594,10 @@ def test_save_glm_to_bids_second_level(tmp_path_factory):
         contrasts=contrasts,
         contrast_types=contrast_types,
         out_dir=tmpdir,
-        prefix="task-nback",
+        prefix=prefix,
     )
 
+    assert (tmpdir / "dataset_description.json").exists()
+
     for fname in EXPECTED_FILENAMES:
-        full_filename = os.path.join(tmpdir, fname)
-        assert os.path.isfile(full_filename)
+        assert (tmpdir / "group" / f"{prefix}_{fname}").exists()