Merge branch 'cw-7196-b' into 'dev'
Per-sample mod summary See merge request epi2melabs/workflows/wf-transcriptomes!294
This commit is contained in:
commit
dda764f48c
@ -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
|
||||
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
|
||||
bedMethyl file and one bigWig track per requested or inferred modification
|
||||
under `samples/<alias>/mods/`.
|
||||
bedMethyl file, a simple per-sample summary table, and one bigWig track per
|
||||
requested or inferred modification under `samples/<alias>/mods/`.
|
||||
|
||||
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`
|
||||
@ -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 |
|
||||
| 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 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 |
|
||||
| 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 |
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Create workflow report for wf-transcriptomes."""
|
||||
|
||||
from html import escape
|
||||
import json
|
||||
import math
|
||||
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 .volcano import volcano # noqa: ABS101
|
||||
|
||||
logger = get_named_logger("Report")
|
||||
|
||||
|
||||
# Suppress asyncio deprecation warning triggered by dominate on Python 3.10+.
|
||||
# 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")
|
||||
|
||||
|
||||
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):
|
||||
"""Return a dict of per-contrast result DataFrames read from filename."""
|
||||
tables = {}
|
||||
@ -490,6 +655,23 @@ def main(args):
|
||||
.rename(columns={"index": "Field"}),
|
||||
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)
|
||||
|
||||
if annotation_reference_summary:
|
||||
@ -1233,6 +1415,11 @@ def argparser():
|
||||
required=True,
|
||||
help="Per-sample output directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mod_summary_dir",
|
||||
default=None,
|
||||
help="Modified base summary directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sqanti_dir",
|
||||
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.mkdir()
|
||||
mod_summaries = tmp_path / "mod_summaries"
|
||||
mod_summaries.mkdir()
|
||||
sqanti = tmp_path / "sqanti"
|
||||
sqanti.mkdir()
|
||||
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"),
|
||||
"--samples_dir",
|
||||
str(samples),
|
||||
"--mod_summary_dir",
|
||||
str(mod_summaries),
|
||||
"--sqanti_dir",
|
||||
str(sqanti),
|
||||
"--versions",
|
||||
@ -213,6 +217,10 @@ def test_report_main_accepts_optional_file_sentinels(monkeypatch, tmp_path):
|
||||
samples.mkdir()
|
||||
(samples / "OPTIONAL_FILE").touch()
|
||||
|
||||
mod_summaries = tmp_path / "mod_summaries"
|
||||
mod_summaries.mkdir()
|
||||
(mod_summaries / "OPTIONAL_FILE").touch()
|
||||
|
||||
sqanti = tmp_path / "sqanti"
|
||||
sqanti.mkdir()
|
||||
(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"),
|
||||
"--samples_dir",
|
||||
str(samples),
|
||||
"--mod_summary_dir",
|
||||
str(mod_summaries),
|
||||
"--sqanti_dir",
|
||||
str(sqanti),
|
||||
"--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)
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch,
|
||||
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
|
||||
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
|
||||
bedMethyl file and one bigWig track per requested or inferred modification
|
||||
under `samples/<alias>/mods/`.
|
||||
bedMethyl file, a simple per-sample summary table, and one bigWig track per
|
||||
requested or inferred modification under `samples/<alias>/mods/`.
|
||||
|
||||
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`
|
||||
|
||||
@ -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 |
|
||||
| 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 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 |
|
||||
| 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 |
|
||||
|
||||
8
main.nf
8
main.nf
@ -43,6 +43,7 @@ process makeReport {
|
||||
path "params.json"
|
||||
path cohort_dir, stageAs: "cohort"
|
||||
path sample_dirs, stageAs: "samples/*"
|
||||
path mod_summary_files, stageAs: "mod_summaries/*"
|
||||
path sqanti_dirs, stageAs: "sqanti/*"
|
||||
path de_files
|
||||
path "annotation_reference_summary.tsv"
|
||||
@ -62,6 +63,7 @@ process makeReport {
|
||||
${stats_args} \
|
||||
--cohort_dir cohort \
|
||||
--samples_dir samples \
|
||||
--mod_summary_dir mod_summaries \
|
||||
--sqanti_dir sqanti \
|
||||
${de_args} \
|
||||
--versions versions \
|
||||
@ -132,6 +134,11 @@ workflow wf {
|
||||
.map { meta, sample_dir -> sample_dir }
|
||||
.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
|
||||
.concat(transcriptome_results.sample_sqanti_dirs.map { meta, sqanti_dir -> sqanti_dir })
|
||||
.ifEmpty(OPTIONAL_FILE)
|
||||
@ -155,6 +162,7 @@ workflow wf {
|
||||
workflow_params,
|
||||
transcriptome_results.joint_dir.ifEmpty(OPTIONAL_FILE),
|
||||
sample_dirs_for_report,
|
||||
mod_summaries_for_report,
|
||||
sqanti_dirs_for_report,
|
||||
de_dir,
|
||||
transcriptome_results.annotation_reference_summary,
|
||||
|
||||
@ -40,6 +40,14 @@
|
||||
"optional": true,
|
||||
"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": {
|
||||
"filepath": "samples/{{ alias }}/mods/{{ alias }}.mods.*.bw",
|
||||
"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 {
|
||||
label "modkit"
|
||||
cpus 1
|
||||
@ -153,6 +180,10 @@ workflow mods {
|
||||
mod_samples = xams_with.mods.join(sample_modcodes)
|
||||
|
||||
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(
|
||||
ref_genome,
|
||||
@ -162,5 +193,6 @@ workflow mods {
|
||||
|
||||
emit:
|
||||
bedmethyl = pileup.bedmethyl
|
||||
summary = sample_summaries.summary
|
||||
bigwig = sample_bigwigs.bigwig
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user