wf-transcriptomes v2.0.0

This commit is contained in:
Sam Nicholls 2026-06-09 11:58:17 +00:00
parent f1dbc69191
commit c21588761b
24 changed files with 440 additions and 385 deletions

View File

@ -16,9 +16,7 @@ variables:
PYTEST_TESTS_PATH: "bin/workflow_glue/tests/common" PYTEST_TESTS_PATH: "bin/workflow_glue/tests/common"
RTEST_CONTAINER_NAME: "wf-transcriptomes-core" RTEST_CONTAINER_NAME: "wf-transcriptomes-core"
RTEST_CONTAINER_CONFIG_KEY: "container_sha" RTEST_CONTAINER_CONFIG_KEY: "container_sha"
WF_TEMPLATE_ENFORCEMENT_BRANCH: "ruo-next" WF_TEMPLATE_ENFORCEMENT_REF: "v6.1.0-rc1"
RELEASE_WORKFLOW: "no" # do not release this workflow!
RELEASE_WITHOUT_DEMO: "yes"
pytest_wfcontainer: pytest_wfcontainer:
extends: pytest extends: pytest
@ -74,7 +72,8 @@ docker-run:
- MATRIX_NAME: [ - MATRIX_NAME: [
"int_discover_dna", "int_fixed_rna", "int_de_control_vs_control", "int_discover_dna", "int_fixed_rna", "int_de_control_vs_control",
"smoke_discover", "smoke_fixed", "smoke_direct_rna", "smoke_de", "smoke_discover", "smoke_fixed", "smoke_direct_rna", "smoke_de",
"mouse_de_0countquant", "mods_bigwig_igv", "mismatch-sample-alias" "mouse_de_0countquant", "mods_bigwig_igv", "mismatch-sample-alias",
"mouse_splice_error", "mouse_splice_realign"
] ]
rules: rules:
# NOTE As we're overriding the rules block for the included docker-run # NOTE As we're overriding the rules block for the included docker-run
@ -202,6 +201,26 @@ docker-run:
--ref_annotation ${CI_PROJECT_NAME}/data/gencode.v22.annotation.chr20.gtf \ --ref_annotation ${CI_PROJECT_NAME}/data/gencode.v22.annotation.chr20.gtf \
--sample_sheet ${CI_PROJECT_NAME}/data/mods_rna_subset/sample_sheet.csv \ --sample_sheet ${CI_PROJECT_NAME}/data/mods_rna_subset/sample_sheet.csv \
--igv" --igv"
- if: $MATRIX_NAME == "mouse_splice_error"
variables:
NF_BEFORE_SCRIPT: "mkdir -p ${CI_PROJECT_NAME}/data/ && wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/mouse_splice_fail.tar.gz -O ${CI_PROJECT_NAME}/data/mouse_splice_fail.tar.gz && tar -xzvf ${CI_PROJECT_NAME}/data/mouse_splice_fail.tar.gz -C ${CI_PROJECT_NAME}/data/"
NF_WORKFLOW_OPTS: "--bam ${CI_PROJECT_NAME}/data/mouse_splice_fail/samples \
--de_analysis --ref_genome ${CI_PROJECT_NAME}/data/mouse_splice_fail/mouse_subset.fa \
--ref_annotation ${CI_PROJECT_NAME}/data/mouse_splice_fail/mouse_subset.gtf.gz \
--sample_sheet ${CI_PROJECT_NAME}/data/mouse_splice_fail/sample_sheet.csv"
ASSERT_NEXTFLOW_FAILURE: "1"
AFTER_NEXTFLOW_CMD: >
grep -qF 'Cannot proceed with mixed splice-aware CIGAR evidence in input BAMs.' .nextflow.log
- if: $MATRIX_NAME == "mouse_splice_realign"
variables:
NF_BEFORE_SCRIPT: "mkdir -p ${CI_PROJECT_NAME}/data/ && wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/mouse_splice_realign.tar.gz -O ${CI_PROJECT_NAME}/data/mouse_splice_realign.tar.gz && tar -xzvf ${CI_PROJECT_NAME}/data/mouse_splice_realign.tar.gz -C ${CI_PROJECT_NAME}/data/"
NF_WORKFLOW_OPTS: "--bam ${CI_PROJECT_NAME}/data/mouse_splice_realign/samples \
--de_analysis --ref_genome ${CI_PROJECT_NAME}/data/mouse_splice_realign/mouse_subset.fa \
--ref_annotation ${CI_PROJECT_NAME}/data/mouse_splice_realign/mouse_subset.gtf.gz \
--sample_sheet ${CI_PROJECT_NAME}/data/mouse_splice_realign/sample_sheet.csv"
AFTER_NEXTFLOW_CMD: >
grep -q 'bam_alignment' ${CI_PROJECT_NAME}/execution/trace.txt && grep -q 'No input BAMs appear to contain splice-aware CIGAR evidence. The workflow will realign all inputs.' .nextflow.log
singularity-run: singularity-run:

View File

@ -4,7 +4,8 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v2.0.0-rc1]
## [v2.0.0]
This release refreshes `wf-transcriptomes` around a new reference-guided transcriptomics workflow built on `bambu`, with `SQANTI3` transcript classification and QC, `DESeq2` for differential gene expression, `DEXSeq` for differential transcript usage, and per-sample modified base summarisation with `modkit` when modification tags are present in aligned BAMs. This release refreshes `wf-transcriptomes` around a new reference-guided transcriptomics workflow built on `bambu`, with `SQANTI3` transcript classification and QC, `DESeq2` for differential gene expression, `DEXSeq` for differential transcript usage, and per-sample modified base summarisation with `modkit` when modification tags are present in aligned BAMs.
@ -32,9 +33,10 @@ This release refreshes `wf-transcriptomes` around a new reference-guided transcr
- Sample-level hierarchical clustering, PCA and distance heatmap plots - Sample-level hierarchical clustering, PCA and distance heatmap plots
- Contrast-level interactive volcano plots - Contrast-level interactive volcano plots
### Removed ### Removed
- Dependence on the older `StringTie/GffCompare/Salmon` transcriptomics pathway.
- `--transcriptome_source` parameter; use `--transcriptome_mode` instead. - `--transcriptome_source` parameter; use `--transcriptome_mode` instead.
- `--ref_transcriptome` parameter; use `--transcriptome_mode fixed_annotation` together with `--ref_genome` and `--ref_annotation`. - `--ref_transcriptome` parameter; use `--transcriptome_mode fixed_annotation` together with `--ref_genome` and `--ref_annotation`.
- Dependence on the older `StringTie/GffCompare/Salmon` transcriptomics pathway. - `--threads` parameter; advanced users may use Nextflow process selectors to override the preset per-process CPU and memory limits.
## [v1.7.2] ## [v1.7.2]
This patch release of wf-transcriptomes updates internal workflow naming, and does not affect any workflow outputs. This patch release of wf-transcriptomes updates internal workflow naming, and does not affect any workflow outputs.

View File

