diff --git a/DESCRIPTION b/DESCRIPTION index 8ed8d0b..bf92893 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -28,7 +28,7 @@ Imports: checkmate, cli, copula, - dplyr, + dplyr (>= 1.2.0), fs, gert, ggdist, diff --git a/NAMESPACE b/NAMESPACE index ce88c6f..2ca3beb 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -32,6 +32,7 @@ export(epiyear_first_date) export(epiyear_n_days) export(epiyear_n_weeks) export(expected_prism_locations) +export(filter_largest_lte) export(filter_to_shared_forecasts) export(filter_to_subset_forecasts) export(floor_epiweek) diff --git a/R/categorize_prism.R b/R/categorize_prism.R index 3d11955..aa282d6 100644 --- a/R/categorize_prism.R +++ b/R/categorize_prism.R @@ -5,6 +5,10 @@ prism_signal_deprecation_details <- glue::glue( "PRISM thresholds are now available for both NSSP and NHSN." ) +## current dplyr guidance for handling join_by expressions +## https://dplyr.tidyverse.org/articles/in-packages.html#join-helpers +utils::globalVariables(c("closest", "x", "y")) + prism_bin_names_from_cutpoints <- function(cutpoints) { return( names(cutpoints) |> @@ -14,57 +18,37 @@ prism_bin_names_from_cutpoints <- function(cutpoints) { ) } -get_single_prism_cutpoint <- function(signal, disease, location, as_of) { - checkmate::assert_string(signal) - checkmate::assert_string(disease) - checkmate::assert_string(location) - checkmate::assert_date(as_of, len = 1, any.missing = FALSE) - - candidates <- forecasttools::prism_thresholds |> - dplyr::filter( - .data$signal == !!signal, - .data$disease == !!disease, - .data$location == !!location, - .data$as_of <= !!as_of - ) - - if (nrow(candidates) == 0) { - cli::cli_abort( - "No PRISM cutpoints for signal {.val {signal}}, disease - {.val {disease}}, and location {.val {location}} as of {as_of}." - ) - } - - matches <- candidates |> - dplyr::filter(.data$as_of == max(.data$as_of)) - - checkmate::assert_data_frame(matches, nrows = 1) - - return(matches$values[[1]]) -} -#' Get PRISM activity level cutpoints given -#' disease and location. +#' Get PRISM activity level cutpoint sets. +#' +#' Cutpoint sets are specific to a particular +#' combination of disease, location, and signal. +#' They are also vintaged; you can look up the set of +#' cutpoints that were in place for a given disease, +#' location, and signal as of any particular date (with +#' an error if none were defined as of that date). #' -#' @param disease disease(s) for which to return the -#' cutpoints. One of `"ARI"`, `"COVID-19"`, -#' `"Influenza"`, or `"RSV"`, or an array of those -#' values. NHSN provides no `"ARI"` thresholds. -#' @param location location(s) for which to return the +#' This function is vectorized. It recycles +#' the `disease`, `location`, `signal``, and `as_of` +#' arguments to a common length and returns a +#' corresponding list of cutpoint vectors. +#' +#' @param disease disease for which to return the +#' cutpoints. Options are `"ARI"` (NSSP-only), +#' `"COVID-19"`, `"Influenza"`, and `"RSV"`. +#' @param location location for which to return the #' cutpoints, as a two-letter abbreviation. Use #' [forecasttools::us_location_recode] with #' `location_output_format = "abbr"` to convert to this #' format. -#' @param as_of single date for which the cutpoints are -#' valid, applied to every `location`, `disease`, and -#' `signal`. Defaults to today. -#' @param signal surveillance signal(s) for which to -#' return the cutpoints. One of `"NSSP"` (proportions -#' of emergency department visits) or `"NHSN"` (weekly -#' hospital admissions per 100k population), or an -#' array of those values. If not given, defaults to -#' `"NSSP"` with a deprecation warning (a future -#' version will require it). +#' @param signal surveillance signal for which to +#' return the cutpoints. Options are `"NSSP"` (proportions +#' of emergency department visits) and `"NHSN"` (weekly +#' hospital admissions per 100k population). +#' If not specified, default to `"NSSP"` with a +#' deprecation warning. +#' @param as_of Retrieve cutpoints that were in place as of +#' this date. Defaults to today (current cutpoints). #' @return The cutpoints, as a list of vectors, named #' `very_low`, `low`, `moderate`, `high`, `very_high`, #' and `upper_bound` for every signal. @@ -77,8 +61,8 @@ get_single_prism_cutpoint <- function(signal, disease, location, as_of) { #' get_prism_cutpoints( #' c("US", "WA"), #' c("COVID-19", "RSV"), -#' as.Date("2025-01-01"), -#' signal = "NSSP" +#' signal = "NSSP", +#' as_of = as.Date("2025-01-01") #' ) #' #' get_prism_cutpoints("WA", "Influenza", signal = c("NSSP", "NHSN")) @@ -87,8 +71,8 @@ get_single_prism_cutpoint <- function(signal, disease, location, as_of) { get_prism_cutpoints <- function( location, disease, - as_of = lubridate::today(), - signal = lifecycle::deprecated() + signal = lifecycle::deprecated(), + as_of = lubridate::today() ) { if (!lifecycle::is_present(signal)) { lifecycle::deprecate_warn( @@ -99,20 +83,87 @@ get_prism_cutpoints <- function( signal <- default_prism_signal } - target_signal <- stringr::str_to_lower(signal) - target_location <- stringr::str_to_upper(location) - target_disease <- stringr::str_to_lower(disease) - - as_of <- lubridate::as_date(as_of) + desired_cutpoints <- tibble::tibble( + signal = stringr::str_to_lower(signal), + location = stringr::str_to_upper(location), + disease = stringr::str_to_lower(disease), + target_as_of = lubridate::as_date(as_of) + ) - return(purrr::pmap( - list(target_disease, target_location, target_signal), - \(disease, location, signal) { - get_single_prism_cutpoint(signal, disease, location, as_of) + matches <- rlang::try_fetch( + dplyr::inner_join( + desired_cutpoints, + forecasttools::prism_thresholds, + by = dplyr::join_by( + "location", + "disease", + "signal", + closest(x$target_as_of >= y$as_of) + ), + unmatched = c("error", "drop"), + relationship = "many-to-one" + ), + error = function(cnd) { + .raise_prism_cutpoint_lookup_error( + desired_cutpoints, + cnd + ) } - )) + ) + + return(matches$values) +} + +#' Raise a more informative error when PRISM cutpoint lookup +#' fails. In particular, flag the missing cutpoints when possible. +#' +#' @noRd +.raise_prism_cutpoint_lookup_error <- function(desired_cutpoints, cnd) { + fully_missing_cutpoints <- dplyr::anti_join( + desired_cutpoints, + forecasttools::prism_thresholds, + by = c("location", "disease", "signal") + ) + + if (nrow(fully_missing_cutpoints) > 0) { + ## cli::cli_abort doesn't yet print tibbles nicely + ## https://github.com/r-lib/cli/issues/699 + rlang::abort( + message = "At least one requested set of cutpoints not found for any as-of date", + body = c( + "Cutpoints not found:", + utils::capture.output(fully_missing_cutpoints) + ), + parent = cnd + ) + } + + no_vintage <- desired_cutpoints |> + dplyr::anti_join( + forecasttools::prism_thresholds, + by = dplyr::join_by( + "location", + "disease", + "signal", + closest(x$target_as_of >= y$as_of) + ) + ) + + if (nrow(no_vintage) > 0) { + rlang::abort( + message = "At least one requested set of cutpoints does not have a vintage matching the requested as-of date.", + body = c( + "Cutpoints missing a requested vintage:", + utils::capture.output(no_vintage) + ), + parent = cnd + ) + } + + rlang::abort("Unexpected error retrieving PRISM cutpoints", parent = cnd) } + #' Categorize a numeric vector into PRISM #' activity level bins. #' diff --git a/R/utils.R b/R/utils.R index 6771290..0d0cb91 100644 --- a/R/utils.R +++ b/R/utils.R @@ -326,3 +326,61 @@ sym_limits <- function(values, transform = "identity", center = NULL) { return(transform_fn$inverse(transformed_center + c(-span, span))) } + + +#' Filter a data frame by a column to rows with +#' the largest value of that column that is +#' less than or equal to specified maximum value. +#' +#' Useful for getting the last date prior to or on +#' a given target date (e.g. matching data vintages). +#' +#' Uses [dplyr::filter()] syntax, and accepts data-masked +#' expressions for `column` and `max_value`. +#' +#' Returns a 0-row tibble if no rows match the criteria. +#' +#' @param df data frame to filter +#' @param column column to filter on. +#' @param max_value Maximum value. Filter to the +#' largest value in `column` less than or equal to `max_value`. +#' @param .by Optional grouping columns in `df` for the +#' filter. Passed as the `.by` argument to [dplyr::filter()]. +#' Default `NULL`, matching the [dplyr::filter()] default. +#' @param .preserve Preserve all groups present in grouped input? +#' Passed as the `.preverse` argument to [dplyr::filter()]. +#' Default `FALSE`, matching the [dplyr::filter()] default. +#' @return The filtered data frame. +#' +#' @examples +#' some_dates <- tibble::tibble( +#' row_no = 1:3, +#' date = as.Date(c("2026-01-01", "2026-07-02", "2026-07-03")) +#' ) +#' +#' some_dates |> filter_largest_lte(date, as.Date("2026-07-02")) +#' some_dates |> filter_largest_lte(date, as.Date("2026-07-03")) +#' some_dates |> filter_largest_lte(date, as.Date("2026-07-01")) +#' +#' @export +filter_largest_lte <- function( + df, + column, + max_value, + .by = NULL, + .preserve = FALSE +) { + # avoid warning when filtering groups of size 0 + max_or_na <- function(x) if (length(x) == 0) NA else max(x) + dplyr::filter( + df, + {{ column }} <= {{ max_value }}, + .by = {{ .by }}, + .preserve = .preserve + ) |> + dplyr::filter( + {{ column }} == max_or_na({{ column }}), + .by = {{ .by }}, + .preserve = .preserve + ) +} diff --git a/man/categorize_prism.Rd b/man/categorize_prism.Rd index f333484..329331c 100644 --- a/man/categorize_prism.Rd +++ b/man/categorize_prism.Rd @@ -23,9 +23,8 @@ to \code{value} or a single location for all \code{value}.} \item{disease}{vector of disease of length equal to \code{value} or a single disease for all \code{value}.} -\item{as_of}{single date for which the cutpoints are -valid, applied to every \code{location}, \code{disease}, and -\code{signal}. Defaults to today.} +\item{as_of}{Retrieve cutpoints that were in place as of +this date. Defaults to today (current cutpoints).} \item{prism_bin_names}{Bin names for the PRISM bins, in order from lowest to highest. Must be a vector of @@ -36,13 +35,12 @@ names by dropping the upper bound and converting to title case, giving \code{"Very Low"}, \code{"Low"}, \code{"Moderate"}, \code{"High"}, and \code{"Very High"}.} -\item{signal}{surveillance signal(s) for which to -return the cutpoints. One of \code{"NSSP"} (proportions -of emergency department visits) or \code{"NHSN"} (weekly -hospital admissions per 100k population), or an -array of those values. If not given, defaults to -\code{"NSSP"} with a deprecation warning (a future -version will require it).} +\item{signal}{surveillance signal for which to +return the cutpoints. Options are \code{"NSSP"} (proportions +of emergency department visits) and \code{"NHSN"} (weekly +hospital admissions per 100k population). +If not specified, default to \code{"NSSP"} with a +deprecation warning.} } \value{ A factor vector of category labels, equal in diff --git a/man/filter_largest_lte.Rd b/man/filter_largest_lte.Rd new file mode 100644 index 0000000..555b382 --- /dev/null +++ b/man/filter_largest_lte.Rd @@ -0,0 +1,50 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{filter_largest_lte} +\alias{filter_largest_lte} +\title{Filter a data frame by a column to rows with +the largest value of that column that is +less than or equal to specified maximum value.} +\usage{ +filter_largest_lte(df, column, max_value, .by = NULL, .preserve = FALSE) +} +\arguments{ +\item{df}{data frame to filter} + +\item{column}{column to filter on.} + +\item{max_value}{Maximum value. Filter to the +largest value in \code{column} less than or equal to \code{max_value}.} + +\item{.by}{Optional grouping columns in \code{df} for the +filter. Passed as the \code{.by} argument to \code{\link[dplyr:filter]{dplyr::filter()}}. +Default \code{NULL}, matching the \code{\link[dplyr:filter]{dplyr::filter()}} default.} + +\item{.preserve}{Preserve all groups present in grouped input? +Passed as the \code{.preverse} argument to \code{\link[dplyr:filter]{dplyr::filter()}}. +Default \code{FALSE}, matching the \code{\link[dplyr:filter]{dplyr::filter()}} default.} +} +\value{ +The filtered data frame. +} +\description{ +Useful for getting the last date prior to or on +a given target date (e.g. matching data vintages). +} +\details{ +Uses \code{\link[dplyr:filter]{dplyr::filter()}} syntax, and accepts data-masked +expressions for \code{column} and \code{max_value}. + +Returns a 0-row tibble if no rows match the criteria. +} +\examples{ +some_dates <- tibble::tibble( + row_no = 1:3, + date = as.Date(c("2026-01-01", "2026-07-02", "2026-07-03")) +) + +some_dates |> filter_largest_lte(date, as.Date("2026-07-02")) +some_dates |> filter_largest_lte(date, as.Date("2026-07-03")) +some_dates |> filter_largest_lte(date, as.Date("2026-07-01")) + +} diff --git a/man/get_prism_cutpoints.Rd b/man/get_prism_cutpoints.Rd index 9d2c521..05bbdda 100644 --- a/man/get_prism_cutpoints.Rd +++ b/man/get_prism_cutpoints.Rd @@ -2,39 +2,35 @@ % Please edit documentation in R/categorize_prism.R \name{get_prism_cutpoints} \alias{get_prism_cutpoints} -\title{Get PRISM activity level cutpoints given -disease and location.} +\title{Get PRISM activity level cutpoint sets.} \usage{ get_prism_cutpoints( location, disease, - as_of = lubridate::today(), - signal = lifecycle::deprecated() + signal = lifecycle::deprecated(), + as_of = lubridate::today() ) } \arguments{ -\item{location}{location(s) for which to return the +\item{location}{location for which to return the cutpoints, as a two-letter abbreviation. Use \link{us_location_recode} with \code{location_output_format = "abbr"} to convert to this format.} -\item{disease}{disease(s) for which to return the -cutpoints. One of \code{"ARI"}, \code{"COVID-19"}, -\code{"Influenza"}, or \code{"RSV"}, or an array of those -values. NHSN provides no \code{"ARI"} thresholds.} +\item{disease}{disease for which to return the +cutpoints. Options are \code{"ARI"} (NSSP-only), +\code{"COVID-19"}, \code{"Influenza"}, and \code{"RSV"}.} -\item{as_of}{single date for which the cutpoints are -valid, applied to every \code{location}, \code{disease}, and -\code{signal}. Defaults to today.} +\item{signal}{surveillance signal for which to +return the cutpoints. Options are \code{"NSSP"} (proportions +of emergency department visits) and \code{"NHSN"} (weekly +hospital admissions per 100k population). +If not specified, default to \code{"NSSP"} with a +deprecation warning.} -\item{signal}{surveillance signal(s) for which to -return the cutpoints. One of \code{"NSSP"} (proportions -of emergency department visits) or \code{"NHSN"} (weekly -hospital admissions per 100k population), or an -array of those values. If not given, defaults to -\code{"NSSP"} with a deprecation warning (a future -version will require it).} +\item{as_of}{Retrieve cutpoints that were in place as of +this date. Defaults to today (current cutpoints).} } \value{ The cutpoints, as a list of vectors, named @@ -42,8 +38,18 @@ The cutpoints, as a list of vectors, named and \code{upper_bound} for every signal. } \description{ -Get PRISM activity level cutpoints given -disease and location. +Cutpoint sets are specific to a particular +combination of disease, location, and signal. +They are also vintaged; you can look up the set of +cutpoints that were in place for a given disease, +location, and signal as of any particular date (with +an error if none were defined as of that date). +} +\details{ +This function is vectorized. It recycles +the \code{disease}, \code{location}, \verb{signal``, and }as_of` +arguments to a common length and returns a +corresponding list of cutpoint vectors. } \examples{ get_prism_cutpoints("WA", "Influenza", signal = "NHSN") @@ -53,8 +59,8 @@ get_prism_cutpoints(c("US", "WA"), "COVID-19", signal = "NSSP") get_prism_cutpoints( c("US", "WA"), c("COVID-19", "RSV"), - as.Date("2025-01-01"), - signal = "NSSP" + signal = "NSSP", + as_of = as.Date("2025-01-01") ) get_prism_cutpoints("WA", "Influenza", signal = c("NSSP", "NHSN")) diff --git a/man/prism_thresholds.Rd b/man/prism_thresholds.Rd index 2396e8d..49e4ce6 100644 --- a/man/prism_thresholds.Rd +++ b/man/prism_thresholds.Rd @@ -5,7 +5,7 @@ \alias{prism_thresholds} \title{PRISM respiratory virus activity level thresholds.} \format{ -An object of class \code{tbl_df} (inherits from \code{tbl}, \code{data.frame}) with 589 rows and 5 columns. +An object of class \code{tbl_df} (inherits from \code{tbl}, \code{data.frame}) with 797 rows and 5 columns. } \source{ diff --git a/man/quantiles_to_category_cdf.Rd b/man/quantiles_to_category_cdf.Rd index 1d6522e..f24596c 100644 --- a/man/quantiles_to_category_cdf.Rd +++ b/man/quantiles_to_category_cdf.Rd @@ -95,13 +95,13 @@ values <- c(0.05, 0.2, 0.3) quantiles_to_category_cdf( quantile_levels, values, - cutpoints, + cutpoints ) quantiles_to_category_pmf( quantile_levels, values, - cutpoints, + cutpoints ) } diff --git a/tests/testthat/test_categorize_prism.R b/tests/testthat/test_categorize_prism.R index 1ef5cfb..0a16661 100644 --- a/tests/testthat/test_categorize_prism.R +++ b/tests/testthat/test_categorize_prism.R @@ -28,10 +28,6 @@ as_ofs_for_signal <- function(signal) { unique() } -latest_as_of_for_signal <- function(signal) { - max(as_ofs_for_signal(signal)) -} - query_date_for <- function(signal, vintage) { vintages <- sort(as_ofs_for_signal(signal)) later_vintages <- vintages[vintages > vintage] @@ -55,11 +51,7 @@ prism_rows <- forecasttools::prism_thresholds |> ) prism_params <- prism_rows |> - dplyr::filter( - .data$as_of == latest_as_of_for_signal(.data$signal), - .by = "signal" - ) |> - dplyr::select("signal", "location", "disease") + dplyr::distinct(.data$signal, .data$location, .data$disease) test_that( @@ -183,9 +175,8 @@ test_that("error is thrown for invalid as_of", { "Influenza", as_of = "1900-01-01", signal = signal - ) |> - suppressWarnings(), - regexp = "No PRISM" + ), + regexp = "does not have a vintage matching the requested" ) }) }) @@ -193,7 +184,21 @@ test_that("error is thrown for invalid as_of", { test_that("error is thrown for an unknown signal", { expect_error( get_prism_cutpoints("WA", "Influenza", signal = "NREVSS"), - regexp = "signal" + regexp = "for any as-of date" + ) +}) + +test_that("unknown location errors", { + expect_error( + get_prism_cutpoints("ZZ", "Influenza", signal = "NSSP"), + regexp = "for any as-of date" + ) +}) + +test_that("unknown disease errors", { + expect_error( + get_prism_cutpoints("WA", "Norovirus", signal = "NSSP"), + regexp = "for any as-of date" ) }) diff --git a/tests/testthat/test_utils.R b/tests/testthat/test_utils.R index 54e3c5c..36f1238 100644 --- a/tests/testthat/test_utils.R +++ b/tests/testthat/test_utils.R @@ -208,3 +208,101 @@ test_that("sym_limits functions argument checks work", { expect_error(sym_limits(c(1.3, "a")), "character") expect_error(sym_limits(c()), "NULL") }) + +test_filter_lte_df <- tibble::tibble( + number = c(-1, 1, 1, 3), + letter = c("A", "N", "Y", "Y"), + date = as.Date(c("2026-07-01", "2026-07-01", "2026-07-05", "2026-07-09")) +) + +test_that("filter_largest_lte treats values above the bound equally to the bound", { + expect_equal( + test_filter_lte_df |> filter_largest_lte(date, as.Date("2027-01-01")), + test_filter_lte_df |> filter_largest_lte(date, as.Date("2026-07-09")) + ) + + expect_equal( + test_filter_lte_df |> filter_largest_lte(number, 5000), + test_filter_lte_df |> filter_largest_lte(number, 3) + ) + + expect_equal( + test_filter_lte_df |> filter_largest_lte(letter, "Z"), + test_filter_lte_df |> filter_largest_lte(letter, "Y") + ) +}) + +test_that( + paste0( + "filter_largest_lte returns a length 0 tibble without a ", + "warning when the lower bound is below all values" + ), + { + expected_empty <- test_filter_lte_df |> dplyr::filter_out(TRUE) + expect_no_warning(expect_equal( + test_filter_lte_df |> filter_largest_lte(date, as.Date("1900-01-01")), + expected_empty + )) + + expect_no_warning(expect_equal( + test_filter_lte_df |> filter_largest_lte(number, -2), + expected_empty + )) + + expect_no_warning(expect_equal( + test_filter_lte_df |> filter_largest_lte(letter, ""), + expected_empty + )) + } +) + +test_that( + paste0( + "filter_largest_lte agrees with manual expectation on internal ", + "values and preserves multiple row matches" + ), + { + expect_equal( + test_filter_lte_df |> filter_largest_lte(date, as.Date("2026-07-02")), + test_filter_lte_df |> dplyr::filter(date == as.Date("2026-07-01")) + ) + + expect_equal( + test_filter_lte_df |> filter_largest_lte(number, 2), + test_filter_lte_df |> dplyr::filter(number == 1) + ) + + expect_equal( + test_filter_lte_df |> filter_largest_lte(letter, "O"), + test_filter_lte_df |> dplyr::filter(letter == "N") + ) + } +) + +test_that("filter_largest_lte works grouped", { + # .by and piping in a grouped df are equivalent + expect_equal( + test_filter_lte_df |> + dplyr::group_by(.data$letter) |> + filter_largest_lte(date, as.Date("2026-07-02")) |> + dplyr::ungroup(), + test_filter_lte_df |> + filter_largest_lte(date, as.Date("2026-07-02"), .by = "letter") + ) + ## .preserve is respected, so if we set it to true even the group + ## that gets filtered to size 0 ("Y") is retained. + expect_equal( + test_filter_lte_df |> + dplyr::group_by(letter) |> + filter_largest_lte(date, as.Date("2026-07-02")) |> + dplyr::n_groups(), + 2 + ) + expect_equal( + test_filter_lte_df |> + dplyr::group_by(letter) |> + filter_largest_lte(date, as.Date("2026-07-02"), .preserve = TRUE) |> + dplyr::n_groups(), + 3 + ) +})