nonhuman-screen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonhuman_screen/__init__.py +63 -0
- nonhuman_screen/alleles.py +85 -0
- nonhuman_screen/bam.py +216 -0
- nonhuman_screen/cli.py +187 -0
- nonhuman_screen/engine.py +828 -0
- nonhuman_screen/py.typed +0 -0
- nonhuman_screen/result.py +138 -0
- nonhuman_screen/votes.py +59 -0
- nonhuman_screen-0.1.0.dist-info/METADATA +168 -0
- nonhuman_screen-0.1.0.dist-info/RECORD +14 -0
- nonhuman_screen-0.1.0.dist-info/WHEEL +5 -0
- nonhuman_screen-0.1.0.dist-info/entry_points.txt +2 -0
- nonhuman_screen-0.1.0.dist-info/licenses/LICENSE +21 -0
- nonhuman_screen-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""nonhuman-screen: classify sequencing reads by non-human taxonomic content.
|
|
2
|
+
|
|
3
|
+
Wraps kraken2's LCA classification and reduces per-read verdicts to a
|
|
4
|
+
non-human fraction (NHF) plus per-domain breakdowns, with a human-homology
|
|
5
|
+
guard and UniVec-Core exclusion. Sample-agnostic: it classifies whatever
|
|
6
|
+
named reads you hand it, so it works equally on a proband, a parent, a tumour,
|
|
7
|
+
or a plain FASTQ.
|
|
8
|
+
|
|
9
|
+
Two levels of API:
|
|
10
|
+
|
|
11
|
+
* Engine (stdlib only) — ``Kraken2Runner.classify_sequences({name: seq})``.
|
|
12
|
+
* BAM/allele helpers (needs the ``[bam]`` extra) — ``classify_variant_alt_reads``
|
|
13
|
+
and ``classify_variants_alt_reads`` compute the non-human fraction of the
|
|
14
|
+
reads supporting a variant's ALT allele.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from nonhuman_screen.engine import (
|
|
18
|
+
ClassificationResult,
|
|
19
|
+
Kraken2Runner,
|
|
20
|
+
_ARCHAEA_TAXID,
|
|
21
|
+
_BACTERIA_TAXID,
|
|
22
|
+
_EUKARYOTA_TAXID,
|
|
23
|
+
_FUNGI_TAXID,
|
|
24
|
+
_HUMAN_TAXID,
|
|
25
|
+
_METAZOA_TAXID,
|
|
26
|
+
_UNIVEC_CORE_TAXID,
|
|
27
|
+
_VIRIDIPLANTAE_TAXID,
|
|
28
|
+
_VIRUSES_TAXID,
|
|
29
|
+
)
|
|
30
|
+
from nonhuman_screen.alleles import read_supports_alt
|
|
31
|
+
from nonhuman_screen.result import TaxonomicFractions, VariantNHF
|
|
32
|
+
from nonhuman_screen.votes import parse_kmer_votes
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"Kraken2Runner",
|
|
38
|
+
"ClassificationResult",
|
|
39
|
+
"TaxonomicFractions",
|
|
40
|
+
"VariantNHF",
|
|
41
|
+
"read_supports_alt",
|
|
42
|
+
"parse_kmer_votes",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# BAM/allele helpers require pysam ([bam] extra). Expose them at the top level
|
|
47
|
+
# when available, but never make importing the core engine depend on pysam.
|
|
48
|
+
try:
|
|
49
|
+
from nonhuman_screen.bam import ( # noqa: F401
|
|
50
|
+
classify_reads_from_bam,
|
|
51
|
+
classify_variant_alt_reads,
|
|
52
|
+
classify_variants_alt_reads,
|
|
53
|
+
reads_supporting_alt,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ += [
|
|
57
|
+
"classify_reads_from_bam",
|
|
58
|
+
"classify_variant_alt_reads",
|
|
59
|
+
"classify_variants_alt_reads",
|
|
60
|
+
"reads_supporting_alt",
|
|
61
|
+
]
|
|
62
|
+
except ImportError:
|
|
63
|
+
pass
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Allele-support determination for reads at a variant locus.
|
|
2
|
+
|
|
3
|
+
Pure-Python helpers that decide whether an aligned read carries a given
|
|
4
|
+
alternate allele. They operate on a pysam ``AlignedSegment`` passed in by
|
|
5
|
+
the caller but do **not** import pysam themselves, so this module has no
|
|
6
|
+
third-party dependency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _is_symbolic(allele):
|
|
11
|
+
"""Return True if *allele* is a symbolic VCF allele with no literal sequence.
|
|
12
|
+
|
|
13
|
+
Symbolic alleles include ``<DEL>``, ``<INS>``, ``<DUP>``, breakend
|
|
14
|
+
notation containing ``[`` or ``]``, and the overlapping-deletion
|
|
15
|
+
marker ``*``.
|
|
16
|
+
"""
|
|
17
|
+
if not allele:
|
|
18
|
+
return True
|
|
19
|
+
return allele[0] == "<" or allele == "*" or "[" in allele or "]" in allele
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_supports_alt(
|
|
23
|
+
read, variant_pos, ref, alt, min_baseq=0, *,
|
|
24
|
+
aligned_pairs=None, seq=None, quals=None,
|
|
25
|
+
):
|
|
26
|
+
"""Return True if *read* carries the alternate allele at *variant_pos*.
|
|
27
|
+
|
|
28
|
+
Extracts the exact read sequence aligned to the reference span of the
|
|
29
|
+
variant and compares it strictly to the candidate alternate allele.
|
|
30
|
+
Handles SNPs, MNPs, insertions, deletions, and complex indels natively.
|
|
31
|
+
|
|
32
|
+
Returns ``False`` for symbolic alleles or when *alt* is ``None``.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
min_baseq: Minimum base quality threshold for bases considered as
|
|
36
|
+
alt support.
|
|
37
|
+
aligned_pairs: Optional pre-computed result of
|
|
38
|
+
``read.get_aligned_pairs(matches_only=False)``. Computed from
|
|
39
|
+
*read* when not provided.
|
|
40
|
+
seq: Optional pre-decoded ``read.query_sequence``. Decoded from
|
|
41
|
+
*read* when not provided.
|
|
42
|
+
quals: Optional pre-decoded ``read.query_qualities``. Decoded from
|
|
43
|
+
*read* only when ``min_baseq > 0`` and not provided.
|
|
44
|
+
"""
|
|
45
|
+
if alt is None or _is_symbolic(alt):
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if seq is None:
|
|
49
|
+
seq = read.query_sequence
|
|
50
|
+
if seq is None:
|
|
51
|
+
return False
|
|
52
|
+
if min_baseq > 0 and quals is None:
|
|
53
|
+
quals = read.query_qualities
|
|
54
|
+
|
|
55
|
+
if aligned_pairs is None:
|
|
56
|
+
aligned_pairs = read.get_aligned_pairs(matches_only=False)
|
|
57
|
+
|
|
58
|
+
extracted_seq = []
|
|
59
|
+
in_variant_region = False
|
|
60
|
+
|
|
61
|
+
for qpos, rpos in aligned_pairs:
|
|
62
|
+
# Stop collecting once we reach or pass the end of the reference allele span
|
|
63
|
+
if rpos is not None and rpos >= variant_pos + len(ref):
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
# Start collecting when we hit the exact start of the variant
|
|
67
|
+
if rpos == variant_pos:
|
|
68
|
+
in_variant_region = True
|
|
69
|
+
|
|
70
|
+
if in_variant_region:
|
|
71
|
+
# qpos is None for deleted bases (skip), otherwise append the read base
|
|
72
|
+
if qpos is not None:
|
|
73
|
+
if (
|
|
74
|
+
min_baseq > 0 and quals is not None
|
|
75
|
+
and quals[qpos] < min_baseq
|
|
76
|
+
):
|
|
77
|
+
return False
|
|
78
|
+
extracted_seq.append(seq[qpos])
|
|
79
|
+
|
|
80
|
+
# If the variant region was skipped entirely due to read boundaries
|
|
81
|
+
if not in_variant_region:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
return "".join(extracted_seq).upper() == alt.upper()
|
|
85
|
+
|
nonhuman_screen/bam.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""BAM-aware entry points: classify reads, and classify per-variant allele support.
|
|
2
|
+
|
|
3
|
+
This module is the convenient, high-level surface most callers want. It pulls
|
|
4
|
+
read sequences out of a BAM/CRAM and hands them to the pysam-free
|
|
5
|
+
:class:`~nonhuman_screen.engine.Kraken2Runner`, so no consumer has to
|
|
6
|
+
re-implement the "which reads support this allele, and how non-human are they"
|
|
7
|
+
machinery.
|
|
8
|
+
|
|
9
|
+
Requires the ``[bam]`` extra (pysam). Import errors here are deliberately
|
|
10
|
+
actionable.
|
|
11
|
+
|
|
12
|
+
Coordinate convention: every ``pos`` is **0-based** (pysam reference
|
|
13
|
+
coordinates), and variant keys are ``"{chrom}:{pos}:{ref}:{alt}"`` to match the
|
|
14
|
+
internal conventions of consuming pipelines.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import pysam
|
|
21
|
+
except ImportError as exc: # pragma: no cover - import-guard
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"nonhuman_screen.bam requires pysam. Install with: "
|
|
24
|
+
"pip install 'nonhuman-screen[bam]'"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
from nonhuman_screen.alleles import read_supports_alt
|
|
28
|
+
from nonhuman_screen.engine import Kraken2Runner
|
|
29
|
+
from nonhuman_screen.result import TaxonomicFractions, VariantNHF
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_variant(v):
|
|
33
|
+
"""Normalize a variant into a ``(chrom, pos, ref, alt)`` tuple (pos 0-based)."""
|
|
34
|
+
if isinstance(v, (tuple, list)):
|
|
35
|
+
chrom, pos, ref, alt = v
|
|
36
|
+
return (chrom, int(pos), ref, alt)
|
|
37
|
+
if isinstance(v, dict):
|
|
38
|
+
return (v["chrom"], int(v["pos"]), v["ref"], v["alt"])
|
|
39
|
+
return (v.chrom, int(v.pos), v.ref, v.alt)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _open(bam_path, ref_fasta):
|
|
43
|
+
return pysam.AlignmentFile(
|
|
44
|
+
bam_path, reference_filename=ref_fasta if ref_fasta else None
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _alt_support_seqs(bam, chrom, pos, ref, alt, *, min_baseq=0):
|
|
49
|
+
"""Return ``{read_name: sequence}`` for reads supporting *alt* at 0-based *pos*.
|
|
50
|
+
|
|
51
|
+
Fragments are de-duplicated by read name (first supporting mate wins), so a
|
|
52
|
+
paired-end fragment contributes at most one sequence — consistent with how
|
|
53
|
+
fragment-level fractions are computed downstream.
|
|
54
|
+
"""
|
|
55
|
+
out = {}
|
|
56
|
+
span = pos + max(len(ref) if ref else 1, 1)
|
|
57
|
+
for read in bam.fetch(chrom, pos, span):
|
|
58
|
+
name = read.query_name
|
|
59
|
+
if name in out:
|
|
60
|
+
continue
|
|
61
|
+
seq = read.query_sequence
|
|
62
|
+
if not seq:
|
|
63
|
+
continue
|
|
64
|
+
if read_supports_alt(read, pos, ref, alt, min_baseq):
|
|
65
|
+
out[name] = seq
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reads_supporting_alt(
|
|
70
|
+
bam_path, chrom, pos, ref, alt, *, ref_fasta=None, min_baseq=0
|
|
71
|
+
):
|
|
72
|
+
"""Return the set of read names supporting *alt* at 0-based *pos*.
|
|
73
|
+
|
|
74
|
+
A thin, classification-free helper for callers that only need the
|
|
75
|
+
allele-support read set (e.g. to feed their own logic).
|
|
76
|
+
"""
|
|
77
|
+
with _open(bam_path, ref_fasta) as bam:
|
|
78
|
+
return set(_alt_support_seqs(bam, chrom, pos, ref, alt, min_baseq=min_baseq))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify_reads_from_bam(
|
|
82
|
+
bam_path,
|
|
83
|
+
db_path,
|
|
84
|
+
*,
|
|
85
|
+
read_names=None,
|
|
86
|
+
loci=None,
|
|
87
|
+
ref_fasta=None,
|
|
88
|
+
confidence=0.0,
|
|
89
|
+
threads=1,
|
|
90
|
+
memory_mapping=False,
|
|
91
|
+
tmpdir=None,
|
|
92
|
+
):
|
|
93
|
+
"""Classify a set of reads from a BAM/CRAM and return a ``ClassificationResult``.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
read_names: Set of read names to classify. When *loci* is not given,
|
|
97
|
+
the whole file is scanned for these names.
|
|
98
|
+
loci: Optional ``{(chrom, pos): {read_name, ...}}`` mapping (0-based
|
|
99
|
+
``pos``) to restrict fetching to specific loci — far cheaper than a
|
|
100
|
+
whole-file scan. When both are given, *read_names* acts as an
|
|
101
|
+
additional filter on the fetched reads.
|
|
102
|
+
"""
|
|
103
|
+
if not read_names and not loci:
|
|
104
|
+
return Kraken2Runner.Result()
|
|
105
|
+
|
|
106
|
+
sequences = {}
|
|
107
|
+
with _open(bam_path, ref_fasta) as bam:
|
|
108
|
+
if loci:
|
|
109
|
+
name_filter = set(read_names) if read_names else None
|
|
110
|
+
for (chrom, pos), names in sorted(loci.items()):
|
|
111
|
+
targets = set(names)
|
|
112
|
+
if name_filter is not None:
|
|
113
|
+
targets &= name_filter
|
|
114
|
+
if not targets:
|
|
115
|
+
continue
|
|
116
|
+
for read in bam.fetch(chrom, pos, pos + 1):
|
|
117
|
+
n = read.query_name
|
|
118
|
+
if n in targets and read.query_sequence and n not in sequences:
|
|
119
|
+
sequences[n] = read.query_sequence
|
|
120
|
+
else:
|
|
121
|
+
wanted = set(read_names)
|
|
122
|
+
for read in bam.fetch(until_eof=True):
|
|
123
|
+
n = read.query_name
|
|
124
|
+
if n in wanted and read.query_sequence and n not in sequences:
|
|
125
|
+
sequences[n] = read.query_sequence
|
|
126
|
+
|
|
127
|
+
if not sequences:
|
|
128
|
+
return Kraken2Runner.Result()
|
|
129
|
+
|
|
130
|
+
runner = Kraken2Runner(
|
|
131
|
+
db_path, confidence=confidence, threads=threads,
|
|
132
|
+
memory_mapping=memory_mapping,
|
|
133
|
+
)
|
|
134
|
+
return runner.classify_sequences(sequences, tmpdir=tmpdir)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def classify_variants_alt_reads(
|
|
138
|
+
bam_path,
|
|
139
|
+
db_path,
|
|
140
|
+
variants,
|
|
141
|
+
*,
|
|
142
|
+
ref_fasta=None,
|
|
143
|
+
min_baseq=0,
|
|
144
|
+
confidence=0.0,
|
|
145
|
+
threads=1,
|
|
146
|
+
memory_mapping=False,
|
|
147
|
+
tmpdir=None,
|
|
148
|
+
):
|
|
149
|
+
"""Compute the allele-based non-human fraction for many variants at once.
|
|
150
|
+
|
|
151
|
+
For each variant, the reads supporting its ALT allele are gathered; the
|
|
152
|
+
union of those reads is classified with **a single kraken2 invocation**
|
|
153
|
+
(kraken2's database load dominates runtime, so batching matters), then the
|
|
154
|
+
result is split back per variant by read-name intersection.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
variants: Iterable of variants, each a ``(chrom, pos, ref, alt)`` tuple
|
|
158
|
+
(0-based ``pos``), a mapping with those keys, or any object with
|
|
159
|
+
``chrom``/``pos``/``ref``/``alt`` attributes.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
A list of :class:`~nonhuman_screen.result.VariantNHF`, in input order.
|
|
163
|
+
"""
|
|
164
|
+
normalized = [_as_variant(v) for v in variants]
|
|
165
|
+
|
|
166
|
+
sequences = {}
|
|
167
|
+
per_variant_names = []
|
|
168
|
+
with _open(bam_path, ref_fasta) as bam:
|
|
169
|
+
for chrom, pos, ref, alt in normalized:
|
|
170
|
+
vs = _alt_support_seqs(bam, chrom, pos, ref, alt, min_baseq=min_baseq)
|
|
171
|
+
per_variant_names.append(set(vs))
|
|
172
|
+
for name, seq in vs.items():
|
|
173
|
+
sequences.setdefault(name, seq)
|
|
174
|
+
|
|
175
|
+
runner = Kraken2Runner(
|
|
176
|
+
db_path, confidence=confidence, threads=threads,
|
|
177
|
+
memory_mapping=memory_mapping,
|
|
178
|
+
)
|
|
179
|
+
result = runner.classify_sequences(sequences, tmpdir=tmpdir) if sequences \
|
|
180
|
+
else Kraken2Runner.Result()
|
|
181
|
+
|
|
182
|
+
out = []
|
|
183
|
+
for (chrom, pos, ref, alt), names in zip(normalized, per_variant_names):
|
|
184
|
+
out.append(
|
|
185
|
+
VariantNHF(
|
|
186
|
+
chrom=chrom, pos=pos, ref=ref, alt=alt,
|
|
187
|
+
supporting_read_names=frozenset(names),
|
|
188
|
+
fractions=TaxonomicFractions.over_reads(result, names),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def classify_variant_alt_reads(
|
|
195
|
+
bam_path, db_path, chrom, pos, ref, alt, *,
|
|
196
|
+
ref_fasta=None, min_baseq=0, confidence=0.0, threads=1,
|
|
197
|
+
memory_mapping=False, tmpdir=None,
|
|
198
|
+
):
|
|
199
|
+
"""Allele-based non-human fraction for a single variant.
|
|
200
|
+
|
|
201
|
+
Convenience wrapper over :func:`classify_variants_alt_reads`.
|
|
202
|
+
|
|
203
|
+
Example::
|
|
204
|
+
|
|
205
|
+
vnhf = classify_variant_alt_reads(
|
|
206
|
+
"sample.bam", "kraken2_db", "chr1", 12345, "A", "T",
|
|
207
|
+
ref_fasta="ref.fa",
|
|
208
|
+
)
|
|
209
|
+
vnhf.nonhuman_fraction # 0.0-1.0 over ALT-supporting reads
|
|
210
|
+
vnhf.fractions.bacterial # per-domain breakdown
|
|
211
|
+
"""
|
|
212
|
+
return classify_variants_alt_reads(
|
|
213
|
+
bam_path, db_path, [(chrom, pos, ref, alt)],
|
|
214
|
+
ref_fasta=ref_fasta, min_baseq=min_baseq, confidence=confidence,
|
|
215
|
+
threads=threads, memory_mapping=memory_mapping, tmpdir=tmpdir,
|
|
216
|
+
)[0]
|
nonhuman_screen/cli.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Command-line interface: ``nonhuman-screen``.
|
|
2
|
+
|
|
3
|
+
Subcommands
|
|
4
|
+
-----------
|
|
5
|
+
``classify`` — screen reads from a BAM/CRAM for non-human content. With
|
|
6
|
+
``--variants`` it computes the allele-based non-human fraction for every ALT
|
|
7
|
+
allele (the reads supporting each ALT are classified); otherwise it classifies
|
|
8
|
+
all mapped reads and reports a single batch summary.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from nonhuman_screen import __version__
|
|
20
|
+
from nonhuman_screen.alleles import _is_symbolic
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _build_parser():
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="nonhuman-screen",
|
|
26
|
+
description="Classify sequencing reads by non-human taxonomic content.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
|
|
31
|
+
c = sub.add_parser(
|
|
32
|
+
"classify",
|
|
33
|
+
help="Screen reads from a BAM/CRAM for non-human content.",
|
|
34
|
+
)
|
|
35
|
+
c.add_argument("--bam", required=True, help="Input BAM/CRAM.")
|
|
36
|
+
c.add_argument(
|
|
37
|
+
"--kraken2-db", required=True,
|
|
38
|
+
help="Path to a kraken2 database directory.",
|
|
39
|
+
)
|
|
40
|
+
c.add_argument(
|
|
41
|
+
"--ref-fasta", default=None,
|
|
42
|
+
help="Reference FASTA (required for CRAM).",
|
|
43
|
+
)
|
|
44
|
+
c.add_argument(
|
|
45
|
+
"--variants", default=None,
|
|
46
|
+
help="VCF/BCF of variants. When given, compute the allele-based "
|
|
47
|
+
"non-human fraction for every ALT allele instead of a whole-BAM "
|
|
48
|
+
"summary.",
|
|
49
|
+
)
|
|
50
|
+
c.add_argument(
|
|
51
|
+
"--min-baseq", type=int, default=0,
|
|
52
|
+
help="Minimum base quality for a read to count as ALT support "
|
|
53
|
+
"(default: 0).",
|
|
54
|
+
)
|
|
55
|
+
c.add_argument(
|
|
56
|
+
"--out-prefix", default=None,
|
|
57
|
+
help="Write outputs to <prefix>.variant_nhf.tsv / <prefix>.summary.json. "
|
|
58
|
+
"When omitted, results are printed to stdout.",
|
|
59
|
+
)
|
|
60
|
+
c.add_argument("--threads", type=int, default=1, help="kraken2 threads.")
|
|
61
|
+
c.add_argument(
|
|
62
|
+
"--confidence", type=float, default=0.0,
|
|
63
|
+
help="kraken2 confidence threshold 0.0-1.0 (default: 0.0).",
|
|
64
|
+
)
|
|
65
|
+
c.add_argument(
|
|
66
|
+
"--memory-mapping", action="store_true", default=False,
|
|
67
|
+
help="Pass --memory-mapping to kraken2 to reduce RAM.",
|
|
68
|
+
)
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_vcf_variants(path):
|
|
73
|
+
"""Yield ``(chrom, pos0, ref, alt)`` for every concrete ALT allele."""
|
|
74
|
+
import pysam
|
|
75
|
+
|
|
76
|
+
with pysam.VariantFile(path) as vcf:
|
|
77
|
+
for rec in vcf:
|
|
78
|
+
if rec.alts is None:
|
|
79
|
+
continue
|
|
80
|
+
for alt in rec.alts:
|
|
81
|
+
# Skip symbolic (<DEL>), breakend (N[chr2:321[), and the
|
|
82
|
+
# spanning-deletion (*) alleles — none has a literal sequence to
|
|
83
|
+
# match reads against.
|
|
84
|
+
if _is_symbolic(alt):
|
|
85
|
+
continue
|
|
86
|
+
yield (rec.chrom, rec.start, rec.ref, alt)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_TSV_COLUMNS = (
|
|
90
|
+
"variant_key", "supporting_reads", "nonhuman_fraction",
|
|
91
|
+
"bacterial", "archaeal", "fungal", "protist", "viral", "univec_core",
|
|
92
|
+
"human_lineage", "unclassified",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _classify_variants(args):
|
|
97
|
+
from nonhuman_screen.bam import classify_variants_alt_reads
|
|
98
|
+
|
|
99
|
+
variants = list(_read_vcf_variants(args.variants))
|
|
100
|
+
logging.info("Classifying ALT-supporting reads for %d alleles", len(variants))
|
|
101
|
+
results = classify_variants_alt_reads(
|
|
102
|
+
args.bam, args.kraken2_db, variants,
|
|
103
|
+
ref_fasta=args.ref_fasta, min_baseq=args.min_baseq,
|
|
104
|
+
confidence=args.confidence, threads=args.threads,
|
|
105
|
+
memory_mapping=args.memory_mapping,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
lines = ["\t".join(_TSV_COLUMNS)]
|
|
109
|
+
for v in results:
|
|
110
|
+
f = v.fractions
|
|
111
|
+
lines.append("\t".join(str(x) for x in (
|
|
112
|
+
v.variant_key, v.supporting_reads, v.nonhuman_fraction,
|
|
113
|
+
f.bacterial, f.archaeal, f.fungal, f.protist, f.viral,
|
|
114
|
+
f.univec_core, f.human_lineage, f.unclassified,
|
|
115
|
+
)))
|
|
116
|
+
tsv = "\n".join(lines) + "\n"
|
|
117
|
+
|
|
118
|
+
if args.out_prefix:
|
|
119
|
+
tsv_path = f"{args.out_prefix}.variant_nhf.tsv"
|
|
120
|
+
with open(tsv_path, "w") as fh:
|
|
121
|
+
fh.write(tsv)
|
|
122
|
+
with open(f"{args.out_prefix}.summary.json", "w") as fh:
|
|
123
|
+
json.dump([v.to_dict() for v in results], fh, indent=2)
|
|
124
|
+
logging.info("Wrote %s", tsv_path)
|
|
125
|
+
else:
|
|
126
|
+
sys.stdout.write(tsv)
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _classify_all(args):
|
|
131
|
+
import pysam
|
|
132
|
+
|
|
133
|
+
from nonhuman_screen.engine import Kraken2Runner
|
|
134
|
+
|
|
135
|
+
sequences = {}
|
|
136
|
+
with pysam.AlignmentFile(
|
|
137
|
+
args.bam, reference_filename=args.ref_fasta or None
|
|
138
|
+
) as bam:
|
|
139
|
+
for read in bam.fetch(until_eof=True):
|
|
140
|
+
n = read.query_name
|
|
141
|
+
if read.query_sequence and n not in sequences:
|
|
142
|
+
sequences[n] = read.query_sequence
|
|
143
|
+
|
|
144
|
+
runner = Kraken2Runner(
|
|
145
|
+
args.kraken2_db, confidence=args.confidence, threads=args.threads,
|
|
146
|
+
memory_mapping=args.memory_mapping,
|
|
147
|
+
)
|
|
148
|
+
result = runner.classify_sequences(sequences)
|
|
149
|
+
summary = {
|
|
150
|
+
"reads": result.total,
|
|
151
|
+
"classified": result.classified,
|
|
152
|
+
"taxonomy_available": result.taxonomy_available,
|
|
153
|
+
"nonhuman_fraction": result.nonhuman_fraction,
|
|
154
|
+
"fractions": result.fractions().to_dict(),
|
|
155
|
+
}
|
|
156
|
+
out = json.dumps(summary, indent=2)
|
|
157
|
+
if args.out_prefix:
|
|
158
|
+
with open(f"{args.out_prefix}.summary.json", "w") as fh:
|
|
159
|
+
fh.write(out + "\n")
|
|
160
|
+
else:
|
|
161
|
+
sys.stdout.write(out + "\n")
|
|
162
|
+
logging.info("%s", result.summary())
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv=None):
|
|
167
|
+
logging.basicConfig(
|
|
168
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s",
|
|
169
|
+
)
|
|
170
|
+
args = _build_parser().parse_args(argv)
|
|
171
|
+
|
|
172
|
+
if args.command == "classify":
|
|
173
|
+
if shutil.which("kraken2") is None:
|
|
174
|
+
sys.stderr.write(
|
|
175
|
+
"error: kraken2 not found on PATH. Install kraken2 "
|
|
176
|
+
"(see docs/database.md) and try again.\n"
|
|
177
|
+
)
|
|
178
|
+
return 2
|
|
179
|
+
if args.variants:
|
|
180
|
+
return _classify_variants(args)
|
|
181
|
+
return _classify_all(args)
|
|
182
|
+
|
|
183
|
+
return 1 # pragma: no cover - argparse requires a subcommand
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__": # pragma: no cover
|
|
187
|
+
raise SystemExit(main())
|