Report minor fixes [CW-7281]

This commit is contained in:
Neil Horner 2026-05-27 13:20:41 +00:00
parent 9cba80832d
commit 722d736f24
2 changed files with 119 additions and 42 deletions

View File

@ -112,6 +112,24 @@ def _format_ratio_value(value):
return f"{numeric:.2f}x" 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): def _format_classification_label(name):
"""Return canonical report label for a summary classification value.""" """Return canonical report label for a summary classification value."""
return str(name).strip().replace("-", "_").replace("_", " ").capitalize() return str(name).strip().replace("-", "_").replace("_", " ").capitalize()
@ -380,12 +398,38 @@ def _contrast_results(de_dir, filename, n=None):
data = table data = table
if n is not None: if n is not None:
data = data.head(n) data = data.head(n)
if "padj" in data.columns:
data.sort_values("padj", ascending=True, inplace=True) data.sort_values("padj", ascending=True, inplace=True)
tables[contrast_dir.name] = data tables[contrast_dir.name] = data
return tables 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): def _load_bambu_qc(bambu_dir):
"""Load bambu QC statistics JSON.""" """Load bambu QC statistics JSON."""
qc_file = Path(bambu_dir) / "bambu_qc_stats.json" qc_file = Path(bambu_dir) / "bambu_qc_stats.json"
@ -911,13 +955,17 @@ def main(args):
use_index=False, 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: if tx_counts is not None and not tx_counts.empty:
p( p(
"Top transcript rows from the " f"Top {args.de_table_size} most abundant transcripts"
f"{'sample' if is_single_sample else 'cohort'} abundance table."
) )
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: if not is_single_sample:
with report.add_section( with report.add_section(
@ -934,15 +982,6 @@ def main(args):
use_index=False, 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) sqanti_table = _sqanti_table(args.sqanti_dir)
if sqanti_table is not None and not sqanti_table.empty: if sqanti_table is not None and not sqanti_table.empty:
with report.add_section("SQANTI3 classification", "SQANTI3"): with report.add_section("SQANTI3 classification", "SQANTI3"):
@ -1296,7 +1335,8 @@ def main(args):
strong("Note: ") strong("Note: ")
raw(contrast_data["dtu_power_warning"]) raw(contrast_data["dtu_power_warning"])
DataTable.from_pandas( 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"): with div(cls="clustering-info"):
raw( raw(
@ -1373,7 +1413,8 @@ def main(args):
if contrast_name in dtu_tables: if contrast_name in dtu_tables:
dtu_table = dtu_tables[contrast_name] dtu_table = dtu_tables[contrast_name]
DataTable.from_pandas( 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"): with div(cls="clustering-info"):
raw( raw(
@ -1400,11 +1441,6 @@ def argparser():
parser.add_argument("report", help="Report output file.") parser.add_argument("report", help="Report output file.")
parser.add_argument("--metadata", required=True, help="Sample metadata JSON.") parser.add_argument("--metadata", required=True, help="Sample metadata JSON.")
parser.add_argument("--stats", nargs="+", help="Per-read stats paths.") 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( parser.add_argument(
"--cohort_dir", "--cohort_dir",
required=True, required=True,

View File

@ -3,6 +3,7 @@
import json import json
from pathlib import Path from pathlib import Path
import pandas as pd
from workflow_glue import report from workflow_glue import report
@ -110,11 +111,6 @@ def _build_report_args(tmp_path, de_qc=None):
mod_summaries.mkdir() mod_summaries.mkdir()
sqanti = tmp_path / "sqanti" sqanti = tmp_path / "sqanti"
sqanti.mkdir() 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 de_dir = None
if de_qc is not None: if de_qc is not None:
@ -143,8 +139,6 @@ def _build_report_args(tmp_path, de_qc=None):
str(out_report), str(out_report),
"--metadata", "--metadata",
str(metadata), str(metadata),
"--alignment_stats_dir",
str(alignment_stats),
"--cohort_dir", "--cohort_dir",
str(cohort), str(cohort),
"--ref_summary", "--ref_summary",
@ -225,18 +219,12 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path):
sqanti.mkdir() sqanti.mkdir()
(sqanti / "OPTIONAL_FILE").touch() (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" out_report = tmp_path / "wf-transcriptomes-report.html"
args = report.argparser().parse_args( args = report.argparser().parse_args(
[ [
str(out_report), str(out_report),
"--metadata", "--metadata",
str(metadata), str(metadata),
"--alignment_stats_dir",
str(alignment_stats),
"--cohort_dir", "--cohort_dir",
str(cohort), str(cohort),
"--ref_summary", "--ref_summary",
@ -417,8 +405,6 @@ def test_report_main_handles_degenerate_bambu_qc_and_read_summary(
str(out_report), str(out_report),
"--metadata", "--metadata",
str(metadata), str(metadata),
"--alignment_stats_dir",
str(alignment_stats),
"--stats", "--stats",
str(alignment_stats), str(alignment_stats),
"--cohort_dir", "--cohort_dir",
@ -556,18 +542,12 @@ def test_report_main_uses_cohort_bambu_qc_for_multi_sample_inputs(
sqanti.mkdir() sqanti.mkdir()
(sqanti / "OPTIONAL_FILE").touch() (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" out_report = tmp_path / "wf-transcriptomes-report.html"
args = report.argparser().parse_args( args = report.argparser().parse_args(
[ [
str(out_report), str(out_report),
"--metadata", "--metadata",
str(metadata), str(metadata),
"--alignment_stats_dir",
str(alignment_stats),
"--cohort_dir", "--cohort_dir",
str(cohort), str(cohort),
"--ref_summary", "--ref_summary",
@ -773,3 +753,64 @@ def test_report_main_tolerates_missing_statistical_fields(monkeypatch, tmp_path)
] ]
assert method_tables assert method_tables
assert "parametric" in method_tables[0].to_string() 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"]