File size: 16,041 Bytes
19fdb7b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
#!/usr/bin/env python3
"""map_evidence.py — map AMRFinderPlus hits to per-drug spectrum-confirmed resistance evidence.

Reads an AMRFinderPlus v4 TSV (headers: Element symbol, Element name, Scope,
Type, Subtype, Class, Subclass, Method, ...) and a curated mapping table
(drug_class_map.yaml, same directory by default), and prints the hits that
count as evidence category (i) "spectrum-confirmed known resistance" for the
requested drug, each annotated with:
  - the mapping rule that fired and its confidence (confirmed | review)
  - evidence tier: full_gene (EXACT/ALLELE/BLAST method), degraded
    (PARTIAL/PARTIAL_CONTIG_END/INTERNAL_STOP/HMM), point (curated mutation)

Usage:
  python map_evidence.py AMRFINDER.tsv --drug ciprofloxacin
  python map_evidence.py AMRFINDER.tsv --drug sxt --confirmed-only --format json
  python map_evidence.py --selftest          # no inputs needed

Exit codes: 0 ok (also when zero hits), 2 usage/data error, 1 selftest failure.
Stdlib + PyYAML only.
"""

from __future__ import annotations

import argparse
import csv
import json
import re
import sys
from pathlib import Path

import yaml

DEFAULT_MAP = Path(__file__).resolve().parent / "drug_class_map.yaml"

POINT_SUBTYPES = {"POINT", "POINT_DISRUPT"}
FULL_GENE_METHODS = {"EXACT", "ALLELE", "BLAST"}
DEGRADED_METHODS = {"PARTIAL", "PARTIAL_CONTIG_END", "INTERNAL_STOP", "HMM"}


def norm_drug_name(name: str, drugs: dict) -> str:
    """Resolve a drug name/alias to the canonical key in the YAML."""
    key = name.strip().lower()
    if key in drugs:
        return key
    for canon, spec in drugs.items():
        if key in [a.lower() for a in spec.get("aliases", [])]:
            return canon
    raise KeyError(name)


def method_base(method: str) -> str:
    """Strip the X/P/N search-type suffix (EXACTX->EXACT, POINTN->POINT)."""
    m = method.strip().upper()
    if m.endswith(("X", "P", "N")) and m[:-1] in (
        FULL_GENE_METHODS | DEGRADED_METHODS | {"POINT"}
    ):
        return m[:-1]
    return m


def tier_of(hit: dict) -> str:
    subtype = hit.get("Subtype", "").strip().upper()
    method = hit.get("Method", "").strip()
    if subtype in POINT_SUBTYPES or method_base(method) == "POINT":
        return "point"
    base = method_base(method)
    if base in FULL_GENE_METHODS:
        return "full_gene"
    if base in DEGRADED_METHODS:
        return "degraded"
    return "unknown"


class CompiledRule:
    def __init__(self, rule: dict):
        self.name = rule["name"]
        self.confidence = rule.get("confidence", "review")
        self.component = rule.get("component")
        self.note = rule.get("note", "")
        m = rule.get("match", {})
        self.subtype = m.get("subtype")  # None | "AMR" | "POINT"
        self.symbol_re = re.compile(m["symbol_regex"]) if "symbol_regex" in m else None
        self.class_re = re.compile(m["class_regex"]) if "class_regex" in m else None
        self.subclass_re = (
            re.compile(m["subclass_regex"]) if "subclass_regex" in m else None
        )

    def matches(self, hit: dict) -> bool:
        subtype = hit.get("Subtype", "").strip().upper()
        if self.subtype == "AMR" and subtype != "AMR":
            return False
        if self.subtype == "POINT" and subtype not in POINT_SUBTYPES:
            return False
        if self.symbol_re and not self.symbol_re.search(hit.get("Element symbol", "")):
            return False
        if self.class_re and not self.class_re.search(hit.get("Class", "")):
            return False
        if self.subclass_re and not self.subclass_re.search(hit.get("Subclass", "")):
            return False
        return True


def load_map(path: Path) -> dict:
    with open(path) as fh:
        return yaml.safe_load(fh)


