diff --git a/README.md b/README.md index f9dcb47..35b63e0 100644 --- a/README.md +++ b/README.md @@ -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//mods/`. +bedMethyl file, a simple per-sample summary table, and one bigWig track per +requested or inferred modification under `samples//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 | diff --git a/bin/workflow_glue/report.py b/bin/workflow_glue/report.py index fe245e8..ebc20fb 100644 --- a/bin/workflow_glue/report.py +++ b/bin/workflow_glue/report.py @@ -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"{escape(mod)}" 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( + "" + "
" + "" + ) + continue + cells.append( + "" + f"
" + f"{_format_mod_summary_percent(row.modification_percent)}%" + f"
" + f"
" + f"{_format_mod_summary_count(row.modified_calls)} modified" + f"
" + f"
" + f"{_format_mod_summary_count(row.valid_coverage)} valid" + f"
" + "" + ) + body_rows.append( + "" + f"{escape(sample)}" + + "".join(cells) + + "" + ) + + return ( + "
" + "" + "" + f"{header_html}" + f"{''.join(body_rows)}" + "
Sample
" + "
" + ) + + +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( + "Valid coverage 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. " + "Modified calls Number of calls passing filters " + "that were classified as a residue with a specified base " + "modification. " + "Modification percent (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, diff --git a/bin/workflow_glue/summarise_modkit_bedmethyl.py b/bin/workflow_glue/summarise_modkit_bedmethyl.py new file mode 100644 index 0000000..e735b74 --- /dev/null +++ b/bin/workflow_glue/summarise_modkit_bedmethyl.py @@ -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 diff --git a/bin/workflow_glue/tests/common/test_report.py b/bin/workflow_glue/tests/common/test_report.py index 77a5e76..9f38742 100644 --- a/bin/workflow_glue/tests/common/test_report.py +++ b/bin/workflow_glue/tests/common/test_report.py @@ -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 "" 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, diff --git a/bin/workflow_glue/tests/common/test_summarise_modkit_bedmethyl.py b/bin/workflow_glue/tests/common/test_summarise_modkit_bedmethyl.py new file mode 100644 index 0000000..6747e14 --- /dev/null +++ b/bin/workflow_glue/tests/common/test_summarise_modkit_bedmethyl.py @@ -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", + } + ] diff --git a/docs/06_pipeline_overview.md b/docs/06_pipeline_overview.md index be1b4cb..9803ece 100644 --- a/docs/06_pipeline_overview.md +++ b/docs/06_pipeline_overview.md @@ -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//mods/`. +bedMethyl file, a simple per-sample summary table, and one bigWig track per +requested or inferred modification under `samples//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` diff --git a/docs/08_outputs.md b/docs/08_outputs.md index a0f6e1b..943edff 100644 --- a/docs/08_outputs.md +++ b/docs/08_outputs.md @@ -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 | diff --git a/main.nf b/main.nf index 91cd914..d808e89 100644 --- a/main.nf +++ b/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, diff --git a/output_definition.json b/output_definition.json index 2eeb5c2..147808a 100644 --- a/output_definition.json +++ b/output_definition.json @@ -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", diff --git a/subworkflows/mods.nf b/subworkflows/mods.nf index d7fbcda..547c276 100644 --- a/subworkflows/mods.nf +++ b/subworkflows/mods.nf @@ -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 }