(v3.0.1.9147) Keep EUCAST blocking rows for infections other than UTI where EUCAST only gives a UTI breakpoint, e.g. breakpoints in brackets for aminoglycosides in Acinetobacter, and let as.sir() with unknown uti consistently prefer non-UTI breakpoints, use UTI breakpoints only if no others exist and note this in sir_interpretation_history() every time

The pruning of blocking rows in the EUCAST reproduction script now compares UTI status too, and the script is fixed for the renamed column of the as.ab() coercion cache. In clinical_breakpoints, only the blocking rows were replaced (356 organism/agent/method combinations newly blocked, none unblocked); other changes of a full rebuild, caused by taxonomy updates in the WHONET-based rows, are not included.
This commit is contained in:
dr. M.S. (Matthijs) Berends committed 2026-10-15 14:55:57 +02:00
1 parent d69e7fa740
commit fcfaa87f5d
16 files changed
+2002 -845

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
Package: AMR
Version: 3.0.1.9146
Version: 3.0.1.9147
Date: 2026-10-15
Title: Antimicrobial Resistance Data Analysis
Description: Functions to simplify and standardise antimicrobial resistance (AMR)
+3 -2
View File
@@ -1,4 +1,4 @@
# AMR 3.0.1.9146
# AMR 3.0.1.9147
Planned as v3.1.0, mid October 2026.
@@ -32,7 +32,7 @@ Planned as v3.1.0, mid October 2026.
* **Updated clinical breakpoints**
* EUCAST 2026 and CLSI 2026 are now supported, by adding over 5,700 new breakpoints to the `clinical_breakpoints` data set. EUCAST 2026 is now the default guideline for all MIC and disk diffusion interpretations.
* Human EUCAST breakpoints from 2019 (v9.0) onwards are now retrieved directly from the EUCAST Excel files, instead of relying on the WHONET repository. As a result:
* `clinical_breakpoints` now contains blocking rows (`NA` breakpoints, with the reason in `note`) where EUCAST gives no breakpoint for an organism, so that `as.sir()` no longer falls back to the breakpoint of a broader taxon
* `clinical_breakpoints` now contains blocking rows (`NA` breakpoints, with the reason in `note`) where EUCAST gives no breakpoint for an organism, so that `as.sir()` no longer falls back to the breakpoint of a broader taxon. This includes infections other than UTI where EUCAST gives breakpoints in brackets or no breakpoint, but does give a breakpoint for UTI, e.g., aminoglycosides for *Acinetobacter* spp.
* Restrictions in EUCAST notes are applied, e.g., zone diameter breakpoints for *E. coli* only
* EUCAST PK-PD (non-species related) breakpoints of 2019-2023 and *H. parainfluenzae* MIC breakpoints are now included
* **Unified interpretive rules**
@@ -68,6 +68,7 @@ Planned as v3.1.0, mid October 2026.
* `info = FALSE` now suppresses all messages, including those for columns without breakpoints
* Combined MIC/SIR input values (e.g., `"<= 0.002; S"` or `"S; 0.002"`) are now parsed correctly (#252)
* Setting a year-qualified guideline, such as `options(AMR_guideline = "EUCAST 2012")`, no longer causes errors or silently wrong results in `interpretive_rules()`, `resistance()`, `susceptibility()`, `count_resistant()`, `count_susceptible()`, and the SIR plotting and printing functions (#298)
* With an unknown `uti` (`NA`, the default), non-UTI breakpoints are now consistently preferred over UTI breakpoints. Previously, this preference was only applied together with its note, which is shown once. UTI breakpoints are only used if there are no non-UTI breakpoints, which is now noted in `sir_interpretation_history()` for every interpretation. A blocking row of a broader taxon does not override a UTI breakpoint of the organism itself, e.g., Enterobacterales and nitroxoline for *E. coli*.
* `clinical_breakpoints` data set
* Fixed EUCAST breakpoint rules for *Aerococcus* (v12 onwards), which were never applied, and for *Pasteurella*, which were partly filed under *Neisseria meningitidis* (#290)
* Fixed streptomycin breakpoints imported from WHONET, which were coded as streptoduocin (`STR`) instead of streptomycin (`STR1`)
+24 -12
View File
@@ -38,7 +38,7 @@ VALID_SIR_LEVELS <- c("S", "SDD", "I", "R", "NI", "WT", "NWT", "NS")
#' @param x Vector of values (for class [`mic`]: MIC values in mg/L, for class [`disk`]: a disk diffusion radius in millimetres).
#' @param mo A vector (or column name) with [character]s that can be coerced to valid microorganism codes with [as.mo()], can be left empty to determine it automatically.
#' @param ab A vector (or column name) with [character]s that can be coerced to a valid antimicrobial drug code with [as.ab()].
#' @param uti (Urinary Tract Infection) a vector (or column name) with [logical]s (`TRUE` or `FALSE`) to specify whether a UTI specific interpretation from the guideline should be chosen. For using [as.sir()] on a [data.frame], this can also be a column containing [logical]s or when left blank, the data set will be searched for a column 'specimen', and rows within this column containing 'urin' (such as 'urine', 'urina') will be regarded isolates from a UTI. See *Examples*.
#' @param uti (Urinary Tract Infection) a vector (or column name) with [logical]s (`TRUE` or `FALSE`) to specify whether a UTI specific interpretation from the guideline should be chosen. For using [as.sir()] on a [data.frame], this can also be a column containing [logical]s or when left blank, the data set will be searched for a column 'specimen', and rows within this column containing 'urin' (such as 'urine', 'urina') will be regarded isolates from a UTI. If `uti` is `NA` (unknown), non-UTI breakpoints are preferred, and UTI breakpoints are only used if no non-UTI breakpoints exist for the drug-bug combination, which is noted in [sir_interpretation_history()]. With `uti = FALSE`, UTI breakpoints are never used. See *Examples*.
#' @inheritParams first_isolate
#' @param guideline A guideline name (or column name) to use for SIR interpretation. Defaults to `r AMR::clinical_breakpoints$guideline[1]` (the latest implemented EUCAST guideline in the [AMR::clinical_breakpoints] data set), but can be set with the package option [`AMR_guideline`][AMR-options]. Currently supports EUCAST (`r min(as.integer(gsub("[^0-9]", "", subset(AMR::clinical_breakpoints, guideline %like% "EUCAST")$guideline)))`-`r max(as.integer(gsub("[^0-9]", "", subset(AMR::clinical_breakpoints, guideline %like% "EUCAST")$guideline)))`) and CLSI (`r min(as.integer(gsub("[^0-9]", "", subset(AMR::clinical_breakpoints, guideline %like% "CLSI")$guideline)))`-`r max(as.integer(gsub("[^0-9]", "", subset(AMR::clinical_breakpoints, guideline %like% "CLSI")$guideline)))`), see *Details*. Using a column name allows for straightforward interpretation of historical data, which must be analysed in the context of, for example, different years.
#' @param capped_mic_handling A [character] string that controls how MIC values with a cap (i.e., starting with `<`, `<=`, `>`, or `>=`) are interpreted. Supports the following options:
@@ -1814,8 +1814,28 @@ as_sir_method <- function(method_short,
}
# if the user explicitly set uti, keep only those rows
uti_assumed <- FALSE
if (!is.na(uti_current)) {
breakpoints_current <- breakpoints_current[breakpoints_current$uti == uti_current, , drop = FALSE]
} else if (any(breakpoints_current$uti %in% TRUE)) {
# uti is unknown: non-UTI rows take precedence over UTI rows, and UTI rows are only used if there are no non-UTI rows. A non-UTI breakpoint applies at any taxonomic level, but a non-UTI row without breakpoints (a 'blocking row', e.g. for EUCAST's breakpoints in brackets) only applies if it is at least as specific as the UTI rows: the blocking row of e.g. Enterobacterales must not override the UTI breakpoint given for E. coli itself.
# taxonomic level without the 0.1 that a blocking row adds to its rank_index, e.g. 3.1 -> 3 and 2.6 -> 2.5
taxonomic_level <- round(breakpoints_current$rank_index * 2) / 2
is_blocking <- is.na(breakpoints_current$breakpoint_S) & is.na(breakpoints_current$breakpoint_R)
is_uti <- breakpoints_current$uti %in% TRUE
too_broad <- !is_uti & is_blocking & taxonomic_level > min(taxonomic_level[is_uti])
breakpoints_current <- breakpoints_current[!too_broad, , drop = FALSE]
if (any(!breakpoints_current$uti %in% TRUE)) {
if (length(unique(breakpoints_current$site)) > 1) {
notes_current <- paste0(
notes_current, "\n",
paste0("Breakpoints for UTI ", font_bold("and"), " non-UTI available for ", ab_formatted, " in ", mo_formatted, " - assuming non-UTI. Use argument `uti` to set which isolates are from urine. See `?as.sir`.")
)
}
breakpoints_current <- breakpoints_current[!breakpoints_current$uti %in% TRUE, , drop = FALSE]
} else {
uti_assumed <- TRUE
}
}
# build a helper factor so FALSE < NA < TRUE
@@ -1837,19 +1857,11 @@ as_sir_method <- function(method_short,
} else {
site <- paste0("body site '", site, "'")
}
if (nrow(breakpoints_current) == 1 && all(breakpoints_current$uti == TRUE) && is.na(uti_current) && message_not_thrown_before("as.sir", "uti", ab_current)) {
# only UTI breakpoints available
if (isTRUE(uti_assumed)) {
# only UTI breakpoints available, noted for every interpretation so that sir_interpretation_history() shows it
notes_current <- paste0(
notes_current, "\n",
paste0("Breakpoints for ", font_bold(ab_formatted), " in ", mo_formatted, " are only available for (uncomplicated) urinary tract infections (UTI) - assuming `uti = TRUE`.")
)
} else if (nrow(breakpoints_current) > 1 && length(unique(breakpoints_current$site)) > 1 && any(is.na(uti_current)) && all(c(TRUE, FALSE) %in% breakpoints_current$uti, na.rm = TRUE) && message_not_thrown_before("as.sir", "siteUTI", mo_current, ab_current)) {
# both UTI and Non-UTI breakpoints available
breakpoints_current <- breakpoints_current %pm>%
pm_filter(uti == FALSE)
notes_current <- paste0(
notes_current, "\n",
paste0("Breakpoints for UTI ", font_bold("and"), " non-UTI available for ", ab_formatted, " in ", mo_formatted, " - assuming ", site, ". Use argument `uti` to set which isolates are from urine. See `?as.sir`.")
paste0("Breakpoints for ", font_bold(ab_formatted), " in ", mo_formatted, " are only available for (uncomplicated) urinary tract infections (UTI) - assuming `uti = TRUE`. Use `uti = FALSE` to prevent this.")
)
} else if (nrow(breakpoints_current) > 1 && length(unique(breakpoints_current$site)) > 1 && all(breakpoints_current$uti == FALSE, na.rm = TRUE) && message_not_thrown_before("as.sir", "siteOther", mo_current, ab_current)) {
# breakpoints for multiple body sites available
@@ -494,7 +494,7 @@ parse_bp <- function(txt) {
t <- trimws(txt)
# placeholders such as "-" (susceptibility testing not recommended), "IE"
# or "Note" are kept as they are: the cleanup turns them into blocking rows
# Parenthesised values like "(2)" are EUCAST's "breakpoints in brackets" (see is_bracketed_bp()). They are kept as they are in the raw file, so that they remain distinguishable from clinical breakpoints. The cleanup turns them into blocking rows as well: the row stays in clinical_breakpoints, but with breakpoint_S and breakpoint_R set to NA and the bracketed values stated in the note (see "Blocking rows").
# Parenthesised values like "(2)" are EUCAST's "breakpoints in brackets" (see is_bracketed_bp()). They are kept as they are in the raw file, so that they remain distinguishable from clinical breakpoints. The cleanup turns them into blocking rows as well, with breakpoint_S and breakpoint_R set to NA and the bracketed values stated in the note (see "Blocking rows"). Like any blocking row, it is left out of clinical_breakpoints where the organism has a (non-screening) breakpoint of its own for the same agent, method and UTI status (see "Pruning blocking rows").
t
}
@@ -1975,7 +1975,7 @@ split_mo_list_one <- function(s, sheet_genus_fallback) {
# kept as "blocking rows": breakpoint_S and breakpoint_R are NA, so as.sir()
# returns NA, and the note starts with "[No breakpoint]" and the reason. A
# blocking row is only kept where the organism has no breakpoint of its own
# for that agent and method (see "Pruning blocking rows" below), and its
# for that agent, method and UTI status (see "Pruning blocking rows" below), and its
# rank_index is that of its organism + 0.1, so that a breakpoint for the same
# organism (e.g. for another route, or a screening breakpoint when
# as.sir(..., include_screening = TRUE)) always takes precedence. Blocking rows
@@ -2216,7 +2216,7 @@ breakpoints_eucast <- breakpoints_eucast |>
# Restrict to rows whose logged input is one of the actual, full ab_input
# strings this pipeline passed in, which is what a reviewer needs to see.
ab_previously_coerced_eucast <- AMR:::AMR_env$ab_previously_coerced |>
filter(tolower(trimws(x_bak)) %in% tolower(trimws(unique(breakpoints_eucast$ab_input))))
filter(tolower(trimws(input)) %in% tolower(trimws(unique(breakpoints_eucast$ab_input))))
breakpoints_eucast <- breakpoints_eucast |> select(-ab_input)
if (nrow(ab_previously_coerced_eucast) > 0) {
message(
@@ -2627,20 +2627,17 @@ if (nrow(unresolved_mo) > 0 || nrow(unresolved_ab) > 0) {
}
# Pruning blocking rows ----
# A blocking row is only needed where its organism has no (non-screening)
# breakpoint of its own for that agent and method, and blocking rows for the
# PK-PD table ("UNKNOWN") have no function. Of several blocking rows for the
# same organism, agent and method, the first is kept.
# A blocking row is only needed where its organism has no (non-screening) breakpoint of its own for that agent, method and UTI status, and blocking rows for the PK-PD table ("UNKNOWN") have no function. UTI status is part of the comparison, so that a breakpoint for UTI only does not remove the blocking row for other infections, e.g. Acinetobacter spp. and amikacin since v14.0: breakpoints in brackets for systemic infections, a breakpoint for infections originating from the urinary tract. Of several blocking rows for the same organism, agent, method and UTI status, the first is kept. Blocking rows for UTI keep their site, so that they remain distinguishable from the blocking row for other infections.
has_own_bp <- breakpoints_eucast |>
filter(!is_blocker, !grepl("screen", site, ignore.case = TRUE)) |>
distinct(guideline, type, host, mo, ab, method)
distinct(guideline, type, host, mo, ab, method, uti)
breakpoints_eucast <- bind_rows(
breakpoints_eucast |> filter(!is_blocker),
breakpoints_eucast |>
filter(is_blocker, mo != "UNKNOWN") |>
anti_join(has_own_bp, by = c("guideline", "type", "host", "mo", "ab", "method")) |>
distinct(guideline, type, host, mo, ab, method, .keep_all = TRUE) |>
mutate(site = NA_character_, uti = FALSE, disk_dose = NA_character_)
anti_join(has_own_bp, by = c("guideline", "type", "host", "mo", "ab", "method", "uti")) |>
distinct(guideline, type, host, mo, ab, method, uti, .keep_all = TRUE) |>
mutate(site = if_else(uti, site, NA_character_), disk_dose = NA_character_)
)
breakpoints_eucast <- breakpoints_eucast |>
+1 -1
View File
@@ -1 +1 @@
3affa43a0ac95513a6a956cd3afe035f
48ce51f62aa57d2fc24ef71500874b31
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -125,7 +125,7 @@ Otherwise: arguments passed on to methods.}
\item{guideline}{A guideline name (or column name) to use for SIR interpretation. Defaults to EUCAST 2026 (the latest implemented EUCAST guideline in the \link{clinical_breakpoints} data set), but can be set with the package option \code{\link[=AMR-options]{AMR_guideline}}. Currently supports EUCAST (2011-2026) and CLSI (2011-2026), see \emph{Details}. Using a column name allows for straightforward interpretation of historical data, which must be analysed in the context of, for example, different years.}
\item{uti}{(Urinary Tract Infection) a vector (or column name) with \link{logical}s (\code{TRUE} or \code{FALSE}) to specify whether a UTI specific interpretation from the guideline should be chosen. For using \code{\link[=as.sir]{as.sir()}} on a \link{data.frame}, this can also be a column containing \link{logical}s or when left blank, the data set will be searched for a column 'specimen', and rows within this column containing 'urin' (such as 'urine', 'urina') will be regarded isolates from a UTI. See \emph{Examples}.}
\item{uti}{(Urinary Tract Infection) a vector (or column name) with \link{logical}s (\code{TRUE} or \code{FALSE}) to specify whether a UTI specific interpretation from the guideline should be chosen. For using \code{\link[=as.sir]{as.sir()}} on a \link{data.frame}, this can also be a column containing \link{logical}s or when left blank, the data set will be searched for a column 'specimen', and rows within this column containing 'urin' (such as 'urine', 'urina') will be regarded isolates from a UTI. If \code{uti} is \code{NA} (unknown), non-UTI breakpoints are preferred, and UTI breakpoints are only used if no non-UTI breakpoints exist for the drug-bug combination, which is noted in \code{\link[=sir_interpretation_history]{sir_interpretation_history()}}. With \code{uti = FALSE}, UTI breakpoints are never used. See \emph{Examples}.}
\item{capped_mic_handling}{A \link{character} string that controls how MIC values with a cap (i.e., starting with \code{<}, \code{<=}, \code{>}, or \code{>=}) are interpreted. Supports the following options:
+2 -2
View File
@@ -5,7 +5,7 @@
\alias{clinical_breakpoints}
\title{Data Set with Clinical Breakpoints for SIR Interpretation}
\format{
A \link[tibble:tibble]{tibble} with 60 237 observations and 15 variables:
A \link[tibble:tibble]{tibble} with 61 367 observations and 15 variables:
\itemize{
\item \code{guideline}\cr Name of the guideline
\item \code{type}\cr Breakpoint type, either \code{"ECOFF"}, \code{"animal"}, or \code{"human"}
@@ -49,7 +49,7 @@ The default is \code{"human"}, which can also be set with the package option \co
\subsection{Blocking Rows}{
Where EUCAST lists an organism but gives no breakpoint for it (e.g. "IE", "-", "Note", breakpoints in brackets, or an organism excluded with "except"), \code{\link[=as.sir]{as.sir()}} must not fall back to the breakpoint of a broader taxon, such as the breakpoint for \emph{Vibrio} spp. where EUCAST gives "IE" for \emph{V. fluvialis}. Such cases are included as "blocking rows": rows in which \code{breakpoint_S} and \code{breakpoint_R} are both \code{NA} and the \code{note} starts with \code{"[No breakpoint]"} followed by the reason. \code{\link[=as.sir]{as.sir()}} returns \code{NA} for these. This currently applies to 14 523 rows. Remove them with \code{subset(clinical_breakpoints, !is.na(breakpoint_S))} if only actual breakpoints are needed.
Where EUCAST lists an organism but gives no breakpoint for it (e.g. "IE", "-", "Note", breakpoints in brackets, or an organism excluded with "except"), \code{\link[=as.sir]{as.sir()}} must not fall back to the breakpoint of a broader taxon, such as the breakpoint for \emph{Vibrio} spp. where EUCAST gives "IE" for \emph{V. fluvialis}. Such cases are included as "blocking rows": rows in which \code{breakpoint_S} and \code{breakpoint_R} are both \code{NA} and the \code{note} starts with \code{"[No breakpoint]"} followed by the reason. \code{\link[=as.sir]{as.sir()}} returns \code{NA} for these. This currently applies to 15 653 rows. Remove them with \code{subset(clinical_breakpoints, !is.na(breakpoint_S))} if only actual breakpoints are needed.
}
\subsection{Imported From WHONET}{
+19 -2
View File
@@ -640,9 +640,9 @@ test_that("custom reference_data: host = NA acts as host-agnostic fallback", {
test_that("EUCAST blocking rows prevent falling back to broader breakpoints", {
skip_on_cran()
sir <- function(x, mo, ab, guideline, include_screening = FALSE) {
sir <- function(x, mo, ab, guideline, include_screening = FALSE, uti = NA) {
as.character(suppressMessages(suppressWarnings(
as.sir(x, mo = mo, ab = ab, guideline = guideline, include_screening = include_screening)
as.sir(x, mo = mo, ab = ab, guideline = guideline, include_screening = include_screening, uti = uti)
)))
}
# IE for V. fluvialis, while Vibrio spp. has breakpoints
@@ -678,4 +678,21 @@ test_that("EUCAST blocking rows prevent falling back to broader breakpoints", {
expect_identical(sir(as.mic(0.064), "Haemophilus influenzae", "LVX", "EUCAST 2026"), "S")
# high-level gentamicin resistance (screening test given in the notes)
expect_identical(sir(as.mic(256), "Enterococcus faecium", "GEH", "EUCAST 2026"), "R")
# breakpoints in brackets for systemic infections are not replaced by the UTI breakpoint if uti is unknown
expect_identical(sir(as.mic(4), "Acinetobacter baumannii", "AMK", "EUCAST 2026"), NA_character_)
expect_identical(sir(as.mic(4), "Acinetobacter baumannii", "AMK", "EUCAST 2026", uti = FALSE), NA_character_)
expect_identical(sir(as.mic(4), "Acinetobacter baumannii", "AMK", "EUCAST 2026", uti = TRUE), "S")
# UTI breakpoints are only used for unknown uti if there are no non-UTI breakpoints, which is noted every time
for (i in 1:2) {
sir_interpretation_history(clean = TRUE)
expect_identical(sir(as.mic(16), "Escherichia coli", "NTR", "EUCAST 2026"), "S")
sir_history <- sir_interpretation_history(clean = TRUE)
expect_match(sir_history$notes, "only available for (uncomplicated) urinary tract infections", fixed = TRUE)
}
expect_identical(sir(as.mic(16), "Escherichia coli", "NTR", "EUCAST 2026", uti = FALSE), NA_character_)
# the blocking row of Enterobacterales does not override the UTI breakpoint for E. coli itself
expect_identical(sir(as.mic(2), "Escherichia coli", "CZO", "EUCAST 2026"), "I")
# with UTI and non-UTI breakpoints, unknown uti takes the non-UTI breakpoint
expect_identical(sir(as.mic(c(2, 8, 32)), "Escherichia coli", "CZO", "CLSI 2025"), c("S", "R", "R"))
expect_identical(sir(as.mic(c(2, 8, 32)), "Escherichia coli", "CZO", "CLSI 2025", uti = TRUE), c("S", "S", "R"))
})