Repository: ME-ICA/tedana

Issue:
Releasing version 25.0.0
### Summary

We last released a new version of tedana in Sept 2024. We have about 15 merged PRs since then that include enhancements and minor to moderate fixes. [Following our contribution guide](https://tedana.readthedocs.io/en/stable/contributing.html#when-to-release-new-software-versions), I am opening an issue to propose releasing a new version.

### Additional Detail

We are hoping to send an email out soonish to try to schedule a multi-echo users meeting for late May. It would be nice to have that email be both the new version release & a survey for the multi-echo users meeting time. If others agree, we then we should release a new version soon.

### Next Steps

- Are there any things we want to merge before the version release?
  - #1209 is possible but still needs work
  - I'm mixed on #1153. The current version is useful, but I'm still hesitant to release a quality check visualization which we can't yet give clear interpretation advice on.
  - We sometimes skim through https://tedana.readthedocs.io for potential changes since the main page only changes with version releases. Anything we want to adjust there?
- I can draft release notes. I'd love to either release early in the week of April 28th or have a plan for when we'll release.

Hints from issue comments:
@handwerkerd: Also, for the email announcing this, I also want to include a subset of potential meeting times for the multi-echo users meeting. The subset will be defined by when a critical number of us are available, so please fill out the availability poll soon. https://whenisgood.net/wzwtea2@handwerkerd: Tagging contributors since the last release & some recent dev call attendees: @tsalo @eurunuela @n-reddy @Lestropie @smoia @BahmanTahayori @katielamar @marlyr @goodalse2019 @mmedina002@eurunuela: I would say we could add #1153 in case people do find it useful as is, but we clearly need to improve it. Maybe having a short paragraph in the docs saying it is a work in progress, or a pre-release of the feature could help keep expectations down. What do you think?@handwerkerd: Thank you to @eurunuela for fixing or reviewing a huge number of PRs so that we can include a bunch of new stuff in this release. I think we're don't adding code for this release.

The draft release notes are here: https://github.com/ME-ICA/tedana/releases/tag/untagged-6ce129a57657c711e2df
I'll wait for at least two people to approve the notes before releasing. I'm not sure everyone can view at that link. If you want to read the draft & can't see it there, I can either copy it here or send it to you directly.@eurunuela: Release notes look good to me!@handwerkerd: I got @tsalo's approval and v25.0.0 is released!
https://github.com/ME-ICA/tedana/releases/tag/25.0.0

Solution patch:
diff --git a/docs/api.rst b/docs/api.rst
index 529e81a..d36601a 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -199,9 +199,9 @@ API
 
 .. _api_stats_ref:
 
-******************************************
+********************************************
 :mod:`tedana.reporting`: Reporting functions
-******************************************
+********************************************
 
 .. automodule:: tedana.reporting
    :no-members:
@@ -214,7 +214,6 @@ API
    :template: function.rst
 
    tedana.reporting.html_report.generate_report
-   tedana.reporting.html_report.get_elbow_val
    tedana.reporting.quality_metrics.calculate_rejected_components_impact
    tedana.reporting.static_figures.comp_figures
    tedana.reporting.static_figures.pca_results
diff --git a/docs/faq.rst b/docs/faq.rst
index c3fecde..d75e20a 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -161,6 +161,14 @@ If users are having trouble with PCA component estimation failing on a dataset,
 we recommend using RobustICA;
 and we invite users to send us feedback on its behavior and efficacy.
 
+Also note that ``--ica_method robustica`` can be paired with ``--n_robust_runs``.
+That second option defines how many interations of ICA to run.
+The default is ``--n_robust_runs 30`` which was selected because the resulting components
+stabilized and the activation volumes plateaued during testing on a large dataset.
+If one wants to examine on other data sets, a basic guide is,
+since larger ``--n_robust_runs`` means more compute time,
+it should be set to the lowest value where the stability of the resulting components plateaus.
+
 
 .. _MAPCA: https://github.com/ME-ICA/mapca
 .. _robustica: https://github.com/CRG-CNAG/robustica
diff --git a/docs/outputs.rst b/docs/outputs.rst
index 2116259..2ed3ddb 100644
--- a/docs/outputs.rst
+++ b/docs/outputs.rst
@@ -313,7 +313,7 @@ ICA Components Report
 
 The reporting page for the tedana decomposition presents a series
 of interactive plots designed to help you evaluate the quality of your
-analyses. This page describes the plots forming the reports and well as
+analyses. This page describes the plots forming the reports as well as
 information on how to take advantage of the interactive functionalities.
 You can also play around with `our demo`_.
 
@@ -513,6 +513,34 @@ Save          |Save|   Saves an image reproduction of the plot in PNG format.
   the toolbar of a given plot. Active interactions show an horizontal blue line underneath their
   icon, while inactive ones lack the line.
 
+****************************
+RobustICA Component Clusters
+****************************
+
+This plot will appear when the ``--ica_method robustica`` option is used.
+RobustICA runs multiple iterations of ICA and identifies stable components
+across iterations. This plot uses t-SNE to plot the components in 2D.
+Each marker is a component from one iteration.
+Components that are in stable clusters are black dots
+and the dashed blue line surrounds the cluster.
+Hover text shows the cluster number for each dot.
+Components that are not in stable clusters are red x's.
+This figure is a work-in-progress in attempting to find ways to evaluate
+robustICA results.
+In general, tight clusters of black dots are better.
+When evaluating results, a run with more diffuse clusters than others
+with the same acquisition parameters,
+but it's unclear what variations are genuinely problemmatic.
+The red x's sometimes appear in clusters that are below the threshold
+for being a cluster, but it's unclear how to decide if clusters were missed.
+If a cluster has some black dots that are far from others in the cluster,
+that might be an effect of the t-SNE dimensionality reduction.
+The goal of future work will be to improve visualizations
+and quantification of clusters.
+
+.. image:: /_static/rep01_ica_tsne_plot.png
+  :align: center
+  :height: 400px
 
 ************
 Carpet plots
diff --git a/tedana/decomposition/ica.py b/tedana/decomposition/ica.py
index d687db6..96625f9 100644
--- a/tedana/decomposition/ica.py
+++ b/tedana/decomposition/ica.py
@@ -4,8 +4,9 @@ import logging
 import warnings
 
 import numpy as np
-from robustica import RobustICA
+from robustica import RobustICA, abs_pearson_dist
 from scipy import stats
+from sklearn import manifold
 from sklearn.decomposition import FastICA
 
 from tedana.config import (
@@ -68,8 +69,11 @@ def tedica(
 
     ica_method = ica_method.lower()
 
+    # Default r_ica results to None to avoid errors in the case of fastica
+    c_labels, similarity_t_sne = None, None
+
     if ica_method == "robustica":
-        mixing, fixed_seed = r_ica(
+        mixing, fixed_seed, c_labels, similarity_t_sne = r_ica(
             data,
             n_components=n_components,
             fixed_seed=fixed_seed,
@@ -87,7 +91,7 @@ def tedica(
     else:
         raise ValueError("The selected ICA method is invalid!")
 
-    return mixing, fixed_seed
+    return mixing, fixed_seed, c_labels, similarity_t_sne
 
 
 def r_ica(data, n_components, fixed_seed, n_robust_runs, max_it):
@@ -192,7 +196,23 @@ def r_ica(data, n_components, fixed_seed, n_robust_runs, max_it):
             f"decomposition."
         )
 
-    return mixing, fixed_seed
+    c_labels = robust_ica.clustering.labels_
+
+    perplexity = min(robust_ica.S_all.shape[1] - 1, 80)
+
+    perplexity = perplexity - 1 if perplexity < 81 else 80
+    t_sne = manifold.TSNE(
+        n_components=2,
+        perplexity=perplexity,
+        init="random",
+        n_iter=2500,
+        random_state=10,
+    )
+
+    p_dissimilarity = abs_pearson_dist(robust_ica.S_all)
+    similarity_t_sne = t_sne.fit_transform(p_dissimilarity)
+
+    return mixing, fixed_seed, c_labels, similarity_t_sne
 
 
 def f_ica(data, n_components, fixed_seed, maxit, maxrestart):
diff --git a/tedana/io.py b/tedana/io.py
index 30570e3..c92dd81 100644
--- a/tedana/io.py
+++ b/tedana/io.py
@@ -16,6 +16,7 @@ from typing import List
 import nibabel as nib
 import numpy as np
 import pandas as pd
+import requests
 from nilearn._utils import check_niimg
 from nilearn.image import new_img_like
 
@@ -502,6 +503,52 @@ def load_json(path: str) -> dict:
     return data
 
 
+def download_json(tree: str, out_dir: str) -> str:
+    """Download a json file from figshare unless the file already exists.
+
+    Parameters
+    ----------
+    tree : str
+        The name of the tree to download
+    out_dir : str
+        The directory where the json file will be saved
+
+    Returns
+    -------
+    save_path : str
+        The filepath of the downloaded decision tree.
+    """
+    base_url = "https://api.figshare.com/v2"
+    item_id = 25251433
+
+    fname = tree + ".json" if not tree.endswith(".json") else tree
+    save_path = op.join(out_dir, fname)
+
+    if op.isfile(save_path):
+        return save_path
+
+    try:
+        r = requests.get(f"{base_url}/articles/{item_id}")
+        r.raise_for_status()
+        metadata = r.json()
+
+        file_info = next((f for f in metadata["files"] if f["name"] == fname.lower()), None)
+
+        if not file_info:
+            return
+
+        download_r = requests.get(file_info["download_url"])
+        download_r.raise_for_status()
+
+        with open(save_path, "wb") as f:
+            f.write(download_r.content)
+        LGR.info(f"Tree {tree} downloaded from figshare to {save_path}")
+        return save_path
+
+    except requests.RequestException as e:
+        LGR.error(f"Cannot connect to figshare: {e}")
+
+
 def add_decomp_prefix(comp_num, prefix, max_value):
     """Create component name with leading zeros matching number of components.
 
diff --git a/tedana/reporting/data/html/report_body_template.html b/tedana/reporting/data/html/report_body_template.html
index 23f202f..75304e2 100644
--- a/tedana/reporting/data/html/report_body_template.html
+++ b/tedana/reporting/data/html/report_body_template.html
@@ -39,9 +39,6 @@
 
   .carpet-plots {
     float: left;
-  }
-
-  .carpet-plots {
     margin-left: 5%;
     margin-right: 5%;
     margin-bottom: 100px;
@@ -64,6 +61,13 @@
     float: left;
   }
 
+  .tsne-plots {
+    float: left;
+    margin-left: 5%;
+    margin-right: 5%;
+    margin-bottom: 100px;
+  }
+
   button {
     margin-right: 15px;
     width: auto;
@@ -171,6 +175,7 @@
   <h1>ICA components</h1>
   {{ content }}
 </div>
+<div>{{ tsne }}</div>
 <div class="carpet-plots">
   <h1>Carpet plots</h1>
   <div class="carpet-wrapper">
diff --git a/tedana/reporting/dynamic_figures.py b/tedana/reporting/dynamic_figures.py
index d95bdd2..0747303 100644
--- a/tedana/reporting/dynamic_figures.py
+++ b/tedana/reporting/dynamic_figures.py
@@ -426,3 +426,201 @@ def _link_figures(fig, comptable_ds, div_content, io_generator):
     """
     fig.js_on_event(events.Tap, _tap_callback(comptable_ds, div_content, io_generator))
     return fig
+
+
+def _create_clustering_tsne_plt(cluster_labels, similarity_t_sne):
+    """Plot the clustering results of robustica using Bokeh.
+
+    Parameters
+    ----------
+    cluster_labels : (n_pca_components x n_robust_runs,) : numpy.ndarray
+        A one dimensional array that has the cluster label of each run.
+    similarity_t_sne : (n_pca_components x n_robust_runs,2) : numpy.ndarray
+        An array containing the coordinates of projected data.
+    """
+    title = "2D projection of clustered ICA runs using TSNE"
+    marker_size = 8
+    alpha = 0.8
+    line_width = 2
+    scaling_factor = 1.1  # Moderate scaling factor
+
+    # First create the figure without the hover tool
+    p = plotting.figure(
+        title=title,
+        width=800,
+        height=600,
+        tools=["pan", "box_zoom", "wheel_zoom", "reset", "save"],  # No hover tool here
+    )
+
+    point_renderers = []  # List to store point renderers
+    has_drawn_boundary_legend = False  # Track if we've added the legend entry
+
+    # Plot regular clusters
+    for cluster_id in range(np.max(cluster_labels) + 1):
+        cluster_mask = cluster_labels == cluster_id
+        if not np.any(cluster_mask):
+            continue
+
+        # Get points for this cluster
+        cluster_points = similarity_t_sne[cluster_mask]
+
+        # Format hover text with proper string formatting for coordinates
+        hover_texts = []
+        for point in cluster_points:
+            # Format each coordinate to 4 decimal places and create a nice string representation
+            coords_str = f"({point[0]:.4f}, {point[1]:.4f})"
+            hover_texts.append(f"Cluster {cluster_id}: {coords_str}")
+
+        # Add scatter plot for cluster points with hover info
+        circle_renderer = p.scatter(
+            x="x",
+            y="y",
+            source=models.ColumnDataSource(
+                {
+                    "x": cluster_points[:, 0],
+                    "y": cluster_points[:, 1],
+                    "cluster_label": hover_texts,
+                }
+            ),
+            size=marker_size,
+            alpha=alpha,
+            line_color="black",
+            fill_color=None,
+            line_width=line_width,
+            legend_label="Clustered runs",
+            name="points",
+            marker="circle",  # Explicitly specify circle marker
+        )
+        point_renderers.append(circle_renderer)
+
+        # Handle boundary drawing based on number of points
+        if cluster_points.shape[0] > 2:
+            # For 3+ points, draw convex hull
+            from scipy.spatial import ConvexHull
+
+            try:
+                hull = ConvexHull(cluster_points)
+                # Get the vertices of the hull in order
+                hull_vertices = hull.vertices
+
+                # Calculate the centroid of the cluster
+                centroid = np.mean(cluster_points, axis=0)
+
+                # Create hull line segments with moderate scaling from centroid
+                hull_points = cluster_points[hull_vertices]
+                # Apply moderate scaling from centroid
+                scaled_hull_points = centroid + scaling_factor * (hull_points - centroid)
+
+                # Extract x and y coordinates
+                x_hull = scaled_hull_points[:, 0]
+                y_hull = scaled_hull_points[:, 1]
+
+                # Close the loop by adding the first point at the end
+                x_hull = np.append(x_hull, x_hull[0])
+                y_hull = np.append(y_hull, y_hull[0])
+
+                # Add line without hover tooltips
+                # FIXED: Only add legend_label for the first boundary
+                line_kwargs = {
+                    "x": x_hull,
+                    "y": y_hull,
+                    "line_color": "blue",
+                    "line_dash": "dashed",
+                    "line_width": line_width,
+                }
+
+                if not has_drawn_boundary_legend:
+                    line_kwargs["legend_label"] = "Cluster's boundary"
+                    has_drawn_boundary_legend = True
+
+                p.line(**line_kwargs)
+            except Exception:
+                # Skip hull if it can't be computed (e.g., coplanar points)
+                pass
+
+        elif cluster_points.shape[0] == 2:
+            # Special handling for exactly 2 points - just draw a simple line connecting them
+            point1 = cluster_points[0]
+            point2 = cluster_points[1]
+
+            # Draw a straight line connecting the two points directly
+            line_kwargs = {
+                "x": [point1[0], point2[0]],
+                "y": [point1[1], point2[1]],
+                "line_color": "blue",
+                "line_dash": "dashed",
+                "line_width": line_width,
+            }
+
+            if not has_drawn_boundary_legend:
+                line_kwargs["legend_label"] = "Cluster's boundary"
+                has_drawn_boundary_legend = True
+
+            p.line(**line_kwargs)
+
+        elif cluster_points.shape[0] == 1:
+            # For a single point, draw a small circle around it
+            point = cluster_points[0]
+            # Make the circle less expansive but still visible
+            circle_radius = marker_size * 0.4  # Reduced from 0.8 to 0.4
+
+            # FIXED: Only add legend_label for the first boundary
+            ellipse_kwargs = {
+                "x": point[0],
+                "y": point[1],
+                "width": circle_radius * 2,  # width is diameter
+                "height": circle_radius * 2,  # height is diameter
+                "line_color": "blue",
+                "line_dash": "dashed",
+                "line_width": line_width,
+                "fill_color": None,
+            }
+
+            if not has_drawn_boundary_legend:
+                ellipse_kwargs["legend_label"] = "Cluster's boundary"
+                has_drawn_boundary_legend = True
+
+            p.ellipse(**ellipse_kwargs)
+
+    # Plot noise clusters if they exist
+    if np.min(cluster_labels) == -1:
+        noise_mask = cluster_labels == -1
+        noise_points = similarity_t_sne[noise_mask]
+
+        # Format hover text for unclustered points
+        noise_hover_texts = []
+        for point in noise_points:
+            coords_str = f"({point[0]:.4f}, {point[1]:.4f})"
+            noise_hover_texts.append(f"Unclustered: {coords_str}")
+
+        # Add noise points with hover tooltips
+        x_renderer = p.scatter(
+            x="x",
+            y="y",
+            size=marker_size * 2,
+            alpha=0.6,
+            color="red",
+            legend_label="Unclustered runs",
+            source=models.ColumnDataSource(
+                {
+                    "x": noise_points[:, 0],
+                    "y": noise_points[:, 1],
+                    "cluster_label": noise_hover_texts,
+                }
+            ),
+            marker="x",  # Use 'x' marker instead of x() method
+        )
+        point_renderers.append(x_renderer)
+
+    # Update hover tool to use the new cluster_label field
+    hover_tool = models.HoverTool(
+        tooltips=[("", "@cluster_label")],  # No label, just show the formatted cluster info
+        renderers=point_renderers,  # Only apply to stored point renderers
+    )
+    p.add_tools(hover_tool)
+
+    # Configure legend
+    p.legend.click_policy = "hide"
+    p.legend.location = "top_right"
+
+    return p
diff --git a/tedana/reporting/html_report.py b/tedana/reporting/html_report.py
index d578ba2..4c50cfa 100644
--- a/tedana/reporting/html_report.py
+++ b/tedana/reporting/html_report.py
@@ -118,7 +118,9 @@ def _generate_buttons(out_dir, io_generator):
     return buttons_html
 
 
-def _update_template_bokeh(bokeh_id, info_table, about, prefix, references, bokeh_js, buttons):
+def _update_template_bokeh(
+    bokeh_id, info_table, about, prefix, references, bokeh_js, buttons, tsne
+):
     """
     Populate a report with content.
 
@@ -138,6 +140,8 @@ def _update_template_bokeh(bokeh_id, info_table, about, prefix, references, boke
         Javascript created by bokeh.embed.components
     buttons : str
         HTML div created by _generate_buttons()
+    tsne : str
+        HTML div created by _create_clustering_tsne_plt()
 
     Returns
     -------
@@ -223,6 +227,7 @@ def _update_template_bokeh(bokeh_id, info_table, about, prefix, references, boke
         references=references,
         javascript=bokeh_js,
         buttons=buttons,
+        tsne=tsne,
     )
     return body
 
@@ -267,7 +272,7 @@ def _generate_info_table(info_dict):
     return info_html
 
 
-def generate_report(io_generator: OutputGenerator) -> None:
+def generate_report(io_generator: OutputGenerator, cluster_labels, similarity_t_sne) -> None:
     """Generate an HTML report.
 
     Parameters
@@ -356,6 +361,17 @@ def generate_report(io_generator: OutputGenerator) -> None:
     )
     varexp_pie_plot = df._create_varexp_pie_plt(comptable_cds)
 
+    # Create clustering plot
+    if cluster_labels is not None:
+        clustering_tsne_plot = df._create_clustering_tsne_plt(cluster_labels, similarity_t_sne)
+        tsne_script, tsne_div = embed.components(clustering_tsne_plot)
+        tsne_html = f"{tsne_script}"
+        tsne_html += (
+            f"<div class='tsne-plots'><h1>Robust ICA component clustering</h1>{tsne_div}</div>"
+        )
+    else:
+        tsne_html = ""
+
     # link all dynamic figures
     figs = [kappa_rho_plot, kappa_sorted_plot, rho_sorted_plot, varexp_pie_plot]
 
@@ -407,6 +423,7 @@ def generate_report(io_generator: OutputGenerator) -> None:
         prefix=io_generator.prefix,
         bokeh_js=kr_script,
         buttons=buttons_html,
+        tsne=tsne_html,
     )
     html = _save_as_html(body)
     with open(opj(io_generator.out_dir, f"{io_generator.prefix}tedana_report.html"), "wb") as f:
diff --git a/tedana/selection/component_selector.py b/tedana/selection/component_selector.py
index fd74cd4..789be2e 100644
--- a/tedana/selection/component_selector.py
+++ b/tedana/selection/component_selector.py
@@ -8,7 +8,7 @@ from typing import Dict, List, Union
 import pandas as pd
 from numpy import asarray
 
-from tedana.io import load_json
+from tedana.io import download_json, load_json
 from tedana.selection import selection_nodes
 from tedana.selection.selection_utils import (
     clean_dataframe,
@@ -43,19 +43,27 @@ class TreeError(Exception):
     pass
 
 
-def load_config(tree: str) -> Dict:
+def load_config(tree: str, out_dir: str = ".") -> Dict:
     """Load the json file with the decision tree and validate the fields in the decision tree.
 
     Parameters
     ----------
     tree : :obj:`str`
         The named tree or path to a JSON file that defines one
+    out_dir :obj:`str`, optional
+        The output directory. Default is current working directory.
 
     Returns
     -------
     tree : :obj:`dict`
         A validated decision tree for the component selection process.
+
+    Raises
+    ------
+    ValueError if the tree can't be loaded
     """
+    fname, dectree = None, None
+
     if tree in DEFAULT_TREES:
         fname = op.join(get_resource_path(), "decision_trees", tree + ".json")
     elif tree == "kundu":
@@ -65,20 +73,17 @@ def load_config(tree: str) -> Dict:
         )
         tree = "tedana_orig"
         fname = op.join(get_resource_path(), "decision_trees", tree + ".json")
-    else:
+    elif op.isfile(tree):
         fname = tree
+    else:
+        fname = download_json(tree, out_dir)
 
-    try:
+    if fname and op.isfile(fname):
         dectree = load_json(fname)
-    except FileNotFoundError:
+    else:
         raise ValueError(
             f"Cannot find tree {tree}. Please check your path or use a "
-            f"default tree ({DEFAULT_TREES})."
-        )
-    except IsADirectoryError:
-        raise ValueError(
-            f"Tree {tree} is a directory. Please supply a JSON file or "
-            f"default tree ({DEFAULT_TREES})."
+            f"default tree ({DEFAULT_TREES}) or one from figshare."
         )
 
     return validate_tree(dectree)
@@ -307,13 +312,15 @@ def validate_tree(tree: Dict) -> Dict:
 class ComponentSelector:
     """Load and classify components based on a specified ``tree``."""
 
-    def __init__(self, tree: str):
+    def __init__(self, tree: str, out_dir: str = "."):
         """Initialize the class using the info specified in the json file ``tree``.
 
         Parameters
         ----------
         tree : :obj:`str`
             The named tree or path to a JSON file that defines one.
+        out_dir :obj:`str`, optional
+            The output directory. Default is current working directory.
 
         Notes
         -----
@@ -321,7 +328,7 @@ class ComponentSelector:
         loads all information in the tree json file into ``ComponentSelector``.
         """
         self.tree_name = tree
-        self.tree = load_config(self.tree_name)
+        self.tree = load_config(self.tree_name, out_dir)
 
         LGR.info("Performing component selection with " + self.tree["tree_id"])
         LGR.info(self.tree.get("info", ""))
diff --git a/tedana/workflows/ica_reclassify.py b/tedana/workflows/ica_reclassify.py
index 6025983..5d89ef2 100644
--- a/tedana/workflows/ica_reclassify.py
+++ b/tedana/workflows/ica_reclassify.py
@@ -581,7 +581,7 @@ def ica_reclassify_workflow(
         )
 
         LGR.info("Generating dynamic report")
-        reporting.generate_report(io_generator)
+        reporting.generate_report(io_generator, cluster_labels=None, similarity_t_sne=None)
 
     io_generator.save_self()
     LGR.info("Workflow completed")
diff --git a/tedana/workflows/tedana.py b/tedana/workflows/tedana.py
index b8d3045..7ebe468 100644
--- a/tedana/workflows/tedana.py
+++ b/tedana/workflows/tedana.py
@@ -613,7 +613,7 @@ def tedana_workflow(
         data = [data]
 
     LGR.info("Initializing and validating component selection tree")
-    selector = ComponentSelector(tree)
+    selector = ComponentSelector(tree, out_dir)
 
     LGR.info(f"Loading input data: {[f for f in data]}")
     data_cat, ref_img = io.load_data(data, n_echos=n_echos)
@@ -806,6 +806,10 @@ def tedana_workflow(
     fout = io_generator.save_file(data_optcom, "combined img")
     LGR.info(f"Writing optimally combined data set: {fout}")
 
+    # Default r_ica results to None as they are expected for the reports
+    cluster_labels = None
+    similarity_t_sne = None
+
     if mixing_file is None:
         # Identify and remove thermal noise from data
         data_reduced, n_components = decomposition.tedpca(
@@ -829,8 +833,9 @@ def tedana_workflow(
         keep_restarting = True
         n_restarts = 0
         seed = fixed_seed
+
         while keep_restarting:
-            mixing, seed = decomposition.tedica(
+            mixing, seed, cluster_labels, similarity_t_sne = decomposition.tedica(
                 data_reduced,
                 n_components,
                 seed,
@@ -897,7 +902,7 @@ def tedana_workflow(
                 # regular expressions immediately after initialization,
                 # store and copy this key
                 tmp_external_regressor_config = selector.tree["external_regressor_config"]
-                selector = ComponentSelector(tree)
+                selector = ComponentSelector(tree, out_dir)
                 selector.tree["external_regressor_config"] = tmp_external_regressor_config
 
             RepLGR.disabled = True  # Disable the report to avoid duplicate text
@@ -1115,7 +1120,7 @@ def tedana_workflow(
             )
 
         LGR.info("Generating dynamic report")
-        reporting.generate_report(io_generator)
+        reporting.generate_report(io_generator, cluster_labels, similarity_t_sne)
 
     LGR.info("Workflow completed")
 


Test patch:
diff --git a/tedana/tests/test_io.py b/tedana/tests/test_io.py
index e204f99..4af9f05 100644
--- a/tedana/tests/test_io.py
+++ b/tedana/tests/test_io.py
@@ -2,15 +2,17 @@
 
 import json
 import os
+from unittest import mock
 
 import nibabel as nib
 import numpy as np
 import pandas as pd
 import pytest
+import requests
 
 from tedana import io as me
 from tedana.tests.test_utils import fnames, tes
-from tedana.tests.utils import get_test_data_path
+from tedana.tests.utils import data_for_testing_info, get_test_data_path
 
 data_dir = get_test_data_path()
 
@@ -318,3 +320,75 @@ def test_custom_encoder():
     encoded = json.dumps(test_data, cls=me.CustomEncoder)
     decoded = json.loads(encoded)
     assert test_data == decoded
+
+
+@mock.patch("tedana.io.requests.get")
+@mock.patch("tedana.io.op.isfile")
+def test_download_json_file_not_found(mock_isfile, mock_requests_get):
+    """Test case when file doesn't exist locally or on figshare"""
+    mock_isfile.return_value = False
+
+    mock_response = mock.Mock()
+    mock_response.raise_for_status = mock.Mock()
+    mock_response.json.return_value = {"files": [{"name": "tree.json", "download_url": "url.com"}]}
+    mock_requests_get.return_value = mock_response
+
+    result = me.download_json("non_existent_tree", "some_dir")
+
+    assert result is None
+    mock_response.raise_for_status.assert_called_once()
+
+
+@mock.patch("tedana.io.requests.get")
+@mock.patch("tedana.io.op.isfile")
+def test_download_json_skips_if_exists(mock_isfile, mock_requests_get):
+    """Test case when file already exists locally"""
+    mock_isfile.return_value = True
+
+    result = me.download_json("my_tree", "some_dir")
+
+    assert result == "some_dir/my_tree.json"
+    mock_requests_get.assert_not_called()
+
+
+@mock.patch("tedana.io.requests.get")
+def test_download_json_doesnt_connect_to_url(mock_requests_get, caplog: pytest.LogCaptureFixture):
+    """Tests that the correct log message appears when URL not connected"""
+    mock_requests_get.side_effect = requests.exceptions.ConnectionError(
+        "Simulated connection error"
+    )
+
+    result = me.download_json("tedana_orig", "./")
+    assert result is None
+    assert "Cannot connect to figshare" in caplog.text
+
+
+@mock.patch("tedana.io.requests.get")
+@mock.patch("tedana.io.op.isfile")
+def test_download_json_file_is_downloaded(mock_isfile, mock_requests_get):
+    """Test json is downloaded if it exists on figshare"""
+    mock_isfile.return_value = False
+
+    metadata_response = mock.Mock()
+    metadata_response.raise_for_status = mock.Mock()
+    metadata_response.json.return_value = {
+        "files": [{"name": "tree.json", "download_url": "url.com"}]
+    }
+
+    download_response = mock.Mock()
+    download_response.raise_for_status = mock.Mock()
+    file_content = {"sample": "data"}
+    download_response.content = json.dumps(file_content).encode("utf-8")
+
+    mock_requests_get.side_effect = [metadata_response, download_response]
+
+    out_dir = data_for_testing_info("path")
+    result = me.download_json("tree.json", out_dir)
+
+    assert result == f"{out_dir}/tree.json"
+    assert os.path.exists(result)
+
+    with open(result, "r") as f:
+        content = json.load(f)
+    assert content == file_content
+    os.remove(result)
diff --git a/tedana/tests/utils.py b/tedana/tests/utils.py
index 6147165..0e93f39 100644
--- a/tedana/tests/utils.py
+++ b/tedana/tests/utils.py
@@ -45,15 +45,20 @@ def data_for_testing_info(test_dataset=str):
         three-echo-reclassify
         four-echo
         five-echo
+       To only return the base path without downloading data,
+       you can supply:
+        path
 
     Returns
     -------
     test_data_path : str
        The path to the local directory where the data will be downloaded
+       If "path" is specified, returns the base directory path.
     osf_id : str
        The ID for the OSF file.
        Data download link would be https://osf.io/osf_id/download
        Metadata download link would be https://osf.io/osf_id/metadata/?format=datacite-json
+       If "path" is specified, this value is not returned.
     """
 
     tedana_path = dirname(tedana_cli.__file__)
@@ -79,6 +84,8 @@ def data_for_testing_info(test_dataset=str):
         osf_id = "9c42e"
         makedirs(join(base_data_path, "five-echo"), exist_ok=True)
         makedirs(join(base_data_path, "outputs/five-echo"), exist_ok=True)
+    elif test_dataset == "path":
+        return base_data_path
     else:
         raise ValueError(f"{test_dataset} is not a valid dataset string for data_for_testing_info")