From 472c84d20d95ce7122778d58b531296deff508c2 Mon Sep 17 00:00:00 2001 From: Matthijs Berends Date: Mon, 2 Nov 2026 17:47:28 +0100 Subject: [PATCH] Capture more EUCAST breakpoints from the Notes, using regular expressions only (#329) Added: the oxacillin disk for S. pseudintermedius and related species, the oxacillin MIC for coagulase-negative staphylococci, mupirocin for nasal decolonisation, and meropenem in other infections for N. meningitidis. The Bacteroides statement is now matched as a pattern, and other wording stops the script. The script documents why statements are left out, e.g., the oxacillin statements for S. aureus, S. lugdunensis and S. saprophyticus are hedged ("mostly methicillin resistant"), unlike those for cefoxitin. --- ...roduction_of_clinical_breakpoints_eucast.R | 80 ++++++++++++++++--- tests/testthat/test-data.R | 44 +++++++++- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/data-raw/_reproduction_scripts/reproduction_of_clinical_breakpoints_eucast.R b/data-raw/_reproduction_scripts/reproduction_of_clinical_breakpoints_eucast.R index 889562a46..21928ae65 100644 --- a/data-raw/_reproduction_scripts/reproduction_of_clinical_breakpoints_eucast.R +++ b/data-raw/_reproduction_scripts/reproduction_of_clinical_breakpoints_eucast.R @@ -622,6 +622,7 @@ parse_sheet <- function(xlsx_path, sheet_name, abbreviations = list()) { # pattern does not recognise stops the script, so that it is not missed. sheet_text <- unique(parsed$full_text[!is.na(parsed$full_text) & parsed$col == 1]) scope_pattern <- "In the absence of specific breakpoints, the ([A-Z])\\. ([a-z]+) MIC ?breakpoints can be applied to ([A-Z])\\. ([a-z]+)\\." + bacteroides_pattern <- "Breakpoints for Bacteroides spp\\. are also valid for Parabacteroides spp\\. and for Phocaeicola dorei/vulgatus" for (txt in sheet_text) { txt_squished <- gsub("\\s+", " ", txt) m <- regmatches(txt_squished, regexec(scope_pattern, txt_squished))[[1]] @@ -629,7 +630,7 @@ parse_sheet <- function(xlsx_path, sheet_name, abbreviations = list()) { sheet_title <- trimws(get_cell(parsed, 1, 1)$base) genus <- sub("\\s.*$", "", sheet_title) if (substr(genus, 1, 1) != m[2] || m[2] != m[4]) { - stop("parse_sheet(): MIC scope statement on sheet '", sheet_name, "' does not match the sheet's genus '", genus, "': ", m[1]) + stop("parse_sheet(): scope statement on sheet '", sheet_name, "' does not match the sheet's genus '", genus, "': ", m[1]) } parse_log$scope_extensions <- rbind( parse_log$scope_extensions, @@ -638,7 +639,7 @@ parse_sheet <- function(xlsx_path, sheet_name, abbreviations = list()) { from_text = paste(genus, m[3]), to_text = paste(genus, m[5]), statement = m[1], method = "MIC" ) ) - } else if (grepl("breakpoints can (also )?be applied to [A-Z]\\.", txt_squished)) { + } else if (grepl("breakpoints can (also )?be applied to [A-Z]\\.|breakpoints for .* are also valid for", txt_squished, ignore.case = TRUE) && !grepl(bacteroides_pattern, txt_squished)) { stop( "parse_sheet(): unrecognised statement about applying breakpoints to another species on sheet '", sheet_name, "': ", substr(txt_squished, 1, 300) @@ -646,20 +647,22 @@ parse_sheet <- function(xlsx_path, sheet_name, abbreviations = list()) { } } - # EUCAST extends Bacteroides breakpoints to these taxa for both methods. - statement <- paste0( - "Breakpoints for Bacteroides spp. are also valid for Parabacteroides spp. ", - "and for Phocaeicola dorei/vulgatus (previously named Bacteroides dorei/vulgatus)." - ) - if (any(grepl(statement, gsub("\\s+", " ", sheet_text), fixed = TRUE))) { + # EUCAST extends the Bacteroides breakpoints to these taxa, for both methods. Any other wording of such a statement stops the script, see above. + statement <- sheet_text[grepl(bacteroides_pattern, gsub("\\s+", " ", sheet_text))] + if (length(statement) > 0) { extension <- expand.grid( to_text = c("Parabacteroides", "Phocaeicola dorei", "Phocaeicola vulgatus"), method = c("MIC", "DISK"), stringsAsFactors = FALSE ) parse_log$scope_extensions <- bind_rows( parse_log$scope_extensions, - extension |> mutate(guideline = ver$guideline, sheet = sheet_name, - from_text = "Bacteroides", statement = statement) + extension |> + mutate( + guideline = ver$guideline, + sheet = sheet_name, + from_text = "Bacteroides", + statement = regmatches(gsub("\\s+", " ", statement[1]), regexpr(paste0(bacteroides_pattern, "[^.]*\\."), gsub("\\s+", " ", statement[1]))) + ) ) } @@ -2467,6 +2470,11 @@ for (i in seq_len(nrow(eucast_note_rules))) { # staphylococci except S. epidermidis and S. lugdunensis" then applies to # the identified CoNS species (one row per species), while the CoNS group # itself (an isolate only identified as CoNS) gets 25 mm. +# 4. The oxacillin disk for staphylococci in which cefoxitin is less predictive, e.g. S. pseudintermedius: "Use the oxacillin 1 ug disk with zone diameter breakpoints S>=20, R<20 mm". Stored under OXA-S. +# 5. The oxacillin MIC for coagulase-negative staphylococci: "For coagulase-negative staphylococci other than S. saprophyticus and S. lugdunensis, the oxacillin MIC in methicillin resistant isolates is >0.25 mg/L". Stored under OXA-S, with blocking rows for these two species. The oxacillin statements for S. aureus, S. lugdunensis and S. saprophyticus read "mostly methicillin resistant", and are hedged, unlike the cefoxitin statements. +# 6. Mupirocin for nasal decolonisation of S. aureus: "Breakpoints for nasal decontamination S <=1, R >256 mg/L (S >=30, R <18 mm for the mupirocin 200 ug disk)". Stored under MUP, not as a screening test, with site "Topical". +# 7. Meropenem for N. meningitidis in other infections: "The meningitis breakpoint can be used to categorise meropenem for other serious infections". +# Not taken over: statements about a surrogate agent without an agent of its own in this package (kanamycin for amikacin), provisional values (pretomanid), statements about wild-type populations, about countries that prefer another approach, and about inferring other agents. # Statements that are not breakpoints, such as ECOFFs ("There are no clinical # breakpoints but acquired resistance (indicated by MIC >1 mg/L) should be # excluded") or hedged ones ("... with oxacillin MIC values >2 mg/L are mostly @@ -2531,10 +2539,60 @@ for (r in seq_len(nrow(fox_mic))) { ) } } +strip_blocking_note <- function(x) sub("^\\[No breakpoint\\][^|]*(\\| )?", "", x) +squish <- function(x) gsub("\\s+", " ", x) +# 4. the oxacillin disk, for the organisms of the row with this note +oxa_disk_rule <- "Use the oxacillin 1 .?g disk with zone diameter breakpoints S\u2265([0-9]+), R<([0-9]+) mm" +oxa_disk <- breakpoints_eucast |> + filter(ab == "FOX-S", method == "DISK", grepl(oxa_disk_rule, squish(note))) |> + distinct(guideline, mo, .keep_all = TRUE) +for (r in seq_len(nrow(oxa_disk))) { + m <- regmatches(squish(oxa_disk$note[r]), regexec(oxa_disk_rule, squish(oxa_disk$note[r])))[[1]] + derived_rows[[length(derived_rows) + 1]] <- oxa_disk[r, ] |> + mutate(ab = as.ab("OXA-S"), site = "Screen", disk_dose = "1 mcg", is_blocker = FALSE, breakpoint_S = m[2], breakpoint_R = m[3], note = strip_blocking_note(note)) +} +# 5. the oxacillin MIC for coagulase-negative staphylococci, except two species +oxa_mic_rule <- "For coagulase-negative staphylococci other than S\\. saprophyticus and S\\. lugdunensis, the oxacillin MIC in methicillin resistant (isolates|strains) is >([0-9.]+) mg/L" +oxa_mic <- breakpoints_eucast |> + filter(ab == "OXA", method == "MIC", grepl(oxa_mic_rule, squish(note))) |> + distinct(guideline, .keep_all = TRUE) +for (r in seq_len(nrow(oxa_mic))) { + m <- regmatches(squish(oxa_mic$note[r]), regexec(oxa_mic_rule, squish(oxa_mic$note[r])))[[1]] + row <- oxa_mic[r, ] |> + mutate(ab = as.ab("OXA-S"), site = "Screen", mo = as.mo("B_STPHY_CONS"), is_blocker = FALSE, breakpoint_S = m[3], breakpoint_R = m[3], note = strip_blocking_note(note)) + derived_rows[[length(derived_rows) + 1]] <- row + for (sp in c("B_STPHY_SPRP", "B_STPHY_LGDN")) { + derived_rows[[length(derived_rows) + 1]] <- row |> + mutate(mo = as.mo(sp), is_blocker = TRUE, breakpoint_S = NA_character_, breakpoint_R = NA_character_, note = blocking_note(paste0("EUCAST: '", m[1], "'"), NA_character_)) + } +} +# 6. mupirocin for nasal decolonisation; both notations of the zone diameters occur +mupirocin_rule <- "Breakpoints for nasal de[a-z]+[^(]*S \u2264([0-9]+), R >([0-9]+) mg/L \\((?:disk diffusion with mupirocin 200 .?g disk )?S \u2265([0-9]+), R <([0-9]+) mm" +mupirocin_rows <- breakpoints_eucast |> + filter(ab == "MUP-S", site %in% "Topical", grepl("Breakpoints for nasal de", squish(note), fixed = TRUE)) +for (r in seq_len(nrow(mupirocin_rows))) { + m <- regmatches(squish(mupirocin_rows$note[r]), regexec(mupirocin_rule, squish(mupirocin_rows$note[r])))[[1]] + if (length(m) != 5) stop("unexpected wording of the mupirocin breakpoints in the note of ", mupirocin_rows$guideline[r], ": ", substr(mupirocin_rows$note[r], 1, 300)) + values <- if (mupirocin_rows$method[r] == "MIC") m[2:3] else m[4:5] + derived_rows[[length(derived_rows) + 1]] <- mupirocin_rows[r, ] |> + mutate(ab = as.ab("MUP"), breakpoint_S = values[1], breakpoint_R = values[2]) +} +# 7. meropenem for N. meningitidis +meropenem_rule <- "The meningitis breakpoint can be used to categorise meropenem for other serious infections" +meropenem_rows <- breakpoints_eucast |> + filter(ab == "MEM", is_blocker, !site %in% "Meningitis", grepl(meropenem_rule, squish(note), fixed = TRUE)) +for (r in seq_len(nrow(meropenem_rows))) { + row <- meropenem_rows[r, ] + source_row <- breakpoints_eucast |> + filter(guideline == row$guideline, ab == "MEM", mo == row$mo, method == row$method, site %in% "Meningitis", !is_blocker) + if (nrow(source_row) != 1) stop("expected one breakpoint for meningitis in ", row$guideline, " for ", row$mo) + derived_rows[[length(derived_rows) + 1]] <- row |> + mutate(is_blocker = FALSE, breakpoint_S = source_row$breakpoint_S, breakpoint_R = source_row$breakpoint_R, note = strip_blocking_note(note)) +} if (length(derived_rows) > 0) { derived <- bind_rows(derived_rows) message( - "NOTE: ", nrow(derived), " screening breakpoint row(s) taken from the notes (", + "NOTE: ", nrow(derived), " breakpoint row(s) taken from the notes (", paste(unique(paste(derived$guideline, derived$ab, derived$method)), collapse = ", "), ")" ) breakpoints_eucast <- bind_rows(breakpoints_eucast, derived) diff --git a/tests/testthat/test-data.R b/tests/testthat/test-data.R index e6cd286b0..8cd7ec174 100644 --- a/tests/testthat/test-data.R +++ b/tests/testthat/test-data.R @@ -599,7 +599,8 @@ test_that("clinical_breakpoints: screening tests are under screening agents (#32 expect_false(any(ab %in% c("GEH", "STH"))) expect_true(all(c("GEH", "STH") %in% AMR::antimicrobials$ab)) # topical cut-off values - topical <- cb[cb$site %in% "Topical", , drop = FALSE] + # (mupirocin also has actual breakpoints for nasal decolonisation, #329) + topical <- cb[cb$site %in% "Topical" & !as.character(cb$ab) %in% "MUP", , drop = FALSE] expect_true(all(grepl("-S$", as.character(topical$ab)))) expect_false(any(cb$site %in% "Topical, Screen")) # explicit lists: moved @@ -616,6 +617,47 @@ test_that("clinical_breakpoints: screening tests are under screening agents (#32 expect_identical(unname(as.list(ery("ERY-S"))), unname(as.list(ery("ERY")))) }) +test_that("clinical_breakpoints: EUCAST breakpoints stated in the Notes (#329)", { + skip_on_cran() + cb <- as.data.frame(clinical_breakpoints, stringsAsFactors = FALSE) + cb <- cb[cb$type == "human", , drop = FALSE] + bp <- function(guideline, ab, mo, method, site = NULL) { + x <- cb[cb$guideline == guideline & as.character(cb$ab) == ab & as.character(cb$mo) == as.character(as.mo(mo, info = FALSE)) & cb$method == method, , drop = FALSE] + if (!is.null(site)) { + x <- x[x$site %in% site, , drop = FALSE] + } + c(x$breakpoint_S, x$breakpoint_R) + } + # oxacillin disk for S. pseudintermedius and related species + expect_identical(bp("EUCAST 2019", "OXA-S", "S. pseudintermedius", "DISK"), c(20, 20)) + expect_identical(bp("EUCAST 2026", "OXA-S", "S. pseudintermedius", "DISK"), c(20, 20)) + # oxacillin MIC for coagulase-negative staphylococci, not for the species excluded by EUCAST + expect_identical(bp("EUCAST 2026", "OXA-S", "B_STPHY_CONS", "MIC"), c(0.25, 0.25)) + expect_identical(bp("EUCAST 2026", "OXA-S", "S. lugdunensis", "MIC"), c(NA_real_, NA_real_)) + expect_identical(bp("EUCAST 2026", "OXA-S", "S. saprophyticus", "MIC"), c(NA_real_, NA_real_)) + # hedged statements are not taken over + expect_false(any(as.character(cb$ab) == "OXA-S" & as.character(cb$mo) == "B_STPHY_AURS" & cb$method == "MIC" & !is.na(cb$breakpoint_R))) + # mupirocin for nasal decolonisation, which changed over the years + expect_identical(bp("EUCAST 2023", "MUP", "S. aureus", "MIC", "Topical"), c(1, 256)) + expect_identical(bp("EUCAST 2023", "MUP", "S. aureus", "DISK", "Topical"), c(30, 18)) + expect_identical(bp("EUCAST 2026", "MUP", "S. aureus", "MIC", "Topical"), c(1, 1)) + expect_identical(bp("EUCAST 2026", "MUP", "S. aureus", "DISK", "Topical"), c(30, 30)) + # meropenem in other infections than meningitis for N. meningitidis, stated in EUCAST 2021 + expect_identical(bp("EUCAST 2021", "MEM", "N. meningitidis", "MIC", "Non-meningitis"), c(0.25, 0.25)) + # Bacteroides extends to these taxa for both methods, unlike H. influenzae, which extends for MIC only + for (g in paste("EUCAST", 2023:2026)) { + for (to in c("Parabacteroides", "Phocaeicola dorei", "Phocaeicola vulgatus")) { + for (m in c("MIC", "DISK")) { + from_rows <- cb[cb$guideline == g & as.character(cb$mo) == "B_BCTRD" & cb$method == m & !is.na(cb$breakpoint_S), , drop = FALSE] + to_rows <- cb[cb$guideline == g & as.character(cb$mo) == as.character(as.mo(to, info = FALSE)) & cb$method == m & !is.na(cb$breakpoint_S), , drop = FALSE] + expect_true(nrow(from_rows) > 0, info = paste(g, m)) + expect_true(all(as.character(from_rows$ab) %in% as.character(to_rows$ab)), info = paste(g, to, m)) + } + } + } + expect_false(any(as.character(cb$mo) == "B_HMPHL_PRNF" & cb$method == "DISK" & cb$guideline %like% "EUCAST 202" & !is.na(cb$breakpoint_S))) +}) + test_that("clinical_breakpoints: no EUCAST breakpoints in brackets (#327)", { skip_on_cran() expect_false(any(grepl("brackets", clinical_breakpoints$note, fixed = TRUE)))