Per-sample mod summary
This commit is contained in:
parent
74cbe196b2
commit
4bc3cf727f
@ -227,8 +227,8 @@ analysis, optional [`SQANTI3`](https://github.com/conesalab/SQANTI3) QC, and opt
|
|||||||
When aligned BAMs contain modified base tags (`MM` and `ML`), the workflow also
|
When aligned BAMs contain modified base tags (`MM` and `ML`), the workflow also
|
||||||
runs `modkit` on each sample alignment. It first checks which modified base
|
runs `modkit` on each sample alignment. It first checks which modified base
|
||||||
codes are present in the BAM, then runs `modkit pileup` to produce a per-sample
|
codes are present in the BAM, then runs `modkit pileup` to produce a per-sample
|
||||||
bedMethyl file and one bigWig track per requested or inferred modification
|
bedMethyl file, a simple per-sample summary table, and one bigWig track per
|
||||||
under `samples/<alias>/mods/`.
|
requested or inferred modification under `samples/<alias>/mods/`.
|
||||||
|
|
||||||
If `--mod_codes` is set, those codes are passed directly to `modkit pileup`.
|
If `--mod_codes` is set, those codes are passed directly to `modkit pileup`.
|
||||||
If it is omitted, the workflow infers the available `primary_base:mod_code`
|
If it is omitted, the workflow infers the available `primary_base:mod_code`
|
||||||
@ -378,6 +378,7 @@ Output files may be aggregated including information for all samples or provided
|
|||||||
| Aligned BAM index | samples/{{ alias }}/alignment/reads.bam.bai | Index for the aligned BAM. | per-sample |
|
| Aligned BAM index | samples/{{ alias }}/alignment/reads.bam.bai | Index for the aligned BAM. | per-sample |
|
||||||
| Alignment summary | samples/{{ alias }}/alignment/bamstats.flagstat.tsv | bamstats flagstat summary for the aligned BAM. | per-sample |
|
| Alignment summary | samples/{{ alias }}/alignment/bamstats.flagstat.tsv | bamstats flagstat summary for the aligned BAM. | per-sample |
|
||||||
| Modified base pileup | samples/{{ alias }}/mods/{{ alias }}.mods.bedmethyl.gz | Per-sample modkit bedMethyl pileup generated from the aligned BAM when MM and ML tags are present. | per-sample |
|
| Modified base pileup | samples/{{ alias }}/mods/{{ alias }}.mods.bedmethyl.gz | Per-sample modkit bedMethyl pileup generated from the aligned BAM when MM and ML tags are present. | per-sample |
|
||||||
|
| Modified base summary | samples/{{ alias }}/mods/{{ alias }}.mods.summary.tsv | Per-sample global modification-percent summary aggregated from the modkit bedMethyl pileup, with one row per modification code. | per-sample |
|
||||||
| Modified base bigWig | samples/{{ alias }}/mods/{{ alias }}.mods.*.bw | Per-sample modkit bigWig tracks generated from the aligned BAM, with one file per requested or inferred modification code. | per-sample |
|
| Modified base bigWig | samples/{{ alias }}/mods/{{ alias }}.mods.*.bw | Per-sample modkit bigWig tracks generated from the aligned BAM, with one file per requested or inferred modification code. | per-sample |
|
||||||
| Reference and annotation preparation summary | cohort/reference/annotation_reference_summary.json | Summary of reference and annotation preparation, including seqname overlap, build/provider hints, and excluded unstranded annotation counts. | aggregated |
|
| Reference and annotation preparation summary | cohort/reference/annotation_reference_summary.json | Summary of reference and annotation preparation, including seqname overlap, build/provider hints, and excluded unstranded annotation counts. | aggregated |
|
||||||
| Excluded unstranded annotation records | cohort/reference/unstranded_annotation.gtf | Full set of annotation records excluded because their strand was not '+' or '-'. Present only when unstranded records are found. | aggregated |
|
| Excluded unstranded annotation records | cohort/reference/unstranded_annotation.gtf | Full set of annotation records excluded because their strand was not '+' or '-'. Present only when unstranded records are found. | aggregated |
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Create workflow report for wf-transcriptomes."""
|
"""Create workflow report for wf-transcriptomes."""
|
||||||
|
|
||||||
|
from html import escape
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@ -23,6 +24,8 @@ from .hierarchical_clustering import hierarchical, clustering_info # noqa: ABS
|
|||||||
from .util import get_named_logger, wf_parser # noqa: ABS101
|
from .util import get_named_logger, wf_parser # noqa: ABS101
|
||||||
from .volcano import volcano # noqa: ABS101
|
from .volcano import volcano # noqa: ABS101
|
||||||
|
|
||||||
|
logger = get_named_logger("Report")
|
||||||
|
|
||||||
|
|
||||||
# Suppress asyncio deprecation warning triggered by dominate on Python 3.10+.
|
# Suppress asyncio deprecation warning triggered by dominate on Python 3.10+.
|
||||||
# dominate calls asyncio.get_event_loop() outside a running async context.
|
# dominate calls asyncio.get_event_loop() outside a running async context.
|
||||||
@ -203,6 +206,168 @@ def _sqanti_table(sqanti_dir):
|
|||||||
return sqanti_df.sort_values(["_is_cohort", "Sample"]).drop(columns="_is_cohort")
|
return sqanti_df.sort_values(["_is_cohort", "Sample"]).drop(columns="_is_cohort")
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_mod_summaries(summary_dir):
|
||||||
|
"""Return a combined modified base summary table across all samples."""
|
||||||
|
if not summary_dir:
|
||||||
|
return None
|
||||||
|
summaries_path = Path(summary_dir)
|
||||||
|
if not summaries_path.exists() or not summaries_path.is_dir():
|
||||||
|
return None
|
||||||
|
summaries = []
|
||||||
|
for summary_file in sorted(summaries_path.glob("*.mods.summary.tsv")):
|
||||||
|
if not summary_file.is_file():
|
||||||
|
continue
|
||||||
|
summary = _read_table(summary_file)
|
||||||
|
if summary is None or summary.empty:
|
||||||
|
continue
|
||||||
|
if "sample" not in summary.columns:
|
||||||
|
raise ValueError(
|
||||||
|
f"Modified base summary is missing required 'sample' column: "
|
||||||
|
f"{summary_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sample_names = summary["sample"].unique()
|
||||||
|
if len(sample_names) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Modified base summary must contain exactly one sample name: "
|
||||||
|
f"{summary_file}"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary["mod"] = summary["mod_label"].where(
|
||||||
|
summary["mod_label"] != "",
|
||||||
|
summary["full_mod_code"],
|
||||||
|
)
|
||||||
|
summary = summary[
|
||||||
|
[
|
||||||
|
"sample",
|
||||||
|
"mod",
|
||||||
|
"full_mod_code",
|
||||||
|
"modification_percent",
|
||||||
|
"modified_calls",
|
||||||
|
"valid_coverage",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
summaries.append(summary)
|
||||||
|
|
||||||
|
if not summaries:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary = pd.concat(summaries, ignore_index=True)
|
||||||
|
return summary.sort_values(["mod", "sample"]).reset_index(drop=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mod_summary_matrix(summary):
|
||||||
|
"""Render modified base summaries as a cross-sample comparison matrix."""
|
||||||
|
if summary.duplicated(["sample", "mod"]).any():
|
||||||
|
raise ValueError(
|
||||||
|
"Modified base summary contains duplicate sample/mod combinations."
|
||||||
|
)
|
||||||
|
|
||||||
|
samples = sorted(summary["sample"].astype(str).unique())
|
||||||
|
mods = sorted(summary["mod"].astype(str).unique())
|
||||||
|
lookup = {
|
||||||
|
(str(row.sample), str(row.mod)): row
|
||||||
|
for row in summary.itertuples(index=False)
|
||||||
|
}
|
||||||
|
|
||||||
|
header_html = "".join(
|
||||||
|
f"<th>{escape(mod)}</th>" for mod in mods
|
||||||
|
)
|
||||||
|
body_rows = []
|
||||||
|
for sample in samples:
|
||||||
|
cells = []
|
||||||
|
for mod in mods:
|
||||||
|
row = lookup.get((sample, mod))
|
||||||
|
if row is None:
|
||||||
|
cells.append(
|
||||||
|
"<td class='mod-summary-cell mod-summary-empty'>"
|
||||||
|
"<div class='mod-summary-empty-mark'>—</div>"
|
||||||
|
"</td>"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cells.append(
|
||||||
|
"<td class='mod-summary-cell'>"
|
||||||
|
f"<div class='mod-summary-percent'>"
|
||||||
|
f"{_format_mod_summary_percent(row.modification_percent)}%"
|
||||||
|
f"</div>"
|
||||||
|
f"<div class='mod-summary-meta'>"
|
||||||
|
f"{_format_mod_summary_count(row.modified_calls)} modified"
|
||||||
|
f"</div>"
|
||||||
|
f"<div class='mod-summary-meta'>"
|
||||||
|
f"{_format_mod_summary_count(row.valid_coverage)} valid"
|
||||||
|
f"</div>"
|
||||||
|
"</td>"
|
||||||
|
)
|
||||||
|
body_rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<th class='mod-summary-sample'>{escape(sample)}</th>"
|
||||||
|
+ "".join(cells)
|
||||||
|
+ "</tr>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"<div class='mod-summary-matrix-wrap'>"
|
||||||
|
"<table class='mod-summary-matrix'>"
|
||||||
|
"<thead><tr><th>Sample</th>"
|
||||||
|
f"{header_html}</tr></thead>"
|
||||||
|
f"<tbody>{''.join(body_rows)}</tbody>"
|
||||||
|
"</table>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mod_summary_percent(value):
|
||||||
|
"""Format a modified base percentage for display."""
|
||||||
|
return f"{float(value):.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mod_summary_count(value):
|
||||||
|
"""Format a modified base count for display."""
|
||||||
|
return format(int(round(float(value))), ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _mod_summary_matrix_style():
|
||||||
|
"""Return CSS for the modified base summary comparison matrix."""
|
||||||
|
return """
|
||||||
|
.mod-summary-matrix-wrap { overflow-x: auto; margin-bottom: 0.75rem; }
|
||||||
|
.mod-summary-matrix { width: 100%; border-collapse: collapse; }
|
||||||
|
.mod-summary-matrix th,
|
||||||
|
.mod-summary-matrix td {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.mod-summary-matrix thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mod-summary-sample {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.mod-summary-percent {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.mod-summary-meta {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mod-summary-empty {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.mod-summary-empty-mark {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _contrast_results(de_dir, filename, n=None):
|
def _contrast_results(de_dir, filename, n=None):
|
||||||
"""Return a dict of per-contrast result DataFrames read from filename."""
|
"""Return a dict of per-contrast result DataFrames read from filename."""
|
||||||
tables = {}
|
tables = {}
|
||||||
@ -490,6 +655,23 @@ def main(args):
|
|||||||
.rename(columns={"index": "Field"}),
|
.rename(columns={"index": "Field"}),
|
||||||
use_index=False,
|
use_index=False,
|
||||||
)
|
)
|
||||||
|
mod_summaries = _sample_mod_summaries(args.mod_summary_dir)
|
||||||
|
if mod_summaries is not None and not mod_summaries.empty:
|
||||||
|
with report.add_section("Modified base summaries", "Modifications"):
|
||||||
|
dom_style(raw(_mod_summary_matrix_style()))
|
||||||
|
raw(_render_mod_summary_matrix(mod_summaries))
|
||||||
|
small(raw(
|
||||||
|
"<b>Valid coverage</b> Sum of the valid residues: all the "
|
||||||
|
"modified, canonical and other mod (where the modification "
|
||||||
|
"is different from the listed base) bedMethyl columns "
|
||||||
|
"counts for this combination of sample and mod. "
|
||||||
|
"<b>Modified calls</b> Number of calls passing filters "
|
||||||
|
"that were classified as a residue with a specified base "
|
||||||
|
"modification. "
|
||||||
|
"<b>Modification percent</b> (Modified calls / Valid "
|
||||||
|
"coverage) * 100"
|
||||||
|
))
|
||||||
|
|
||||||
annotation_reference_summary = _load_annotation_reference_summary(args.ref_summary)
|
annotation_reference_summary = _load_annotation_reference_summary(args.ref_summary)
|
||||||
|
|
||||||
if annotation_reference_summary:
|
if annotation_reference_summary:
|
||||||
@ -1233,6 +1415,11 @@ def argparser():
|
|||||||
required=True,
|
required=True,
|
||||||
help="Per-sample output directory.",
|
help="Per-sample output directory.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mod_summary_dir",
|
||||||
|
default=None,
|
||||||
|
help="Modified base summary directory.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--sqanti_dir",
|
"--sqanti_dir",
|
||||||
required=True,
|
required=True,
|
||||||
|
|||||||
145
bin/workflow_glue/summarise_modkit_bedmethyl.py
Normal file
145
bin/workflow_glue/summarise_modkit_bedmethyl.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Summarise per-sample modkit bedMethyl outputs."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
from .util import wf_parser # noqa: ABS101
|
||||||
|
|
||||||
|
|
||||||
|
SUMMARY_FIELDS = (
|
||||||
|
"sample",
|
||||||
|
"full_mod_code",
|
||||||
|
"mod_code",
|
||||||
|
"mod_label",
|
||||||
|
"valid_coverage",
|
||||||
|
"modified_calls",
|
||||||
|
"canonical_calls",
|
||||||
|
"other_calls",
|
||||||
|
"delete_calls",
|
||||||
|
"fail_calls",
|
||||||
|
"diff_calls",
|
||||||
|
"nocall_calls",
|
||||||
|
"modification_percent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_mod_code_labels(label_tsv):
|
||||||
|
"""Load friendly labels keyed by full mod code."""
|
||||||
|
labels = {}
|
||||||
|
with open(label_tsv, newline="", encoding="utf-8") as handle:
|
||||||
|
reader = csv.DictReader(handle, delimiter="\t")
|
||||||
|
for row in reader:
|
||||||
|
mod_code = row.get("mod_code", "").strip()
|
||||||
|
label = row.get("label", "").strip()
|
||||||
|
if mod_code and label:
|
||||||
|
labels[mod_code] = label
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def open_text_maybe_gzip(path):
|
||||||
|
"""Open plain text or gzipped text files transparently."""
|
||||||
|
if str(path).endswith(".gz"):
|
||||||
|
return gzip.open(path, "rt", encoding="utf-8")
|
||||||
|
return open(path, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def build_full_code_lookup(mod_codes):
|
||||||
|
"""Map stripped mod codes back to their full primary_base:mod_code form."""
|
||||||
|
lookup = {}
|
||||||
|
for full_code in mod_codes.split(","):
|
||||||
|
full_code = full_code.strip()
|
||||||
|
if not full_code:
|
||||||
|
continue
|
||||||
|
if ":" in full_code:
|
||||||
|
_, mod_code = full_code.split(":", 1)
|
||||||
|
else:
|
||||||
|
mod_code = full_code
|
||||||
|
lookup[mod_code] = full_code
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def summarise_bedmethyl(bedmethyl, sample, mod_codes, labels):
|
||||||
|
"""Aggregate a bedMethyl file into one row per modification code."""
|
||||||
|
full_code_lookup = build_full_code_lookup(mod_codes)
|
||||||
|
counts = defaultdict(
|
||||||
|
lambda: {
|
||||||
|
"valid_coverage": 0,
|
||||||
|
"modified_calls": 0,
|
||||||
|
"canonical_calls": 0,
|
||||||
|
"other_calls": 0,
|
||||||
|
"delete_calls": 0,
|
||||||
|
"fail_calls": 0,
|
||||||
|
"diff_calls": 0,
|
||||||
|
"nocall_calls": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO would be nice to tidy this up as an ezcharts component
|
||||||
|
with open_text_maybe_gzip(bedmethyl) as handle:
|
||||||
|
for line in handle:
|
||||||
|
if not line.strip() or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
fields = line.rstrip("\n").split("\t")
|
||||||
|
mod_code = fields[3]
|
||||||
|
stats = counts[mod_code]
|
||||||
|
stats["valid_coverage"] += int(fields[9])
|
||||||
|
stats["modified_calls"] += int(fields[11])
|
||||||
|
stats["canonical_calls"] += int(fields[12])
|
||||||
|
stats["other_calls"] += int(fields[13])
|
||||||
|
stats["delete_calls"] += int(fields[14])
|
||||||
|
stats["fail_calls"] += int(fields[15])
|
||||||
|
stats["diff_calls"] += int(fields[16])
|
||||||
|
stats["nocall_calls"] += int(fields[17])
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for mod_code, stats in sorted(counts.items()):
|
||||||
|
full_mod_code = full_code_lookup.get(mod_code, mod_code)
|
||||||
|
valid_coverage = stats["valid_coverage"]
|
||||||
|
modification_percent = (
|
||||||
|
(stats["modified_calls"] / valid_coverage) * 100
|
||||||
|
if valid_coverage else 0.0
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"sample": sample,
|
||||||
|
"full_mod_code": full_mod_code,
|
||||||
|
"mod_code": mod_code,
|
||||||
|
"mod_label": labels.get(full_mod_code, mod_code),
|
||||||
|
**stats,
|
||||||
|
"modification_percent": f"{modification_percent:.2f}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def write_summary(rows, output):
|
||||||
|
"""Write summary rows to a TSV file."""
|
||||||
|
with open(output, "w", newline="", encoding="utf-8") as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=SUMMARY_FIELDS, delimiter="\t")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
"""Run the entry point."""
|
||||||
|
labels = load_mod_code_labels(args.mod_code_labels)
|
||||||
|
rows = summarise_bedmethyl(
|
||||||
|
args.bedmethyl,
|
||||||
|
args.sample,
|
||||||
|
args.mod_codes,
|
||||||
|
labels,
|
||||||
|
)
|
||||||
|
write_summary(rows, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
def argparser():
|
||||||
|
"""Argument parser for entrypoint."""
|
||||||
|
parser = wf_parser("summarise_modkit_bedmethyl")
|
||||||
|
parser.add_argument("bedmethyl", help="Input modkit bedMethyl file")
|
||||||
|
parser.add_argument("sample", help="Sample alias")
|
||||||
|
parser.add_argument("mod_codes", help="Comma-separated full modification codes")
|
||||||
|
parser.add_argument("mod_code_labels", help="TSV mapping full mod codes to labels")
|
||||||
|
parser.add_argument("output", help="Output TSV")
|
||||||
|
return parser
|
||||||
@ -106,6 +106,8 @@ def _build_report_args(tmp_path, de_qc=None):
|
|||||||
|
|
||||||
samples = tmp_path / "samples"
|
samples = tmp_path / "samples"
|
||||||
samples.mkdir()
|
samples.mkdir()
|
||||||
|
mod_summaries = tmp_path / "mod_summaries"
|
||||||
|
mod_summaries.mkdir()
|
||||||
sqanti = tmp_path / "sqanti"
|
sqanti = tmp_path / "sqanti"
|
||||||
sqanti.mkdir()
|
sqanti.mkdir()
|
||||||
alignment_stats = tmp_path / "alignment_stats"
|
alignment_stats = tmp_path / "alignment_stats"
|
||||||
@ -149,6 +151,8 @@ def _build_report_args(tmp_path, de_qc=None):
|
|||||||
str(reference / "annotation_reference_summary.json"),
|
str(reference / "annotation_reference_summary.json"),
|
||||||
"--samples_dir",
|
"--samples_dir",
|
||||||
str(samples),
|
str(samples),
|
||||||
|
"--mod_summary_dir",
|
||||||
|
str(mod_summaries),
|
||||||
"--sqanti_dir",
|
"--sqanti_dir",
|
||||||
str(sqanti),
|
str(sqanti),
|
||||||
"--versions",
|
"--versions",
|
||||||
@ -213,6 +217,10 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path):
|
|||||||
samples.mkdir()
|
samples.mkdir()
|
||||||
(samples / "OPTIONAL_FILE").touch()
|
(samples / "OPTIONAL_FILE").touch()
|
||||||
|
|
||||||
|
mod_summaries = tmp_path / "mod_summaries"
|
||||||
|
mod_summaries.mkdir()
|
||||||
|
(mod_summaries / "OPTIONAL_FILE").touch()
|
||||||
|
|
||||||
sqanti = tmp_path / "sqanti"
|
sqanti = tmp_path / "sqanti"
|
||||||
sqanti.mkdir()
|
sqanti.mkdir()
|
||||||
(sqanti / "OPTIONAL_FILE").touch()
|
(sqanti / "OPTIONAL_FILE").touch()
|
||||||
@ -235,6 +243,8 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path):
|
|||||||
str(cohort / "reference" / "annotation_reference_summary.json"),
|
str(cohort / "reference" / "annotation_reference_summary.json"),
|
||||||
"--samples_dir",
|
"--samples_dir",
|
||||||
str(samples),
|
str(samples),
|
||||||
|
"--mod_summary_dir",
|
||||||
|
str(mod_summaries),
|
||||||
"--sqanti_dir",
|
"--sqanti_dir",
|
||||||
str(sqanti),
|
str(sqanti),
|
||||||
"--versions",
|
"--versions",
|
||||||
@ -254,6 +264,64 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path):
|
|||||||
assert any("GRCh38" in table.to_string() for table in tables)
|
assert any("GRCh38" in table.to_string() for table in tables)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_main_renders_modified_base_summary_tables(monkeypatch, tmp_path):
|
||||||
|
"""Per-sample modified base summaries should render as report tables."""
|
||||||
|
tables = []
|
||||||
|
raw_calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(report.labs, "LabsReport", _FakeReport)
|
||||||
|
monkeypatch.setattr(report, "Tabs", _FakeTabs)
|
||||||
|
monkeypatch.setattr(report, "p", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(report, "pre", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(report, "raw", lambda value: raw_calls.append(value) or value)
|
||||||
|
monkeypatch.setattr(report, "_create_warning_banner", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(report.fastcat, "SeqSummary", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
report.DataTable,
|
||||||
|
"from_pandas",
|
||||||
|
staticmethod(lambda table, *args, **kwargs: tables.append(table.copy())),
|
||||||
|
)
|
||||||
|
|
||||||
|
args, out_report = _build_report_args(tmp_path)
|
||||||
|
mod_summaries = tmp_path / "mod_summaries"
|
||||||
|
_write(
|
||||||
|
mod_summaries / "not_the_sample_name.mods.summary.tsv",
|
||||||
|
(
|
||||||
|
"sample\tfull_mod_code\tmod_code\tmod_label\tvalid_coverage\t"
|
||||||
|
"modified_calls\tcanonical_calls\tother_calls\tdelete_calls\t"
|
||||||
|
"fail_calls\tdiff_calls\tnocall_calls\tmodification_percent\n"
|
||||||
|
"sampleA\tA:a\ta\tm6A\t12\t6\t6\t0\t0\t3\t0\t0\t50.00\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_write(
|
||||||
|
mod_summaries / "sampleB.mods.summary.tsv",
|
||||||
|
(
|
||||||
|
"sample\tfull_mod_code\tmod_code\tmod_label\tvalid_coverage\t"
|
||||||
|
"modified_calls\tcanonical_calls\tother_calls\tdelete_calls\t"
|
||||||
|
"fail_calls\tdiff_calls\tnocall_calls\tmodification_percent\n"
|
||||||
|
"sampleB\tC:m\tm\tm5C\t20\t5\t15\t0\t0\t0\t0\t0\t25.00\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
report.main(args)
|
||||||
|
|
||||||
|
assert out_report.exists()
|
||||||
|
matrix_html = next(
|
||||||
|
value for value in raw_calls
|
||||||
|
if "<table class='mod-summary-matrix'>" in value
|
||||||
|
)
|
||||||
|
assert "sampleA" in matrix_html
|
||||||
|
assert "sampleB" in matrix_html
|
||||||
|
assert "m6A" in matrix_html
|
||||||
|
assert "m5C" in matrix_html
|
||||||
|
assert "50.00%" in matrix_html
|
||||||
|
assert "25.00%" in matrix_html
|
||||||
|
assert "6 modified" in matrix_html
|
||||||
|
assert "12 valid" in matrix_html
|
||||||
|
assert "5 modified" in matrix_html
|
||||||
|
assert "20 valid" in matrix_html
|
||||||
|
|
||||||
|
|
||||||
def test_report_main_handles_degenerate_bambu_qc_and_read_summary(
|
def test_report_main_handles_degenerate_bambu_qc_and_read_summary(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
tmp_path,
|
tmp_path,
|
||||||
|
|||||||
@ -0,0 +1,102 @@
|
|||||||
|
"""Tests for modkit bedMethyl summarisation."""
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
from workflow_glue import summarise_modkit_bedmethyl
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path, text):
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_bedmethyl_aggregates_per_mod_code(tmp_path):
|
||||||
|
"""Rows should be summed into one row per modification code."""
|
||||||
|
label_tsv = _write(
|
||||||
|
tmp_path / "mod_code_labels.tsv",
|
||||||
|
"mod_code\tlabel\nA:a\tm6A\nT:17802\tpseU\n",
|
||||||
|
)
|
||||||
|
bedmethyl = _write(
|
||||||
|
tmp_path / "sample.mods.bedmethyl",
|
||||||
|
(
|
||||||
|
"chr1\t10\t11\ta\t1\t+\t10\t11\t255,0,0\t5\t40.00\t2\t3\t0\t0\t1\t0\t0\n"
|
||||||
|
"chr1\t11\t12\ta\t1\t+\t11\t12\t255,0,0\t7\t57.14\t4\t3\t0\t0\t2\t0\t0\n"
|
||||||
|
"chr1\t20\t21\t17802\t1\t+\t20\t21\t255,0,0\t4\t25.00\t1\t3\t0\t0\t0\t0\t"
|
||||||
|
"0\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = summarise_modkit_bedmethyl.summarise_bedmethyl(
|
||||||
|
bedmethyl,
|
||||||
|
"sample1",
|
||||||
|
"A:a,T:17802",
|
||||||
|
summarise_modkit_bedmethyl.load_mod_code_labels(label_tsv),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
{
|
||||||
|
"sample": "sample1",
|
||||||
|
"full_mod_code": "T:17802",
|
||||||
|
"mod_code": "17802",
|
||||||
|
"mod_label": "pseU",
|
||||||
|
"valid_coverage": 4,
|
||||||
|
"modified_calls": 1,
|
||||||
|
"canonical_calls": 3,
|
||||||
|
"other_calls": 0,
|
||||||
|
"delete_calls": 0,
|
||||||
|
"fail_calls": 0,
|
||||||
|
"diff_calls": 0,
|
||||||
|
"nocall_calls": 0,
|
||||||
|
"modification_percent": "25.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sample": "sample1",
|
||||||
|
"full_mod_code": "A:a",
|
||||||
|
"mod_code": "a",
|
||||||
|
"mod_label": "m6A",
|
||||||
|
"valid_coverage": 12,
|
||||||
|
"modified_calls": 6,
|
||||||
|
"canonical_calls": 6,
|
||||||
|
"other_calls": 0,
|
||||||
|
"delete_calls": 0,
|
||||||
|
"fail_calls": 3,
|
||||||
|
"diff_calls": 0,
|
||||||
|
"nocall_calls": 0,
|
||||||
|
"modification_percent": "50.00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_bedmethyl_reads_gzipped_input(tmp_path):
|
||||||
|
"""The helper should aggregate multiple rows from gzipped bedMethyl input."""
|
||||||
|
bedmethyl = tmp_path / "sample.mods.bedmethyl.gz"
|
||||||
|
with gzip.open(bedmethyl, "wt", encoding="utf-8") as handle:
|
||||||
|
handle.write(
|
||||||
|
"chr1\t10\t11\ta\t1\t+\t10\t11\t255,0,0\t5\t40.00\t2\t3\t0\t0\t1\t0\t0\n"
|
||||||
|
"chr1\t11\t12\ta\t1\t+\t11\t12\t255,0,0\t7\t57.14\t4\t3\t0\t0\t2\t0\t0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = summarise_modkit_bedmethyl.summarise_bedmethyl(
|
||||||
|
bedmethyl,
|
||||||
|
"sample1",
|
||||||
|
"A:a",
|
||||||
|
{"A:a": "m6A"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rows == [
|
||||||
|
{
|
||||||
|
"sample": "sample1",
|
||||||
|
"full_mod_code": "A:a",
|
||||||
|
"mod_code": "a",
|
||||||
|
"mod_label": "m6A",
|
||||||
|
"valid_coverage": 12,
|
||||||
|
"modified_calls": 6,
|
||||||
|
"canonical_calls": 6,
|
||||||
|
"other_calls": 0,
|
||||||
|
"delete_calls": 0,
|
||||||
|
"fail_calls": 3,
|
||||||
|
"diff_calls": 0,
|
||||||
|
"nocall_calls": 0,
|
||||||
|
"modification_percent": "50.00",
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -72,8 +72,8 @@ analysis, optional [`SQANTI3`](https://github.com/conesalab/SQANTI3) QC, and opt
|
|||||||
When aligned BAMs contain modified base tags (`MM` and `ML`), the workflow also
|
When aligned BAMs contain modified base tags (`MM` and `ML`), the workflow also
|
||||||
runs `modkit` on each sample alignment. It first checks which modified base
|
runs `modkit` on each sample alignment. It first checks which modified base
|
||||||
codes are present in the BAM, then runs `modkit pileup` to produce a per-sample
|
codes are present in the BAM, then runs `modkit pileup` to produce a per-sample
|
||||||
bedMethyl file and one bigWig track per requested or inferred modification
|
bedMethyl file, a simple per-sample summary table, and one bigWig track per
|
||||||
under `samples/<alias>/mods/`.
|
requested or inferred modification under `samples/<alias>/mods/`.
|
||||||
|
|
||||||
If `--mod_codes` is set, those codes are passed directly to `modkit pileup`.
|
If `--mod_codes` is set, those codes are passed directly to `modkit pileup`.
|
||||||
If it is omitted, the workflow infers the available `primary_base:mod_code`
|
If it is omitted, the workflow infers the available `primary_base:mod_code`
|
||||||
|
|||||||
@ -7,6 +7,7 @@ Output files may be aggregated including information for all samples or provided
|
|||||||
| Aligned BAM index | samples/{{ alias }}/alignment/reads.bam.bai | Index for the aligned BAM. | per-sample |
|
| Aligned BAM index | samples/{{ alias }}/alignment/reads.bam.bai | Index for the aligned BAM. | per-sample |
|
||||||
| Alignment summary | samples/{{ alias }}/alignment/bamstats.flagstat.tsv | bamstats flagstat summary for the aligned BAM. | per-sample |
|
| Alignment summary | samples/{{ alias }}/alignment/bamstats.flagstat.tsv | bamstats flagstat summary for the aligned BAM. | per-sample |
|
||||||
| Modified base pileup | samples/{{ alias }}/mods/{{ alias }}.mods.bedmethyl.gz | Per-sample modkit bedMethyl pileup generated from the aligned BAM when MM and ML tags are present. | per-sample |
|
| Modified base pileup | samples/{{ alias }}/mods/{{ alias }}.mods.bedmethyl.gz | Per-sample modkit bedMethyl pileup generated from the aligned BAM when MM and ML tags are present. | per-sample |
|
||||||
|
| Modified base summary | samples/{{ alias }}/mods/{{ alias }}.mods.summary.tsv | Per-sample global modification-percent summary aggregated from the modkit bedMethyl pileup, with one row per modification code. | per-sample |
|
||||||
| Modified base bigWig | samples/{{ alias }}/mods/{{ alias }}.mods.*.bw | Per-sample modkit bigWig tracks generated from the aligned BAM, with one file per requested or inferred modification code. | per-sample |
|
| Modified base bigWig | samples/{{ alias }}/mods/{{ alias }}.mods.*.bw | Per-sample modkit bigWig tracks generated from the aligned BAM, with one file per requested or inferred modification code. | per-sample |
|
||||||
| Reference and annotation preparation summary | cohort/reference/annotation_reference_summary.json | Summary of reference and annotation preparation, including seqname overlap, build/provider hints, and excluded unstranded annotation counts. | aggregated |
|
| Reference and annotation preparation summary | cohort/reference/annotation_reference_summary.json | Summary of reference and annotation preparation, including seqname overlap, build/provider hints, and excluded unstranded annotation counts. | aggregated |
|
||||||
| Excluded unstranded annotation records | cohort/reference/unstranded_annotation.gtf | Full set of annotation records excluded because their strand was not '+' or '-'. Present only when unstranded records are found. | aggregated |
|
| Excluded unstranded annotation records | cohort/reference/unstranded_annotation.gtf | Full set of annotation records excluded because their strand was not '+' or '-'. Present only when unstranded records are found. | aggregated |
|
||||||
|
|||||||
8
main.nf
8
main.nf
@ -43,6 +43,7 @@ process makeReport {
|
|||||||
path "params.json"
|
path "params.json"
|
||||||
path cohort_dir, stageAs: "cohort"
|
path cohort_dir, stageAs: "cohort"
|
||||||
path sample_dirs, stageAs: "samples/*"
|
path sample_dirs, stageAs: "samples/*"
|
||||||
|
path mod_summary_files, stageAs: "mod_summaries/*"
|
||||||
path sqanti_dirs, stageAs: "sqanti/*"
|
path sqanti_dirs, stageAs: "sqanti/*"
|
||||||
path de_files
|
path de_files
|
||||||
path "annotation_reference_summary.tsv"
|
path "annotation_reference_summary.tsv"
|
||||||
@ -62,6 +63,7 @@ process makeReport {
|
|||||||
${stats_args} \
|
${stats_args} \
|
||||||
--cohort_dir cohort \
|
--cohort_dir cohort \
|
||||||
--samples_dir samples \
|
--samples_dir samples \
|
||||||
|
--mod_summary_dir mod_summaries \
|
||||||
--sqanti_dir sqanti \
|
--sqanti_dir sqanti \
|
||||||
${de_args} \
|
${de_args} \
|
||||||
--versions versions \
|
--versions versions \
|
||||||
@ -132,6 +134,11 @@ workflow wf {
|
|||||||
.map { meta, sample_dir -> sample_dir }
|
.map { meta, sample_dir -> sample_dir }
|
||||||
.collect()
|
.collect()
|
||||||
|
|
||||||
|
mod_summaries_for_report = mod_results.summary
|
||||||
|
.map { alias, summary -> summary }
|
||||||
|
.ifEmpty(OPTIONAL_FILE)
|
||||||
|
.collect()
|
||||||
|
|
||||||
sqanti_dirs_for_report = transcriptome_results.joint_sqanti_dir
|
sqanti_dirs_for_report = transcriptome_results.joint_sqanti_dir
|
||||||
.concat(transcriptome_results.sample_sqanti_dirs.map { meta, sqanti_dir -> sqanti_dir })
|
.concat(transcriptome_results.sample_sqanti_dirs.map { meta, sqanti_dir -> sqanti_dir })
|
||||||
.ifEmpty(OPTIONAL_FILE)
|
.ifEmpty(OPTIONAL_FILE)
|
||||||
@ -155,6 +162,7 @@ workflow wf {
|
|||||||
workflow_params,
|
workflow_params,
|
||||||
transcriptome_results.joint_dir.ifEmpty(OPTIONAL_FILE),
|
transcriptome_results.joint_dir.ifEmpty(OPTIONAL_FILE),
|
||||||
sample_dirs_for_report,
|
sample_dirs_for_report,
|
||||||
|
mod_summaries_for_report,
|
||||||
sqanti_dirs_for_report,
|
sqanti_dirs_for_report,
|
||||||
de_dir,
|
de_dir,
|
||||||
transcriptome_results.annotation_reference_summary,
|
transcriptome_results.annotation_reference_summary,
|
||||||
|
|||||||
@ -40,6 +40,14 @@
|
|||||||
"optional": true,
|
"optional": true,
|
||||||
"type": "per-sample"
|
"type": "per-sample"
|
||||||
},
|
},
|
||||||
|
"sample-mod-summary": {
|
||||||
|
"filepath": "samples/{{ alias }}/mods/{{ alias }}.mods.summary.tsv",
|
||||||
|
"title": "Modified base summary",
|
||||||
|
"description": "Per-sample global modification-percent summary aggregated from the modkit bedMethyl pileup, with one row per modification code.",
|
||||||
|
"mime-type": "text/tab-separated-values",
|
||||||
|
"optional": true,
|
||||||
|
"type": "per-sample"
|
||||||
|
},
|
||||||
"sample-mod-bigwig": {
|
"sample-mod-bigwig": {
|
||||||
"filepath": "samples/{{ alias }}/mods/{{ alias }}.mods.*.bw",
|
"filepath": "samples/{{ alias }}/mods/{{ alias }}.mods.*.bw",
|
||||||
"title": "Modified base bigWig",
|
"title": "Modified base bigWig",
|
||||||
|
|||||||
@ -99,6 +99,33 @@ process modkit_tobigwig {
|
|||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process summariseModkitBedmethyl {
|
||||||
|
label "wf_common"
|
||||||
|
cpus 1
|
||||||
|
memory "2 GB"
|
||||||
|
input:
|
||||||
|
tuple val(alias),
|
||||||
|
path(bedmethyl),
|
||||||
|
val(mod_codes)
|
||||||
|
path mod_code_labels
|
||||||
|
output:
|
||||||
|
tuple val(alias),
|
||||||
|
path("${alias}.mods.summary.tsv"),
|
||||||
|
emit: summary
|
||||||
|
publishDir "${params.out_dir}/${output_key}/mods"
|
||||||
|
|
||||||
|
script:
|
||||||
|
output_key = alias == "cohort" ? "cohort" : "samples/${alias}" // nodef
|
||||||
|
"""
|
||||||
|
workflow-glue summarise_modkit_bedmethyl \
|
||||||
|
"${bedmethyl}" \
|
||||||
|
"${alias}" \
|
||||||
|
"${mod_codes}" \
|
||||||
|
"${mod_code_labels}" \
|
||||||
|
"${alias}.mods.summary.tsv"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
process inferModkitBases {
|
process inferModkitBases {
|
||||||
label "modkit"
|
label "modkit"
|
||||||
cpus 1
|
cpus 1
|
||||||
@ -153,6 +180,10 @@ workflow mods {
|
|||||||
mod_samples = xams_with.mods.join(sample_modcodes)
|
mod_samples = xams_with.mods.join(sample_modcodes)
|
||||||
|
|
||||||
pileup = runModkitPileup(mod_samples, ref_genome)
|
pileup = runModkitPileup(mod_samples, ref_genome)
|
||||||
|
sample_summaries = summariseModkitBedmethyl(
|
||||||
|
pileup.bedmethyl.join(sample_modcodes),
|
||||||
|
file("$projectDir/data/mod_code_labels.tsv")
|
||||||
|
)
|
||||||
|
|
||||||
sample_bigwigs = modkit_tobigwig(
|
sample_bigwigs = modkit_tobigwig(
|
||||||
ref_genome,
|
ref_genome,
|
||||||
@ -162,5 +193,6 @@ workflow mods {
|
|||||||
|
|
||||||
emit:
|
emit:
|
||||||
bedmethyl = pileup.bedmethyl
|
bedmethyl = pileup.bedmethyl
|
||||||
|
summary = sample_summaries.summary
|
||||||
bigwig = sample_bigwigs.bigwig
|
bigwig = sample_bigwigs.bigwig
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user