def map_hits(hits: list[dict], drug_spec: dict) -> list[dict]:
    """Return hits matching any rule of drug_spec, annotated."""
    rules = [CompiledRule(r) for r in drug_spec.get("rules", [])]
    excl = drug_spec.get("exclude_symbol_regex")
    excl_re = re.compile(excl) if excl else None
    out = []
    for hit in hits:
        symbol = hit.get("Element symbol", "")
        if excl_re and excl_re.search(symbol):
            continue
        for rule in rules:
            if rule.matches(hit):
                row = {
                    "element_symbol": symbol,
                    "element_name": hit.get("Element name", ""),
                    "rule": rule.name,
                    "confidence": rule.confidence,
                    "tier": tier_of(hit),
                    "method": hit.get("Method", ""),
                    "subtype": hit.get("Subtype", ""),
                    "class": hit.get("Class", ""),
                    "subclass": hit.get("Subclass", ""),
                    "component": rule.component or "",
                }
                out.append(row)
                break  # first matching rule wins; rules are ORed
    return out


def read_amrfinder_tsv(path: str) -> list[dict]:
    with open(path, newline="") as fh:
        reader = csv.DictReader(fh, delimiter="\t")
        missing = {"Element symbol", "Subtype", "Class", "Subclass", "Method"} - set(
            reader.fieldnames or []
        )
        if missing:
            raise ValueError(
                f"{path}: not an AMRFinderPlus v4 TSV — missing headers: {sorted(missing)}"
            )
        return list(reader)


# --------------------------------------------------------------------- selftest
def _hit(symbol, name, subtype, cls, subcls, method):
    return {
        "Element symbol": symbol,
        "Element name": name,
        "Subtype": subtype,
        "Class": cls,
        "Subclass": subcls,
        "Method": method,
    }


