Merge branch 'template_update_0624' into 'dev'

Template update

See merge request epi2melabs/workflows/wf-transcriptomes!175
This commit is contained in:
Neil Horner 2024-07-04 13:25:20 +00:00
commit 3d5c4c38eb
9 changed files with 265 additions and 307 deletions

View File

@ -8,7 +8,7 @@ repos:
always_run: true always_run: true
pass_filenames: false pass_filenames: false
additional_dependencies: additional_dependencies:
- epi2melabs>=0.0.53 - epi2melabs==0.0.55
- id: build_models - id: build_models
name: build_models name: build_models
entry: datamodel-codegen --strict-nullable --base-class workflow_glue.results_schema_helpers.BaseModel --use-schema-description --disable-timestamp --input results_schema.yml --input-file-type openapi --output bin/workflow_glue/results_schema.py entry: datamodel-codegen --strict-nullable --base-class workflow_glue.results_schema_helpers.BaseModel --use-schema-description --disable-timestamp --input results_schema.yml --input-file-type openapi --output bin/workflow_glue/results_schema.py

View File

@ -38,40 +38,68 @@ ARM processor support: False
## Install and run ## Install and run
These are instructions to install and run the workflow on command line. You can also access the workflow via the [EPI2ME application](https://labs.epi2me.io/downloads/).
The workflow uses [Nextflow](https://www.nextflow.io/) to manage compute and software resources, therefore nextflow will need to be installed before attempting to run the workflow. These are instructions to install and run the workflow on command line.
You can also access the workflow via the
[EPI2ME Desktop application](https://labs.epi2me.io/downloads/).
The workflow can currently be run using either [Docker](https://www.docker.com/products/docker-desktop) or The workflow uses [Nextflow](https://www.nextflow.io/) to manage
[Singularity](https://docs.sylabs.io/guides/3.0/user-guide/index.html) to provide isolation of compute and software resources,
the required software. Both methods are automated out-of-the-box provided therefore Nextflow will need to be
either docker or singularity is installed. This is controlled by the [`-profile`](https://www.nextflow.io/docs/latest/config.html#config-profiles) parameter as exemplified below. installed before attempting to run the workflow.
It is not required to clone or download the git repository in order to run the workflow. The workflow can currently be run using either
More information on running EPI2ME workflows can be found on our [website](https://labs.epi2me.io/wfindex). [Docker](https://www.docker.com/products/docker-desktop
or [Singularity](https://docs.sylabs.io/guides/3.0/user-guide/index.html)
to provide isolation of the required software.
Both methods are automated out-of-the-box provided
either Docker or Singularity is installed.
This is controlled by the
[`-profile`](https://www.nextflow.io/docs/latest/config.html#config-profiles)
parameter as exemplified below.
The following command can be used to obtain the workflow. This will pull the repository in to the assets folder of nextflow and provide a list of all parameters available for the workflow as well as an example command: It is not required to clone or download the git repository
in order to run the workflow.
More information on running EPI2ME workflows can
be found on our [website](https://labs.epi2me.io/wfindex).
The following command can be used to obtain the workflow.
This will pull the repository in to the assets folder of
Nextflow and provide a list of all parameters
available for the workflow as well as an example command:
``` ```
nextflow run epi2me-labs/wf-transcriptomes -help nextflow run epi2me-labs/wf-transcriptomes --help
``` ```
A demo dataset is provided for testing of the workflow. It can be downloaded using: To update a workflow to the latest version on the command line use
the following command:
``` ```
wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-isoforms/differential_expression.tar.gz nextflow pull epi2me-labs/wf-transcriptomes
tar -xzvf differential_expression.tar.gz
``` ```
The workflow can be run with the demo data using: 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/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 \ nextflow run epi2me-labs/wf-transcriptomes \
--fastq differential_expression/differential_expression_fastq \ --de_analysis \
--de_analysis --ref_genome differential_expression/hg38_chr20.fa \ --direct_rna \
--transcriptome-source reference-guided \ --fastq 'wf-transcriptomes-demo/differential_expression_fastq' \
--ref_annotation differential_expression/gencode.v22.annotation.chr20.gtf \ --jaffal_annotation 'genCode22' \
--direct_rna --minimap2_index_opts '-k 15' --sample_sheet differential_expression/sample_sheet.csv \ --jaffal_genome 'hg38_chr20' \
--jaffal_refBase differential_expression/chr20/ --jaffal_genome hg38_chr20 --jaffal_annotation genCode22 \ --jaffal_refBase 'wf-transcriptomes-demo/chr20' \
-profile standard --minimap2_index_opts '-k15' \
--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
``` ```
For further information about running a workflow on the cmd line see https://labs.epi2me.io/wfquickstart/ For further information about running a workflow on
the command line see https://labs.epi2me.io/wfquickstart/

View File

@ -38,6 +38,7 @@ def main(args):
barcodes = [] barcodes = []
aliases = [] aliases = []
sample_types = [] sample_types = []
analysis_groups = []
allowed_sample_types = [ allowed_sample_types = [
"test_sample", "positive_control", "negative_control", "no_template_control" "test_sample", "positive_control", "negative_control", "no_template_control"
] ]
@ -91,6 +92,10 @@ def main(args):
sample_types.append(row["type"]) sample_types.append(row["type"])
except KeyError: except KeyError:
pass pass
try:
analysis_groups.append(row["analysis_group"])
except KeyError:
pass
except Exception as e: except Exception as e:
sys.stdout.write(f"Parsing error: {e}") sys.stdout.write(f"Parsing error: {e}")
sys.exit() sys.exit()
@ -136,6 +141,14 @@ def main(args):
sys.stdout.write( sys.stdout.write(
f"Sample sheet requires at least 1 of {required_type}") f"Sample sheet requires at least 1 of {required_type}")
sys.exit() sys.exit()
if analysis_groups:
# if there was a "analysis_group" column, make sure it had values for all
# samples
if not all(analysis_groups):
sys.stdout.write(
"if an 'analysis_group' column exists, it needs values in each row"
)
sys.exit()
logger.info(f"Checked sample sheet {args.sample_sheet}.") logger.info(f"Checked sample sheet {args.sample_sheet}.")

View File

@ -8,16 +8,16 @@ from .util import get_named_logger, wf_parser # noqa: ABS101
def parse_fnames(fofn): def parse_fnames(fofn):
"""Parse list with filenames and return them grouped as ref-, BAM-, or VCF-related. """Parse list with filenames and return them grouped as ref-, XAM-, or VCF-related.
:param fofn: File with list of file names (one per line) :param fofn: File with list of file names (one per line)
:return: dict of reference-related filenames (with keys 'ref', 'fai', and '.gzi' and :return: dict of reference-related filenames (with keys 'ref', 'fai', and '.gzi' and
`None` as default values); lists of BAM- and VCF-related filenames `None` as default values); lists of XAM- and VCF-related filenames
""" """
ref_extensions = [".fasta", ".fasta.gz", ".fa", ".fa.gz", ".fna", ".fna.gz"] ref_extensions = [".fasta", ".fasta.gz", ".fa", ".fa.gz", ".fna", ".fna.gz"]
ref_dict = {} ref_dict = {}
bams = [] xams = []
bam_indices = [] xam_indices = []
vcfs = [] vcfs = []
vcf_indices = [] vcf_indices = []
with open(fofn, "r") as f: with open(fofn, "r") as f:
@ -29,10 +29,10 @@ def parse_fnames(fofn):
ref_dict["fai"] = fname ref_dict["fai"] = fname
elif fname.endswith(".gzi"): elif fname.endswith(".gzi"):
ref_dict["gzi"] = fname ref_dict["gzi"] = fname
elif fname.endswith(".bam"): elif fname.endswith(".bam") or fname.endswith(".cram"):
bams.append(fname) xams.append(fname)
elif fname.endswith(".bai"): elif fname.endswith(".bai") or fname.endswith(".crai"):
bam_indices.append(fname) xam_indices.append(fname)
elif fname.endswith(".vcf") or fname.endswith(".vcf.gz"): elif fname.endswith(".vcf") or fname.endswith(".vcf.gz"):
vcfs.append(fname) vcfs.append(fname)
elif fname.endswith(".csi") or fname.endswith(".tbi"): elif fname.endswith(".csi") or fname.endswith(".tbi"):
@ -52,20 +52,20 @@ def parse_fnames(fofn):
f"Found GZI reference index '{gzi}', but the reference file " f"Found GZI reference index '{gzi}', but the reference file "
f"'{ref}' appears not to be compressed." f"'{ref}' appears not to be compressed."
) )
if bam_indices: if xam_indices:
if len(bams) != len(bam_indices): if len(xams) != len(xam_indices):
raise ValueError("Got different number of BAM and BAM index files.") raise ValueError("Got different number of XAM and XAM index files.")
if vcf_indices: if vcf_indices:
if len(vcfs) != len(vcf_indices): if len(vcfs) != len(vcf_indices):
raise ValueError("Got different number of VCF and VCF index files.") raise ValueError("Got different number of VCF and VCF index files.")
if bams and vcfs: if xams and vcfs:
if len(bams) != len(vcfs): if len(xams) != len(vcfs):
raise ValueError("Got different number of BAM and VCF files.") raise ValueError("Got different number of XAM and VCF files.")
# if we got BAM or VCF indices, pair them up with their corresponding files (and # if we got XAM or VCF indices, pair them up with their corresponding files (and
# otherwise with `None`) # otherwise with `None`)
bams_with_indices = zip_longest(bams, bam_indices) xams_with_indices = zip_longest(xams, xam_indices)
vcfs_with_indices = zip_longest(vcfs, vcf_indices) vcfs_with_indices = zip_longest(vcfs, vcf_indices)
return ref_dict, bams_with_indices, vcfs_with_indices return ref_dict, xams_with_indices, vcfs_with_indices
def get_reference_options(ref, fai=None, gzi=None): def get_reference_options(ref, fai=None, gzi=None):
@ -90,23 +90,23 @@ def get_reference_options(ref, fai=None, gzi=None):
return ref_opts return ref_opts
def get_alignment_track(bam, bai=None, extra_opts=None): def get_alignment_track(xam, xai=None, extra_opts=None):
"""Create dict with options for IGV alignment track. """Create dict with options for IGV alignment track.
:param bam: name of BAM file to be displayed :param xam: name of XAM file to be displayed
:param bai: name of BAM index file :param xai: name of XAM index file
:param extra_opts: dict of extra options for the alignment track :param extra_opts: dict of extra options for the alignment track
:return: dict with alignment track options :return: dict with alignment track options
""" """
alignment_track_dict = { alignment_track_dict = {
"name": bam, "name": xam,
"type": "alignment", "type": "alignment",
"format": "bam", "format": xam.split(".")[-1],
"url": bam, "url": xam,
} }
# add the BAM index if present # add the XAM index if present
if bai is not None: if xai is not None:
alignment_track_dict["indexURL"] = bai alignment_track_dict["indexURL"] = xai
alignment_track_dict.update(extra_opts or {}) alignment_track_dict.update(extra_opts or {})
return alignment_track_dict return alignment_track_dict
@ -137,7 +137,7 @@ def main(args):
logger = get_named_logger("configIGV") logger = get_named_logger("configIGV")
# parse the FOFN # parse the FOFN
ref_dict, bams_with_indices, vcfs_with_indices = parse_fnames(args.fofn) ref_dict, xams_with_indices, vcfs_with_indices = parse_fnames(args.fofn)
# initialise the IGV options dict with the reference options # initialise the IGV options dict with the reference options
json_dict = {"reference": get_reference_options(**ref_dict)} json_dict = {"reference": get_reference_options(**ref_dict)}
@ -145,30 +145,30 @@ def main(args):
# if we got JSON files with extra options for the alignment / variant tracks, read # if we got JSON files with extra options for the alignment / variant tracks, read
# them # them
extra_alignment_opts = {} extra_alignment_opts = {}
if args.extra_bam_opts is not None: if args.extra_alignment_opts is not None:
with open(args.extra_bam_opts, "r") as f: with open(args.extra_alignment_opts, "r") as f:
extra_alignment_opts = json.load(f) extra_alignment_opts = json.load(f)
extra_variant_opts = {} extra_variant_opts = {}
if args.extra_vcf_opts is not None: if args.extra_variant_opts is not None:
with open(args.extra_vcf_opts, "r") as f: with open(args.extra_variant_opts, "r") as f:
extra_variant_opts = json.load(f) extra_variant_opts = json.load(f)
# now add the alignment and variant tracks # now add the alignment and variant tracks
json_dict["tracks"] = [] json_dict["tracks"] = []
# we use `zip_longest` to make sure that variant and alignment tracks from the same # we use `zip_longest` to make sure that variant and alignment tracks from the same
# sample are added after each other # sample are added after each other
for (vcf, vcf_index), (bam, bam_index) in zip_longest( for (vcf, vcf_index), (xam, xam_index) in zip_longest(
vcfs_with_indices, bams_with_indices, fillvalue=(None, None) vcfs_with_indices, xams_with_indices, fillvalue=(None, None)
): ):
if vcf is not None: if vcf is not None:
# add an variant track for the VCF # add a variant track for the VCF
json_dict["tracks"].append( json_dict["tracks"].append(
get_variant_track(vcf, vcf_index, extra_variant_opts) get_variant_track(vcf, vcf_index, extra_variant_opts)
) )
if bam is not None: if xam is not None:
# add an alignment track for the BAM # add an alignment track for the XAM
json_dict["tracks"].append( json_dict["tracks"].append(
get_alignment_track(bam, bam_index, extra_alignment_opts) get_alignment_track(xam, xam_index, extra_alignment_opts)
) )
if args.locus is not None: if args.locus is not None:
@ -186,7 +186,7 @@ def argparser():
"--fofn", "--fofn",
required=True, required=True,
help=( help=(
"File with list of names of reference / BAM / VCF files and indices " "File with list of names of reference / XAM / VCF files and indices "
"(one filename per line)" "(one filename per line)"
), ),
) )
@ -195,11 +195,11 @@ def argparser():
help="Locus string to set initial genomic coordinates to display in IGV", help="Locus string to set initial genomic coordinates to display in IGV",
) )
parser.add_argument( parser.add_argument(
"--extra-bam-opts", "--extra-alignment-opts",
help="JSON file with extra options for alignment tracks", help="JSON file with extra options for alignment tracks",
) )
parser.add_argument( parser.add_argument(
"--extra-vcf-opts", "--extra-variant-opts",
help="JSON file with extra options for variant tracks", help="JSON file with extra options for variant tracks",
) )
return parser return parser

View File

@ -1,166 +0,0 @@
"""Extract unique values for a key from XAM RG DS headers or FASTX comments.
Use pysam to read the description tag of XAM read group header(s) to
collect values for a given key and check the expected cardinality from
one or more XAM; or equivalently the comments of one or more FASTX to
do the same.
Use for example, to extract a single basecaller configuration name in
order to match the input data to suitable models for downstream tools
without troubling the user. No guarantee is made for ordering.
"""
import os
import sys
import pysam
from .util import wf_parser # noqa: ABS101
# This is not my ideal way to raise nice user-facing errors, but embedding them in the
# Nextflow process itself is (a) confusing for users; as the error log includes echo
# commands which may or may not actually be printed and (b) a footgun for developers;
# who may inadvertently mishandle catching and then (re)returning a bad exit code.
# This also rather neatly keeps intended messaging for users near the code that will
# cause errors to be raised.
def get_extended_errmsg(key, expected_cardinality):
"""Get an applicable extended error message for a given key and cardinality."""
if key == "basecall_model" and expected_cardinality == "zero-or-one":
return """
################################################################################
# INPUT DATA PROBLEM
Your input data contains reads basecalled with more than one basecaller model.
Our workflows automatically select appropriate configuration and models for
downstream tools for a given basecaller model. This cannot be done reliably when
reads with different basecaller models are mixed in the same data set.
## Next steps
To use this workflow you must separate your input files, making sure all reads
are have been basecalled with the same basecaller model.
################################################################################
"""
def path_to_lofn(input_path):
"""Convert the input path to a list of one or more files to be checked."""
if os.path.isdir(input_path):
return [
os.path.join(root, f)
for (root, dirnames, filenames) in os.walk(input_path)
for f in filenames
]
else:
return [input_path]
def xam_extract_ds_key(xam_lofn, key):
"""Extract the set of values for a given key from all RG DS tags."""
entries = set()
for xam_fn in xam_lofn:
with pysam.AlignmentFile(xam_fn, check_sq=False) as xam:
for read_group in xam.header.get("RG", []):
for ds_kv in read_group.get("DS", "").split():
k, v = ds_kv.split("=", 1)
if k == key:
entries.add(v)
return entries
def fastx_extract_ds_key(fastx_lofn, key, stop_after=0):
"""Extract the set of values for a given key from all FASTQ tags."""
entries = set()
for fastx_fn in fastx_lofn:
with pysam.FastxFile(fastx_fn) as fastx:
for i, read in enumerate(fastx):
if stop_after > 0 and i > stop_after:
break
for ds_kv in read.comment.split():
k, v = ds_kv.split("=", 1)
if k == key:
entries.add(v)
return entries
def check_cardinality(obs, desired_cardinality):
"""Return whether the observed cardinality meets the desired cardinality."""
cardinality_lookup = {
0: ["zero", "zero-or-one", "zero-or-more"],
1: ["zero-or-one", "zero-or-more", "one", "one-or-more"],
2: ["zero-or-more", "one-or-more", "more-than-one"],
}
if obs > 1:
obs = 2
return desired_cardinality in cardinality_lookup[obs]
def main(args):
"""Script entrypoint.
Extracts values using the XAM or FASTX extractor and checks the set
is of the right cardinality. Prints a message to stdout (or stderr)
and exits appropriately.
"""
if args.xam:
extractor = xam_extract_ds_key
input_path = args.xam
elif args.fastx:
extractor = fastx_extract_ds_key
input_path = args.fastx
lofn = path_to_lofn(input_path)
entries = extractor(lofn, args.key)
if not check_cardinality(len(entries), args.cardinality):
sys.stdout.write(args.sep.join(entries) + '\n')
extended_error = get_extended_errmsg(args.key, args.cardinality)
if args.explode_obviously and extended_error:
sys.stderr.write(extended_error)
else:
sys.stderr.write(
f"Required {args.cardinality} {args.key} but found {len(entries)}\n"
)
sys.exit(os.EX_DATAERR)
sys.stdout.write(args.sep.join(entries) + '\n')
sys.exit(os.EX_OK)
def argparser():
"""Argument parser for entrypoint."""
parser = wf_parser("get_ds_records")
parser.add_argument("--key", required=True)
input_arg = parser.add_mutually_exclusive_group(required=True)
input_arg.add_argument("--xam", help="Path to a single XAM or folder of XAM")
input_arg.add_argument("--fastx", help="Path to a single FASTX or folder of FASTX")
parser.add_argument(
"--sep",
default="\n",
help=(
"Value separator to use if more than one element is printed to stdout."
),
)
parser.add_argument(
"--cardinality",
default="zero-or-more",
choices=[
"zero",
"zero-or-one",
"zero-or-more",
"one",
"one-or-more",
"more-than-one",
],
help=(
"Expected cardinality of entries. Will exit EX_DATAERR if the wrong "
"number of elements are found. Defaults to zero-or-more."
),
)
parser.add_argument(
"--explode_obviously",
action="store_true",
help=(
"If appropriate, print a more fulsome user facing error if the expected"
"cardinality has been violated."
)
)
return parser

View File

@ -1,34 +1,61 @@
These are instructions to install and run the workflow on command line. You can also access the workflow via the [EPI2ME application](https://labs.epi2me.io/downloads/).
The workflow uses [Nextflow](https://www.nextflow.io/) to manage compute and software resources, therefore nextflow will need to be installed before attempting to run the workflow. These are instructions to install and run the workflow on command line.
You can also access the workflow via the
[EPI2ME Desktop application](https://labs.epi2me.io/downloads/).
The workflow can currently be run using either [Docker](https://www.docker.com/products/docker-desktop) or The workflow uses [Nextflow](https://www.nextflow.io/) to manage
[Singularity](https://docs.sylabs.io/guides/3.0/user-guide/index.html) to provide isolation of compute and software resources,
the required software. Both methods are automated out-of-the-box provided therefore Nextflow will need to be
either docker or singularity is installed. This is controlled by the [`-profile`](https://www.nextflow.io/docs/latest/config.html#config-profiles) parameter as exemplified below. installed before attempting to run the workflow.
It is not required to clone or download the git repository in order to run the workflow. The workflow can currently be run using either
More information on running EPI2ME workflows can be found on our [website](https://labs.epi2me.io/wfindex). [Docker](https://www.docker.com/products/docker-desktop
or [Singularity](https://docs.sylabs.io/guides/3.0/user-guide/index.html)
to provide isolation of the required software.
Both methods are automated out-of-the-box provided
either Docker or Singularity is installed.
This is controlled by the
[`-profile`](https://www.nextflow.io/docs/latest/config.html#config-profiles)
parameter as exemplified below.
The following command can be used to obtain the workflow. This will pull the repository in to the assets folder of nextflow and provide a list of all parameters available for the workflow as well as an example command: It is not required to clone or download the git repository
in order to run the workflow.
More information on running EPI2ME workflows can
be found on our [website](https://labs.epi2me.io/wfindex).
The following command can be used to obtain the workflow.
This will pull the repository in to the assets folder of
Nextflow and provide a list of all parameters
available for the workflow as well as an example command:
``` ```
nextflow run epi2me-labs/wf-transcriptomes -help nextflow run epi2me-labs/wf-transcriptomes --help
``` ```
A demo dataset is provided for testing of the workflow. It can be downloaded using: To update a workflow to the latest version on the command line use
the following command:
``` ```
wget https://ont-exd-int-s3-euwst1-epi2me-labs.s3.amazonaws.com/wf-isoforms/differential_expression.tar.gz nextflow pull epi2me-labs/wf-transcriptomes
tar -xzvf differential_expression.tar.gz
``` ```
The workflow can be run with the demo data using: 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/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 \ nextflow run epi2me-labs/wf-transcriptomes \
--fastq differential_expression/differential_expression_fastq \ --de_analysis \
--de_analysis --ref_genome differential_expression/hg38_chr20.fa \ --direct_rna \
--transcriptome-source reference-guided \ --fastq 'wf-transcriptomes-demo/differential_expression_fastq' \
--ref_annotation differential_expression/gencode.v22.annotation.chr20.gtf \ --jaffal_annotation 'genCode22' \
--direct_rna --minimap2_index_opts '-k 15' --sample_sheet differential_expression/sample_sheet.csv \ --jaffal_genome 'hg38_chr20' \
--jaffal_refBase differential_expression/chr20/ --jaffal_genome hg38_chr20 --jaffal_annotation genCode22 \ --jaffal_refBase 'wf-transcriptomes-demo/chr20' \
-profile standard --minimap2_index_opts '-k15' \
--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
``` ```
For further information about running a workflow on the cmd line see https://labs.epi2me.io/wfquickstart/ For further information about running a workflow on
the command line see https://labs.epi2me.io/wfquickstart/

View File

@ -23,33 +23,33 @@ process configure_igv {
// extensions // extensions
path "file-names.txt" path "file-names.txt"
val locus_str val locus_str
val bam_extra_opts val aln_extra_opts
val vcf_extra_opts val var_extra_opts
output: path "igv.json" output: path "igv.json"
script: script:
// the locus argument just makes sure that the initial view in IGV shows something // the locus argument just makes sure that the initial view in IGV shows something
// interesting // interesting
String locus_arg = locus_str ? "--locus $locus_str" : "" String locus_arg = locus_str ? "--locus $locus_str" : ""
// extra options for alignment tracks // extra options for alignment tracks
def bam_opts_json_str = \ def aln_opts_json_str = \
bam_extra_opts ? new JsonBuilder(bam_extra_opts).toPrettyString() : "" aln_extra_opts ? new JsonBuilder(aln_extra_opts).toPrettyString() : ""
String bam_extra_opts_arg = \ String aln_extra_opts_arg = \
bam_extra_opts ? "--extra-bam-opts bam-extra-opts.json" : "" aln_extra_opts ? "--extra-alignment-opts extra-aln-opts.json" : ""
// extra options for variant tracks // extra options for variant tracks
def vcf_opts_json_str = \ def var_opts_json_str = \
vcf_extra_opts ? new JsonBuilder(vcf_extra_opts).toPrettyString() : "" var_extra_opts ? new JsonBuilder(var_extra_opts).toPrettyString() : ""
String vcf_extra_opts_arg = \ String var_extra_opts_arg = \
vcf_extra_opts ? "--extra-vcf-opts vcf-extra-opts.json" : "" var_extra_opts ? "--extra-vcf-opts extra-var-opts.json" : ""
""" """
# write out JSON files with extra options for the alignment and variant tracks # write out JSON files with extra options for the alignment and variant tracks
echo '$bam_opts_json_str' > bam-extra-opts.json echo '$aln_opts_json_str' > extra-aln-opts.json
echo '$vcf_opts_json_str' > vcf-extra-opts.json echo '$var_opts_json_str' > extra-var-opts.json
workflow-glue configure_igv \ workflow-glue configure_igv \
--fofn file-names.txt \ --fofn file-names.txt \
$locus_arg \ $locus_arg \
$bam_extra_opts_arg \ $aln_extra_opts_arg \
$vcf_extra_opts_arg \ $var_extra_opts_arg \
> igv.json > igv.json
""" """
} }

View File

@ -26,28 +26,40 @@ def is_target_file(Path file, List extensions) {
/** /**
* Take a channel of the shape `[meta, reads, path-to-stats-dir | null]` (or * Take a channel of the shape `[meta, reads, path-to-stats-dir | null]` (or
* `[meta, [reads, index], path-to-stats-dir | null]` in the case of XAM) and extract the * `[meta, [reads, index], path-to-stats-dir | null]` in the case of XAM) and extract the
* run IDs from the `run_ids` file in the stats directory into the metamap. If the path * run IDs and basecall model, from the `run_ids` and `basecaller` files in the stats
* to the stats dir is `null`, add an empty list. * directory, into the metamap. If the path to the stats dir is `null`, add an empty list.
* *
* @param ch: input channel of shape `[meta, reads, path-to-stats-dir | null]` * @param ch: input channel of shape `[meta, reads, path-to-stats-dir | null]`
* @return: channel with a list of run IDs added to the metamap * @return: channel with lists of run IDs and basecall models added to the metamap
*/ */
def add_run_IDs_to_meta(ch) { def add_run_IDs_and_basecall_models_to_meta(ch, boolean allow_multiple_basecall_models) {
// HashSet for all observed run_ids // HashSet for all observed run_ids
Set<String> ingressed_run_ids = new HashSet<String>() Set<String> ingressed_run_ids = new HashSet<String>()
// extract run_ids from fastcat stats / bamstats results and add to metadata as well // extract run_ids from fastcat stats / bamstats results and add to metadata as well
// as `ingressed_run_ids` // as `ingressed_run_ids`
ch = ch | map { meta, reads, stats -> ch = ch | map { meta, reads, stats ->
ArrayList run_ids = []
if (stats) { if (stats) {
run_ids = stats.resolve("run_ids").splitText().collect { it.strip() } run_ids = stats.resolve("run_ids").splitText().collect { it.strip() }
ingressed_run_ids += run_ids ingressed_run_ids += run_ids
basecall_models = \
stats.resolve("basecallers").splitText().collect { it.strip() }
// check if we got more than one basecall model and set reads + stats to
// `null` for that sample unless `allow_multiple_basecall_models`
if ((basecall_models.size() > 1) && !allow_multiple_basecall_models) {
log.warn "Found multiple basecall models for sample " + \
"'$meta.alias': ${basecall_models.join(", ")}. The sample's " + \
"reads were discarded."
reads = reads instanceof List ? [null, null] : null
stats = null
} }
// `meta + [...]` returns a new map which is handy to avoid any // `meta + [...]` returns a new map which is handy to avoid any
// modifying-maps-in-closures weirdness // modifying-maps-in-closures weirdness
// See https://github.com/nextflow-io/nextflow/issues/2660 // See https://github.com/nextflow-io/nextflow/issues/2660
[meta + [run_ids: run_ids], reads, stats] meta = meta + [run_ids: run_ids, basecall_models: basecall_models]
}
[meta, reads, stats]
} }
// put run_ids somewhere global for trivial access later // put run_ids somewhere global for trivial access later
// bit grim but decouples ingress metadata from workflow main.nf // bit grim but decouples ingress metadata from workflow main.nf
@ -125,6 +137,9 @@ def add_number_of_reads_to_meta(ch, String input_type_format) {
* - "required_sample_types": list of required sample types in the sample sheet * - "required_sample_types": list of required sample types in the sample sheet
* - "watch_path": boolean whether to use `watchPath` and run in streaming mode * - "watch_path": boolean whether to use `watchPath` and run in streaming mode
* - "fastq_chunk": null or a number of reads to place into chunked FASTQ files * - "fastq_chunk": null or a number of reads to place into chunked FASTQ files
* - "allow_multiple_basecall_models": emit data of samples that had more than one
* basecall model; if this is `false`, such samples will be emitted as `[meta, null,
* null]`
* @return: channel of `[Map(alias, barcode, type, ...), Path|null, Path|null]`. * @return: channel of `[Map(alias, barcode, type, ...), Path|null, Path|null]`.
* The first element is a map with metadata, the second is the path to the * The first element is a map with metadata, the second is the path to the
* `.fastq.gz` file with the (potentially concatenated) sequences and the third is * `.fastq.gz` file with the (potentially concatenated) sequences and the third is
@ -140,7 +155,6 @@ def fastq_ingress(Map arguments)
[ [
"fastcat_extra_args": "", "fastcat_extra_args": "",
"fastq_chunk": null, "fastq_chunk": null,
"per_read_stats": false
] ]
) )
margs["fastq_chunk"] ?= 0 // cant pass null through channel margs["fastq_chunk"] ?= 0 // cant pass null through channel
@ -196,8 +210,11 @@ def fastq_ingress(Map arguments)
[meta + new_keys, files, stats] [meta + new_keys, files, stats]
} }
def ch_final = add_number_of_reads_to_meta( // add number of reads, run IDs, and basecall models to meta
add_run_IDs_to_meta(ch_spread_result), "fastq") def ch_final = add_number_of_reads_to_meta(ch_spread_result, "fastq")
ch_final = add_run_IDs_and_basecall_models_to_meta(
ch_final, margs.allow_multiple_basecall_models
)
return ch_final return ch_final
} }
@ -242,7 +259,6 @@ def xam_ingress(Map arguments)
"return_fastq": false, "return_fastq": false,
"fastcat_extra_args": "", "fastcat_extra_args": "",
"fastq_chunk": null, "fastq_chunk": null,
"per_read_stats": false
] ]
) )
margs["fastq_chunk"] ?= 0 // cant pass null through channel margs["fastq_chunk"] ?= 0 // cant pass null through channel
@ -276,7 +292,7 @@ def xam_ingress(Map arguments)
[meta + [xai_fn: xai_fn], paths] [meta + [xai_fn: xai_fn], paths]
} }
| checkBamHeaders | checkBamHeaders
| map { meta, paths, is_unaligned_env, mixed_headers_env, is_sorted_env, ds_basecaller_env, ds_runids_env -> | map { meta, paths, is_unaligned_env, mixed_headers_env, is_sorted_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_headers = mixed_headers_env as int as boolean boolean mixed_headers = mixed_headers_env as int as boolean
@ -288,15 +304,7 @@ def xam_ingress(Map arguments)
// 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], paths]
meta + [
is_unaligned: is_unaligned,
is_sorted: is_sorted,
ds_runids: ds_runids_env.tokenize(','),
ds_basecall_models: ds_basecaller_env.tokenize(','),
],
paths
]
} }
| branch { meta, paths -> | branch { meta, paths ->
// set `paths` to `null` for uBAM samples if unallowed (they will be added to // set `paths` to `null` for uBAM samples if unallowed (they will be added to
@ -366,8 +374,11 @@ def xam_ingress(Map arguments)
[meta.findAll { it.key !in ['xai_fn', 'is_sorted'] }, path, stats] [meta.findAll { it.key !in ['xai_fn', 'is_sorted'] }, path, stats]
} }
def ch_final = add_number_of_reads_to_meta( // add number of reads, run IDs, and basecall models to meta
add_run_IDs_to_meta(ch_spread_result), "fastq") def ch_final = add_number_of_reads_to_meta(ch_spread_result, "fastq")
ch_final = add_run_IDs_and_basecall_models_to_meta(
ch_final, margs.allow_multiple_basecall_models
)
return ch_final return ch_final
} }
@ -457,7 +468,6 @@ def xam_ingress(Map arguments)
| map{ | map{
it[3] ? [it[0], [it[1], it[2]], it[3]] : it it[3] ? [it[0], [it[1], it[2]], it[3]] : it
} }
| add_run_IDs_to_meta
| map{ | map{
it.flatten() it.flatten()
} }
@ -483,6 +493,7 @@ def xam_ingress(Map arguments)
// n_unmapped: always present, but can be `null` // n_unmapped: always present, but can be `null`
// is_unaligned: present if there is a (u)BAM file // is_unaligned: present if there is a (u)BAM file
// ] // ]
// also, add number of reads, run IDs, and basecall models to meta
ch_result = add_number_of_reads_to_meta( ch_result = add_number_of_reads_to_meta(
ch_result ch_result
| map{ | map{
@ -491,6 +502,9 @@ def xam_ingress(Map arguments)
}, },
"xam" "xam"
) )
ch_result = add_run_IDs_and_basecall_models_to_meta(
ch_result, margs.allow_multiple_basecall_models
)
| map{ | map{
it.flatten() it.flatten()
} }
@ -529,7 +543,8 @@ process fastcat {
fastcat \ fastcat \
-s ${meta["alias"]} \ -s ${meta["alias"]} \
-f fastcat_stats/per-file-stats.tsv \ -f fastcat_stats/per-file-stats.tsv \
-i fastcat_stats/per-file-runids.txt \ -i fastcat_stats/per-file-runids.tsv \
-l fastcat_stats/per-file-basecallers.tsv \
--histograms histograms \ --histograms histograms \
$stats_args \ $stats_args \
${fcargs["fastcat_extra_args"]} \ ${fcargs["fastcat_extra_args"]} \
@ -545,9 +560,19 @@ process fastcat {
# get n_seqs from per-file stats - need to sum them up # get n_seqs from per-file stats - need to sum them up
awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {c+=\$ix["n_seqs"]} END{print c}' \ awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {c+=\$ix["n_seqs"]} END{print c}' \
fastcat_stats/per-file-stats.tsv > fastcat_stats/n_seqs fastcat_stats/per-file-stats.tsv > fastcat_stats/n_seqs
# get unique run IDs # get unique run IDs (we add `-F '\\t'` as `awk` uses any stretch of whitespace
awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {print \$ix["run_id"]}' \ # as field delimiter per default and thus ignores empty columns)
fastcat_stats/per-file-runids.txt | sort | uniq > fastcat_stats/run_ids awk -F '\\t' '
NR==1 {for (i=1; i<=NF; i++) {ix[\$i] = i}}
# only print run_id if present
NR>1 && \$ix["run_id"] != "" {print \$ix["run_id"]}
' fastcat_stats/per-file-runids.tsv | sort | uniq > fastcat_stats/run_ids
# get unique basecall models
awk -F '\\t' '
NR==1 {for (i=1; i<=NF; i++) {ix[\$i] = i}}
# only print basecall model if present
NR>1 && \$ix["basecaller"] != "" {print \$ix["basecaller"]}
' fastcat_stats/per-file-basecallers.tsv | sort | uniq > fastcat_stats/basecallers
""" """
} }
@ -564,15 +589,11 @@ process checkBamHeaders {
env(IS_UNALIGNED), env(IS_UNALIGNED),
env(MIXED_HEADERS), env(MIXED_HEADERS),
env(IS_SORTED), env(IS_SORTED),
env(DS_BASECALL_MODELS),
env(DS_RUNIDS),
) )
script: script:
""" """
workflow-glue check_bam_headers_in_dir input_dir > env.vars workflow-glue check_bam_headers_in_dir input_dir > env.vars
source env.vars source env.vars
DS_RUNIDS=\$(workflow-glue get_ds_records --xam input_dir --key runid --cardinality zero-or-more --sep ',')
DS_BASECALL_MODELS=\$(workflow-glue get_ds_records --xam input_dir --key basecall_model --cardinality zero-or-one --sep ',' --explode_obviously)
""" """
} }
@ -667,7 +688,8 @@ process bamstats {
mkdir bamstats_results mkdir bamstats_results
bamstats reads.bam -s $meta.alias -u \ bamstats reads.bam -s $meta.alias -u \
-f bamstats_results/bamstats.flagstat.tsv -t $bamstats_threads \ -f bamstats_results/bamstats.flagstat.tsv -t $bamstats_threads \
-i bamstats_results/bamstats.runids.txt \ -i bamstats_results/bamstats.runids.tsv \
-l bamstats_results/bamstats.basecallers.tsv \
--histograms histograms \ --histograms histograms \
$per_read_stats_arg $per_read_stats_arg
mv histograms/* bamstats_results/ mv histograms/* bamstats_results/
@ -675,9 +697,19 @@ process bamstats {
# get n_seqs from flagstats - need to sum them up # get n_seqs from flagstats - need to sum them up
awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {c+=\$ix["total"]} END{print c}' \ awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {c+=\$ix["total"]} END{print c}' \
bamstats_results/bamstats.flagstat.tsv > bamstats_results/n_seqs bamstats_results/bamstats.flagstat.tsv > bamstats_results/n_seqs
# get unique run IDs # get unique run IDs (we add `-F '\\t'` as `awk` uses any stretch of whitespace
awk 'NR==1{for (i=1; i<=NF; i++) {ix[\$i] = i}} NR>1 {print \$ix["run_id"]}' \ # as field delimiter otherwise and thus ignore empty columns)
bamstats_results/bamstats.runids.txt | sort | uniq > bamstats_results/run_ids awk -F '\\t' '
NR==1 {for (i=1; i<=NF; i++) {ix[\$i] = i}}
# only print run_id if present
NR>1 && \$ix["run_id"] != "" {print \$ix["run_id"]}
' bamstats_results/bamstats.runids.tsv | sort | uniq > bamstats_results/run_ids
# get unique basecall models
awk -F '\\t' '
NR==1 {for (i=1; i<=NF; i++) {ix[\$i] = i}}
# only print run_id if present
NR>1 && \$ix["basecaller"] != "" {print \$ix["basecaller"]}
' bamstats_results/bamstats.basecallers.tsv | sort | uniq > bamstats_results/basecallers
""" """
} }
/** /**
@ -840,7 +872,8 @@ Map parse_arguments(String func_name, Map arguments, Map extra_kwargs=[:]) {
"stats": true, "stats": true,
"required_sample_types": [], "required_sample_types": [],
"watch_path": false, "watch_path": false,
"per_read_stats": false "per_read_stats": false,
"allow_multiple_basecall_models": false,
] ]
ArgumentParser parser = new ArgumentParser( ArgumentParser parser = new ArgumentParser(
args: required_args, args: required_args,
@ -1033,6 +1066,7 @@ Map create_metamap(Map arguments) {
"barcode": null, "barcode": null,
"type": "test_sample", "type": "test_sample",
"run_ids": [], "run_ids": [],
"basecall_models": [],
], ],
name: "create_metamap", name: "create_metamap",
) )
@ -1077,9 +1111,31 @@ def get_sample_sheet(Path sample_sheet, ArrayList required_sample_types) {
// concat the channel holding the path to the sample sheet to `ch_err` and call // concat the channel holding the path to the sample sheet to `ch_err` and call
// `.last()` to make sure that the error-checking closure above executes before // `.last()` to make sure that the error-checking closure above executes before
// emitting values from the CSV // emitting values from the CSV
return ch_err.concat(Channel.fromPath(sample_sheet)).last().splitCsv( ch_sample_sheet = ch_err.concat(Channel.fromPath(sample_sheet)).last().splitCsv(
header: true, quote: '"' header: true, quote: '"'
) )
// in case there is an 'analysis_group' column, we need to define a `groupKey` to
// allow for non-blocking calls of `groupTuple` later (on the values in the
// 'analysis_group' column); we first collect the sample sheet in a single list of
// maps and then count the occurrences of each group before using these to create
// the `groupKey` objects; note that the below doesn't do anything if there is no
// 'analysis_group' column
ch_group_counts = ch_sample_sheet
| collect
| map { rows -> rows.collect { it.analysis_group } .countBy { it } }
// now we `combine` the analysis group counts with the sample sheet channel and add
// the `groupKey` to the entries
ch_sample_sheet = ch_sample_sheet
| combine(ch_group_counts)
| map { row, group_counts ->
if (row.analysis_group) {
int counts = group_counts[row.analysis_group]
row = row + [analysis_group: groupKey(row.analysis_group, counts)]
}
row
}
return ch_sample_sheet
} }

View File

@ -106,7 +106,7 @@ params {
] ]
agent = null agent = null
container_sha = "shae7c9f184996a384e99be68e790f0612f0c732867" container_sha = "shae7c9f184996a384e99be68e790f0612f0c732867"
common_sha = "sha338caea0a2532dc0ea8f46638ccc322bb8f9af48" common_sha = "sha8b5843d549bb210558cbb676fe537a153ce771d6"
} }
} }