diff --git a/DESCRIPTION b/DESCRIPTION index d5b8a8681..9badbcd47 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: AMR -Version: 3.0.1.9030 -Date: 2026-03-07 +Version: 3.0.1.9031 +Date: 2026-03-08 Title: Antimicrobial Resistance Data Analysis Description: Functions to simplify and standardise antimicrobial resistance (AMR) data analysis and to work with microbial and antimicrobial properties by diff --git a/NEWS.md b/NEWS.md index 71738ef11..ebbfffc15 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -# AMR 3.0.1.9030 +# AMR 3.0.1.9031 ### New * Integration with the **tidymodels** framework to allow seamless use of SIR, MIC and disk data in modelling pipelines via `recipes` @@ -18,7 +18,6 @@ * Two new `NA` objects, `NA_ab_` and `NA_mo_`, analogous to base R's `NA_character_` and `NA_integer_`, for use in pipelines that require typed missing values ### Fixes -* `mdro()`: when a base beta-lactam drug column is missing but a corresponding drug+inhibitor combination is present in the data and resistant (e.g., piperacillin/tazobactam = R while piperacillin is absent), the base drug is now correctly inferred as resistant. This ensures MDRO classification is not missed due to test-ordering differences in the laboratory. The reverse direction is also valid: susceptibility in a combination does not imply susceptibility in the base drug (the inhibitor may be responsible), so only resistance is propagated. Closes #209 * Fixed a bug in `as.sir()` where values that were purely numeric (e.g., `"1"`) and matched the broad SIR-matching regex would be incorrectly stripped of all content by the Unicode letter filter * Fixed a bug in `as.mic()` where MIC values in scientific notation (e.g., `"1e-3"`) were incorrectly handled because the letter `e` was removed along with other Unicode letters; scientific notation `e` is now preserved * Fixed a bug in `as.ab()` where certain AB codes containing "PH" or "TH" (such as `ETH`, `MTH`, `PHE`, `PHN`, `STH`, `THA`, `THI1`) would incorrectly return `NA` when combined in a vector with any untranslatable value (#245) @@ -31,6 +30,7 @@ * Fixed SIR and MIC coercion of combined values, e.g. `as.sir("<= 0.002; S") ` or `as.mic("S; 0.002")` (#252) ### Updates +* `mdro()` now infers resistance for a _missing_ base drug column from an _available_ corresponding drug+inhibitor combination showing resistance (e.g., piperacillin is absent but required, while piperacillin/tazobactam available and resistant). Can be set with the new argument `infer_from_combinations`, which defaults to `TRUE` (#209). Note that this can yield a higher MDRO detection (which is a good thing as it has become more reliable). * `susceptibility()` and `resistance()` gained the argument `guideline`, which defaults to EUCAST, for interpreting the 'I' category correctly. * `as.mic()` and `rescale_mic()` gained the argument `round_to_next_log2`, which can be set to `TRUE` to round all values up to the nearest next log2 level (#255) * `antimicrobials$group` is now a `list` instead of a `character`, to contain any group the drug is in (#246) diff --git a/R/aa_helper_functions.R b/R/aa_helper_functions.R index 9c13f8632..ae9217912 100644 --- a/R/aa_helper_functions.R +++ b/R/aa_helper_functions.R @@ -387,6 +387,10 @@ import_fn <- function(name, pkg, error_on_fail = TRUE) { if (isTRUE(error_on_fail)) { stop_ifnot_installed(pkg) } + if (pkg == "rstudioapi" && tryCatch(!rstudioapi::isAvailable(), error = function(e) TRUE)) { + # only allow rstudioapi to be imported if RStudio is available + return(NULL) + } tryCatch( # don't use get() to avoid fetching non-API functions getExportedValue(name = name, ns = asNamespace(pkg)), @@ -1220,10 +1224,14 @@ try_colour <- function(..., before, after, collapse = " ") { } } is_dark <- function() { - AMR_env$current_theme <- tryCatch(getExportedValue("getThemeInfo", ns = asNamespace("rstudioapi"))()$editor, error = function(e) NULL) + AMR_env$current_theme <- NULL + current_theme_fn <- import_fn("getThemeInfo", "rstudioapi", error_on_fail = FALSE) + if (!is.null(current_theme_fn)) { + AMR_env$current_theme <- current_theme_fn()$editor + } if (!identical(AMR_env$current_theme, AMR_env$former_theme) || is.null(AMR_env$is_dark_theme)) { AMR_env$former_theme <- AMR_env$current_theme - AMR_env$is_dark_theme <- !has_colour() || tryCatch(isTRUE(getExportedValue("getThemeInfo", ns = asNamespace("rstudioapi"))()$dark), error = function(e) TRUE) + AMR_env$is_dark_theme <- !has_colour() || tryCatch(isTRUE(current_theme_fn()$dark), error = function(e) TRUE) } isTRUE(AMR_env$is_dark_theme) } diff --git a/R/mdro.R b/R/mdro.R index c1e302719..a801b5c54 100755 --- a/R/mdro.R +++ b/R/mdro.R @@ -31,7 +31,7 @@ #' #' Determine which isolates are multidrug-resistant organisms (MDRO) according to international, national, or custom guidelines. #' @param x A [data.frame] with antimicrobials columns, like `AMX` or `amox`. Can be left blank for automatic determination. -#' @param guideline A specific guideline to follow, see sections *Supported international / national guidelines* and *Using Custom Guidelines* below. When left empty, the publication by Magiorakos *et al.* (see below) will be followed. +#' @param guideline A specific guideline to follow, see sections *Supported International / National Guidelines* and *Using Custom Guidelines* below. When left empty, the publication by Magiorakos *et al.* (see below) will be followed. #' @param esbl [logical] values, or a column name containing logical values, indicating the presence of an ESBL gene (or production of its proteins). #' @param carbapenemase [logical] values, or a column name containing logical values, indicating the presence of a carbapenemase gene (or production of its proteins). #' @param mecA [logical] values, or a column name containing logical values, indicating the presence of a *mecA* gene (or production of its proteins). @@ -42,6 +42,7 @@ #' @param pct_required_classes Minimal required percentage of antimicrobial classes that must be available per isolate, rounded down. For example, with the default guideline, 17 antimicrobial classes must be available for *S. aureus*. Setting this `pct_required_classes` argument to `0.5` (default) means that for every *S. aureus* isolate at least 8 different classes must be available. Any lower number of available classes will return `NA` for that isolate. #' @param combine_SI A [logical] to indicate whether all values of S and I must be merged into one, so resistance is only considered when isolates are R, not I. As this is the default behaviour of the [mdro()] function, it follows the redefinition by EUCAST about the interpretation of I (increased exposure) in 2019, see section 'Interpretation of S, I and R' below. When using `combine_SI = FALSE`, resistance is considered when isolates are R or I. #' @param verbose A [logical] to turn Verbose mode on and off (default is off). In Verbose mode, the function returns a data set with the MDRO results in logbook form with extensive info about which isolates would be MDRO-positive, or why they are not. +#' @param infer_from_combinations A [logical] to indicate whether resistance for a missing base beta-lactam drug should be inferred from an available drug+inhibitor combination (e.g., piperacillin from piperacillin/tazobactam). The clinical basis is that resistance in a combination always implies resistance in the base drug, since the enzyme inhibitor provides no benefit when the organism is truly resistant. Only resistance is inferred; susceptibility in a combination does **not** imply susceptibility in the base drug (the inhibitor may be responsible). Defaults to `TRUE`. #' @details #' These functions are context-aware. This means that the `x` argument can be left blank if used inside a [data.frame] call, see *Examples*. #' @@ -143,6 +144,7 @@ mdro <- function(x = NULL, combine_SI = TRUE, verbose = FALSE, only_sir_columns = any(is.sir(x)), + infer_from_combinations = TRUE, ...) { if (is_null_or_grouped_tbl(x)) { # when `x` is left blank, auto determine it (get_current_data() searches underlying data within call) @@ -165,7 +167,7 @@ mdro <- function(x = NULL, meet_criteria(combine_SI, allow_class = "logical", has_length = 1) meet_criteria(verbose, allow_class = "logical", has_length = 1) meet_criteria(only_sir_columns, allow_class = "logical", has_length = 1) - + meet_criteria(infer_from_combinations, allow_class = "logical", has_length = 1) if (isTRUE(only_sir_columns) && !any(is.sir(x))) { stop_("There were no SIR columns found in the data set, despite `only_sir_columns` being `TRUE`. Transform columns with `as.sir()` for valid antimicrobial interpretations.") @@ -480,49 +482,51 @@ mdro <- function(x = NULL, } cols_ab <- cols_ab[!duplicated(cols_ab)] - # Infer resistance for missing base drugs from available drug+inhibitor combination columns. - # Clinical principle: resistance in drug+inhibitor (e.g., piperacillin/tazobactam = R) - # always implies resistance in the base drug (e.g., piperacillin = R), because the - # enzyme inhibitor adds nothing when the organism is truly resistant to the base drug. - # NOTE: susceptibility in a combination does NOT imply susceptibility in the base drug - # (the inhibitor may be responsible), so synthetic proxy columns only propagate R, not S/I. - .combos_in_data <- AB_BETALACTAMS_WITH_INHIBITOR[AB_BETALACTAMS_WITH_INHIBITOR %in% names(cols_ab)] - if (length(.combos_in_data) > 0) { - .base_drugs <- suppressMessages( - as.ab(gsub("/.*", "", ab_name(as.character(.combos_in_data), language = NULL))) - ) - .unique_bases <- unique(.base_drugs[!is.na(.base_drugs)]) - for (.base in .unique_bases) { - .base_code <- as.character(.base) - if (!.base_code %in% names(cols_ab)) { - # Base drug column absent; find all available combo columns for this base drug - .combos <- .combos_in_data[!is.na(.base_drugs) & as.character(.base_drugs) == .base_code] - .combo_cols <- unname(cols_ab[as.character(.combos)]) - .combo_cols <- .combo_cols[!is.na(.combo_cols)] - if (length(.combo_cols) > 0) { - # Vectorised: if ANY combination is R, infer base drug as R; otherwise NA - .sir_chars <- as.data.frame( - lapply(x[, .combo_cols, drop = FALSE], function(col) as.character(as.sir(col))), - stringsAsFactors = FALSE - ) - .new_col <- paste0(".sir_proxy_", .base_code) - x[[.new_col]] <- ifelse(rowSums(.sir_chars == "R", na.rm = TRUE) > 0L, "R", NA_character_) - cols_ab <- c(cols_ab, stats::setNames(.new_col, .base_code)) - if (isTRUE(verbose)) { - message_( - "Inferring resistance for ", ab_name(.base_code, language = NULL), - " from available drug+inhibitor combination(s): ", - paste(ab_name(as.character(.combos), language = NULL), collapse = ", "), - " (resistance in a combination always implies resistance in the base drug)", - add_fn = font_blue + # Infer resistance for missing base drugs ---- + if (isTRUE(infer_from_combinations)) { + .combos_in_data <- AB_BETALACTAMS_WITH_INHIBITOR[AB_BETALACTAMS_WITH_INHIBITOR %in% names(cols_ab)] + if (length(.combos_in_data) > 0) { + .base_drugs <- suppressMessages( + as.ab(gsub("/.*", "", ab_name(as.character(.combos_in_data), language = NULL))) + ) + .unique_bases <- unique(.base_drugs[!is.na(.base_drugs)]) + for (.base in .unique_bases) { + .base_code <- as.character(.base) + if (!.base_code %in% names(cols_ab)) { + # Base drug column absent; find all available combo columns for this base drug + .combos <- .combos_in_data[!is.na(.base_drugs) & as.character(.base_drugs) == .base_code] + .combo_cols <- unname(cols_ab[as.character(.combos)]) + .combo_cols <- .combo_cols[!is.na(.combo_cols)] + if (length(.combo_cols) > 0) { + # Vectorised: if ANY combination is R, infer base drug as R; otherwise NA + .sir_chars <- as.data.frame( + lapply(x[, .combo_cols, drop = FALSE], function(col) as.character(as.sir(col))), + stringsAsFactors = FALSE ) + .new_col <- paste0(.base_code, ".inferred_sir_proxy_from#", paste0(.combos, collapse = "/"), "#") + x[[.new_col]] <- ifelse(rowSums(.sir_chars == "R", na.rm = TRUE) > 0L, "R", NA_character_) + cols_ab <- c(cols_ab, stats::setNames(.new_col, .base_code)) + if (info == TRUE) { + message_( + "Inferring resistance for ", + ab_name(.base_code, language = NULL, tolower = TRUE), + " (", font_bold(.base_code, collapse = NULL), ", ", font_italic("missing"), ") from ", + vector_or( + quotes = FALSE, + last_sep = " and/or ", + paste0( + ab_name(.combos, language = NULL, tolower = TRUE), + " (", font_bold(.combos, collapse = NULL), ", ", font_italic("available"), ")" + ) + ) + ) + } } } } + cols_ab <- cols_ab[!duplicated(names(cols_ab))] } - cols_ab <- cols_ab[!duplicated(names(cols_ab))] } - rm(list = intersect(ls(), c(".combos_in_data", ".base_drugs", ".unique_bases", ".base", ".base_code", ".combos", ".combo_cols", ".sir_chars", ".new_col"))) # nolint start AMC <- cols_ab["AMC"] @@ -1937,7 +1941,8 @@ mdro <- function(x = NULL, # format data set colnames(x)[colnames(x) == col_mo] <- "microorganism" x$microorganism <- mo_name(x$microorganism, language = NULL) - x$guideline <- paste0(guideline$author, " - ", guideline$name, ", ", guideline$version, ")") + x$guideline <- paste0(guideline$author, " - ", guideline$name, ifelse(is.na(guideline$version), "", paste0(" (", guideline$version, ")"))) + x$all_nonsusceptible_columns <- gsub(".inferred_sir_proxy_from#(.*?)#", " (inferred from \\1)", x$all_nonsusceptible_columns, perl = TRUE) x[, c( "row_number", "microorganism", diff --git a/man/mdro.Rd b/man/mdro.Rd index dfadaf25a..bb748cc7d 100644 --- a/man/mdro.Rd +++ b/man/mdro.Rd @@ -18,7 +18,8 @@ mdro(x = NULL, guideline = "CMI 2012", col_mo = NULL, esbl = NA, carbapenemase = NA, mecA = NA, mecC = NA, vanA = NA, vanB = NA, info = interactive(), pct_required_classes = 0.5, combine_SI = TRUE, - verbose = FALSE, only_sir_columns = any(is.sir(x)), ...) + verbose = FALSE, only_sir_columns = any(is.sir(x)), + infer_from_combinations = TRUE, ...) brmo(x = NULL, only_sir_columns = any(is.sir(x)), ...) @@ -35,7 +36,7 @@ eucast_exceptional_phenotypes(x = NULL, only_sir_columns = any(is.sir(x)), \arguments{ \item{x}{A \link{data.frame} with antimicrobials columns, like \code{AMX} or \code{amox}. Can be left blank for automatic determination.} -\item{guideline}{A specific guideline to follow, see sections \emph{Supported international / national guidelines} and \emph{Using Custom Guidelines} below. When left empty, the publication by Magiorakos \emph{et al.} (see below) will be followed.} +\item{guideline}{A specific guideline to follow, see sections \emph{Supported International / National Guidelines} and \emph{Using Custom Guidelines} below. When left empty, the publication by Magiorakos \emph{et al.} (see below) will be followed.} \item{col_mo}{Column name of the names or codes of the microorganisms (see \code{\link[=as.mo]{as.mo()}}) - the default is the first column of class \code{\link{mo}}. Values will be coerced using \code{\link[=as.mo]{as.mo()}}.} @@ -61,6 +62,8 @@ eucast_exceptional_phenotypes(x = NULL, only_sir_columns = any(is.sir(x)), \item{only_sir_columns}{A \link{logical} to indicate whether only antimicrobial columns must be included that were transformed to class \link[=as.sir]{sir} on beforehand. Defaults to \code{FALSE} if no columns of \code{x} have a class \link[=as.sir]{sir}.} +\item{infer_from_combinations}{A \link{logical} to indicate whether resistance for a missing base beta-lactam drug should be inferred from an available drug+inhibitor combination (e.g., piperacillin from piperacillin/tazobactam). The clinical basis is that resistance in a combination always implies resistance in the base drug, since the enzyme inhibitor provides no benefit when the organism is truly resistant. Only resistance is inferred; susceptibility in a combination does \strong{not} imply susceptibility in the base drug (the inhibitor may be responsible). Defaults to \code{TRUE}.} + \item{...}{Column names of antimicrobials. To automatically detect antimicrobial column names, do not provide any named arguments; \code{\link[=guess_ab_col]{guess_ab_col()}} will then be used for detection. To manually specify a column, provide its name (case-insensitive) as an argument, e.g. \code{AMX = "amoxicillin"}. To skip a specific antimicrobial, set it to \code{NULL}, e.g. \code{TIC = NULL} to exclude ticarcillin. If a manually defined column does not exist in the data, it will be skipped with a warning.} } \value{ diff --git a/tests/testthat/test-mdro.R b/tests/testthat/test-mdro.R index 00b928386..9d98f3a7d 100755 --- a/tests/testthat/test-mdro.R +++ b/tests/testthat/test-mdro.R @@ -313,14 +313,14 @@ test_that("test-mdro.R", { # Inference message goes to message() / stderr, not stdout # -> must use expect_message(), NOT expect_output() expect_message( - suppressWarnings(mdro(pseud_no_pip, guideline = "mrgn", info = FALSE, verbose = TRUE)), + suppressWarnings(mdro(pseud_no_pip, guideline = "mrgn", info = TRUE)), "Inferring resistance" ) - # With TZP=R, PIP is inferred R -> 4MRGN criteria met -> level 3 (> 1) - result_no_pip <- suppressMessages(suppressWarnings( - mdro(pseud_no_pip, guideline = "mrgn", info = FALSE) - )) - expect_true(as.integer(result_no_pip) > 1L) + inferred <- suppressWarnings(mdro(pseud_no_pip, guideline = "mrgn", info = FALSE)) + not_inferred <- suppressWarnings(mdro(pseud_no_pip, guideline = "mrgn", info = FALSE, infer_from_combinations = FALSE)) + expect_equal(as.character(inferred), "4MRGN") + expect_equal(as.character(not_inferred), "Negative") + # Susceptibility in combo does NOT propagate: proxy = NA, not S # -> 4MRGN criteria no longer met -> lower level than when TZP=R pseud_tzp_s <- pseud_no_pip @@ -328,18 +328,23 @@ test_that("test-mdro.R", { result_tzp_s <- suppressMessages(suppressWarnings( mdro(pseud_tzp_s, guideline = "mrgn", info = FALSE) )) - expect_true(as.integer(result_tzp_s) < as.integer(result_no_pip)) + expect_true(as.integer(result_tzp_s) < as.integer(inferred)) - # Multiple combos for the same base drug: AMX can come from AMC (amoxicillin/clavulanic acid) + # Multiple combos for the same base drug: AMX can come from AMC (amoxi/clavulanic acid) and AXS (amoxi/sulbactam) ente_no_amx <- data.frame( mo = as.mo("Enterococcus faecium"), - AMC = as.sir("R"), # amoxicillin/clavulanic acid; no AMX column + AMC = as.sir("R"), # amoxicillin/clavulanic acid + AXS = as.sir("R"), # amoxicillin/sulbactam VAN = as.sir("R"), TEC = as.sir("R"), LNZ = as.sir("R"), DAP = as.sir("R"), stringsAsFactors = FALSE ) - # Should run without error and return an ordered factor; AMX inferred R from AMC - expect_inherits(suppressMessages(suppressWarnings(mdro(ente_no_amx, guideline = "EUCAST", info = FALSE))), c("factor", "ordered")) + # Should have multiple columns in the verbose explanation + out <- mdro(ente_no_amx, guideline = "EUCAST 3.3", info = FALSE, verbose = TRUE) + expect_identical( + out$all_nonsusceptible_columns, + "AMC, AMX (inferred from AMC/AXS), AXS, DAP, LNZ, TEC, VAN" + ) })