diff --git a/bin/workflow_glue/report.py b/bin/workflow_glue/report.py index ebc20fb..458a58b 100644 --- a/bin/workflow_glue/report.py +++ b/bin/workflow_glue/report.py @@ -112,6 +112,24 @@ def _format_ratio_value(value): return f"{numeric:.2f}x" +def _sorted_transcript_abundance_table(tx_counts_file, sample_aliases): + """Get transcript abundance rows by descending total abundance of sample columns.""" + tx_counts = _read_table(tx_counts_file) + if tx_counts is None or tx_counts.empty: + return tx_counts + + count_columns = [ + column for column in tx_counts.columns if column in set(sample_aliases) + ] + if not count_columns: + return tx_counts + + numeric_counts = tx_counts[count_columns].apply(pd.to_numeric, errors="coerce") + abundance = numeric_counts.sum(axis=1, min_count=1) + order = abundance.fillna(float("-inf")).sort_values(ascending=False).index + return tx_counts.loc[order].reset_index(drop=True) + + def _format_classification_label(name): """Return canonical report label for a summary classification value.""" return str(name).strip().replace("-", "_").replace("_", " ").capitalize() @@ -380,12 +398,38 @@ def _contrast_results(de_dir, filename, n=None): data = table if n is not None: data = data.head(n) - data.sort_values("padj", ascending=True, inplace=True) + if "padj" in data.columns: + data.sort_values("padj", ascending=True, inplace=True) tables[contrast_dir.name] = data return tables +def _round_de_table(table): + """Return a display-formatted copy of a DE/DTU result table.""" + rounded_columns = [ + "baseMean", "exonBaseMean", "log2FoldChange", "lfcSE", "stat", "pvalue", "padj" + ] + + def _format_numeric(value): + numeric_value = pd.to_numeric(value, errors="coerce") + if pd.isna(numeric_value): + return value + fixed_decimal = f"{numeric_value:.3f}" + if numeric_value != 0 and ( + abs(numeric_value) < 0.0001 or fixed_decimal in {"0.000", "-0.000"} + ): + return f"{numeric_value:.3e}" + return fixed_decimal + + rounded = table.copy() + for column in rounded_columns: + if column not in rounded.columns: + continue + rounded[column] = rounded[column].map(_format_numeric) + return rounded + + def _load_bambu_qc(bambu_dir): """Load bambu QC statistics JSON.""" qc_file = Path(bambu_dir) / "bambu_qc_stats.json" @@ -911,13 +955,17 @@ def main(args): use_index=False, ) - tx_counts = _read_table(Path(bambu_dir) / "transcript_counts.tsv") + tx_counts = _sorted_transcript_abundance_table( + Path(bambu_dir) / "transcript_counts.tsv", + [item["alias"] for item in metadata] + ) if tx_counts is not None and not tx_counts.empty: p( - "Top transcript rows from the " - f"{'sample' if is_single_sample else 'cohort'} abundance table." + f"Top {args.de_table_size} most abundant transcripts" ) - DataTable.from_pandas(tx_counts.head(20), use_index=False) + DataTable.from_pandas(tx_counts.head(args.de_table_size), use_index=False) + else: + _create_warning_banner("No trancrips discovered") if not is_single_sample: with report.add_section( @@ -934,15 +982,6 @@ def main(args): use_index=False, ) - if args.alignment_stats_dir and Path(args.alignment_stats_dir).exists(): - with report.add_section("Alignment statistics", "Alignments"): - tabs = Tabs() - for stats_file in sorted( - Path(args.alignment_stats_dir).glob("*.flagstat.txt") - ): - with tabs.add_tab(stats_file.stem.replace(".flagstat", "")): - pre(stats_file.read_text()) - sqanti_table = _sqanti_table(args.sqanti_dir) if sqanti_table is not None and not sqanti_table.empty: with report.add_section("SQANTI3 classification", "SQANTI3"): @@ -1296,7 +1335,8 @@ def main(args): strong("Note: ") raw(contrast_data["dtu_power_warning"]) DataTable.from_pandas( - table.head(args.de_table_size), use_index=False + _round_de_table(table.head(args.de_table_size)), + use_index=False ) with div(cls="clustering-info"): raw( @@ -1373,7 +1413,8 @@ def main(args): if contrast_name in dtu_tables: dtu_table = dtu_tables[contrast_name] DataTable.from_pandas( - dtu_table.head(args.de_table_size), use_index=False + _round_de_table(dtu_table.head(args.de_table_size)), + use_index=False ) with div(cls="clustering-info"): raw( @@ -1400,11 +1441,6 @@ def argparser(): parser.add_argument("report", help="Report output file.") parser.add_argument("--metadata", required=True, help="Sample metadata JSON.") parser.add_argument("--stats", nargs="+", help="Per-read stats paths.") - parser.add_argument( - "--alignment_stats_dir", - default=None, - help="Alignment stats directory.", - ) parser.add_argument( "--cohort_dir", required=True, diff --git a/bin/workflow_glue/tests/common/test_report.py b/bin/workflow_glue/tests/common/test_report.py index 9f38742..f429d6a 100644 --- a/bin/workflow_glue/tests/common/test_report.py +++ b/bin/workflow_glue/tests/common/test_report.py @@ -3,6 +3,7 @@ import json from pathlib import Path +import pandas as pd from workflow_glue import report @@ -110,11 +111,6 @@ def _build_report_args(tmp_path, de_qc=None): mod_summaries.mkdir() sqanti = tmp_path / "sqanti" sqanti.mkdir() - alignment_stats = tmp_path / "alignment_stats" - alignment_stats.mkdir() - (samples / "OPTIONAL_FILE").touch() - (sqanti / "OPTIONAL_FILE").touch() - (alignment_stats / "OPTIONAL_FILE").touch() de_dir = None if de_qc is not None: @@ -143,8 +139,6 @@ def _build_report_args(tmp_path, de_qc=None): str(out_report), "--metadata", str(metadata), - "--alignment_stats_dir", - str(alignment_stats), "--cohort_dir", str(cohort), "--ref_summary", @@ -225,18 +219,12 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path): sqanti.mkdir() (sqanti / "OPTIONAL_FILE").touch() - alignment_stats = tmp_path / "alignment_stats" - alignment_stats.mkdir() - (alignment_stats / "OPTIONAL_FILE").touch() - out_report = tmp_path / "wf-transcriptomes-report.html" args = report.argparser().parse_args( [ str(out_report), "--metadata", str(metadata), - "--alignment_stats_dir", - str(alignment_stats), "--cohort_dir", str(cohort), "--ref_summary", @@ -417,8 +405,6 @@ def test_report_main_handles_degenerate_bambu_qc_and_read_summary( str(out_report), "--metadata", str(metadata), - "--alignment_stats_dir", - str(alignment_stats), "--stats", str(alignment_stats), "--cohort_dir", @@ -556,18 +542,12 @@ def test_report_main_uses_cohort_bambu_qc_for_multi_sample_inputs( sqanti.mkdir() (sqanti / "OPTIONAL_FILE").touch() - alignment_stats = tmp_path / "alignment_stats" - alignment_stats.mkdir() - (alignment_stats / "OPTIONAL_FILE").touch() - out_report = tmp_path / "wf-transcriptomes-report.html" args = report.argparser().parse_args( [ str(out_report), "--metadata", str(metadata), - "--alignment_stats_dir", - str(alignment_stats), "--cohort_dir", str(cohort), "--ref_summary", @@ -773,3 +753,64 @@ def test_report_main_tolerates_missing_statistical_fields(monkeypatch, tmp_path) ] assert method_tables assert "parametric" in method_tables[0].to_string() + + +def test_round_de_table_formats_supported_numeric_columns(): + """DE/DTU preview tables should round known numeric columns for display.""" + table = pd.DataFrame( + { + "GENEID": ["gene1"], + "baseMean": [123.4567], + "log2FoldChange": [0.00001234], + "lfcSE": [0.98765], + "stat": [-45.6789], + "pvalue": [0.00001234], + "padj": [0.123456], + "other": [7.89123], + } + ) + + rounded = report._round_de_table(table) + + assert rounded.loc[0, "baseMean"] == "123.457" + assert rounded.loc[0, "log2FoldChange"] == "1.234e-05" + assert rounded.loc[0, "lfcSE"] == "0.988" + assert rounded.loc[0, "stat"] == "-45.679" + assert rounded.loc[0, "pvalue"] == "1.234e-05" + assert rounded.loc[0, "padj"] == "0.123" + assert rounded.loc[0, "other"] == 7.89123 + + +def test_round_de_table_uses_scientific_notation_when_fixed_decimal_would_zero(): + """Tiny non-zero values should not display as 0.000 in DE/DTU tables.""" + table = pd.DataFrame( + { + "GENEID": ["gene1"], + "pvalue": [0.00012], + "padj": [0.00049], + } + ) + + rounded = report._round_de_table(table) + + assert rounded.loc[0, "pvalue"] == "1.200e-04" + assert rounded.loc[0, "padj"] == "4.900e-04" + + +def test_sorted_transcript_abundance_table_uses_sample_alias_columns(tmp_path): + """Transcript abundance sorting should use only real sample alias columns.""" + tx_counts_file = _write( + tmp_path / "transcript_counts.tsv", + ( + "TXNAME\tsampleA\tsampleB\tgene_length\tannotation_score\n" + "tx_low\t1\t1\t10000\t999\n" + "tx_high\t5\t5\t10\t1\n" + "tx_mid\t2\t2\t5000\t500\n" + ), + ) + + sorted_table = report._sorted_transcript_abundance_table( + tx_counts_file, ["sampleA", "sampleB"] + ) + + assert sorted_table["TXNAME"].tolist() == ["tx_high", "tx_mid", "tx_low"]