gside 0.1.1__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.
- gside/__init__.py +15 -0
- gside/__main__.py +6 -0
- gside/analysis/__init__.py +0 -0
- gside/analysis/ani_identifier.py +184 -0
- gside/analysis/multigene_identifier.py +218 -0
- gside/analysis/sourmash_identifier.py +125 -0
- gside/analysis/species_canon.py +53 -0
- gside/analysis/taxonomic_validator.py +234 -0
- gside/cli.py +374 -0
- gside/config.py +49 -0
- gside/data/db/D1_marker/marker_rules.yaml +234 -0
- gside/data/db/D1_marker/markers.fasta +1502 -0
- gside/data/db/D1_marker/markers_blastdb.ndb +0 -0
- gside/data/db/D1_marker/markers_blastdb.nhr +0 -0
- gside/data/db/D1_marker/markers_blastdb.nin +0 -0
- gside/data/db/D1_marker/markers_blastdb.njs +22 -0
- gside/data/db/D1_marker/markers_blastdb.not +0 -0
- gside/data/db/D1_marker/markers_blastdb.nsq +0 -0
- gside/data/db/D1_marker/markers_blastdb.ntf +0 -0
- gside/data/db/D1_marker/markers_blastdb.nto +0 -0
- gside/db.py +447 -0
- gside/engine/__init__.py +97 -0
- gside/engine/_env.py +48 -0
- gside/engine/backends/__init__.py +51 -0
- gside/engine/backends/blast.py +114 -0
- gside/engine/backends/kma.py +141 -0
- gside/engine/backends/kmer.py +232 -0
- gside/engine/backends/minimap2.py +83 -0
- gside/engine/backends/mmseqs2.py +70 -0
- gside/engine/backends/skani.py +73 -0
- gside/engine/hits.py +116 -0
- gside/engine/read_mapper.py +183 -0
- gside/engine/registry.py +29 -0
- gside/engine/utils.py +82 -0
- gside/utils.py +18 -0
- gside-0.1.1.dist-info/METADATA +114 -0
- gside-0.1.1.dist-info/RECORD +40 -0
- gside-0.1.1.dist-info/WHEEL +4 -0
- gside-0.1.1.dist-info/entry_points.txt +2 -0
- gside-0.1.1.dist-info/licenses/LICENSE +21 -0
gside/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""gside — Genome Species IDentification Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("gside")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.1.0.dev"
|
|
12
|
+
except Exception: # pragma: no cover — extremely defensive fallback
|
|
13
|
+
__version__ = "0.1.0.dev"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
gside/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""ANI-based species identification (species-id plan A: panel / skani_gtdb / mash_refseq)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gside.config import SPECIES_DB_DIR
|
|
11
|
+
from gside.engine.backends.skani import AniHit
|
|
12
|
+
|
|
13
|
+
_DB_BY_MODE = {
|
|
14
|
+
"panel": "D2_ani",
|
|
15
|
+
"skani_gtdb": "D2_skani_gtdb",
|
|
16
|
+
"mash_refseq": "D3_mash",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_ANI_HIGH = 95.0
|
|
20
|
+
_ANI_MEDIUM = 93.0
|
|
21
|
+
_AF_MIN = 0.65
|
|
22
|
+
_MASH_IDENTITY_HIGH = 0.97
|
|
23
|
+
_TOP_N = 10
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AniIdResult:
|
|
28
|
+
method: str
|
|
29
|
+
database: dict[str, str]
|
|
30
|
+
result: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"analysis_type": "species_identification",
|
|
35
|
+
"method": self.method,
|
|
36
|
+
"database": self.database,
|
|
37
|
+
"result": self.result,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _hits_from_rows(rows: list[tuple[str, float, float]]) -> list[AniHit]:
|
|
42
|
+
return [AniHit(ref=r, ani=a, aligned_fraction=af) for r, a, af in rows]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _skani_search(query: str | Path, db: Path) -> list[AniHit]:
|
|
46
|
+
from gside.engine.backends.skani import SkaniBackend
|
|
47
|
+
|
|
48
|
+
return SkaniBackend().search(Path(query), db)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _mash_dist(query: str | Path, msh: Path) -> list[tuple[str, float]]:
|
|
52
|
+
from gside.config import which
|
|
53
|
+
from gside.engine._env import require_bin
|
|
54
|
+
from gside.engine.backends.kmer import MashBackend
|
|
55
|
+
|
|
56
|
+
mash = require_bin(
|
|
57
|
+
"mash",
|
|
58
|
+
hint="mash not found — install via pixi (conda) or point GSIDE_PIXI_BIN at a bin dir",
|
|
59
|
+
resolver=which,
|
|
60
|
+
)
|
|
61
|
+
# mash CLI is `dist <reference> <query>`: output column 0 carries the
|
|
62
|
+
# reference (sketch DB genome) IDs, so the sketch goes in the first slot.
|
|
63
|
+
# MashBackend.distance() places its first positional there; max_distance=1.0
|
|
64
|
+
# is mash's own default cutoff, i.e. no extra filtering vs. a bare `mash dist`.
|
|
65
|
+
results = MashBackend(binary=mash).distance(Path(msh), Path(query), max_distance=1.0)
|
|
66
|
+
return [(r.reference_id, 1.0 - r.distance) for r in results]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mash_species(ref_id: str) -> str:
|
|
70
|
+
return ref_id
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _load_taxa_map(db_dir: Path) -> dict[str, str]:
|
|
74
|
+
for candidate in ("metadata.tsv", "taxa_map.tsv"):
|
|
75
|
+
path = db_dir / candidate
|
|
76
|
+
if path.is_file():
|
|
77
|
+
mapping: dict[str, str] = {}
|
|
78
|
+
for ln in path.read_text(encoding="utf-8").splitlines()[1:]:
|
|
79
|
+
cols = ln.split("\t")
|
|
80
|
+
if len(cols) >= 2:
|
|
81
|
+
mapping[cols[0].strip()] = cols[1].strip()
|
|
82
|
+
return mapping
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _db_version(db_dir: Path, name: str) -> str:
|
|
87
|
+
manifest = db_dir.parent / "manifests" / f"{name}.json"
|
|
88
|
+
if manifest.is_file():
|
|
89
|
+
try:
|
|
90
|
+
checksum = json.loads(manifest.read_text()).get("checksum", "")
|
|
91
|
+
return checksum[:8] if checksum else "unknown"
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
return "unknown"
|
|
94
|
+
return "unknown"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def identify_by_ani(
|
|
98
|
+
contigs: str | Path,
|
|
99
|
+
mode: str,
|
|
100
|
+
db_dir: str | Path | None = None,
|
|
101
|
+
) -> AniIdResult:
|
|
102
|
+
if mode not in _DB_BY_MODE:
|
|
103
|
+
raise ValueError(f"unsupported ANI mode {mode!r}; expected one of {sorted(_DB_BY_MODE)}")
|
|
104
|
+
db_name = _DB_BY_MODE[mode]
|
|
105
|
+
base = Path(db_dir) if db_dir else SPECIES_DB_DIR / db_name
|
|
106
|
+
taxa = _load_taxa_map(base)
|
|
107
|
+
database = {"name": db_name, "version": _db_version(base, db_name)}
|
|
108
|
+
|
|
109
|
+
if mode == "mash_refseq":
|
|
110
|
+
msh = base / "mash.msh"
|
|
111
|
+
if not msh.exists():
|
|
112
|
+
msh = base / "payload.bin"
|
|
113
|
+
if not msh.exists():
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
f"mash sketch DB not found in {base} (run: gside db setup --tier mash)"
|
|
116
|
+
)
|
|
117
|
+
mash_rows = _mash_dist(contigs, msh)
|
|
118
|
+
mash_top = sorted(mash_rows, key=lambda r: -r[1])[:_TOP_N]
|
|
119
|
+
best_ref, best_identity = mash_top[0] if mash_top else ("", 0.0)
|
|
120
|
+
species = taxa.get(best_ref, _mash_species(best_ref)) if best_ref else "Unknown"
|
|
121
|
+
confidence = "high" if best_identity >= _MASH_IDENTITY_HIGH else "low"
|
|
122
|
+
result = {
|
|
123
|
+
"species": species or "Unknown",
|
|
124
|
+
"confidence": confidence,
|
|
125
|
+
"identity": best_identity,
|
|
126
|
+
"top_hits": [{"genome": r, "identity": i} for r, i in mash_top],
|
|
127
|
+
}
|
|
128
|
+
if 0.90 <= best_identity < _MASH_IDENTITY_HIGH:
|
|
129
|
+
result["confidence"] = "medium"
|
|
130
|
+
return AniIdResult(method=mode, database=database, result=result)
|
|
131
|
+
|
|
132
|
+
skani_db = base / "panel.sketch" if (base / "panel.sketch").exists() else base
|
|
133
|
+
skani_hits = sorted(
|
|
134
|
+
_skani_search(contigs, skani_db), key=lambda h: (-h.ani, -h.aligned_fraction)
|
|
135
|
+
)
|
|
136
|
+
top = skani_hits[:_TOP_N]
|
|
137
|
+
best = top[0] if top else None
|
|
138
|
+
|
|
139
|
+
species, confidence = "Unknown", "low"
|
|
140
|
+
if best is not None:
|
|
141
|
+
if best.ani >= _ANI_HIGH and best.aligned_fraction >= _AF_MIN:
|
|
142
|
+
species, confidence = taxa.get(best.ref, best.ref), "high"
|
|
143
|
+
elif _ANI_MEDIUM <= best.ani < _ANI_HIGH and best.aligned_fraction >= _AF_MIN:
|
|
144
|
+
species, confidence = taxa.get(best.ref, best.ref), "medium"
|
|
145
|
+
|
|
146
|
+
result = {
|
|
147
|
+
"species": species,
|
|
148
|
+
"confidence": confidence,
|
|
149
|
+
"ani": best.ani if best else None,
|
|
150
|
+
"aligned_fraction": best.aligned_fraction if best else None,
|
|
151
|
+
"top_hits": [
|
|
152
|
+
{
|
|
153
|
+
"genome": h.ref,
|
|
154
|
+
"species": taxa.get(h.ref, h.ref),
|
|
155
|
+
"ani": h.ani,
|
|
156
|
+
"af": h.aligned_fraction,
|
|
157
|
+
}
|
|
158
|
+
for h in top
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
return AniIdResult(method=mode, database=database, result=result)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> None:
|
|
165
|
+
import argparse
|
|
166
|
+
|
|
167
|
+
parser = argparse.ArgumentParser(description="ANI species identifier")
|
|
168
|
+
parser.add_argument("contigs")
|
|
169
|
+
parser.add_argument("--mode", required=True, choices=sorted(_DB_BY_MODE))
|
|
170
|
+
parser.add_argument("--json", action="store_true")
|
|
171
|
+
args = parser.parse_args()
|
|
172
|
+
|
|
173
|
+
res = identify_by_ani(args.contigs, args.mode)
|
|
174
|
+
if args.json:
|
|
175
|
+
print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
|
|
176
|
+
else:
|
|
177
|
+
r = res.result
|
|
178
|
+
print(f"Species: {r['species']} ({r['confidence']}) via {res.method}")
|
|
179
|
+
for h in r.get("top_hits", [])[:5]:
|
|
180
|
+
print(f" {h['genome']}: {h.get('ani', h.get('identity'))}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Multi-gene species identifier using the expanded markers BLAST database.
|
|
2
|
+
|
|
3
|
+
Replaces the single-gene species_identifier for routine use. Reads
|
|
4
|
+
marker_rules.yaml for gene combination logic and scoring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from gside.config import SPECIES_DB_DIR
|
|
18
|
+
|
|
19
|
+
_MARKERS_FASTA = SPECIES_DB_DIR / "D1_marker" / "markers.fasta"
|
|
20
|
+
_MARKER_RULES = SPECIES_DB_DIR / "D1_marker" / "marker_rules.yaml"
|
|
21
|
+
|
|
22
|
+
_MIN_IDENTITY = 85.0
|
|
23
|
+
_MIN_COVERAGE = 60.0
|
|
24
|
+
_HIGH_CONF = 90.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MultiGeneResult:
|
|
29
|
+
species: str = "Unknown"
|
|
30
|
+
confidence: str = "low"
|
|
31
|
+
detected_markers: list[dict[str, Any]] = field(default_factory=list)
|
|
32
|
+
all_hits: list[dict[str, Any]] = field(default_factory=list)
|
|
33
|
+
matched_rule: str = ""
|
|
34
|
+
method: str = "multigene"
|
|
35
|
+
database_version: str = "unknown"
|
|
36
|
+
notes: list[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return {
|
|
40
|
+
"species": self.species,
|
|
41
|
+
"confidence": self.confidence,
|
|
42
|
+
"method": self.method,
|
|
43
|
+
"database": {"name": "species_markers_v2", "version": self.database_version},
|
|
44
|
+
"detected_markers": self.detected_markers,
|
|
45
|
+
"matched_rule": self.matched_rule,
|
|
46
|
+
"notes": self.notes,
|
|
47
|
+
"interpretation": self._interpret(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _interpret(self) -> str:
|
|
51
|
+
if self.species == "Unknown":
|
|
52
|
+
return "No species-specific markers detected"
|
|
53
|
+
markers = [m["gene"] for m in self.detected_markers]
|
|
54
|
+
return f"Identified as {self.species} based on: {', '.join(markers)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _db_version() -> str:
|
|
58
|
+
try:
|
|
59
|
+
return hashlib.sha256(_MARKERS_FASTA.read_bytes()).hexdigest()[:8]
|
|
60
|
+
except OSError:
|
|
61
|
+
return "unknown"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_rules() -> list[dict[str, Any]]:
|
|
65
|
+
if not _MARKER_RULES.is_file():
|
|
66
|
+
return []
|
|
67
|
+
data = yaml.safe_load(_MARKER_RULES.read_text())
|
|
68
|
+
return list(data.get("rules", []))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _blast_contigs(contigs_fasta: str) -> list[dict[str, Any]]:
|
|
72
|
+
from gside.config import which
|
|
73
|
+
from gside.engine._env import require_bin
|
|
74
|
+
from gside.engine.backends.blast import BlastBackend
|
|
75
|
+
|
|
76
|
+
blastn = require_bin(
|
|
77
|
+
"blastn",
|
|
78
|
+
hint="blastn not found — install via pixi (conda) or point GSIDE_PIXI_BIN at a bin dir",
|
|
79
|
+
resolver=which,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
db = str(SPECIES_DB_DIR / "D1_marker" / "markers_blastdb")
|
|
83
|
+
backend = BlastBackend(binary=blastn)
|
|
84
|
+
engine_hits = backend.find(
|
|
85
|
+
query=Path(contigs_fasta),
|
|
86
|
+
db_path=db,
|
|
87
|
+
evalue=1e-10,
|
|
88
|
+
word_size=11,
|
|
89
|
+
)
|
|
90
|
+
hits = []
|
|
91
|
+
for hit in engine_hits:
|
|
92
|
+
seqid = hit.subject_id
|
|
93
|
+
parts = seqid.split("~~~")
|
|
94
|
+
gene = parts[1].lower() if len(parts) >= 2 else seqid.lower()
|
|
95
|
+
hits.append(
|
|
96
|
+
{
|
|
97
|
+
"gene": gene,
|
|
98
|
+
"identity": hit.identity,
|
|
99
|
+
"coverage": round(hit.subject_coverage, 1),
|
|
100
|
+
"ref_len": hit.subject_length,
|
|
101
|
+
"seqid": seqid,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return hits
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def identify_multigene(contigs_fasta: str) -> MultiGeneResult:
|
|
108
|
+
raw_hits = _blast_contigs(contigs_fasta)
|
|
109
|
+
rules = _load_rules()
|
|
110
|
+
|
|
111
|
+
best_hits: dict[str, dict[str, Any]] = {}
|
|
112
|
+
for hit in raw_hits:
|
|
113
|
+
gene = hit["gene"]
|
|
114
|
+
if gene not in best_hits or hit["identity"] > best_hits[gene]["identity"]:
|
|
115
|
+
best_hits[gene] = hit
|
|
116
|
+
|
|
117
|
+
significant: dict[str, dict[str, Any]] = {}
|
|
118
|
+
for hit in raw_hits:
|
|
119
|
+
if hit["identity"] < _MIN_IDENTITY or hit["coverage"] < _MIN_COVERAGE:
|
|
120
|
+
continue
|
|
121
|
+
gene = hit["gene"]
|
|
122
|
+
if gene not in significant or hit["identity"] > significant[gene]["identity"]:
|
|
123
|
+
significant[gene] = hit
|
|
124
|
+
|
|
125
|
+
result = MultiGeneResult(database_version=_db_version())
|
|
126
|
+
result.all_hits = list(best_hits.values())
|
|
127
|
+
|
|
128
|
+
priority_species = set()
|
|
129
|
+
for rule in rules:
|
|
130
|
+
if rule.get("priority_over"):
|
|
131
|
+
for _target in rule["priority_over"]:
|
|
132
|
+
priority_species.add(rule["species"])
|
|
133
|
+
|
|
134
|
+
best_species = "Unknown"
|
|
135
|
+
best_conf = "low"
|
|
136
|
+
best_score = 0
|
|
137
|
+
best_rule = ""
|
|
138
|
+
|
|
139
|
+
for rule in rules:
|
|
140
|
+
genes = [g.lower() for g in rule.get("genes", [])]
|
|
141
|
+
min_hits = rule.get("min_hits", 1)
|
|
142
|
+
min_id = rule.get("min_identity", _HIGH_CONF)
|
|
143
|
+
exclude = [g.lower() for g in rule.get("exclude_genes", [])]
|
|
144
|
+
|
|
145
|
+
matched = []
|
|
146
|
+
for gene in genes:
|
|
147
|
+
if (
|
|
148
|
+
gene in significant
|
|
149
|
+
and significant[gene]["identity"] >= min_id
|
|
150
|
+
and gene not in exclude
|
|
151
|
+
):
|
|
152
|
+
matched.append(gene)
|
|
153
|
+
|
|
154
|
+
excluded_present = [g for g in exclude if g in significant]
|
|
155
|
+
|
|
156
|
+
if len(matched) >= min_hits and not excluded_present:
|
|
157
|
+
score = len(matched)
|
|
158
|
+
avg_id = sum(significant[g]["identity"] for g in matched) / len(matched)
|
|
159
|
+
conf = "high" if avg_id >= _HIGH_CONF else "medium"
|
|
160
|
+
|
|
161
|
+
if score > best_score or (score == best_score and conf == "high"):
|
|
162
|
+
best_species = rule["species"]
|
|
163
|
+
best_conf = conf
|
|
164
|
+
best_score = score
|
|
165
|
+
best_rule = f"{'+'.join(matched)} ({len(matched)}/{min_hits} required)"
|
|
166
|
+
|
|
167
|
+
result.species = best_species
|
|
168
|
+
result.confidence = best_conf
|
|
169
|
+
result.matched_rule = best_rule
|
|
170
|
+
|
|
171
|
+
if significant:
|
|
172
|
+
result.detected_markers = [
|
|
173
|
+
{"gene": g, "identity": h["identity"], "coverage": h["coverage"]}
|
|
174
|
+
for g, h in sorted(significant.items(), key=lambda x: -x[1]["identity"])
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
if best_species.startswith("Vibrio_cholerae"):
|
|
178
|
+
if "ctxa" in significant:
|
|
179
|
+
result.notes.append("ctxA detected - toxigenic Vibrio cholerae strain.")
|
|
180
|
+
else:
|
|
181
|
+
result.notes.append("ctxA not detected - non-toxigenic by this marker assay.")
|
|
182
|
+
|
|
183
|
+
if not significant and raw_hits:
|
|
184
|
+
near = [h for h in raw_hits if h["identity"] >= 75 and h["coverage"] >= 20]
|
|
185
|
+
if near:
|
|
186
|
+
result.notes.append(
|
|
187
|
+
f"Near-threshold hits detected (identity 75-85%): "
|
|
188
|
+
f"{', '.join(h['gene'] for h in near[:5])}. "
|
|
189
|
+
"Possible divergence or contamination; ANI recheck advised."
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main() -> None:
|
|
196
|
+
import argparse
|
|
197
|
+
|
|
198
|
+
parser = argparse.ArgumentParser(description="Multi-gene species identifier")
|
|
199
|
+
parser.add_argument("contigs")
|
|
200
|
+
parser.add_argument("--json", action="store_true")
|
|
201
|
+
args = parser.parse_args()
|
|
202
|
+
|
|
203
|
+
res = identify_multigene(args.contigs)
|
|
204
|
+
if args.json:
|
|
205
|
+
print(json.dumps(res.to_dict(), ensure_ascii=False, indent=2))
|
|
206
|
+
else:
|
|
207
|
+
d = res.to_dict()
|
|
208
|
+
print(f"Species: {d['species']} ({d['confidence']})")
|
|
209
|
+
print(f"Method: {d['method']}, Rule: {d['matched_rule']}")
|
|
210
|
+
for m in d.get("detected_markers", []):
|
|
211
|
+
print(f" {m['gene']}: {m['identity']}% identity, {m['coverage']}% coverage")
|
|
212
|
+
for n in d.get("notes", []):
|
|
213
|
+
print(f" NOTE: {n}")
|
|
214
|
+
print(d["interpretation"])
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""sourmash gather-based species identification (species-id plan B)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from gside.config import SPECIES_DB_DIR
|
|
14
|
+
|
|
15
|
+
_FUW_HIGH = 0.90
|
|
16
|
+
_FUW_MEDIUM = 0.70
|
|
17
|
+
_MIXTURE_MEMBER = 0.10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SourmashIdResult:
|
|
22
|
+
method: str
|
|
23
|
+
database: dict[str, str]
|
|
24
|
+
result: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"analysis_type": "species_identification",
|
|
29
|
+
"method": self.method,
|
|
30
|
+
"database": self.database,
|
|
31
|
+
"result": self.result,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(cmd: list[str]) -> str:
|
|
36
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
raise RuntimeError(f"{cmd[0]} failed: {result.stderr.strip()}")
|
|
39
|
+
return result.stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _gather_and_tax(contigs: str | Path, db_dir: Path) -> list[dict[str, Any]]:
|
|
43
|
+
db_zip = db_dir / "gtdb-reps-k31.zip"
|
|
44
|
+
lineages = db_dir / "lineages.csv"
|
|
45
|
+
if not db_zip.exists() or not lineages.exists():
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
f"sourmash GTDB database not found in {db_dir} (run: gside db setup --tier sourmash)"
|
|
48
|
+
)
|
|
49
|
+
with tempfile.TemporaryDirectory(prefix="gside-sourmash-") as tmp:
|
|
50
|
+
work = Path(tmp)
|
|
51
|
+
sig = work / "query.sig"
|
|
52
|
+
gather_csv = work / "gather.csv"
|
|
53
|
+
_run(["sourmash", "sketch", "dna", "-p", "k=31,scaled=1000", str(contigs), "-o", str(sig)])
|
|
54
|
+
try:
|
|
55
|
+
_run(["sourmash", "gather", str(sig), str(db_zip), "-o", str(gather_csv)])
|
|
56
|
+
except RuntimeError:
|
|
57
|
+
return []
|
|
58
|
+
rows = (
|
|
59
|
+
list(csv.DictReader(gather_csv.read_text().splitlines()))
|
|
60
|
+
if gather_csv.is_file()
|
|
61
|
+
else []
|
|
62
|
+
)
|
|
63
|
+
lineage_map = _lineage_map(lineages)
|
|
64
|
+
out: list[dict[str, Any]] = []
|
|
65
|
+
for row in rows:
|
|
66
|
+
name = row.get("name", "")
|
|
67
|
+
try:
|
|
68
|
+
fuw = float(row.get("f_unique_weighted", 0.0))
|
|
69
|
+
except ValueError:
|
|
70
|
+
fuw = 0.0
|
|
71
|
+
out.append({"lineage": lineage_map.get(name, name), "f_unique_weighted": fuw})
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _lineage_map(path: Path) -> dict[str, str]:
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
return {}
|
|
78
|
+
mapping: dict[str, str] = {}
|
|
79
|
+
with path.open() as fh:
|
|
80
|
+
for row in csv.reader(fh):
|
|
81
|
+
if len(row) >= 2:
|
|
82
|
+
mapping[row[0]] = row[-1].split(";")[-1].strip()
|
|
83
|
+
return mapping
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _db_version(db_dir: Path) -> str:
|
|
87
|
+
manifest = db_dir.parent / "manifests" / "D4_sourmash.json"
|
|
88
|
+
if manifest.is_file():
|
|
89
|
+
try:
|
|
90
|
+
checksum = json.loads(manifest.read_text()).get("checksum", "")
|
|
91
|
+
return checksum[:8] if checksum else "RS226"
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
return "RS226"
|
|
94
|
+
return "RS226"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def identify_by_sourmash(contigs: str | Path, db_dir: str | Path | None = None) -> SourmashIdResult:
|
|
98
|
+
base = Path(db_dir) if db_dir else SPECIES_DB_DIR / "D4_sourmash"
|
|
99
|
+
partition = _gather_and_tax(contigs, base)
|
|
100
|
+
database = {"name": "D4_sourmash", "version": _db_version(base)}
|
|
101
|
+
|
|
102
|
+
best_fuw = max((p["f_unique_weighted"] for p in partition), default=0.0)
|
|
103
|
+
flags: list[str] = []
|
|
104
|
+
strong_members = [p for p in partition if p["f_unique_weighted"] >= _MIXTURE_MEMBER]
|
|
105
|
+
if len(strong_members) >= 2:
|
|
106
|
+
flags.append("possible_mixture")
|
|
107
|
+
|
|
108
|
+
if best_fuw >= _FUW_HIGH and not flags:
|
|
109
|
+
species, confidence = partition[0]["lineage"], "high"
|
|
110
|
+
elif best_fuw >= _FUW_MEDIUM:
|
|
111
|
+
species, confidence = partition[0]["lineage"], "medium"
|
|
112
|
+
else:
|
|
113
|
+
species, confidence = "Mixed/Unknown", "low"
|
|
114
|
+
|
|
115
|
+
result = {
|
|
116
|
+
"species": species or "Mixed/Unknown",
|
|
117
|
+
"confidence": confidence,
|
|
118
|
+
"f_unique_weighted": best_fuw,
|
|
119
|
+
"gather_partition": [
|
|
120
|
+
{"lineage": p["lineage"], "f_unique_weighted": p["f_unique_weighted"]}
|
|
121
|
+
for p in partition
|
|
122
|
+
],
|
|
123
|
+
"flags": flags,
|
|
124
|
+
}
|
|
125
|
+
return SourmashIdResult(method="sourmash", database=database, result=result)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""物种判决规范化 —— 共享单一实现(评审 A5:消除同 PR 内 ≥4 份拷贝)。
|
|
2
|
+
|
|
3
|
+
- ``canonical_from_verdict(verdict, ipah)``:管线 verdict → 规范物种名。
|
|
4
|
+
not_ 前缀按 ipaH 分流(Shigella/E. coli);供 nli_reflector / validate
|
|
5
|
+
类消费者使用。
|
|
6
|
+
- ``collapse_binomial(verdict)``:二名形/别名 → verify_species 期望的 token。
|
|
7
|
+
not_ 前缀原样保留(维持 Layer 2 报错信息可读);供 deterministic_verifier
|
|
8
|
+
使用。
|
|
9
|
+
- 规范名常量:各模块的文本提取表 / gold 前缀表的值必须引用这些常量,
|
|
10
|
+
新增病原只改本模块一处。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
SALMONELLA = "Salmonella"
|
|
16
|
+
ECOLI = "E. coli"
|
|
17
|
+
SHIGELLA = "Shigella"
|
|
18
|
+
VPARA = "V. parahaemolyticus"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
# 子串 → 规范名(顺序敏感:物种专名先于属名缩写)
|
|
22
|
+
_VERDICT_NEEDLES: tuple[tuple[str, str], ...] = (
|
|
23
|
+
("Salmonella", SALMONELLA),
|
|
24
|
+
("parahaemolyticus", VPARA),
|
|
25
|
+
("Shigella", SHIGELLA),
|
|
26
|
+
("E. coli", ECOLI),
|
|
27
|
+
("E.coli", ECOLI),
|
|
28
|
+
("DEC", ECOLI),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def canonical_from_verdict(verdict: str, ipah: str = "") -> str:
|
|
33
|
+
"""verdict → 规范物种名;not_X 按 ipaH 分流(阳性→Shigella,否则 E. coli)。"""
|
|
34
|
+
v = verdict.strip()
|
|
35
|
+
if not v:
|
|
36
|
+
return UNKNOWN
|
|
37
|
+
if v.startswith("not_"):
|
|
38
|
+
return SHIGELLA if "positive" in ipah.lower() else ECOLI
|
|
39
|
+
for needle, canonical in _VERDICT_NEEDLES:
|
|
40
|
+
if needle in v:
|
|
41
|
+
return canonical
|
|
42
|
+
return UNKNOWN
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collapse_binomial(verdict: str) -> str:
|
|
46
|
+
"""二名形/别名 → verify_species 期望 token;not_ 前缀原样保留。"""
|
|
47
|
+
v = verdict.strip()
|
|
48
|
+
if v.startswith("not_"):
|
|
49
|
+
return v
|
|
50
|
+
for needle, canonical in _VERDICT_NEEDLES:
|
|
51
|
+
if needle in v:
|
|
52
|
+
return canonical
|
|
53
|
+
return v
|