#!/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())