gmlst 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.
- gmlst/__init__.py +3 -0
- gmlst/__main__.py +6 -0
- gmlst/aligners/__init__.py +44 -0
- gmlst/aligners/base.py +181 -0
- gmlst/aligners/blastn.py +287 -0
- gmlst/aligners/kma.py +165 -0
- gmlst/aligners/minimap2.py +1369 -0
- gmlst/aligners/nucmer.py +221 -0
- gmlst/calling/__init__.py +6 -0
- gmlst/calling/allele.py +185 -0
- gmlst/calling/chew_policy.py +286 -0
- gmlst/calling/confidence.py +53 -0
- gmlst/calling/st_lookup.py +199 -0
- gmlst/cli.py +43 -0
- gmlst/commands/__init__.py +31 -0
- gmlst/commands/common.py +138 -0
- gmlst/commands/scheme.py +1329 -0
- gmlst/commands/typing.py +1336 -0
- gmlst/commands/typing_output.py +84 -0
- gmlst/commands/typing_runner.py +128 -0
- gmlst/commands/typing_runtime.py +54 -0
- gmlst/commands/typing_scheme.py +70 -0
- gmlst/commands/utils.py +1006 -0
- gmlst/core/__init__.py +230 -0
- gmlst/core/adapters_cds.py +49 -0
- gmlst/core/adapters_exact_hash.py +148 -0
- gmlst/core/adapters_index_prefilter.py +157 -0
- gmlst/core/adapters_refinement.py +251 -0
- gmlst/core/cds.py +125 -0
- gmlst/core/config.py +65 -0
- gmlst/core/exact_hash.py +440 -0
- gmlst/core/indexing.py +206 -0
- gmlst/core/pipeline.py +690 -0
- gmlst/core/prefilter.py +215 -0
- gmlst/core/ranking.py +67 -0
- gmlst/core/refinement.py +534 -0
- gmlst/core/sequences.py +98 -0
- gmlst/core/types.py +70 -0
- gmlst/core_config.py +199 -0
- gmlst/data/__init__.py +0 -0
- gmlst/data/blocked_schemes.json +10 -0
- gmlst/data/catalogs/__init__.py +0 -0
- gmlst/data/catalogs/cgmlst.json +368 -0
- gmlst/data/catalogs/enterobase.json +261 -0
- gmlst/data/catalogs/pasteur.json +776 -0
- gmlst/data/catalogs/pubmlst.json +2792 -0
- gmlst/data/organism_mapping.json +18 -0
- gmlst/database/__init__.py +6 -0
- gmlst/database/atomic.py +17 -0
- gmlst/database/cache.py +576 -0
- gmlst/database/download.py +527 -0
- gmlst/database/providers/__init__.py +63 -0
- gmlst/database/providers/base.py +126 -0
- gmlst/database/providers/bigsdb.py +767 -0
- gmlst/database/providers/cgmlst.py +277 -0
- gmlst/database/providers/cgmlst_schemes.py +286 -0
- gmlst/database/providers/enterobase.py +509 -0
- gmlst/database/schema.py +159 -0
- gmlst/fasta_io.py +55 -0
- gmlst/kmer_prefilter.py +91 -0
- gmlst/metadata_io.py +23 -0
- gmlst/novel/__init__.py +12 -0
- gmlst/novel/reader.py +188 -0
- gmlst/novel/service.py +170 -0
- gmlst/novel/writer.py +226 -0
- gmlst/readers/__init__.py +7 -0
- gmlst/readers/fasta.py +69 -0
- gmlst/readers/fastq.py +67 -0
- gmlst/readers/sample.py +177 -0
- gmlst/schemefree/__init__.py +53 -0
- gmlst/schemefree/assembly_engine.py +96 -0
- gmlst/schemefree/cluster_engine.py +124 -0
- gmlst/schemefree/config.py +197 -0
- gmlst/schemefree/gene_predictor.py +286 -0
- gmlst/schemefree/hasher.py +463 -0
- gmlst/schemefree/io_handler.py +91 -0
- gmlst/schemefree/typing_engine.py +428 -0
- gmlst/utils.py +173 -0
- gmlst/visual/__init__.py +6 -0
- gmlst/visual/app.py +606 -0
- gmlst/visual/cli.py +995 -0
- gmlst/visual/mst.py +335 -0
- gmlst/visual/mst_edmonds.py +527 -0
- gmlst/visual/mst_grapetree.py +543 -0
- gmlst/visual/mst_shared.py +527 -0
- gmlst/web/README.md +33 -0
- gmlst/web/frontend/index.html +15 -0
- gmlst/web/frontend/package-lock.json +1591 -0
- gmlst/web/frontend/package.json +20 -0
- gmlst/web/frontend/src/App.vue +4176 -0
- gmlst/web/frontend/src/main.js +5 -0
- gmlst/web/frontend/src/style.css +1364 -0
- gmlst/web/frontend/src/visualSelection.js +264 -0
- gmlst/web/frontend/src/visualSelection.test.js +304 -0
- gmlst/web/frontend/vite.config.js +32 -0
- gmlst/web/static/visual/dist/app.css +1 -0
- gmlst/web/static/visual/dist/app.js +6 -0
- gmlst/web/static/visual/dist/index.html +16 -0
- gmlst/web/templates/visual/index.html +16 -0
- gmlst-0.1.0.dist-info/METADATA +396 -0
- gmlst-0.1.0.dist-info/RECORD +104 -0
- gmlst-0.1.0.dist-info/WHEEL +4 -0
- gmlst-0.1.0.dist-info/entry_points.txt +2 -0
- gmlst-0.1.0.dist-info/licenses/LICENSE +21 -0
gmlst/__init__.py
ADDED
gmlst/__main__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Alignment backend registry."""
|
|
2
|
+
|
|
3
|
+
from gmlst.aligners.base import Aligner, AlignmentResult, AlleleMatch
|
|
4
|
+
from gmlst.aligners.blastn import BlastnAligner
|
|
5
|
+
from gmlst.aligners.kma import KmaAligner
|
|
6
|
+
from gmlst.aligners.minimap2 import Minimap2Aligner
|
|
7
|
+
from gmlst.aligners.nucmer import NucmerAligner
|
|
8
|
+
|
|
9
|
+
_REGISTRY: dict[str, type[Aligner]] = {
|
|
10
|
+
"blastn": BlastnAligner,
|
|
11
|
+
"kma": KmaAligner,
|
|
12
|
+
"minimap2": Minimap2Aligner,
|
|
13
|
+
"nucmer": NucmerAligner,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
AVAILABLE_BACKENDS: list[str] = list(_REGISTRY.keys())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_aligner(name: str, **kwargs) -> Aligner:
|
|
20
|
+
"""Return an instantiated aligner by backend name.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
name:
|
|
25
|
+
Backend name (blastn, kma, minimap2, nucmer).
|
|
26
|
+
**kwargs:
|
|
27
|
+
Additional arguments passed to aligner constructor.
|
|
28
|
+
For blastn: threads (int, default=1)
|
|
29
|
+
"""
|
|
30
|
+
name = name.lower()
|
|
31
|
+
if name not in _REGISTRY:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Unknown backend '{name}'. Available: {', '.join(AVAILABLE_BACKENDS)}"
|
|
34
|
+
)
|
|
35
|
+
return _REGISTRY[name](**kwargs)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Aligner",
|
|
40
|
+
"AlleleMatch",
|
|
41
|
+
"AlignmentResult",
|
|
42
|
+
"AVAILABLE_BACKENDS",
|
|
43
|
+
"get_aligner",
|
|
44
|
+
]
|
gmlst/aligners/base.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Core types and Aligner Protocol shared by all backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Data classes
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
CallType = Literal["exact", "closest", "novel", "partial", "missing"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AlleleMatch:
|
|
19
|
+
"""Normalised alignment hit for one allele against one locus.
|
|
20
|
+
|
|
21
|
+
All backends must convert their native output into this structure so that
|
|
22
|
+
downstream calling logic is backend-agnostic.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
locus: str
|
|
26
|
+
"""Gene name, e.g. ``"abcZ"``."""
|
|
27
|
+
|
|
28
|
+
allele_id: str
|
|
29
|
+
"""Allele number as a string, e.g. ``"42"``."""
|
|
30
|
+
|
|
31
|
+
identity: float
|
|
32
|
+
"""Percent identity (0–100)."""
|
|
33
|
+
|
|
34
|
+
coverage: float
|
|
35
|
+
"""Fraction of the allele sequence covered by the alignment (0.0–1.0)."""
|
|
36
|
+
|
|
37
|
+
strand: str = "+"
|
|
38
|
+
"""Alignment strand: ``"+"`` or ``"-"``."""
|
|
39
|
+
|
|
40
|
+
score: float = 0.0
|
|
41
|
+
"""Backend-specific score, normalised to 0–100 where possible."""
|
|
42
|
+
|
|
43
|
+
depth: float | None = None
|
|
44
|
+
"""Mean read depth across the allele (FASTQ inputs only)."""
|
|
45
|
+
|
|
46
|
+
alignment_length: int = 0
|
|
47
|
+
"""Length of the alignment block in bases."""
|
|
48
|
+
|
|
49
|
+
sequence: str | None = None
|
|
50
|
+
"""Extracted sequence for novel alleles (optional)."""
|
|
51
|
+
copy_count: int = 1
|
|
52
|
+
"""Approximate number of distinct genomic occurrences for this allele."""
|
|
53
|
+
query_contig: str | None = None
|
|
54
|
+
"""Contig/chromosome identifier for FASTA assembly mappings."""
|
|
55
|
+
query_contig_length: int | None = None
|
|
56
|
+
"""Contig/chromosome length when available."""
|
|
57
|
+
query_start: int | None = None
|
|
58
|
+
"""0-based start coordinate on the query contig/chromosome."""
|
|
59
|
+
query_end: int | None = None
|
|
60
|
+
"""0-based end coordinate on the query contig/chromosome."""
|
|
61
|
+
allele_length: int | None = None
|
|
62
|
+
"""Allele/template full length when available."""
|
|
63
|
+
allele_start: int | None = None
|
|
64
|
+
"""0-based aligned start on allele/template."""
|
|
65
|
+
allele_end: int | None = None
|
|
66
|
+
"""0-based aligned end on allele/template."""
|
|
67
|
+
"""Length of the alignment block in bases."""
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def call_type(self) -> CallType:
|
|
71
|
+
"""Classify this match using tseemann-compatible thresholds."""
|
|
72
|
+
if self.identity == 100.0 and self.coverage >= 1.0:
|
|
73
|
+
return "exact"
|
|
74
|
+
if self.identity >= 95.0 and self.coverage >= 0.95:
|
|
75
|
+
return "closest"
|
|
76
|
+
if self.coverage >= 0.95:
|
|
77
|
+
# Good coverage but low identity → likely a novel allele
|
|
78
|
+
return "novel"
|
|
79
|
+
if self.coverage > 0.0:
|
|
80
|
+
return "partial"
|
|
81
|
+
return "missing"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_exact(self) -> bool:
|
|
85
|
+
return self.identity == 100.0 and self.coverage >= 1.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class AlignmentResult:
|
|
90
|
+
"""All allele matches produced by one backend for one sample."""
|
|
91
|
+
|
|
92
|
+
sample_id: str
|
|
93
|
+
matches: list[AlleleMatch] = field(default_factory=list)
|
|
94
|
+
failed_loci: list[str] = field(default_factory=list)
|
|
95
|
+
backend: str = ""
|
|
96
|
+
runtime_seconds: float = 0.0
|
|
97
|
+
_matches_by_locus: dict[str, list[AlleleMatch]] | None = field(
|
|
98
|
+
default=None,
|
|
99
|
+
init=False,
|
|
100
|
+
repr=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def matches_for(self, locus: str) -> list[AlleleMatch]:
|
|
104
|
+
"""Return all hits for a single locus, sorted best-first."""
|
|
105
|
+
if self._matches_by_locus is None:
|
|
106
|
+
grouped: dict[str, list[AlleleMatch]] = defaultdict(list)
|
|
107
|
+
for match in self.matches:
|
|
108
|
+
grouped[match.locus].append(match)
|
|
109
|
+
for hits in grouped.values():
|
|
110
|
+
hits.sort(
|
|
111
|
+
key=lambda m: (m.identity, m.coverage, m.depth or 0.0),
|
|
112
|
+
reverse=True,
|
|
113
|
+
)
|
|
114
|
+
self._matches_by_locus = dict(grouped)
|
|
115
|
+
return self._matches_by_locus.get(locus, [])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Aligner Protocol
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@runtime_checkable
|
|
124
|
+
class Aligner(Protocol):
|
|
125
|
+
"""Interface every alignment backend must satisfy.
|
|
126
|
+
|
|
127
|
+
Backends are stateless; per-run state lives in ``index_dir``.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def name(self) -> str:
|
|
132
|
+
"""Short identifier, e.g. ``"blastn"``."""
|
|
133
|
+
...
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def supports_fastq(self) -> bool:
|
|
137
|
+
"""Whether this backend can handle raw FASTQ reads."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
def check_dependencies(self) -> None:
|
|
141
|
+
"""Raise ``RuntimeError`` if required external tools are missing."""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
|
|
145
|
+
"""Build a backend-specific index from allele FASTA files.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
allele_fastas:
|
|
150
|
+
One FASTA file per locus (e.g. ``arcC.tfa``).
|
|
151
|
+
index_dir:
|
|
152
|
+
Directory in which to write index artifacts.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
Path
|
|
157
|
+
The index path (file or directory) to pass back to :meth:`align`.
|
|
158
|
+
"""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
def align(
|
|
162
|
+
self,
|
|
163
|
+
sample: Path | tuple[Path, Path],
|
|
164
|
+
index_path: Path,
|
|
165
|
+
loci: list[str],
|
|
166
|
+
input_type: Literal["fasta", "fastq"],
|
|
167
|
+
) -> AlignmentResult:
|
|
168
|
+
"""Run alignment and return normalised results.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
sample:
|
|
173
|
+
Path to query file, or paired FASTQ paths ``(R1, R2)``.
|
|
174
|
+
index_path:
|
|
175
|
+
Path returned by :meth:`index`.
|
|
176
|
+
loci:
|
|
177
|
+
Gene names expected in the scheme (used to detect missing loci).
|
|
178
|
+
input_type:
|
|
179
|
+
``"fasta"`` for assembled genomes, ``"fastq"`` for raw reads.
|
|
180
|
+
"""
|
|
181
|
+
...
|
gmlst/aligners/blastn.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""BLASTN alignment backend.
|
|
2
|
+
|
|
3
|
+
Strategy
|
|
4
|
+
--------
|
|
5
|
+
FASTA input (assembled genome):
|
|
6
|
+
Query = allele sequences (all loci concatenated)
|
|
7
|
+
Subject = genome contigs → makeblastdb on genome
|
|
8
|
+
This matches tseemann/mlst behaviour exactly.
|
|
9
|
+
|
|
10
|
+
FASTQ input:
|
|
11
|
+
Not supported — BLASTN cannot handle raw reads.
|
|
12
|
+
Use minimap2 or kma backends instead.
|
|
13
|
+
|
|
14
|
+
Output format
|
|
15
|
+
-------------
|
|
16
|
+
We use BLAST tabular output format 6 with the following fields::
|
|
17
|
+
|
|
18
|
+
qseqid sseqid pident length qlen slen qstart qend sstart send evalue bitscore
|
|
19
|
+
|
|
20
|
+
The query sequence id encodes locus and allele, e.g. ``arcC_1``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import time
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
from gmlst.aligners.base import AlignmentResult, AlleleMatch
|
|
31
|
+
from gmlst.utils import require_tool, run_cmd, temp_dir
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("gmlst.aligners.blastn")
|
|
34
|
+
|
|
35
|
+
# Tabular fields we request from BLAST
|
|
36
|
+
_OUTFMT = (
|
|
37
|
+
"6 qseqid sseqid pident length qlen qstart qend sstart send evalue bitscore sseq"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BlastnAligner:
|
|
42
|
+
"""MLST aligner using NCBI BLASTN."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self, threads: int = 1, count_same_copy: bool = False, **kwargs
|
|
46
|
+
) -> None:
|
|
47
|
+
self.threads = threads
|
|
48
|
+
self.count_same_copy = count_same_copy
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name(self) -> str:
|
|
52
|
+
return "blastn"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def supports_fastq(self) -> bool:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
def check_dependencies(self) -> None:
|
|
59
|
+
require_tool("blastn")
|
|
60
|
+
require_tool("makeblastdb")
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
# Indexing
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
|
|
67
|
+
"""Concatenate all allele FASTAs and build a BLAST nucleotide database.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
Path
|
|
72
|
+
The BLAST database prefix (e.g. ``index_dir / "alleles"``).
|
|
73
|
+
"""
|
|
74
|
+
require_tool("makeblastdb")
|
|
75
|
+
index_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
# Merged FASTA path
|
|
78
|
+
merged = index_dir / "alleles.fasta"
|
|
79
|
+
with merged.open("wb") as out:
|
|
80
|
+
for fasta in sorted(allele_fastas):
|
|
81
|
+
with fasta.open("rb") as f:
|
|
82
|
+
import shutil
|
|
83
|
+
|
|
84
|
+
shutil.copyfileobj(f, out)
|
|
85
|
+
merged = index_dir / "alleles.fasta"
|
|
86
|
+
with merged.open("w") as out:
|
|
87
|
+
for fasta in sorted(allele_fastas):
|
|
88
|
+
out.write(fasta.read_text())
|
|
89
|
+
|
|
90
|
+
db_prefix = index_dir / "alleles"
|
|
91
|
+
|
|
92
|
+
# Build BLAST db only if stale
|
|
93
|
+
nhr = db_prefix.with_suffix(".nhr")
|
|
94
|
+
if not nhr.exists() or nhr.stat().st_mtime < merged.stat().st_mtime:
|
|
95
|
+
logger.info("Building BLAST database at %s …", db_prefix)
|
|
96
|
+
run_cmd(
|
|
97
|
+
[
|
|
98
|
+
"makeblastdb",
|
|
99
|
+
"-in",
|
|
100
|
+
str(merged),
|
|
101
|
+
"-dbtype",
|
|
102
|
+
"nucl",
|
|
103
|
+
"-out",
|
|
104
|
+
str(db_prefix),
|
|
105
|
+
"-title",
|
|
106
|
+
"gmlst_alleles",
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
return db_prefix
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Alignment
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def align(
|
|
116
|
+
self,
|
|
117
|
+
sample: Path,
|
|
118
|
+
index_path: Path,
|
|
119
|
+
loci: list[str],
|
|
120
|
+
input_type: Literal["fasta", "fastq"],
|
|
121
|
+
) -> AlignmentResult:
|
|
122
|
+
"""Run BLASTN: allele sequences → assembled genome.
|
|
123
|
+
|
|
124
|
+
For FASTA inputs the allele DB is used as **query** and the genome
|
|
125
|
+
as **subject** (``-db`` flag points to the allele index, genome is
|
|
126
|
+
passed with ``-query``).
|
|
127
|
+
|
|
128
|
+
Wait — tseemann does it the other way: genome is the db, alleles are
|
|
129
|
+
queries. We follow the same convention for compatibility:
|
|
130
|
+
|
|
131
|
+
blastn -query alleles.fasta -db genome -outfmt 6 …
|
|
132
|
+
|
|
133
|
+
This avoids building a db per sample (samples are queries would need
|
|
134
|
+
per-sample dbs). Instead we build one allele db and query each
|
|
135
|
+
sample against it.
|
|
136
|
+
|
|
137
|
+
Actually the most efficient approach for assembled genomes is:
|
|
138
|
+
-query <allele_fasta> -subject <genome> (no db needed per run)
|
|
139
|
+
Or build a db from the genome once per sample (tseemann does this).
|
|
140
|
+
|
|
141
|
+
We use ``-query <alleles> -subject <genome>`` for simplicity — no
|
|
142
|
+
per-sample database build required.
|
|
143
|
+
"""
|
|
144
|
+
if input_type == "fastq":
|
|
145
|
+
raise ValueError(
|
|
146
|
+
"BlastnAligner does not support FASTQ input. "
|
|
147
|
+
"Use minimap2 or kma backend."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
sample_id = sample.stem.split(".")[0]
|
|
151
|
+
t0 = time.perf_counter()
|
|
152
|
+
|
|
153
|
+
# The index_path is the allele db prefix; we query alleles against genome
|
|
154
|
+
alleles_fasta = index_path.parent / "alleles.fasta"
|
|
155
|
+
|
|
156
|
+
with temp_dir("gmlst_blastn_") as tmp:
|
|
157
|
+
out_file = tmp / "hits.tsv"
|
|
158
|
+
run_cmd(
|
|
159
|
+
[
|
|
160
|
+
"blastn",
|
|
161
|
+
"-query",
|
|
162
|
+
str(alleles_fasta),
|
|
163
|
+
"-subject",
|
|
164
|
+
str(sample),
|
|
165
|
+
"-outfmt",
|
|
166
|
+
_OUTFMT,
|
|
167
|
+
"-out",
|
|
168
|
+
str(out_file),
|
|
169
|
+
"-perc_identity",
|
|
170
|
+
"80",
|
|
171
|
+
"-dust",
|
|
172
|
+
"no",
|
|
173
|
+
"-num_threads",
|
|
174
|
+
str(self.threads),
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
matches = _parse_blast_output(
|
|
178
|
+
out_file,
|
|
179
|
+
loci,
|
|
180
|
+
count_same_copy=self.count_same_copy,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
runtime = time.perf_counter() - t0
|
|
184
|
+
called_loci = {m.locus for m in matches}
|
|
185
|
+
failed = [loc for loc in loci if loc not in called_loci]
|
|
186
|
+
|
|
187
|
+
return AlignmentResult(
|
|
188
|
+
sample_id=sample_id,
|
|
189
|
+
matches=matches,
|
|
190
|
+
failed_loci=failed,
|
|
191
|
+
backend=self.name,
|
|
192
|
+
runtime_seconds=runtime,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Output parsing
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_blast_output(
|
|
202
|
+
path: Path,
|
|
203
|
+
loci: list[str],
|
|
204
|
+
*,
|
|
205
|
+
count_same_copy: bool = False,
|
|
206
|
+
) -> list[AlleleMatch]:
|
|
207
|
+
"""Parse BLAST tabular output and return :class:`AlleleMatch` objects.
|
|
208
|
+
|
|
209
|
+
Fields (format 6 custom)::
|
|
210
|
+
|
|
211
|
+
qseqid sseqid pident length qlen qstart qend sstart send evalue bitscore
|
|
212
|
+
|
|
213
|
+
The query id encodes locus and allele as ``<locus>_<allele_id>``.
|
|
214
|
+
We take the **best hit per (locus, allele_id)** pair — highest identity,
|
|
215
|
+
then longest alignment.
|
|
216
|
+
"""
|
|
217
|
+
loci_set = set(loci)
|
|
218
|
+
# best[(locus, allele_id)] = AlleleMatch
|
|
219
|
+
best: dict[tuple[str, str], AlleleMatch] = {}
|
|
220
|
+
copies: dict[tuple[str, str], set[tuple[str, str, str]]] = {}
|
|
221
|
+
|
|
222
|
+
if not path.exists():
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
with path.open() as fh:
|
|
226
|
+
for line in fh:
|
|
227
|
+
line = line.strip()
|
|
228
|
+
if not line or line.startswith("#"):
|
|
229
|
+
continue
|
|
230
|
+
parts = line.split("\t")
|
|
231
|
+
if len(parts) < 11:
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
qseqid = parts[0]
|
|
235
|
+
pident = float(parts[2])
|
|
236
|
+
aln_len = int(parts[3])
|
|
237
|
+
qlen = int(parts[4])
|
|
238
|
+
|
|
239
|
+
# Parse locus + allele from query id (e.g. "arcC_1")
|
|
240
|
+
locus, allele_id = _split_allele_id(qseqid)
|
|
241
|
+
if locus not in loci_set:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
coverage = aln_len / qlen if qlen > 0 else 0.0
|
|
245
|
+
key = (locus, allele_id)
|
|
246
|
+
if count_same_copy:
|
|
247
|
+
sseqid = parts[1]
|
|
248
|
+
sstart = parts[7]
|
|
249
|
+
send = parts[8]
|
|
250
|
+
start, end = (sstart, send) if sstart <= send else (send, sstart)
|
|
251
|
+
copies.setdefault(key, set()).add((sseqid, start, end))
|
|
252
|
+
|
|
253
|
+
existing = best.get(key)
|
|
254
|
+
if existing is None or (
|
|
255
|
+
pident > existing.identity
|
|
256
|
+
or (pident == existing.identity and coverage > existing.coverage)
|
|
257
|
+
):
|
|
258
|
+
# Extract subject sequence if available (for novel alleles)
|
|
259
|
+
sequence = parts[11] if len(parts) > 11 else None
|
|
260
|
+
|
|
261
|
+
best[key] = AlleleMatch(
|
|
262
|
+
locus=locus,
|
|
263
|
+
allele_id=allele_id,
|
|
264
|
+
identity=pident,
|
|
265
|
+
coverage=coverage,
|
|
266
|
+
alignment_length=aln_len,
|
|
267
|
+
score=float(parts[10]), # bitscore
|
|
268
|
+
sequence=sequence,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if count_same_copy:
|
|
272
|
+
for key, match in best.items():
|
|
273
|
+
match.copy_count = max(1, len(copies.get(key, set())))
|
|
274
|
+
|
|
275
|
+
return list(best.values())
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _split_allele_id(qseqid: str) -> tuple[str, str]:
|
|
279
|
+
"""Split ``arcC_1`` → ``("arcC", "1")``.
|
|
280
|
+
|
|
281
|
+
Handles both ``_`` and ``-`` separators.
|
|
282
|
+
"""
|
|
283
|
+
for sep in ("_", "-"):
|
|
284
|
+
if sep in qseqid:
|
|
285
|
+
locus, allele_id = qseqid.rsplit(sep, 1)
|
|
286
|
+
return locus, allele_id
|
|
287
|
+
return qseqid, "1"
|
gmlst/aligners/kma.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from gmlst.aligners.base import AlignmentResult, AlleleMatch
|
|
11
|
+
from gmlst.readers.sample import SampleInput
|
|
12
|
+
from gmlst.utils import temp_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KmaAligner:
|
|
16
|
+
def __init__(self, threads: int = 1, **kwargs) -> None:
|
|
17
|
+
self.threads = threads
|
|
18
|
+
self.fastq_mem_mode = bool(kwargs.get("fastq_mem_mode", False))
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return "kma"
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def supports_fastq(self) -> bool:
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
def check_dependencies(self) -> None:
|
|
29
|
+
if shutil.which("kma") is None:
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"kma backend requires KMA binary. Install by source build: "
|
|
32
|
+
"git clone https://github.com/genomicepidemiology/kma.git "
|
|
33
|
+
"&& cd kma && make"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def index(self, allele_fastas: list[Path], index_dir: Path) -> Path:
|
|
37
|
+
index_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
merged = index_dir / "alleles.fasta"
|
|
39
|
+
db_prefix = index_dir / "kma_db"
|
|
40
|
+
|
|
41
|
+
with merged.open("w") as out:
|
|
42
|
+
for fasta in sorted(allele_fastas):
|
|
43
|
+
opener = gzip.open if fasta.suffix == ".gz" else open
|
|
44
|
+
with opener(fasta, "rt") as fh: # type: ignore[call-overload]
|
|
45
|
+
out.write(fh.read())
|
|
46
|
+
|
|
47
|
+
subprocess.run(
|
|
48
|
+
[
|
|
49
|
+
"kma",
|
|
50
|
+
"index",
|
|
51
|
+
"-i",
|
|
52
|
+
str(merged),
|
|
53
|
+
"-o",
|
|
54
|
+
str(db_prefix),
|
|
55
|
+
],
|
|
56
|
+
check=True,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
)
|
|
60
|
+
return index_dir
|
|
61
|
+
|
|
62
|
+
def align(
|
|
63
|
+
self,
|
|
64
|
+
sample: Path | tuple[Path, Path],
|
|
65
|
+
index_path: Path,
|
|
66
|
+
loci: list[str],
|
|
67
|
+
input_type: Literal["fasta", "fastq"],
|
|
68
|
+
) -> AlignmentResult:
|
|
69
|
+
sample_path = sample[0] if isinstance(sample, tuple) else sample
|
|
70
|
+
sample_id = SampleInput.from_path(sample_path).sample_id
|
|
71
|
+
t0 = time.perf_counter()
|
|
72
|
+
|
|
73
|
+
db_prefix = index_path / "kma_db"
|
|
74
|
+
with temp_dir("gmlst_kma_") as tmp_dir:
|
|
75
|
+
out_prefix = tmp_dir / "kma_out"
|
|
76
|
+
cmd = [
|
|
77
|
+
"kma",
|
|
78
|
+
"-o",
|
|
79
|
+
str(out_prefix),
|
|
80
|
+
"-t_db",
|
|
81
|
+
str(db_prefix),
|
|
82
|
+
"-t",
|
|
83
|
+
str(max(1, self.threads)),
|
|
84
|
+
]
|
|
85
|
+
if isinstance(sample, tuple):
|
|
86
|
+
cmd.extend(["-ipe", str(sample[0]), str(sample[1])])
|
|
87
|
+
else:
|
|
88
|
+
cmd.extend(["-i", str(sample)])
|
|
89
|
+
if input_type == "fasta":
|
|
90
|
+
cmd.append("-asm")
|
|
91
|
+
else:
|
|
92
|
+
cmd.append("-ill")
|
|
93
|
+
if self.fastq_mem_mode:
|
|
94
|
+
cmd.append("-mem_mode")
|
|
95
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
96
|
+
|
|
97
|
+
res_file = out_prefix.with_suffix(".res")
|
|
98
|
+
matches = _parse_kma_res(res_file, loci, input_type)
|
|
99
|
+
|
|
100
|
+
runtime = time.perf_counter() - t0
|
|
101
|
+
called_loci = {m.locus for m in matches}
|
|
102
|
+
failed = [loc for loc in loci if loc not in called_loci]
|
|
103
|
+
return AlignmentResult(
|
|
104
|
+
sample_id=sample_id,
|
|
105
|
+
matches=matches,
|
|
106
|
+
failed_loci=failed,
|
|
107
|
+
backend=self.name,
|
|
108
|
+
runtime_seconds=runtime,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_kma_res(
|
|
113
|
+
path: Path,
|
|
114
|
+
loci: list[str],
|
|
115
|
+
input_type: Literal["fasta", "fastq"],
|
|
116
|
+
) -> list[AlleleMatch]:
|
|
117
|
+
loci_set = set(loci)
|
|
118
|
+
results: list[AlleleMatch] = []
|
|
119
|
+
with path.open() as fh:
|
|
120
|
+
header: dict[str, int] | None = None
|
|
121
|
+
for line in fh:
|
|
122
|
+
line = line.rstrip("\n")
|
|
123
|
+
if not line:
|
|
124
|
+
continue
|
|
125
|
+
if line.startswith("#"):
|
|
126
|
+
cols = line.lstrip("#").split("\t")
|
|
127
|
+
header = {name: idx for idx, name in enumerate(cols)}
|
|
128
|
+
continue
|
|
129
|
+
if header is None:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
cols = line.split("\t")
|
|
133
|
+
template = cols[header.get("Template", 0)]
|
|
134
|
+
locus, allele_id = _split_template(template)
|
|
135
|
+
if locus not in loci_set:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
identity = float(cols[header.get("Template_Identity", 4)])
|
|
139
|
+
coverage = float(cols[header.get("Template_Coverage", 5)]) / 100.0
|
|
140
|
+
score = float(cols[header.get("Score", 1)])
|
|
141
|
+
raw_depth = float(cols[header.get("Depth", 8)])
|
|
142
|
+
depth = raw_depth if input_type == "fastq" else None
|
|
143
|
+
template_len = int(float(cols[header.get("Template_length", 3)]))
|
|
144
|
+
|
|
145
|
+
results.append(
|
|
146
|
+
AlleleMatch(
|
|
147
|
+
locus=locus,
|
|
148
|
+
allele_id=allele_id,
|
|
149
|
+
identity=identity,
|
|
150
|
+
coverage=coverage,
|
|
151
|
+
score=score,
|
|
152
|
+
depth=depth,
|
|
153
|
+
alignment_length=template_len,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return results
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _split_template(template: str) -> tuple[str, str]:
|
|
161
|
+
if "_" in template:
|
|
162
|
+
return tuple(template.rsplit("_", 1)) # type: ignore[return-value]
|
|
163
|
+
if "-" in template:
|
|
164
|
+
return tuple(template.rsplit("-", 1)) # type: ignore[return-value]
|
|
165
|
+
return (template, "")
|