Merge branch 'v200-rc2' into 'dev'
wf-transcriptomes v2.0.0 See merge request epi2melabs/workflows/wf-transcriptomes!320
This commit is contained in:
commit
1fa032ae0e
@ -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:
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
19
README.md
19
README.md
@ -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. | |
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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.")
|
||||||
|
|
||||||
|
|||||||
@ -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)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"))
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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_)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
```
|
||||||
|
|||||||
@ -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. | |
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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]
|
||||||
|
|||||||
@ -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
99
main.nf
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user