@ -112,6 +112,24 @@ the following command:
nextflow pull epi2me-labs/wf-transcriptomes nextflow pull epi2me-labs/wf-transcriptomes
``` ```
A demo dataset is provided for testing of the workflow.
It can be downloaded and unpacked using the following commands:
```
wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/v2/wf-transcriptomes-demo.tar.gz
tar -xzvf wf-transcriptomes-demo.tar.gz
```
The workflow can then be run with the downloaded demo data using:
```
nextflow run epi2me-labs/wf-transcriptomes \
--bam 'wf-transcriptomes-demo/samples' \
--de_analysis \
--direct_rna \
--ref_annotation 'wf-transcriptomes-demo/gencode.v22.annotation.chr20.gtf' \
--ref_genome 'wf-transcriptomes-demo/hg38_chr20.fa' \
--sample_sheet 'wf-transcriptomes-demo/sample_sheet.csv' \
-profile standard
```
@ -351,7 +369,6 @@ The workflow's analysis is controlled by a user provided genome, annotation, and
| Nextflow parameter name | Type | Description | Help | Default | | Nextflow parameter name | Type | Description | Help | Default |
|--------------------------|------|-------------|------|---------| |--------------------------|------|-------------|------|---------|
| threads | integer | Thread count to use for the core workflow processes. | | 4 |
| mod_codes | string | Comma-separated modified base codes to pass to modkit pileup. | Provide values accepted by `modkit pileup --modified-bases`, for example `A:a,C:m`. If omitted, the workflow infers `primary_base:mod_code` pairs from the BAM with `modkit modbam check-tags`. | | | mod_codes | string | Comma-separated modified base codes to pass to modkit pileup. | Provide values accepted by `modkit pileup --modified-bases`, for example `A:a,C:m`. If omitted, the workflow infers `primary_base:mod_code` pairs from the BAM with `modkit modbam check-tags`. | |
| force_alignment | boolean | Force re-alignment of input BAM files. | Read alignment is skipped if the existing sequence names in the aligned BAM match the provided reference. Enable this if the existing alignments used incorrect minimap2 presets (e.g. missing --splice or direct RNA settings). | False | | force_alignment | boolean | Force re-alignment of input BAM files. | Read alignment is skipped if the existing sequence names in the aligned BAM match the provided reference. Enable this if the existing alignments used incorrect minimap2 presets (e.g. missing --splice or direct RNA settings). | False |
| ndr | number | Optional bambu novel discovery rate override. | Lower values are more conservative (higher precision), while higher values are more permissive (higher novel-discovery sensitivity). See the [`bambu` repository](https://github.com/GoekeLab/bambu) for method details. | | | ndr | number | Optional bambu novel discovery rate override. | Lower values are more conservative (higher precision), while higher values are more permissive (higher novel-discovery sensitivity). See the [`bambu` repository](https://github.com/GoekeLab/bambu) for method details. | |

View File

@ -1,36 +0,0 @@
#!/usr/bin/env python
"""Check whether the input is a modbam."""
import os
import sys
import pysam
from .util import get_named_logger, wf_parser # noqa: ABS101
def main(args):
"""Run the entry point."""
logger = get_named_logger("check_valid_modbam")
logger.info(f'Checking file: {args.bam}')
# Check the first 10K reads of the input bam for ML/MM fields
valid_reads = 0
fields = ['mm', 'ml']
for i, alignment in enumerate(pysam.AlignmentFile(args.bam)):
n_tags = len([
tag for (tag, val) in alignment.get_tags() if tag.lower() in fields])
if n_tags == 2:
valid_reads += 1
break
if i >= 9999:
break
if valid_reads == 0:
sys.exit(os.EX_DATAERR)
def argparser():
"""Argument parser for entrypoint."""
parser = wf_parser("check_valid_modbam")
parser.add_argument("bam", help="Input bam file")
return parser

View File

@ -386,21 +386,28 @@ def _mod_summary_matrix_style():
""" """
def _contrast_results(de_dir, filename, n=None): def _contrast_results(de_dir, filename):
"""Return a dict of per-contrast result DataFrames read from filename.""" """Return a dict of per-contrast result DataFrames read from filename."""
tables = {} tables = {}
for contrast_dir in sorted(Path(de_dir).iterdir()): for contrast_dir in sorted(Path(de_dir).iterdir()):
if not contrast_dir.is_dir(): if not contrast_dir.is_dir():
continue continue
table = _read_table(contrast_dir / filename) # Enforce str dtype in case of all Nan values.
table = _read_table(
contrast_dir / filename,
dtype={'gene_name': str, 'transcript_name': str}
)
if table is None or table.empty: if table is None or table.empty:
continue continue
data = table
if n is not None: if 'gene_name' in table.columns:
data = data.head(n) table["gene_name"] = table["gene_name"].fillna("-")
if "padj" in data.columns: if 'transcript_name' in table.columns:
data.sort_values("padj", ascending=True, inplace=True) table["transcript_name"] = table["transcript_name"].fillna("-")
tables[contrast_dir.name] = data
if "padj" in table.columns:
table.sort_values("padj", ascending=True, inplace=True)
tables[contrast_dir.name] = table
return tables return tables
@ -548,17 +555,23 @@ def _heatmap_style():
def _volcano_style(): def _volcano_style():
return """ return """
.volcano-table-grid { .volcano-plot-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr) minmax(430px, 34%);
gap: 20px 10px; gap: 18px;
align-items: start; align-items: start;
} }
.volcano-table-grid > * { .volcano-plot-grid > *,
.volcano-side-panel > * {
min-width: 0; min-width: 0;
} }
@media screen and (max-width: 1000px) { .volcano-side-panel {
.volcano-table-grid { display: grid;
gap: 10px;
align-items: start;
}
@media screen and (max-width: 1100px) {
.volcano-plot-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@ -1417,10 +1430,11 @@ def main(args):
h3("Gene expression volcano Plot") h3("Gene expression volcano Plot")
gn_vol, gn_class_table, gn_selected_table = volcano(table) gn_vol, gn_class_table, gn_selected_table = volcano(table)
EZChart(gn_vol, width="100%", height="550") with div(_class="volcano-plot-grid"):
with div(_class="volcano-table-grid"): EZChart(gn_vol, width="100%", height="550")
EZChart(gn_class_table, width="100%", height="auto") with div(_class="volcano-side-panel"):
EZChart(gn_selected_table, width="100%", height="auto") EZChart(gn_class_table, width="100%", height="auto")
EZChart(gn_selected_table, width="100%", height="auto")
with report.add_section("Differential transcript usage", "DTU"): with report.add_section("Differential transcript usage", "DTU"):
p( p(
@ -1505,10 +1519,11 @@ def main(args):
h3("Transcript expression volcano Plot") h3("Transcript expression volcano Plot")
tr_vol, tr_class_table, tr_selected_table = volcano(dtu_table) tr_vol, tr_class_table, tr_selected_table = volcano(dtu_table)
EZChart(tr_vol, width="100%", height="550") with div(_class="volcano-plot-grid"):
with div(_class="volcano-table-grid"): EZChart(tr_vol, width="100%", height="550")
EZChart(tr_class_table, width="100%", height="auto") with div(_class="volcano-side-panel"):
EZChart(tr_selected_table, width="100%", height="auto") EZChart(tr_class_table, width="100%", height="auto")
EZChart(tr_selected_table, width="100%", height="auto")
else: else:
p("No DTU results available for this contrast.") p("No DTU results available for this contrast.")

View File

@ -26,13 +26,12 @@ def _run_check_sample_sheet(sample_sheet_path, params_json, capsys):
[ [
[ [
"condition", "batch_1", "site.2", "condition", "batch_1", "site.2",
"S01", "a1", "sample_group", "S01", "a1", "sample_group"
"01", "1batch", "2.site-3"
], ],
], ],
) )
def test_validate_r_formula_names_accepts_valid_names(names): def test_validate_r_formula_names_accepts_valid_names(names):
"""R formula names accept alnum-led names with safe punctuation.""" """R formula names accept letter-led names with safe punctuation."""
_validate_r_formula_names(names) _validate_r_formula_names(names)
@ -45,6 +44,9 @@ def test_validate_r_formula_names_accepts_valid_names(names):
["size:batch"], ["size:batch"],
["size|batch"], ["size|batch"],
["sueño"], ["sueño"],
["01"],
["1batch"],
["2.site-3"],
[""] [""]
], ],
) )
@ -240,7 +242,7 @@ def test_check_sample_sheet_rejects_unsafe_covariate_values(
"""Configured covariate values must use safe contrast/design characters.""" """Configured covariate values must use safe contrast/design characters."""
sample_sheet_path = tmp_path / "sample_sheet_invalid_covariate_value.csv" sample_sheet_path = tmp_path / "sample_sheet_invalid_covariate_value.csv"
sample_sheet_path.write_text( sample_sheet_path.write_text(
"sample_name,condition,good-batch,site\n" "sample_name,condition,good_batch,site\n"
"test_name1,control,b1,s1\n" "test_name1,control,b1,s1\n"
"test_name2,control,b2,s2\n" "test_name2,control,b2,s2\n"
"test_name3,treated,b 1,s1\n" "test_name3,treated,b 1,s1\n"
@ -252,7 +254,7 @@ def test_check_sample_sheet_rejects_unsafe_covariate_values(
json.dumps({ json.dumps({
"de_analysis": True, "de_analysis": True,
"condition_column": "condition", "condition_column": "condition",
"covariates": "good-batch,site", "covariates": "good_batch,site",
}) })
) )
@ -260,16 +262,16 @@ def test_check_sample_sheet_rejects_unsafe_covariate_values(
assert out.startswith( assert out.startswith(
"Covariate value names must be safe for R formulas. " "Covariate value names must be safe for R formulas. "
"Invalid names: b 1. Names must start with a letter or number and " "Invalid names: b 1. Names must start with a letter and "
"contain only letters, numbers, underscores, dots, and hyphens. " "contain only letters, numbers, underscores, and dots. "
"(column: good-batch, line: 3)" "(column: good_batch, line: 3)"
) )
def test_check_sample_sheet_rejects_unsafe_column_names( def test_check_sample_sheet_rejects_unsafe_column_names(
tmp_path, capsys tmp_path, capsys
): ):
"""Configured DE condition and covariate column names may include hyphens.""" """Configured DE design names may not include hyphens or spaces."""
sample_sheet_path = tmp_path / "sample_sheet_invalid_design_name.csv" sample_sheet_path = tmp_path / "sample_sheet_invalid_design_name.csv"
sample_sheet_path.write_text( sample_sheet_path.write_text(
"sample_name,condition-column,bad covariate\n" "sample_name,condition-column,bad covariate\n"
@ -289,12 +291,14 @@ def test_check_sample_sheet_rejects_unsafe_column_names(
) )
out = _run_check_sample_sheet(sample_sheet_path, params_json, capsys) out = _run_check_sample_sheet(sample_sheet_path, params_json, capsys)
assert out.startswith( assert out.splitlines() == [
"Design column names must be safe for R formulas. "
"Invalid names: condition-column. Names must start with a letter and "
"contain only letters, numbers, underscores, and dots.",
"Covariate column names must be safe for R formulas. " "Covariate column names must be safe for R formulas. "
"Invalid names: bad covariate. Names must start with a letter or " "Invalid names: bad covariate. Names must start with a letter and "
"number and contain only letters, numbers, underscores, dots, and " "contain only letters, numbers, underscores, and dots.",
"hyphens." ]
)
def test_check_sample_sheet_rejects_unsafe_condition_values( def test_check_sample_sheet_rejects_unsafe_condition_values(
@ -323,13 +327,13 @@ def test_check_sample_sheet_rejects_unsafe_condition_values(
assert out.splitlines() == [ assert out.splitlines() == [
"Condition value names must be safe for R formulas. " "Condition value names must be safe for R formulas. "
"Invalid names: control/group. Names must start with a letter or " "Invalid names: control/group. Names must start with a letter and "
"number and contain only letters, numbers, underscores, dots, and " "contain only letters, numbers, underscores, and dots. "
"hyphens. (column: condition, line: 1)", "(column: condition, line: 1)",
"Condition value names must be safe for R formulas. " "Condition value names must be safe for R formulas. "
"Invalid names: control/group. Names must start with a letter or " "Invalid names: control/group. Names must start with a letter and "
"number and contain only letters, numbers, underscores, dots, and " "contain only letters, numbers, underscores, and dots. "
"hyphens. (column: condition, line: 2)", "(column: condition, line: 2)",
] ]

View File

@ -1,66 +0,0 @@
"""Tests for modified-base BAM validation."""
import os
import pytest
from workflow_glue import check_valid_modbam
class FakeAlignment:
"""Minimal alignment stub exposing pysam's get_tags API."""
def __init__(self, tags):
"""Store the synthetic SAM tags returned by ``get_tags``."""
self._tags = tags
def get_tags(self):
"""Return the synthetic tag list for this fake alignment."""
return self._tags
def _args(*argv):
return check_valid_modbam.argparser().parse_args(list(argv))
def test_main_accepts_bam_with_mm_and_ml_tags(monkeypatch):
"""A read carrying both MM and ML tags should pass validation."""
monkeypatch.setattr(
check_valid_modbam.pysam,
"AlignmentFile",
lambda _: [
FakeAlignment([("MM", "A+a.,0;"), ("ML", [255])]),
],
)
check_valid_modbam.main(_args("input.bam"))
def test_main_rejects_bam_without_mod_tags(monkeypatch):
"""A BAM with no modified-base tags should exit with EX_DATAERR."""
monkeypatch.setattr(
check_valid_modbam.pysam,
"AlignmentFile",
lambda _: [
FakeAlignment([]),
FakeAlignment([("NM", 0)]),
],
)
with pytest.raises(SystemExit) as exc_info:
check_valid_modbam.main(_args("input.bam"))
assert exc_info.value.code == os.EX_DATAERR
def test_main_accepts_when_mod_tags_appear_later_in_scan(monkeypatch):
"""Validation should continue scanning until it finds a tagged read."""
monkeypatch.setattr(
check_valid_modbam.pysam,
"AlignmentFile",
lambda _: [
FakeAlignment([("NM", 0)]),
FakeAlignment([("mm", "C+m.,0;"), ("ml", [200])]),
],
)
check_valid_modbam.main(_args("input.bam"))

View File

@ -1,6 +1,6 @@
"""Interactive volcano plot visualization for differential expression analysis.""" """Interactive volcano plot visualization for differential expression analysis."""
from bokeh.events import Tap from bokeh.events import DocumentReady, Tap
from bokeh.layouts import column as bokeh_column, row as bokeh_row from bokeh.layouts import column as bokeh_column, row as bokeh_row
from bokeh.models import ( from bokeh.models import (
AutocompleteInput, AutocompleteInput,
@ -102,15 +102,7 @@ def _volcano_source_data(data, fold_threshold=1, p_threshold=0.05):
if not has_gene_name: if not has_gene_name:
data["gene_name"] = "" data["gene_name"] = ""
data["gene_group"] = data["GENEID"] if "GENEID" in data.columns else "" data["gene_group"] = data["GENEID"]
if is_transcript_plot:
if "gene_name" in data.columns:
named_group = data["gene_name"].astype(bool)
data.loc[named_group, "gene_group"] = data.loc[named_group, "gene_name"]
else:
named_group = data["GENEID"].astype(bool)
data.loc[named_group, "gene_group"] = data.loc[named_group, "GENEID"]
data["selected_label"] = "" data["selected_label"] = ""
# Transformations and thresholds # Transformations and thresholds
@ -306,6 +298,25 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
_pos_means = source_data["mean_expression"][source_data["mean_expression"] > 0] _pos_means = source_data["mean_expression"][source_data["mean_expression"] > 0]
mean_min_log = float(_pos_means.min()) if len(_pos_means) > 0 else 0.01 mean_min_log = float(_pos_means.min()) if len(_pos_means) > 0 else 0.01
y_threshold = -np.log10(p_threshold) y_threshold = -np.log10(p_threshold)
responsive_slider_title_stylesheet = """
@media screen and (max-width: 1100px) {
.bk-slider-title {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bk-slider-title:hover {
overflow: visible;
text-overflow: clip;
white-space: normal;
position: relative;
z-index: 1;
background: white;
}
}
"""
source = ColumnDataSource(source_data) source = ColumnDataSource(source_data)
selection_state = ColumnDataSource({"indices": [[]]}) selection_state = ColumnDataSource({"indices": [[]]})
@ -577,8 +588,9 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
source=selected_source, source=selected_source,
columns=table_columns, columns=table_columns,
autosize_mode="force_fit", autosize_mode="force_fit",
height=310, height=355,
index_position=None, index_position=None,
visible=False,
) )
legend_table = DataTable( legend_table = DataTable(
source=legend_source, source=legend_source,
@ -654,6 +666,7 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
step=0.1, step=0.1,
show_value=False, show_value=False,
sizing_mode="stretch_width", sizing_mode="stretch_width",
stylesheets=[responsive_slider_title_stylesheet],
) )
fold_input = TextInput( fold_input = TextInput(
title="", title="",
@ -668,6 +681,7 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
step=0.1, step=0.1,
show_value=False, show_value=False,
sizing_mode="stretch_width", sizing_mode="stretch_width",
stylesheets=[responsive_slider_title_stylesheet],
) )
p_input = TextInput( p_input = TextInput(
title="", title="",
@ -681,6 +695,7 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
selection_state=selection_state, selection_state=selection_state,
highlight_source=highlight_source, highlight_source=highlight_source,
selected_source=selected_source, selected_source=selected_source,
selected_table=selected_table,
legend_source=legend_source, legend_source=legend_source,
fold_slider=fold_slider, fold_slider=fold_slider,
fold_input=fold_input, fold_input=fold_input,
@ -783,6 +798,8 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
selectedData.volcano_class.push(data.volcano_class[index]); selectedData.volcano_class.push(data.volcano_class[index]);
}); });
selected_source.data = selectedData; selected_source.data = selectedData;
selected_table.visible = selected.length > 0;
selected_table.height = window.innerWidth <= 1100 ? 180 : 355;
if (view_toggle.active) { if (view_toggle.active) {
const highlightData = { const highlightData = {
log2FoldChange: [], log2FoldChange: [],
@ -1060,7 +1077,8 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
"Search by gene/transcript…" if is_transcript_plot else "Search by gene…"), "Search by gene/transcript…" if is_transcript_plot else "Search by gene…"),
completions=[], completions=[],
min_characters=1, min_characters=1,
width=240, height=32,
width=225,
) )
search_input.js_on_change("value_input", CustomJS( search_input.js_on_change("value_input", CustomJS(
args=dict( args=dict(
@ -1169,7 +1187,8 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
clear_button = Button( clear_button = Button(
label="Clear selection", label="Clear selection",
button_type="default", button_type="default",
width=130, height=32,
width=120,
) )
clear_button.js_on_click(CustomJS( clear_button.js_on_click(CustomJS(
args=dict( args=dict(
@ -1186,6 +1205,7 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
export_button = Button( export_button = Button(
label="\u2B07 TSV", label="\u2B07 TSV",
button_type="light", button_type="light",
height=32,
width=55 width=55
) )
@ -1232,8 +1252,15 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
controls = bokeh_row( controls = bokeh_row(
fold_slider, fold_input, p_slider, p_input, sizing_mode="stretch_width") fold_slider, fold_input, p_slider, p_input, sizing_mode="stretch_width")
toggle_row = bokeh_row( toggle_row = bokeh_row(
plot_mode_toggle, view_toggle, plot_mode_toggle,
gene_select_toggle, select_significant_button view_toggle,
gene_select_toggle,
select_significant_button,
sizing_mode="stretch_width",
styles={
"flex-wrap": "wrap",
"row-gap": "6px",
},
) )
volcano_ma_plot._fig = bokeh_column( volcano_ma_plot._fig = bokeh_column(
bokeh_row(controls, sizing_mode="stretch_width"), bokeh_row(controls, sizing_mode="stretch_width"),
@ -1255,8 +1282,6 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
sizing_mode="stretch_width", sizing_mode="stretch_width",
styles={ styles={
"padding-top": "4px", "padding-top": "4px",
"padding-left": "35px",
"padding-right": "20px"
}, },
) )
selected_plot = BokehPlot() selected_plot = BokehPlot()
@ -1265,8 +1290,27 @@ def volcano(data, fold_threshold=1, p_threshold=0.05):
bokeh_row( bokeh_row(
search_input, clear_button, export_button) search_input, clear_button, export_button)
), ),
selected_table, sizing_mode="stretch_width", selected_table,
sizing_mode="stretch_width",
styles={ styles={
"padding-top": "25px", "padding-top": "8px",
}) })
selected_plot._fig.js_on_event(DocumentReady, CustomJS(
args=dict(
selected_source=selected_source,
selected_table=selected_table,
),
code="""
const listenerKey = "volcano_selected_table_resize_" + selected_source.id;
function updateSelectedTableLayout() {
selected_table.visible = selected_source.data.source_index.length > 0;
selected_table.height = window.innerWidth <= 1100 ? 180 : 355;
}
updateSelectedTableLayout();
if (!window[listenerKey]) {
window[listenerKey] = true;
window.addEventListener("resize", updateSelectedTableLayout);
}
""",
))
return volcano_ma_plot, classes_table, selected_plot return volcano_ma_plot, classes_table, selected_plot

View File

@ -8,37 +8,69 @@ import pysam
from ..util import get_named_logger, wf_parser # noqa: ABS101 from ..util import get_named_logger, wf_parser # noqa: ABS101
def extract_header_info(xam_file, has_reads, check_ref=False): READ_SCAN_LIMIT = 10000
def alignment_has_cigar_op(alignment, cigar_op):
"""Return whether an alignment has a splice/reference-skip CIGAR op."""
return any(op == cigar_op for op, _ in (alignment.cigartuples or []))
def alignment_has_tags(alignment, tags):
"""Return whether an alignment carries all tags in a list of SAM tags."""
alignment_tags = {tag.upper() for tag, _ in alignment.get_tags()}
return all(tag in alignment_tags for tag in tags)
def check_n_reads(
alignment_file,
check_modbase_tags=False,
check_splice_cigars=False,
read_scan_limit=READ_SCAN_LIMIT,
):
"""Extract bounded read-level facts from an open XAM file."""
read_facts = {
"has_reads": False,
}
if check_splice_cigars:
read_facts["has_splice_cigars"] = False
if check_modbase_tags:
read_facts["has_modbase_tags"] = False
for i, alignment in enumerate(alignment_file.fetch(until_eof=True)):
if i >= read_scan_limit:
break
read_facts["has_reads"] = True
if check_splice_cigars and not read_facts["has_splice_cigars"]:
read_facts["has_splice_cigars"] = alignment_has_cigar_op(
alignment, cigar_op=3)
if check_modbase_tags and not read_facts["has_modbase_tags"]:
read_facts["has_modbase_tags"] = alignment_has_tags(
alignment, tags=["MM", "ML"])
if all(read_facts.values()):
break
return read_facts
def check_header(alignment_file, check_ref=False):
"""Extract header information from a BAM/CRAM file.""" """Extract header information from a BAM/CRAM file."""
try: # Extract SQ lines, comparing only SN/LN/M5 elements
f = pysam.AlignmentFile(xam_file, check_sq=False) # (see CW-4842 - ignore different SQ.UR values)
except (ValueError, IOError): sq_lines = [{
return None, None, None, False "SN": sq["SN"],
"LN": sq["LN"],
"M5": sq.get("M5"),
} for sq in alignment_file.header.get("SQ", [])]
with f: hd_lines = alignment_file.header.get("HD")
# Extract SQ lines, comparing only SN/LN/M5 elements
# (see CW-4842 - ignore different SQ.UR values)
sq_lines = [{
"SN": sq["SN"],
"LN": sq["LN"],
"M5": sq.get("M5"),
} for sq in f.header.get("SQ", [])]
hd_lines = f.header.get("HD") xam_reflen = None
if check_ref:
xam_reflen = set(zip(alignment_file.references, alignment_file.lengths))
xam_reflen = None return sq_lines, hd_lines, xam_reflen
if check_ref:
xam_reflen = set(zip(f.references, f.lengths))
# Whilst we have file open check for atleast 1 reads
# unless reads already found
if not has_reads:
try:
next(f.fetch(until_eof=True))
has_reads = True
except StopIteration:
has_reads = False
return sq_lines, hd_lines, xam_reflen, has_reads
def compare_ref_lengths(xam_reflen, ref_reflen, logger): def compare_ref_lengths(xam_reflen, ref_reflen, logger):
@ -107,16 +139,37 @@ def main(args):
requires_realign = False requires_realign = False
any_sq_lines = False # Track if any file has @SQ lines any_sq_lines = False # Track if any file has @SQ lines
has_reads = False has_reads = False
# a bit hacky to check these specific XAM properties here but this is a very good
# place to do it. in future i'd like to abstract this out to be more like how sample
# sheets are checked, with delegation to the workflow scope for determining checks
has_splice_cigars = False
has_modbase_tags = False
for xam_file in target_files: for xam_file in target_files:
sq_lines, hd_lines, xam_reflen, has_reads = extract_header_info( try:
xam_file, has_reads, check_ref=bool(args.ref)) xam_fh = pysam.AlignmentFile(xam_file, check_sq=False)
except (ValueError, IOError):
if sq_lines is None:
# File couldn't be opened # File couldn't be opened
logger.error(f"Failed to open {xam_file}") logger.error(f"Failed to open {xam_file}")
continue continue
with xam_fh:
sq_lines, hd_lines, xam_reflen = check_header(
xam_fh,
check_ref=bool(args.ref),
)
if not (has_reads and has_splice_cigars and has_modbase_tags):
read_facts = check_n_reads(
xam_fh,
check_splice_cigars=not has_splice_cigars,
check_modbase_tags=not has_modbase_tags,
)
has_reads = has_reads or read_facts["has_reads"]
has_splice_cigars = (
has_splice_cigars or read_facts["has_splice_cigars"])
has_modbase_tags = (
has_modbase_tags or read_facts["has_modbase_tags"])
if sq_lines: if sq_lines:
any_sq_lines = True any_sq_lines = True
@ -157,9 +210,11 @@ def main(args):
f"IS_UNALIGNED={int(is_unaligned)};" + f"IS_UNALIGNED={int(is_unaligned)};" +
f"MIXED_SQ_HEADERS={int(mixed_sq_headers)};" + f"MIXED_SQ_HEADERS={int(mixed_sq_headers)};" +
f"IS_SORTED={int(sorted_xam)};" + f"IS_SORTED={int(sorted_xam)};" +
f"HAS_READS={int(has_reads)};" f"HAS_READS={int(has_reads)};" +
f"HAS_SPLICE_CIGARS={int(has_splice_cigars)};" +
f"HAS_MODBASE_TAGS={int(has_modbase_tags)};"
) )
logger.info(f"Checked (u)BAM headers in '{args.input_path}'.") logger.info(f"Checked (u)BAM heads in '{args.input_path}'.")
def argparser(): def argparser():

View File

@ -5,7 +5,7 @@ import re
from .default import SampleSheetValidator # noqa: ABS101 from .default import SampleSheetValidator # noqa: ABS101
_R_FORMULA_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") _R_FORMULA_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.]*$")
def _split_covariates(value): def _split_covariates(value):
@ -31,8 +31,8 @@ def _validate_r_formula_names(names, label="Column"):
details.append(f"Invalid names: {', '.join(invalid)}") details.append(f"Invalid names: {', '.join(invalid)}")
raise ValueError( raise ValueError(
f"{label} names must be safe for R formulas. {'; '.join(details)}. " f"{label} names must be safe for R formulas. {'; '.join(details)}. "
"Names must start with a letter or number and contain " "Names must start with a letter and contain "
"only letters, numbers, underscores, dots, and hyphens." "only letters, numbers, underscores, and dots."
) )

View File

@ -1,24 +1,3 @@
workflow_glue_r_is_r_formula_name <- function(name) {
is.character(name) &&
length(name) == 1 &&
grepl("^[A-Za-z][A-Za-z0-9_.]*$", name)
}
workflow_glue_r_validate_r_formula_names <- function(names, label = "Column") {
invalid <- names[!vapply(names, workflow_glue_r_is_r_formula_name, logical(1))]
if (length(invalid) > 0) {
stop(
sprintf(
"%s names must be safe for R formulas. Invalid names: %s. Names must start with a letter and contain only letters, numbers, underscores, and dots.",
label,
paste(invalid, collapse = ", ")
),
call. = FALSE
)
}
invisible(names)
}
workflow_glue_r_normalise_tsv_value <- function(value) { workflow_glue_r_normalise_tsv_value <- function(value) {
if (length(value) == 0 || all(is.na(value))) { if (length(value) == 0 || all(is.na(value))) {
return(NA_character_) return(NA_character_)

View File

@ -59,10 +59,6 @@ de_analysis_arg_parser <- function() {
de_validate_inputs <- function(tx_se, gene_se, sample_df, argv) { de_validate_inputs <- function(tx_se, gene_se, sample_df, argv) {
covariates <- workflow_glue_r_parse_csv_list(argv$covariates) covariates <- workflow_glue_r_parse_csv_list(argv$covariates)
workflow_glue_r_validate_r_formula_names(
c(argv$condition_column, covariates),
label = "Design column"
)
if (any(duplicated(colnames(tx_se)))) { if (any(duplicated(colnames(tx_se)))) {
stop("Transcript RDS sample names must be unique.", call. = FALSE) stop("Transcript RDS sample names must be unique.", call. = FALSE)

View File

@ -13,39 +13,6 @@ testthat::test_that("covariates parsed and trimmed", {
) )
}) })
# R formulas break on spaces/hyphens in column names (e.g., ~ `time point` produces errors).
# Validate condition_column and covariate names are safe (alphanumeric + underscore).
testthat::test_that("formula-unsafe column names rejected", {
tx_se <- make_test_tx_se()
gene_se <- make_test_gene_se()
sample_df <- data.frame(
alias = colnames(tx_se),
condition = rep(c("control", "treated"), each = 3),
batch = rep(c("b1", "b2", "b1"), 2),
stringsAsFactors = FALSE
)
argv <- list(
transcript_rds = "transcripts.rds",
gene_rds = "genes.rds",
sample_sheet = "sample_sheet.csv",
condition_column = "time point",
covariates = "batch",
reference_level = NULL
)
argv <- workflow_glue_r_normalise_args(argv, de_analysis_arg_spec())
testthat::expect_error(
de_validate_inputs(tx_se, gene_se, sample_df, argv),
"Design column names must be safe for R formulas"
)
argv$condition_column <- "condition"
argv$covariates <- "batch-id"
testthat::expect_error(
de_validate_inputs(tx_se, gene_se, sample_df, argv),
"Invalid names: batch-id"
)
})
testthat::test_that("DTU transcript output renames contrast-specific log2fold column", { testthat::test_that("DTU transcript output renames contrast-specific log2fold column", {
contrast_name <- "treated_control" contrast_name <- "treated_control"

View File

@ -37,3 +37,21 @@ the following command:
``` ```
nextflow pull epi2me-labs/wf-transcriptomes nextflow pull epi2me-labs/wf-transcriptomes
``` ```
A demo dataset is provided for testing of the workflow.
It can be downloaded and unpacked using the following commands:
```
wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/v2/wf-transcriptomes-demo.tar.gz
tar -xzvf wf-transcriptomes-demo.tar.gz
```
The workflow can then be run with the downloaded demo data using:
```
nextflow run epi2me-labs/wf-transcriptomes \
--bam 'wf-transcriptomes-demo/samples' \
--de_analysis \
--direct_rna \
--ref_annotation 'wf-transcriptomes-demo/gencode.v22.annotation.chr20.gtf' \
--ref_genome 'wf-transcriptomes-demo/hg38_chr20.fa' \
--sample_sheet 'wf-transcriptomes-demo/sample_sheet.csv' \
-profile standard
```

View File

@ -48,7 +48,6 @@
| Nextflow parameter name | Type | Description | Help | Default | | Nextflow parameter name | Type | Description | Help | Default |
|--------------------------|------|-------------|------|---------| |--------------------------|------|-------------|------|---------|
| threads | integer | Thread count to use for the core workflow processes. | | 4 |
| mod_codes | string | Comma-separated modified base codes to pass to modkit pileup. | Provide values accepted by `modkit pileup --modified-bases`, for example `A:a,C:m`. If omitted, the workflow infers `primary_base:mod_code` pairs from the BAM with `modkit modbam check-tags`. | | | mod_codes | string | Comma-separated modified base codes to pass to modkit pileup. | Provide values accepted by `modkit pileup --modified-bases`, for example `A:a,C:m`. If omitted, the workflow infers `primary_base:mod_code` pairs from the BAM with `modkit modbam check-tags`. | |
| force_alignment | boolean | Force re-alignment of input BAM files. | Read alignment is skipped if the existing sequence names in the aligned BAM match the provided reference. Enable this if the existing alignments used incorrect minimap2 presets (e.g. missing --splice or direct RNA settings). | False | | force_alignment | boolean | Force re-alignment of input BAM files. | Read alignment is skipped if the existing sequence names in the aligned BAM match the provided reference. Enable this if the existing alignments used incorrect minimap2 presets (e.g. missing --splice or direct RNA settings). | False |
| ndr | number | Optional bambu novel discovery rate override. | Lower values are more conservative (higher precision), while higher values are more permissive (higher novel-discovery sensitivity). See the [`bambu` repository](https://github.com/GoekeLab/bambu) for method details. | | | ndr | number | Optional bambu novel discovery rate override. | Lower values are more conservative (higher precision), while higher values are more permissive (higher novel-discovery sensitivity). See the [`bambu` repository](https://github.com/GoekeLab/bambu) for method details. | |

View File

@ -1,60 +0,0 @@
#!/usr/bin/env bash
# A few simple tests with different combinations of CLI options
# Run from within an appropriate active conda environment
if [[ "$#" -lt 1 ]]; then
echo "usage: tests.sh <outdir> [nextflow.config]"
exit 1
fi
if [[ "$#" -eq 1 ]]; then
config=''
fi
if [[ "$#" -eq 2 ]]; then
config="-c $2";
fi
SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd $SCRIPT_DIR/../;
singledir="test_data/fastq"
multisampledir="test_data/demultiplexed_fastq"
# This is for when using SIRV dataset with non-canonical spice junctions
#"--minimap2_opts '-uf --splice-flank=no'"
results=()
# Reference based tests
OUTPUT=$1/reference_single_dir;
nextflow run . --fastq $singledir $config --ref_genome test_data/SIRV_150601a.fasta --minimap2_opts '-uf --splice-flank=no' \
--ref_annotation test_data/SIRV_isofroms.gtf -profile local --out_dir ${OUTPUT} -w ${OUTPUT}/workspace -resume;
r=$?
results+=("$(basename $OUTPUT): $r")
OUTPUT=$1/multiple_samples;
nextflow run . --fastq $multisampledir $config --ref_genome test_data/SIRV_150601a.fasta --minimap2_opts '-uf --splice-flank=no'\
--ref_annotation test_data/SIRV_isofroms.gtf -profile local --out_dir ${OUTPUT} -w ${OUTPUT}/workspace \
--sample_sheet test_data/sample_sheet -resume;
r=$?
results+=("$(basename $OUTPUT): $r")
OUTPUT=$1/reference_no_ref_annotation;
nextflow run . --fastq $singledir $config --ref_genome test_data/SIRV_150601a.fasta --minimap2_opts '-uf --splice-flank=no'\
-profile local --out_dir ${OUTPUT} -w ${OUTPUT}/workspace -resume;
r=$?
results+=("$(basename $OUTPUT): $r")
# Force split_bam to make multiple alignment bundles
OUTPUT=$1/reference_frce_split_bam;
nextflow run . --fastq $singledir $config --ref_genome test_data/SIRV_150601a.fasta --minimap2_opts '-uf --splice-flank=no'\
--ref_annotation test_data/SIRV_isofroms.gtf -profile local --out_dir ${OUTPUT} -w ${OUTPUT}/workspace \
--bundle_min_reads 5 -resume;
r=$?
results+=("$(basename $OUTPUT): $r")
echo "Exit status codes for each test"
for value in "${results[@]}"; do
echo "${value}"
done

View File

@ -67,7 +67,7 @@ process minimap2_alignment {
cpus { bsargs["alignment_threads"] } cpus { bsargs["alignment_threads"] }
memory { bsargs["minimap2_memory"][task.attempt - 1] } memory { bsargs["minimap2_memory"][task.attempt - 1] }
maxRetries { bsargs["minimap2_memory"].size() - 1 } maxRetries { bsargs["minimap2_memory"].size() - 1 }
errorStrategy = {task.exitStatus in [137,140] ? 'retry' : 'finish'} errorStrategy "retry" // retry on any error as we may not exit with appropriate codes when exceeding memory
input: input:
tuple val(meta), path(reads, stageAs: 'reads/*'), path(reference), path(ref_index) tuple val(meta), path(reads, stageAs: 'reads/*'), path(reference), path(ref_index)
tuple val(align_ext), val(index_ext) // either [bam, bai] or [cram, crai] tuple val(align_ext), val(index_ext) // either [bam, bai] or [cram, crai]

View File

@ -340,6 +340,9 @@ def fastq_ingress(Map arguments, aln_ref_ch = null)
`cram` outputs a CRAM file with index (.cram, .crai) `cram` outputs a CRAM file with index (.cram, .crai)
* - "sort_threads": number of threads to use for sorting aligned reads (default: 1) * - "sort_threads": number of threads to use for sorting aligned reads (default: 1)
* - "force_alignment": boolean. Run alignment regardless of inputs. * - "force_alignment": boolean. Run alignment regardless of inputs.
* - "checked_bam_hook": closure. Optional hook called after all BAM header/read checks
* complete. Receives a list of `[meta, paths]` tuples and must return replacement
* tuples of the same shape. Can warn, error, or add routing metadata.
* @param aln_ref_ch: optional channel with a reference tuple (ref, ref_idx) to align against. * @param aln_ref_ch: optional channel with a reference tuple (ref, ref_idx) to align against.
* If provided alignment will be attempted if inputs are unaligned or not already aligned to this ref. * If provided alignment will be attempted if inputs are unaligned or not already aligned to this ref.
* @return: channel of `[Map(alias, barcode, type, ...), Path|null, Path|null]`. * @return: channel of `[Map(alias, barcode, type, ...), Path|null, Path|null]`.
@ -361,8 +364,13 @@ def xam_ingress(Map arguments, aln_ref_ch = null)
"fastcat_extra_args": "", "fastcat_extra_args": "",
"fastq_chunk": null, "fastq_chunk": null,
"force_alignment": false, "force_alignment": false,
"checked_bam_hook": null,
] ]
) )
// immediately separate hooks from margs as they cannot be serialised
// and are currently only needed in this scope
def checked_bam_hook = margs.remove("checked_bam_hook")
margs["fastq_chunk"] ?= 0 // cant pass null through channel margs["fastq_chunk"] ?= 0 // cant pass null through channel
if (margs["return_fastq"] && aln_ref_ch){ if (margs["return_fastq"] && aln_ref_ch){
@ -408,14 +416,24 @@ def xam_ingress(Map arguments, aln_ref_ch = null)
[meta + [src_xam: src_xam, src_xai: src_xai], paths] [meta + [src_xam: src_xam, src_xai: src_xai], paths]
} }
| combine(aln_ref) | combine(aln_ref)
| checkBamHeaders | checkBamHeads
| map { meta, paths, is_unaligned_env, mixed_sq_headers_env, is_sorted_env, has_reads_env -> | map {
meta,
paths,
is_unaligned_env,
mixed_sq_headers_env,
is_sorted_env,
has_reads_env,
has_splice_cigars_env,
has_modbase_tags_env ->
// convert the env. variables from strings ('0' or '1') into bools // convert the env. variables from strings ('0' or '1') into bools
boolean is_unaligned = is_unaligned_env as int as boolean boolean is_unaligned = is_unaligned_env as int as boolean
boolean mixed_sq_headers = mixed_sq_headers_env as int as boolean boolean mixed_sq_headers = mixed_sq_headers_env as int as boolean
boolean is_sorted = is_sorted_env as int as boolean boolean is_sorted = is_sorted_env as int as boolean
// if no reads, no error (multisample may have some empty bams) but do not attempt alignment // if no reads, no error (multisample may have some empty bams) but do not attempt alignment
boolean has_reads = has_reads_env as int as boolean boolean has_reads = has_reads_env as int as boolean
boolean has_splice_cigars = has_splice_cigars_env as int as boolean
boolean has_modbase_tags = has_modbase_tags_env as int as boolean
// throw an error if there was a sample with mixed headers // throw an error if there was a sample with mixed headers
if (mixed_sq_headers) { if (mixed_sq_headers) {
error "Found mixed headers in (u)BAM files of sample '${meta.alias}'." error "Found mixed headers in (u)BAM files of sample '${meta.alias}'."
@ -423,7 +441,24 @@ def xam_ingress(Map arguments, aln_ref_ch = null)
// add `is_unaligned` to the metamap (note the use of `+` to create a copy of // add `is_unaligned` to the metamap (note the use of `+` to create a copy of
// `meta` to avoid modifying every item in the channel; // `meta` to avoid modifying every item in the channel;
// https://github.com/nextflow-io/nextflow/issues/2660) // https://github.com/nextflow-io/nextflow/issues/2660)
[meta + [is_unaligned: is_unaligned, is_sorted: is_sorted, has_reads: has_reads], paths] [
meta + [
is_unaligned: is_unaligned,
is_sorted: is_sorted,
has_reads: has_reads,
has_splice_cigars: has_splice_cigars,
has_modbase_tags: has_modbase_tags,
],
paths
]
}
if (aln_ref_ch && checked_bam_hook) {
ch_check_bams = ch_check_bams
| collect(flat: false)
| flatMap { checked_bams ->
checked_bam_hook(checked_bams)
}
} }
// Handle alignment // Handle alignment
@ -431,7 +466,7 @@ def xam_ingress(Map arguments, aln_ref_ch = null)
alignment_fork = ch_check_bams alignment_fork = ch_check_bams
| branch { | branch {
meta, paths -> meta, paths ->
to_align: (meta.is_unaligned || margs.force_alignment) && meta.has_reads to_align: (meta.is_unaligned || meta.requires_alignment || margs.force_alignment) && meta.has_reads
noalign: true noalign: true
} }
if (margs["output_xam_fmt"] == "bam"){ if (margs["output_xam_fmt"] == "bam"){
@ -448,14 +483,18 @@ def xam_ingress(Map arguments, aln_ref_ch = null)
// Update meta is unaligned // Update meta is unaligned
mm2_aln_final = mm2_aln.alignment.map{ mm2_aln_final = mm2_aln.alignment.map{
meta, xam, xai, stats -> meta, xam, xai, stats ->
// remove has reads from meta as no longer required // remove alignment routing metadata that is no longer required
def newmeta = meta.findAll { k, v -> k != 'has_reads' } def newmeta = meta.findAll {
k, v -> !(k in ['has_reads', 'requires_alignment'])
}
[newmeta + [is_unaligned: false], xam, xai, stats] [newmeta + [is_unaligned: false], xam, xai, stats]
} }
// Process BAM files that do not require realignment by passing them through the standard downstream steps (merging, sorting, indexing, etc.) // Process BAM files that do not require realignment by passing them through the standard downstream steps (merging, sorting, indexing, etc.)
ch_result_tmp = alignment_fork.noalign.map{ ch_result_tmp = alignment_fork.noalign.map{
meta, paths -> meta, paths ->
def newmeta = meta.findAll { k, v -> k != 'has_reads' } def newmeta = meta.findAll {
k, v -> !(k in ['has_reads', 'requires_alignment'])
}
[newmeta, paths] [newmeta, paths]
} }
} else { } else {
@ -769,7 +808,7 @@ process fastcat {
""" """
} }
process checkBamHeaders { process checkBamHeads {
label "ingress" label "ingress"
label "wf_common" label "wf_common"
cpus 1 cpus 1
@ -784,6 +823,8 @@ process checkBamHeaders {
env(MIXED_SQ_HEADERS), env(MIXED_SQ_HEADERS),
env(IS_SORTED), env(IS_SORTED),
env(HAS_READS), env(HAS_READS),
env(HAS_SPLICE_CIGARS),
env(HAS_MODBASE_TAGS),
) )
script: script:
String ref_arg = ref.fileName.name == OPTIONAL_FILE.name ? "" : "--ref $ref --ref_idx $ref_index" String ref_arg = ref.fileName.name == OPTIONAL_FILE.name ? "" : "--ref $ref --ref_idx $ref_index"

99
main.nf
View File

@ -20,6 +20,8 @@ process getVersions {
publishDir "${params.out_dir}", mode: 'copy', pattern: "versions.txt" publishDir "${params.out_dir}", mode: 'copy', pattern: "versions.txt"
cpus 1 cpus 1
memory "2 GB" memory "2 GB"
input:
path "additional_versions.txt"
output: output:
path "versions.txt" path "versions.txt"
script: script:
@ -28,6 +30,36 @@ process getVersions {
samtools --version | head -n 1 | sed 's/ /,/' >> versions.txt samtools --version | head -n 1 | sed 's/ /,/' >> versions.txt
gffread --version | sed 's/^/gffread,/' >> versions.txt || true gffread --version | sed 's/^/gffread,/' >> versions.txt || true
Rscript -e 'pkgs <- c("bambu", "DESeq2", "DEXSeq"); for (pkg in pkgs) {cat(pkg, as.character(packageVersion(pkg)), sep = ","); cat("\\n")}' >> versions.txt Rscript -e 'pkgs <- c("bambu", "DESeq2", "DEXSeq"); for (pkg in pkgs) {cat(pkg, as.character(packageVersion(pkg)), sep = ","); cat("\\n")}' >> versions.txt
cat additional_versions.txt >> versions.txt
"""
}
process getSqantiVersion {
label "wf_transcriptomes_sqanti"
cpus 1
memory "2 GB"
output:
path "versions.txt"
script:
"""
sqanti3_qc.py --version | sed 's/ /,/' >> versions.txt
"""
}
process getModkitVersion {
label "modkit"
cpus 1
memory "2 GB"
input:
path "old_versions.txt"
output:
path "versions.txt"
script:
"""
cp old_versions.txt versions.txt
modkit --version | sed 's/ /,/' >> versions.txt
""" """
} }
@ -100,7 +132,7 @@ workflow wf {
ref_genome ref_genome
ref_annotation ref_annotation
main: main:
software_versions = getVersions() software_versions = getVersions(getModkitVersion(getSqantiVersion()))
workflow_params = getParams() workflow_params = getParams()
transcriptome_results = transcriptome(reads, ref_genome, ref_annotation, sample_sheet) transcriptome_results = transcriptome(reads, ref_genome, ref_annotation, sample_sheet)
@ -235,6 +267,70 @@ workflow {
"sort_threads": 3, "sort_threads": 3,
] ]
def checked_bam_hook = { checked_bams ->
if (params.force_alignment) {
// short-circuit this hook to allow for forced realignment
return checked_bams
}
// determine BAMs with and without splice-aware CIGAR evidence
ArrayList aliases_with_splice_cigars = []
ArrayList aliases_without_splice_cigars = []
checked_bams.each {
def meta = it[0]
if (meta.has_reads) {
if (meta.has_splice_cigars) {
aliases_with_splice_cigars.add(meta.alias)
} else {
aliases_without_splice_cigars.add(meta.alias)
}
}
}
boolean any_with_splice_cigars = aliases_with_splice_cigars
boolean any_without_splice_cigars = aliases_without_splice_cigars
// explode on mixed splice-aware status
if (any_with_splice_cigars && any_without_splice_cigars) {
log.error """
Cannot proceed with mixed splice-aware CIGAR evidence in input BAMs.
This workflow requires splice-aware alignment. It cannot safely realign
only the BAMs that appear to lack splice-aware CIGARs, because the
original alignment parameters for the other BAMs are unknown.
It is unlikely for a splice-aware aligned transcriptome BAM to have no
reads with N CIGAR operations. Inspect your BAMs to understand why their
splice-aware alignment evidence differs. Either remove the BAMs without
splice-aware CIGARs from the analysis, or use --force_alignment to realign
all inputs.
Samples with splice CIGARs: ${aliases_with_splice_cigars.join(', ')}
Samples without splice CIGARs: ${aliases_without_splice_cigars.join(', ')}
""".stripIndent().trim()
error "Mixed splice-aware CIGAR evidence in input BAMs."
}
// all read-containing BAMs have splice-aware CIGAR evidence, or there are no reads
if (!any_without_splice_cigars) {
return checked_bams
}
// if we're here, all read-containing BAMs lack splice-aware CIGAR evidence
// fiddle the metamap to set requires_alignment (if there are reads to align)
log.warn "No input BAMs appear to contain splice-aware CIGAR evidence. " +
"The workflow will realign all inputs."
checked_bams.collect {
def meta = it[0]
def paths = it[1]
[
meta + [
requires_alignment: meta.has_reads,
],
paths
]
}
}
if (params.fastq) { if (params.fastq) {
samples = fastq_ingress([ samples = fastq_ingress([
"input": params.fastq, "input": params.fastq,
@ -243,6 +339,7 @@ workflow {
samples = xam_ingress([ samples = xam_ingress([
"input": params.bam, "input": params.bam,
"force_alignment": params.force_alignment, "force_alignment": params.force_alignment,
"checked_bam_hook": checked_bam_hook,
] + ingress_args, ref_genome) ] + ingress_args, ref_genome)
} }

View File

@ -22,7 +22,6 @@ params {
analyse_unclassified = false analyse_unclassified = false
analyse_fail = false analyse_fail = false
threads = 4
mod_codes = null mod_codes = null
force_alignment = false force_alignment = false
ndr = null ndr = null
@ -44,9 +43,9 @@ params {
per_read_stats = false per_read_stats = false
allow_multiple_basecall_models = false allow_multiple_basecall_models = false
example_cmd = [ example_cmd = [
"--bam 'wf-transcriptomes-demo/samples'",
"--de_analysis", "--de_analysis",
"--direct_rna", "--direct_rna",
"--fastq 'wf-transcriptomes-demo/differential_expression_fastq'",
"--ref_annotation 'wf-transcriptomes-demo/gencode.v22.annotation.chr20.gtf'", "--ref_annotation 'wf-transcriptomes-demo/gencode.v22.annotation.chr20.gtf'",
"--ref_genome 'wf-transcriptomes-demo/hg38_chr20.fa'", "--ref_genome 'wf-transcriptomes-demo/hg38_chr20.fa'",
"--sample_sheet 'wf-transcriptomes-demo/sample_sheet.csv'", "--sample_sheet 'wf-transcriptomes-demo/sample_sheet.csv'",
@ -68,7 +67,7 @@ manifest {
description = 'Long-read transcript discovery, quantification, differential expression, QC and mod counting.' description = 'Long-read transcript discovery, quantification, differential expression, QC and mod counting.'
mainScript = 'main.nf' mainScript = 'main.nf'
nextflowVersion = '>=23.04.2' nextflowVersion = '>=23.04.2'
version = 'v2.0.0-rc1' version = 'v2.0.0'
} }
process { process {

View File

@ -4,6 +4,8 @@
"title": "epi2me-labs/wf-transcriptomes", "title": "epi2me-labs/wf-transcriptomes",
"workflow_title": "Transcriptomes", "workflow_title": "Transcriptomes",
"description": "Long-read transcript discovery, quantification, differential expression, QC and mod counting.", "description": "Long-read transcript discovery, quantification, differential expression, QC and mod counting.",
"demo_url": "https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/v2/wf-transcriptomes-demo.tar.gz",
"aws_demo_url": "https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-transcriptomes/wf-transcriptomes-demo/transcriptomes.v2.aws.nextflow.config",
"url": "https://github.com/epi2me-labs/wf-transcriptomes", "url": "https://github.com/epi2me-labs/wf-transcriptomes",
"type": "object", "type": "object",
"definitions": { "definitions": {
@ -167,11 +169,6 @@
"type": "object", "type": "object",
"description": "Performance tuning and tool-specific advanced parameters.", "description": "Performance tuning and tool-specific advanced parameters.",
"properties": { "properties": {
"threads": {
"type": "integer",
"default": 4,
"description": "Thread count to use for the core workflow processes."
},
"mod_codes": { "mod_codes": {
"type": "string", "type": "string",
"description": "Comma-separated modified base codes to pass to modkit pileup.", "description": "Comma-separated modified base codes to pass to modkit pileup.",

View File

@ -3,7 +3,7 @@ nextflow.enable.dsl = 2
process runDifferentialAnalysis { process runDifferentialAnalysis {
label "wf_transcriptomes" label "wf_transcriptomes"
cpus { params.threads ?: 4 } cpus 4
memory "32 GB" memory "32 GB"
input: input:
path transcript_rds path transcript_rds

View File

@ -1,35 +1,6 @@
// Check that the bam has modifications
process validate_modbam {
label "wf_common"
cpus 1
memory 4.GB
input:
tuple val(meta),
path(alignment),
path(alignment_index),
val(alignment_stats)
output:
tuple val(meta),
path(alignment),
path(alignment_index),
val(alignment_stats),
env(valid)
script:
"""
valid=0
workflow-glue check_valid_modbam ${alignment} || valid=\$?
# Allow EX_OK and EX_DATAERR, otherwise explode
if [ \$valid -ne 0 ] && [ \$valid -ne 65 ]; then
exit 1
fi
"""
}
process runModkitPileup { process runModkitPileup {
label "modkit" label "modkit"
cpus { params.threads ?: 4 } cpus 4
memory "16 GB" memory "16 GB"
input: input:
tuple val(alias), tuple val(alias),
@ -158,15 +129,12 @@ workflow mods {
xams xams
ref_genome ref_genome
main: main:
// Check inputs have modtags, we'll early abort mod analysis for any that don't xams.branch {
validate_modbam(xams) nomods: it[0].has_modbase_tags != true
.branch { return it[0].alias
nomods: it[-1] == '65' mods: it[0].has_modbase_tags == true
return it[0].alias return [it[0].alias] + it // prepend alias for joining
mods: it[-1] == '0' }.set{xams_with}
return [it[0].alias] + it[0..-2] // prepend alias for joining and drop exit_code marker
}
.set{xams_with}
// warn for samples without mods // warn for samples without mods
xams_with.nomods.subscribe { xams_with.nomods.subscribe {

View File

@ -138,7 +138,7 @@ process buildSampleTranscriptomeFasta {
process runJointSqanti { process runJointSqanti {
label "wf_transcriptomes_sqanti" label "wf_transcriptomes_sqanti"
cpus { params.threads ?: 4 } cpus 2
memory "24 GB" memory "24 GB"
publishDir "${params.out_dir}/cohort", publishDir "${params.out_dir}/cohort",
mode: "copy", mode: "copy",
@ -172,7 +172,7 @@ process runJointSqanti {
process runPerSampleSqanti { process runPerSampleSqanti {
label "wf_transcriptomes_sqanti" label "wf_transcriptomes_sqanti"
cpus { params.threads ?: 4 } cpus 2
memory "24 GB" memory "24 GB"
publishDir "${params.out_dir}/samples/${meta.alias}", publishDir "${params.out_dir}/samples/${meta.alias}",
mode: "copy", mode: "copy",