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 @@

Open in Rica

+
+

tedana QC summary

+ + {% for row in qcCard %} + + + + + {% endfor %} +
{{ row.label }}{{ row.value }}
+

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)