diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..540bbbe46
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,18 @@
+# tedana
+
+## Environment
+
+Development uses the micromamba environment **`tedenv`** (python 3.12.13; numpy,
+scipy, nibabel, nilearn, pandas, scikit-learn, mapca, matplotlib). `tedana` is
+installed editable from this checkout, so source edits take effect immediately
+with no reinstall.
+
+Run everything through it:
+
+ micromamba run -n tedenv pytest tedana/tests/test_decay.py -q
+ micromamba run -n tedenv flake8 tedana/decay.py
+ micromamba run -n tedenv black tedana/decay.py
+
+Use `tedenv`, not `tedanapy`. Both environments have tedana and past sessions
+used them interchangeably, but only `tedenv` has the editable install and the
+lint toolchain (`flake8`, `black`, `isort`). This project does not use ruff.
diff --git a/docs/outputs.rst b/docs/outputs.rst
index 0914942f4..df7b0ae2c 100644
--- a/docs/outputs.rst
+++ b/docs/outputs.rst
@@ -58,6 +58,12 @@ tedana_report.html The
"s0 img": S0map.nii.gz Full S0 3D map. If a voxel has at least 1 good
echo then the first two echoes will be used to estimate
a value
+"decay metrics json": desc-decay_metrics.json Precomputed decay-fit QC summary read by the report's
+ QC summary card: mean/median T2*, S0, and fit RMSE;
+ base-mask and fit-mask voxel counts; a good-echo voxel
+ histogram; and, when curve-fitting is used, first-pass
+ and post-interpolation fit-failure counts. Only written
+ when fitmode is "all".
"PCA mixing tsv": desc-PCA_mixing.tsv Mixing matrix (component time series) from PCA
decomposition in a tab-delimited file. Each column is
a different component, and the column name is the
@@ -99,6 +105,10 @@ tedana_report.html The
"ICA cross component metrics json": desc-ICACrossComponent_metrics.json Metric names and values that are each a single number
calculated across components. For example, kappa and
rho elbows.
+ Also includes aggregate variance measures used by the
+ QC summary card: accepted_variance, rejected_variance,
+ ignored_variance, unmodeled_variance (relative to the
+ raw optimally-combined data), and retained_variance.
"ICA decision tree json": desc-ICA_decision_tree A copy of the inputted decision tree specification with
an added "output" field for each node. The output field
contains information about what happened during
diff --git a/tedana/decay.py b/tedana/decay.py
index e3059697c..0f28de706 100644
--- a/tedana/decay.py
+++ b/tedana/decay.py
@@ -815,6 +815,99 @@ def rmse_of_fit_decay_ts(
return rmse_map, rmse_df
+def generate_decay_metrics(
+ *,
+ t2star,
+ s0,
+ rmse_map,
+ adaptive_mask,
+ n_fit_failures=None,
+ n_fit_failures_after_interpolation=None,
+):
+ """Summarize decay-fit quality into a dict for the QC report.
+
+ All array inputs are in base-mask sample space (one entry per base-mask voxel)
+ and must be 1D (``(S,)``). This function is only meaningful for scalar,
+ per-voxel summaries; time-varying (``fitmode == "ts"``) maps are not supported.
+
+ Parameters
+ ----------
+ t2star, s0, rmse_map : (S,) array_like
+ Full T2*, S0, and fit-RMSE maps in base-mask sample space. Must be 1D.
+ adaptive_mask : (S,) array_like
+ Integer count of good echoes per base-mask voxel (0 means no good echo).
+ Must be 1D.
+ n_fit_failures, n_fit_failures_after_interpolation : int or None
+ Curve-fit failure counts. ``None`` when curve-fitting was not used, in which
+ case the corresponding keys are omitted from the returned dict.
+
+ Returns
+ -------
+ dict
+
+ Raises
+ ------
+ ValueError
+ If any of ``adaptive_mask``, ``t2star``, ``s0``, or ``rmse_map`` is not 1D.
+ This most often happens when ``fitmode == "ts"`` produces 2D
+ (voxels x time) maps, for which a scalar summary is ill-defined.
+ """
+ adaptive_mask = np.asarray(adaptive_mask)
+ for name, arr in (
+ ("adaptive_mask", adaptive_mask),
+ ("t2star", t2star),
+ ("s0", s0),
+ ("rmse_map", rmse_map),
+ ):
+ arr = np.asarray(arr)
+ if arr.ndim != 1:
+ raise ValueError(
+ "generate_decay_metrics expects 1D base-mask-space arrays; "
+ f"got shape {arr.shape} for '{name}'."
+ )
+
+ fit_mask = adaptive_mask >= 1
+
+ def _finite_mean_median(arr):
+ arr = np.asarray(arr, dtype=float)[fit_mask]
+ # Exclude zeros: modify_t2s_s0_maps fills NaN S0 values with 0.0, and
+ # those placeholders would otherwise bias the mean/median.
+ arr = arr[np.isfinite(arr) & (arr != 0)]
+ if arr.size == 0:
+ return None, None
+ return float(np.mean(arr)), float(np.median(arr))
+
+ t2star_mean, t2star_median = _finite_mean_median(t2star)
+ s0_mean, s0_median = _finite_mean_median(s0)
+
+ rmse = np.asarray(rmse_map, dtype=float)[fit_mask]
+ rmse = rmse[np.isfinite(rmse)]
+ rmse_mean = float(np.mean(rmse)) if rmse.size else None
+ rmse_median = float(np.median(rmse)) if rmse.size else None
+
+ good_echo_voxel_counts = {
+ int(n): int((adaptive_mask == n).sum()) for n in np.unique(adaptive_mask[fit_mask])
+ }
+
+ metrics = {
+ "t2star_mean": t2star_mean,
+ "t2star_median": t2star_median,
+ "s0_mean": s0_mean,
+ "s0_median": s0_median,
+ "rmse_mean": rmse_mean,
+ "rmse_median": rmse_median,
+ "n_voxels_base_mask": int(adaptive_mask.size),
+ "n_voxels_fit_mask": int(fit_mask.sum()),
+ "good_echo_voxel_counts": good_echo_voxel_counts,
+ }
+ if n_fit_failures is not None:
+ metrics["n_fit_failures"] = int(n_fit_failures)
+ if n_fit_failures_after_interpolation is not None:
+ metrics["n_fit_failures_after_interpolation"] = int(n_fit_failures_after_interpolation)
+
+ return metrics
+
+
def t2smap_subworkflow(
data_cat,
tes,
@@ -861,6 +954,9 @@ def t2smap_subworkflow(
t2s_full : (Mb x T) :obj:`numpy.ndarray`
The full T2* map.
"""
+ n_fit_failures = None
+ n_fit_failures_after_interpolation = None
+
data_for_fit = (
data_without_excluded_vols if data_without_excluded_vols is not None else data_cat
)
@@ -992,6 +1088,11 @@ def t2smap_subworkflow(
failures_map = first_pass_failures.astype(np.uint8)
if interpolate_failing_voxels and first_pass_failures.any():
failures_map += failures.astype(np.uint8)
+
+ n_fit_failures = int(first_pass_failures.sum())
+ if interpolate_failing_voxels and first_pass_failures.any():
+ n_fit_failures_after_interpolation = int(failures.sum())
+
io_generator.save_file(failures_map, "fit failures img", mask=mask_denoise)
if io_generator.verbose:
@@ -1040,7 +1141,21 @@ def t2smap_subworkflow(
s0=s0_full,
fitmode=fitmode,
)
- del s0_full
io_generator.save_file(rmse_map, "rmse img")
io_generator.save_file(rmse_df, "confounds tsv")
+
+ if fitmode == "all":
+ # In fitmode == "ts", the T2*/S0/RMSE maps are time-varying (Mb x T), so a
+ # single scalar summary per voxel is ill-defined and decay metrics are
+ # simply not written.
+ decay_metrics = generate_decay_metrics(
+ t2star=t2s_full,
+ s0=s0_full,
+ rmse_map=rmse_map,
+ adaptive_mask=masksum_denoise,
+ n_fit_failures=n_fit_failures,
+ n_fit_failures_after_interpolation=n_fit_failures_after_interpolation,
+ )
+ io_generator.save_file(decay_metrics, "decay metrics json")
+
return t2s_full
diff --git a/tedana/reporting/data/html/report_body_template.html b/tedana/reporting/data/html/report_body_template.html
index a2890605d..4516d057b 100644
--- a/tedana/reporting/data/html/report_body_template.html
+++ b/tedana/reporting/data/html/report_body_template.html
@@ -424,6 +424,17 @@
+
+
tedana QC summary
+
+ {% for row in qcCard %}
+
+ | {{ row.label }} |
+ {{ row.value }} |
+
+ {% endfor %}
+
+
Info
{{ info }}
diff --git a/tedana/reporting/html_report.py b/tedana/reporting/html_report.py
index 9f207ef5a..57f4d2877 100644
--- a/tedana/reporting/html_report.py
+++ b/tedana/reporting/html_report.py
@@ -129,6 +129,7 @@ def _update_template_bokeh(
tsne,
tree_table,
status_table,
+ qc_card,
):
"""
Populate a report with content.
@@ -155,6 +156,8 @@ def _update_template_bokeh(
HTML table of decision tree nodes created by _generate_tree_tables()
status_table : str or None
HTML table of component statuses created by _generate_tree_tables()
+ qc_card : list of dict
+ Display rows created by _generate_qc_card()
Returns
-------
@@ -289,6 +292,7 @@ def _update_template_bokeh(
treeExists=tree_exists,
treeTable=tree_table,
statusTable=status_table,
+ qcCard=qc_card,
)
return body
@@ -390,6 +394,98 @@ def _generate_tree_tables(io_generator):
return tree_table, status_table
+def _fmt_num(value, suffix="", decimals=1):
+ """Format a possibly-None number for display, returning 'n/a' for None."""
+ if value is None:
+ return "n/a"
+ return f"{value:.{decimals}f}{suffix}"
+
+
+def _generate_qc_card(
+ *,
+ component_table,
+ cross_comp_metrics_dict,
+ decay_metrics_dict,
+ kappa_elbow,
+ rho_elbow,
+ n_vols,
+ n_comps,
+ tree_node_count,
+ version,
+):
+ """Assemble display rows of precomputed run-level QC values for the summary card.
+
+ Returns a list of ``{"label": str, "value": str}`` rows. Performs no scientific
+ computation; every value is read from precomputed inputs.
+ """
+ counts = component_table["classification"].value_counts().to_dict()
+ n_total = int(len(component_table))
+ n_accepted = int(counts.get("accepted", 0))
+ n_rejected = int(counts.get("rejected", 0))
+ n_ignored = int(counts.get("ignored", 0))
+
+ ccm = cross_comp_metrics_dict or {}
+
+ rows = [
+ {
+ "label": "Components",
+ "value": (
+ f"{n_total} total | {n_accepted} accepted | "
+ f"{n_rejected} rejected | {n_ignored} ignored"
+ ),
+ },
+ {"label": "Variance accepted", "value": _fmt_num(ccm.get("accepted_variance"), "%")},
+ {"label": "Variance rejected", "value": _fmt_num(ccm.get("rejected_variance"), "%")},
+ {"label": "Variance unmodeled", "value": _fmt_num(ccm.get("unmodeled_variance"), "%")},
+ {"label": "Variance retained", "value": _fmt_num(ccm.get("retained_variance"), "%")},
+ {"label": "Kappa elbow", "value": _fmt_num(kappa_elbow, decimals=2)},
+ {"label": "Rho elbow", "value": _fmt_num(rho_elbow, decimals=2)},
+ {
+ "label": "Dimensions",
+ "value": (
+ f"{n_vols} volumes | {ccm.get('n_echos', 'n/a')} echoes | " f"{n_comps} components"
+ ),
+ },
+ {
+ "label": "Decision-tree nodes",
+ "value": "n/a" if tree_node_count is None else str(tree_node_count),
+ },
+ {"label": "tedana version", "value": str(version)},
+ ]
+
+ if decay_metrics_dict:
+ rows.append(
+ {"label": "Mean T2*", "value": _fmt_num(decay_metrics_dict.get("t2star_mean"), " ms")}
+ )
+ rows.append(
+ {"label": "Median RMSE", "value": _fmt_num(decay_metrics_dict.get("rmse_median"))}
+ )
+ fit_vox = decay_metrics_dict.get("n_voxels_fit_mask")
+ base_vox = decay_metrics_dict.get("n_voxels_base_mask")
+ fit_vox_txt = "n/a" if fit_vox is None else str(fit_vox)
+ base_vox_txt = "n/a" if base_vox is None else str(base_vox)
+ rows.append(
+ {
+ "label": "Fit-mask voxels",
+ "value": f"{fit_vox_txt} of {base_vox_txt} base-mask voxels",
+ }
+ )
+ if decay_metrics_dict.get("n_fit_failures") is not None:
+ after = decay_metrics_dict.get("n_fit_failures_after_interpolation")
+ after_txt = "n/a" if after is None else str(after)
+ rows.append(
+ {
+ "label": "Fit failures",
+ "value": (
+ f"{decay_metrics_dict['n_fit_failures']} first-pass | "
+ f"{after_txt} after interpolation"
+ ),
+ }
+ )
+
+ return rows
+
+
def generate_report(io_generator: OutputGenerator, cluster_labels, similarity_t_sne) -> None:
"""Generate an HTML report.
@@ -545,6 +641,33 @@ def get_elbow_val(elbow_prefix):
# Create the decision tree tables
tree_table, status_table = _generate_tree_tables(io_generator)
+ # Read the precomputed decay metrics, if present.
+ decay_metrics_dict = None
+ decay_metrics_path = io_generator.get_name("decay metrics json")
+ if os.path.exists(decay_metrics_path):
+ decay_metrics_dict = load_json(decay_metrics_path)
+
+ # Number of decision-tree nodes, if the tree JSON exists.
+ tree_node_count = None
+ tree_path = io_generator.get_name("ICA decision tree json")
+ if os.path.exists(tree_path):
+ tree_data = load_json(tree_path)
+ nodes = tree_data.get("nodes")
+ if nodes is not None:
+ tree_node_count = len(nodes)
+
+ qc_card = _generate_qc_card(
+ component_table=component_table,
+ cross_comp_metrics_dict=cross_comp_metrics_dict,
+ decay_metrics_dict=decay_metrics_dict,
+ kappa_elbow=kappa_elbow,
+ rho_elbow=rho_elbow,
+ n_vols=n_vols,
+ n_comps=n_comps,
+ tree_node_count=tree_node_count,
+ version=__version__,
+ )
+
body = _update_template_bokeh(
bokeh_id=kr_div,
info_table=info_table,
@@ -556,6 +679,7 @@ def get_elbow_val(elbow_prefix):
tsne=tsne_html,
tree_table=tree_table,
status_table=status_table,
+ qc_card=qc_card,
)
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/reporting/quality_metrics.py b/tedana/reporting/quality_metrics.py
index 941a85a69..168be7ec1 100644
--- a/tedana/reporting/quality_metrics.py
+++ b/tedana/reporting/quality_metrics.py
@@ -2,7 +2,7 @@
import numpy as np
-from tedana.stats import fit_model
+from tedana.stats import fit_model, get_coeffs
def calculate_rejected_components_impact(selector, mixing):
@@ -72,3 +72,46 @@ def calculate_rejected_components_impact(selector, mixing):
)
/ 100
)
+
+
+def calculate_variance_summary(selector, data_optcom_masked, mixing):
+ """Store aggregate variance QC scalars in ``selector.cross_component_metrics_``.
+
+ Adds, as percentages:
+
+ - ``accepted_variance``, ``rejected_variance``, ``ignored_variance``: sums of
+ per-component ``"variance explained"`` grouped by classification. These are
+ relative to the ICA decomposition and together sum to ~100%.
+ - ``unmodeled_variance`` = ``100 - total_r2``, where ``total_r2`` is the variance of
+ the raw optimally-combined data explained by the full decomposition.
+ - ``retained_variance``: variance of the denoised data (rejected components removed)
+ relative to the raw optimally-combined data.
+
+ Parameters
+ ----------
+ selector : :obj:`tedana.selection.component_selector.ComponentSelector`
+ data_optcom_masked : (S x T) array_like
+ Optimally-combined data restricted to the classification mask.
+ mixing : (T x C) array_like
+ ICA mixing matrix.
+ """
+ component_table = selector.component_table_
+
+ for label in ("accepted", "rejected", "ignored"):
+ label_mask = component_table["classification"] == label
+ selector.cross_component_metrics_[f"{label}_variance"] = float(
+ component_table.loc[label_mask, "variance explained"].sum()
+ )
+
+ # Variance relative to the raw optimally-combined data (mirrors io.denoise_ts).
+ dmdata = data_optcom_masked.T - data_optcom_masked.T.mean(axis=0)
+ betas = get_coeffs(dmdata.T, mixing)
+ sst = (dmdata.T**2).sum()
+ reconstruction = betas.dot(mixing.T)
+ total_r2 = (1 - ((dmdata.T - reconstruction) ** 2).sum() / sst) * 100
+ selector.cross_component_metrics_["unmodeled_variance"] = float(100 - total_r2)
+
+ rej = component_table[component_table["classification"] == "rejected"].index.values
+ rejected_reconstruction = betas[:, rej].dot(mixing.T[rej, :])
+ denoised = dmdata.T - rejected_reconstruction
+ selector.cross_component_metrics_["retained_variance"] = float((denoised**2).sum() / sst * 100)
diff --git a/tedana/resources/config/outputs.json b/tedana/resources/config/outputs.json
index 9a6ad6ea3..9a1abc1f4 100644
--- a/tedana/resources/config/outputs.json
+++ b/tedana/resources/config/outputs.json
@@ -235,6 +235,10 @@
"orig": "ica_orth_mixing",
"bidsv1.5.0": "desc-ICAOrth_mixing"
},
+ "decay metrics json": {
+ "orig": "decay_metrics",
+ "bidsv1.5.0": "desc-decay_metrics"
+ },
"registry json": {
"orig": "registry",
"bidsv1.5.0": "desc-tedana_registry"
diff --git a/tedana/tests/data/cornell_three_echo_outputs.txt b/tedana/tests/data/cornell_three_echo_outputs.txt
index 5c279c3bd..cf2cba1e9 100644
--- a/tedana/tests/data/cornell_three_echo_outputs.txt
+++ b/tedana/tests/data/cornell_three_echo_outputs.txt
@@ -20,6 +20,7 @@ desc-PCA_metrics.tsv
desc-PCA_mixing.tsv
desc-PCA_stat-z_components.nii.gz
desc-adaptiveGoodSignal_mask.nii.gz
+desc-decay_metrics.json
desc-denoised_bold.nii.gz
desc-optcom_bold.nii.gz
desc-confounds_timeseries.tsv
diff --git a/tedana/tests/data/cornell_three_echo_preset_mixing_outputs.txt b/tedana/tests/data/cornell_three_echo_preset_mixing_outputs.txt
index 7bb9109ba..0c8d8a32d 100644
--- a/tedana/tests/data/cornell_three_echo_preset_mixing_outputs.txt
+++ b/tedana/tests/data/cornell_three_echo_preset_mixing_outputs.txt
@@ -15,6 +15,7 @@ desc-ICA_mixing.tsv
desc_ICA_mixing_static.tsv
desc-ICA_stat-z_components.nii.gz
desc-adaptiveGoodSignal_mask.nii.gz
+desc-decay_metrics.json
desc-denoised_bold.nii.gz
desc-optcom_bold.nii.gz
desc-confounds_timeseries.tsv
diff --git a/tedana/tests/data/cornell_three_echo_verbose_outputs.txt b/tedana/tests/data/cornell_three_echo_verbose_outputs.txt
index be488534e..60ea7d06c 100644
--- a/tedana/tests/data/cornell_three_echo_verbose_outputs.txt
+++ b/tedana/tests/data/cornell_three_echo_verbose_outputs.txt
@@ -20,6 +20,7 @@ desc-PCA_metrics.tsv
desc-PCA_mixing.tsv
desc-PCA_stat-z_components.nii.gz
desc-adaptiveGoodSignal_mask.nii.gz
+desc-decay_metrics.json
desc-denoised_bold.nii.gz
desc-optcom_bold.nii.gz
desc-confounds_timeseries.tsv
diff --git a/tedana/tests/data/fiu_four_echo_outputs.txt b/tedana/tests/data/fiu_four_echo_outputs.txt
index 753bdc1aa..9f8e173af 100644
--- a/tedana/tests/data/fiu_four_echo_outputs.txt
+++ b/tedana/tests/data/fiu_four_echo_outputs.txt
@@ -22,6 +22,7 @@ sub-01_desc-ICA_mixing.tsv
sub-01_desc-ICA_stat-z_components.nii.gz
sub-01_desc-T1likeEffect_min.nii.gz
sub-01_desc-adaptiveGoodSignal_mask.nii.gz
+sub-01_desc-decay_metrics.json
sub-01_desc-globalSignal_map.nii.gz
sub-01_desc-limited_S0map.nii.gz
sub-01_desc-limited_T2starmap.nii.gz
diff --git a/tedana/tests/data/nih_five_echo_outputs_t2smap.txt b/tedana/tests/data/nih_five_echo_outputs_t2smap.txt
index 2e2dbb392..635b12c73 100644
--- a/tedana/tests/data/nih_five_echo_outputs_t2smap.txt
+++ b/tedana/tests/data/nih_five_echo_outputs_t2smap.txt
@@ -8,4 +8,5 @@ T2starmap.nii.gz
figures
t2smap_call.sh
desc-confounds_timeseries.tsv
+desc-decay_metrics.json
desc-rmse_statmap.nii.gz
diff --git a/tedana/tests/data/nih_five_echo_outputs_verbose.txt b/tedana/tests/data/nih_five_echo_outputs_verbose.txt
index f8c0bf03c..3505740dd 100644
--- a/tedana/tests/data/nih_five_echo_outputs_verbose.txt
+++ b/tedana/tests/data/nih_five_echo_outputs_verbose.txt
@@ -27,6 +27,7 @@ sub-01_desc-PCA_metrics.tsv
sub-01_desc-PCA_mixing.tsv
sub-01_desc-PCA_stat-z_components.nii.gz
sub-01_desc-adaptiveGoodSignal_mask.nii.gz
+sub-01_desc-decay_metrics.json
sub-01_desc-limited_S0map.nii.gz
sub-01_desc-limited_T2starmap.nii.gz
sub-01_desc-optcomAccepted_bold.nii.gz
diff --git a/tedana/tests/test_decay.py b/tedana/tests/test_decay.py
index a7b0f8eed..21cab3d66 100644
--- a/tedana/tests/test_decay.py
+++ b/tedana/tests/test_decay.py
@@ -296,4 +296,54 @@ def test_rmse_includes_adaptive_mask_one():
assert np.all(np.isfinite(rmse_map[am1]))
+def test_generate_decay_metrics_basic():
+ # 5 base-mask voxels; voxel 0 has 0 good echoes (outside fit mask).
+ adaptive_mask = np.array([0, 1, 2, 3, 3])
+ t2star = np.array([np.nan, 20.0, 40.0, 60.0, 80.0])
+ s0 = np.array([np.nan, 100.0, 200.0, 300.0, 400.0])
+ rmse_map = np.array([np.nan, 1.0, 2.0, 3.0, 4.0])
+
+ metrics = me.generate_decay_metrics(
+ t2star=t2star,
+ s0=s0,
+ rmse_map=rmse_map,
+ adaptive_mask=adaptive_mask,
+ n_fit_failures=2,
+ n_fit_failures_after_interpolation=1,
+ )
+
+ assert metrics["n_voxels_base_mask"] == 5
+ assert metrics["n_voxels_fit_mask"] == 4
+ assert metrics["good_echo_voxel_counts"] == {1: 1, 2: 1, 3: 2}
+ # Means/medians computed over the 4 fit-mask voxels only.
+ assert metrics["t2star_mean"] == 50.0
+ assert metrics["t2star_median"] == 50.0
+ assert metrics["rmse_median"] == 2.5
+ assert metrics["n_fit_failures"] == 2
+ assert metrics["n_fit_failures_after_interpolation"] == 1
+
+
+def test_generate_decay_metrics_omits_failures_when_none():
+ metrics = me.generate_decay_metrics(
+ t2star=np.array([10.0, 20.0]),
+ s0=np.array([100.0, 200.0]),
+ rmse_map=np.array([1.0, 2.0]),
+ adaptive_mask=np.array([1, 2]),
+ )
+ assert "n_fit_failures" not in metrics
+ assert "n_fit_failures_after_interpolation" not in metrics
+
+
+def test_generate_decay_metrics_rejects_2d_input():
+ """2D (voxels x time) maps (e.g. fitmode == "ts") must raise, not silently flatten."""
+ t2star_2d = np.ones((4, 3))
+ with pytest.raises(ValueError, match="1D"):
+ me.generate_decay_metrics(
+ t2star=t2star_2d,
+ s0=np.ones((4, 3)),
+ rmse_map=np.ones(4),
+ adaptive_mask=np.array([1, 2, 3, 3]),
+ )
+
+
# TODO: BREAK AND UNIT TESTS
diff --git a/tedana/tests/test_reporting.py b/tedana/tests/test_reporting.py
index fddd443a4..7af2c2aef 100644
--- a/tedana/tests/test_reporting.py
+++ b/tedana/tests/test_reporting.py
@@ -80,6 +80,33 @@ def test_calculate_rejected_components_impact_no_acc():
)
+def test_calculate_variance_summary_sets_keys():
+ import numpy as np
+
+ selector = sample_selector()
+ mixing = sample_mixing_matrix()
+ n_vols = mixing.shape[0]
+ rng = np.random.default_rng(0)
+ data_optcom_masked = rng.standard_normal((50, n_vols))
+
+ reporting.quality_metrics.calculate_variance_summary(selector, data_optcom_masked, mixing)
+
+ ccm = selector.cross_component_metrics_
+ for key in (
+ "accepted_variance",
+ "rejected_variance",
+ "ignored_variance",
+ "unmodeled_variance",
+ "retained_variance",
+ ):
+ assert key in ccm
+ assert isinstance(ccm[key], float)
+
+ # Class-wise variance (decomposition frame) sums to ~ total variance explained.
+ assert 0.0 <= ccm["retained_variance"] <= 100.0
+ assert 0.0 <= ccm["unmodeled_variance"] <= 100.0
+
+
def test_plot_heatmap_nonfinite_distances_warns_and_succeeds(tmp_path):
"""Ensure plot_heatmap does not crash when correlation-derived distances are non-finite.
@@ -153,6 +180,7 @@ def _render_body(tmp_path, **kwargs):
"tsne": "",
"tree_table": None,
"status_table": None,
+ "qc_card": [],
}
render_kwargs.update(kwargs)
return html_report._update_template_bokeh(**render_kwargs)
@@ -259,3 +287,58 @@ def test_generate_tree_tables(tmp_path):
assert "kappa, rho" in tree_table
assert "pure-table" in tree_table
assert "ICA_00" in status_table
+
+
+def test_generate_qc_card_rows():
+ component_table = pd.DataFrame(
+ {
+ "classification": ["accepted", "accepted", "rejected"],
+ "variance explained": [30.0, 26.4, 43.6],
+ }
+ )
+ ccm = {
+ "accepted_variance": 56.4,
+ "rejected_variance": 43.6,
+ "unmodeled_variance": 12.0,
+ "retained_variance": 70.0,
+ "n_echos": 4,
+ }
+ decay = {"t2star_mean": 38.2, "rmse_median": 2.8, "n_voxels_fit_mask": 139812}
+
+ rows = html_report._generate_qc_card(
+ component_table=component_table,
+ cross_comp_metrics_dict=ccm,
+ decay_metrics_dict=decay,
+ kappa_elbow=12.7,
+ rho_elbow=9.4,
+ n_vols=200,
+ n_comps=3,
+ tree_node_count=8,
+ version="26.0.4",
+ )
+
+ labels = {r["label"]: r["value"] for r in rows}
+ assert "3 total | 2 accepted | 1 rejected" in labels["Components"]
+ assert "56.4%" in labels["Variance accepted"]
+ assert "12.0%" in labels["Variance unmodeled"]
+ assert "38.2" in labels["Mean T2*"] # decay row present
+ # Missing decay sub-fields (here n_voxels_base_mask) degrade to "n/a", never "None".
+ assert "None" not in labels["Fit-mask voxels"]
+ assert "n/a" in labels["Fit-mask voxels"]
+
+
+def test_generate_qc_card_omits_decay_when_absent():
+ component_table = pd.DataFrame({"classification": ["accepted"], "variance explained": [100.0]})
+ rows = html_report._generate_qc_card(
+ component_table=component_table,
+ cross_comp_metrics_dict={},
+ decay_metrics_dict=None,
+ kappa_elbow=None,
+ rho_elbow=None,
+ n_vols=100,
+ n_comps=1,
+ tree_node_count=None,
+ version="26.0.4",
+ )
+ labels = {r["label"] for r in rows}
+ assert "Mean T2*" not in labels
diff --git a/tedana/workflows/tedana.py b/tedana/workflows/tedana.py
index 3e4616fb0..ab804fd32 100644
--- a/tedana/workflows/tedana.py
+++ b/tedana/workflows/tedana.py
@@ -1012,6 +1012,9 @@ def tedana_workflow(
# calculate the fit of rejected to accepted components to use as a quality measure
# Note: This adds a column to component_table & needs to run before the table is saved
reporting.quality_metrics.calculate_rejected_components_impact(selector, mixing)
+ reporting.quality_metrics.calculate_variance_summary(
+ selector, data_optcom[mask_clf, :], mixing
+ )
# Save component selector and tree
selector.to_files(io_generator)