SELFTEST_CASES = [
    # (hit, drug, expect_match, expect_confidence, expect_tier)
    # -- required case 1: QRDR points -> ciprofloxacin
    (_hit("gyrA_S83L", "quinolone resistant GyrA", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
     "ciprofloxacin", True, "confirmed", "point"),
    (_hit("parC_E84V", "quinolone resistant ParC", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
     "ciprofloxacin", True, "confirmed", "point"),
    (_hit("gyrA_D87N", "quinolone resistant GyrA", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
     "ciprofloxacin", True, "confirmed", "point"),
    (_hit("parE_I529L", "quinolone resistant ParE", "POINT", "QUINOLONE", "QUINOLONE", "POINTX"),
     "ciprofloxacin", True, "confirmed", "point"),
    # -- required case 2: narrow-spectrum blaTEM must NOT map to cefotaxime
    (_hit("blaTEM-1", "broad-spectrum class A beta-lactamase TEM-1", "AMR", "BETA-LACTAM", "BETA-LACTAM", "ALLELEX"),
     "cefotaxime", False, None, None),
    (_hit("blaTEM-1", "broad-spectrum class A beta-lactamase TEM-1", "AMR", "BETA-LACTAM", "BETA-LACTAM", "ALLELEX"),
     "ampicillin", True, "confirmed", "full_gene"),
    # ESBL TEM allele DOES map (allele-awareness sanity)
    (_hit("blaTEM-68", "extended-spectrum class A beta-lactamase TEM-68", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
     "cefotaxime", True, "confirmed", "full_gene"),
    # -- CTX-M: 3GC yes, carbapenem no
    (_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
     "cefotaxime", True, "confirmed", "full_gene"),
    (_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
     "ceftazidime", True, "confirmed", "full_gene"),
    (_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
     "meropenem", False, None, None),
    # partial CTX-M still maps but as degraded evidence
    (_hit("blaCTX-M-15", "extended-spectrum class A beta-lactamase CTX-M-15", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "PARTIALX"),
     "cefotaxime", True, "confirmed", "degraded"),
    # -- carbapenemases
    (_hit("blaKPC-3", "carbapenem-hydrolyzing class A beta-lactamase KPC-3", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
     "meropenem", True, "confirmed", "full_gene"),
    (_hit("blaKPC-3", "carbapenem-hydrolyzing class A beta-lactamase KPC-3", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
     "cefotaxime", True, "confirmed", "full_gene"),
    (_hit("blaOXA-48", "carbapenem-hydrolyzing class D beta-lactamase OXA-48", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
     "meropenem", True, "confirmed", "full_gene"),
    (_hit("blaOXA-48", "carbapenem-hydrolyzing class D beta-lactamase OXA-48", "AMR", "BETA-LACTAM", "CARBAPENEM", "ALLELEX"),
     "cefotaxime", False, None, None),  # OXA-48: carbapenems only, NOT 3GC
    # -- intrinsic blaEC must map to nothing (every E. coli carries it)
    (_hit("blaEC", "BlaEC family class C beta-lactamase", "AMR", "BETA-LACTAM", "BETA-LACTAM", "BLASTX"),
     "cefotaxime", False, None, None),
    (_hit("blaEC-5", "cephalosporin-hydrolyzing class C beta-lactamase EC-5", "AMR", "BETA-LACTAM", "CEPHALOSPORIN", "ALLELEX"),
     "cefotaxime", False, None, None),
    (_hit("blaEC", "BlaEC family class C beta-lactamase", "AMR", "BETA-LACTAM", "BETA-LACTAM", "BLASTX"),
     "ampicillin", False, None, None),
    # -- aminoglycosides: substrate awareness
    (_hit("ant(2'')-Ia", "aminoglycoside nucleotidyltransferase ANT(2'')-Ia", "AMR", "AMINOGLYCOSIDE", "GENTAMICIN/KANAMYCIN/TOBRAMYCIN", "EXACTX"),
     "gentamicin", True, "confirmed", "full_gene"),
    (_hit("aac(3)-IIa", "aminoglycoside N-acetyltransferase AAC(3)-IIa", "AMR", "AMINOGLYCOSIDE", "GENTAMICIN", "EXACTX"),
     "gentamicin", True, "confirmed", "full_gene"),
    (_hit("aac(6')-Ib", "aminoglycoside N-acetyltransferase AAC(6')-Ib", "AMR", "AMINOGLYCOSIDE", "AMIKACIN/KANAMYCIN/TOBRAMYCIN", "EXACTX"),
     "gentamicin", False, None, None),  # canonical aac(6')-Ib: tobra/amik only
    (_hit("rmtB", "16S rRNA methyltransferase RmtB", "AMR", "AMINOGLYCOSIDE", "AMINOGLYCOSIDE", "EXACTX"),
     "gentamicin", True, "confirmed", "full_gene"),  # generic subclass -> symbol rule
    (_hit("aph(3')-IIa", "aminoglycoside O-phosphotransferase APH(3')-IIa", "AMR", "AMINOGLYCOSIDE", "KANAMYCIN", "EXACTX"),
     "gentamicin", True, "review", "full_gene"),  # sources conflict -> review
    (_hit("aadA5", "ANT(3'')-Ia family aminoglycoside nucleotidyltransferase AadA5", "AMR", "AMINOGLYCOSIDE", "STREPTOMYCIN", "EXACTX"),
     "gentamicin", False, None, None),
    # -- qnr / aac(6')-Ib-cr -> ciprofloxacin
    (_hit("qnrB19", "quinolone resistance pentapeptide repeat protein QnrB19", "AMR", "QUINOLONE", "QUINOLONE", "EXACTX"),
     "ciprofloxacin", True, "confirmed", "full_gene"),
    (_hit("aac(6')-Ib-cr", "aminoglycoside N-acetyltransferase AAC(6')-Ib-cr", "AMR", "AMINOGLYCOSIDE/QUINOLONE", "AMIKACIN/KANAMYCIN/QUINOLONE/TOBRAMYCIN", "EXACTX"),
     "ciprofloxacin", True, "confirmed", "full_gene"),
    # -- SXT components
    (_hit("sul1", "sulfonamide-resistant dihydropteroate synthase Sul1", "AMR", "SULFONAMIDE", "SULFONAMIDE", "EXACTX"),
     "sxt", True, "confirmed", "full_gene"),
    (_hit("dfrA17", "trimethoprim-resistant dihydrofolate reductase DfrA17", "AMR", "TRIMETHOPRIM", "TRIMETHOPRIM", "EXACTX"),
     "sxt", True, "confirmed", "full_gene"),
    (_hit("folP_F28L", "sulfamethoxazole resistant FolP", "POINT", "SULFONAMIDE", "SULFONAMIDE", "POINTX"),
     "trimethoprim-sulfamethoxazole", True, "confirmed", "point"),
    # degraded INTERNAL_STOP gene still maps, tier degraded
    (_hit("sul2", "sulfonamide-resistant dihydropteroate synthase Sul2", "AMR", "SULFONAMIDE", "SULFONAMIDE", "INTERNAL_STOPX"),
     "sxt", True, "confirmed", "degraded"),
]


def run_selftest(map_path: Path) -> int:
    spec = load_map(map_path)
    drugs = spec["drugs"]
    failures = []
    for hit, drug, want_match, want_conf, want_tier in SELFTEST_CASES:
        canon = norm_drug_name(drug, drugs)
        res = map_hits([hit], drugs[canon])
        got_match = bool(res)
        sym = hit["Element symbol"]
        if got_match != want_match:
            failures.append(
                f"{sym} vs {drug}: match={got_match}, want {want_match}"
            )
            continue
        if got_match and (want_conf or want_tier):
            row = res[0]
            if want_conf and row["confidence"] != want_conf:
                failures.append(
                    f"{sym} vs {drug}: confidence={row['confidence']}, want {want_conf}"
                )
            if want_tier and row["tier"] != want_tier:
                failures.append(
                    f"{sym} vs {drug}: tier={row['tier']}, want {want_tier}"
                )
    # SXT component labels
    sxt = drugs[norm_drug_name("sxt", drugs)]
    comps = {
        r["element_symbol"]: r["component"]
        for r in map_hits(
            [
                _hit("sul1", "", "AMR", "SULFONAMIDE", "SULFONAMIDE", "EXACTX"),
                _hit("dfrA17", "", "AMR", "TRIMETHOPRIM", "TRIMETHOPRIM", "EXACTX"),
            ],
            sxt,
        )
    }
    if comps.get("sul1") != "sulfamethoxazole" or comps.get("dfrA17") != "trimethoprim":
        failures.append(f"SXT component labels wrong: {comps}")
    if failures:
        print("SELFTEST FAILED:", file=sys.stderr)
        for f in failures:
            print(f"  - {f}", file=sys.stderr)
        return 1
    print(f"selftest: {len(SELFTEST_CASES) + 1} checks passed", file=sys.stderr)
    return 0


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(
        description="Map AMRFinderPlus v4 TSV hits to per-drug spectrum-confirmed resistance evidence."
    )
    ap.add_argument("tsv", nargs="?", help="AMRFinderPlus v4 output TSV")
    ap.add_argument("--drug", help="drug name or alias (e.g. ciprofloxacin, sxt, meropenem)")
    ap.add_argument("--map", default=str(DEFAULT_MAP), help="path to drug_class_map.yaml")
    ap.add_argument("--confirmed-only", action="store_true", help="drop confidence=review hits")
    ap.add_argument("--format", choices=["tsv", "json"], default="tsv")
    ap.add_argument("--list-drugs", action="store_true", help="print known drugs and exit")
    ap.add_argument("--selftest", action="store_true", help="run built-in checks and exit")
    args = ap.parse_args(argv)

    map_path = Path(args.map)
    if args.selftest:
        return run_selftest(map_path)

    spec = load_map(map_path)
    drugs = spec["drugs"]
    if args.list_drugs:
        for canon, d in drugs.items():
            print(f"{canon}\t{','.join(d.get('aliases', []))}")
        return 0
    if not args.tsv or not args.drug:
        ap.error("TSV and --drug are required (or use --selftest / --list-drugs)")
    try:
        canon = norm_drug_name(args.drug, drugs)
    except KeyError:
        print(f"error: unknown drug '{args.drug}' (try --list-drugs)", file=sys.stderr)
        return 2
    try:
        hits = read_amrfinder_tsv(args.tsv)
    except (OSError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 2

    rows = map_hits(hits, drugs[canon])
    if args.confirmed_only:
        rows = [r for r in rows if r["confidence"] == "confirmed"]

    if args.format == "json":
        json.dump({"drug": canon, "n_hits": len(rows), "hits": rows}, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        cols = [
            "element_symbol", "rule", "confidence", "tier", "method",
            "subtype", "class", "subclass", "component", "element_name",
        ]
        w = csv.DictWriter(sys.stdout, fieldnames=cols, delimiter="\t", extrasaction="ignore")
        w.writeheader()
        w.writerows(rows)

    n_conf = sum(1 for r in rows if r["confidence"] == "confirmed")
    n_rev = len(rows) - n_conf
    tiers = {}
    for r in rows:
        tiers[r["tier"]] = tiers.get(r["tier"], 0) + 1
    tier_s = ", ".join(f"{k}={v}" for k, v in sorted(tiers.items())) or "none"
    print(
        f"# {canon}: {len(rows)} evidence hits ({n_conf} confirmed, {n_rev} review); tiers: {tier_s}",
        file=sys.stderr,
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())