diff --git a/DESCRIPTION b/DESCRIPTION index d8dbd33..be42799 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -56,5 +56,5 @@ Encoding: UTF-8 Language: en-GB LazyData: true Roxygen: list(markdown = TRUE) -RoxygenNote: 7.2.3 +RoxygenNote: 7.3.2 VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index c4efa6d..04a6245 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,8 +1,19 @@ # Generated by roxygen2: do not edit by hand +S3method("[",percent) +S3method(Math,percent) +S3method(Summary,percent) +S3method(as.character,percent) +S3method(format,percent) +S3method(mean,percent) +S3method(print,percent) +S3method(rep,percent) +S3method(unique,percent) +export(NA_percent_) export(age_calculate) export(age_from_chi) export(age_group) +export(as_percent) export(chi_check) export(chi_pad) export(create_age_groups) @@ -21,5 +32,6 @@ export(sex_from_chi) importFrom(lifecycle,deprecated) importFrom(magrittr,"%<>%") importFrom(magrittr,"%>%") +importFrom(rlang,"%||%") importFrom(rlang,.data) importFrom(tibble,tibble) diff --git a/R/percent.R b/R/percent.R new file mode 100644 index 0000000..11a9ea7 --- /dev/null +++ b/R/percent.R @@ -0,0 +1,255 @@ +#' Percentages +#' +#' @description +#' +#' `percent` is a lightweight S3 class allowing for pretty +#' printing of proportions as percentages. \cr +#' It aims to remove the need for creating character vectors of percentages. +#' +#' @param x `[numeric]` vector of proportions. +#' @param digits `[numeric(1)]` - The number of digits that will be used for +#' formatting. This is by default 2 and is applied whenever `format()`, +#' `as.character()` and `print()` are called. This can also be controlled +#' directly via `format()`. +#' +#' @returns +#' An object of class `percent`. +#' +#' @details +#' +#' ### Rounding +#' +#' The rounding for percent vectors differs to that of base R rounding, +#' namely in that halves are rounded up instead of rounded to even. +#' This means that `round(x)` will round the percent vector `x` using +#' halves-up rounding (like in the janitor package). +#' +#' ### Formatting +#' +#' By default all percentages are formatted to 2 decimal places which can be +#' overwritten using `format()` or using `round()` if your required digits are +#' less than 2. It's worth noting that the digits argument in +#' `format.percent` uses decimal rounding instead of the usual +#' significant digit rounding that `format.default()` uses. +#' +#' @examples +#' # Convert proportions to percentages +#' as_percent(seq(0, 1, 0.1)) +#' +#' # You can use round() as usual +#' p <- as_percent(15.56 / 100) +#' round(p) +#' round(p, digits = 1) +#' +#' p2 <- as_percent(0.0005) +#' signif(p2, 2) +#' floor(p2) +#' ceiling(p2) +#' +#' # We can do basic math operations as usual +#' +#' # Order of operations doesn't matter +#' 10 * as_percent(c(0, 0.5, 2)) +#' as_percent(c(0, 0.5, 2)) * 10 +#' +#' as_percent(0.1) + as_percent(0.2) +#' +#' # Formatting options +#' format(as_percent(2.674 / 100), digits = 2, symbol = " (%)") +#' # Prints nicely in data frames (and tibbles) +#' library(dplyr) +#' starwars %>% +#' count(eye_color) %>% +#' mutate(perc = as_percent(n/sum(n))) %>% +#' arrange(desc(perc)) %>% # We can do numeric sorting with percent vectors +#' mutate(perc_rounded = round(perc)) +#' @rdname percent +#' @export +as_percent <- function(x, digits = 2){ + if (inherits(x, "percent")){ + return(new_percent(x, digits)) + } + if (!inherits(x, c("numeric", "integer", "logical"))){ + cli::cli_abort("{.arg x} must be a {.cls numeric} vector, not a {.cls {class(x)}} vector.") + } + new_percent(as.numeric(x), digits = digits) +} +#' @rdname percent +#' @export +NA_percent_ <- structure(NA_real_, class = "percent", .digits = 2) + +new_percent <- function(x, digits = 2){ + class(x) <- "percent" + attr(x, ".digits") <- digits + x +} +get_perc_digits <- function(x){ + attr(x, ".digits") %||% 2 +} +round_half_up <- function(x, digits = 0){ + if (!inherits(x, c("numeric", "integer", "logical"))){ + cli::cli_abort("{.arg x} must be a {.cls numeric} vector, not a {.cls {class(x)}} vector.") + } + x <- as.numeric(x) + if (is.null(digits)){ + return(x) + } + + if (length(digits) == 1 && digits == Inf){ + out <- x + } else { + + out <- ( + trunc( + abs(x) * 10^digits + 0.5 + + sqrt(.Machine$double.eps) + ) / + (10^digits) + ) * sign(x) + + is_inf <- digits == Inf + + # Account for base R recycling + if (length(x) != 0 && length(digits) != 0 && length(x) != length(digits)){ + is_inf <- which(rep_len(is_inf, max(length(x), length(digits)))) + } + + out[is_inf] <- x[is_inf] + } + out +} +signif_half_up <- function(x, digits = 6){ + if (!inherits(x, c("numeric", "integer", "logical"))){ + cli::cli_abort("{.arg x} must be a {.cls numeric} vector, not a {.cls {class(x)}} vector.") + } + x <- as.numeric(x) + if (is.null(digits)){ + return(x) + } + round_half_up(x, digits - ceiling(log10(abs(x)))) +} + +#' @export +format.percent <- function(x, symbol = "%", + trim = TRUE, big.mark = ",", + digits = get_perc_digits(x), + ...){ + out <- stringr::str_c( + format(unclass(round(x, digits) * 100), trim = trim, digits = NULL, + big.mark = big.mark, ...), + symbol + ) + out[is.na(x)] <- NA + names(out) <- names(x) + out +} + + +#' @export +as.character.percent <- function(x, digits = get_perc_digits(x), ...){ + format(unname(x), digits = digits) +} +#' @export +print.percent <- function(x, max = NULL, trim = TRUE, + digits = get_perc_digits(x), + ...){ + out <- x + N <- length(out) + if (N == 0){ + cat(paste("A", cli::col_blue(""), "vector of length 0")) + return(invisible(x)) + } + if (is.null(max)) { + max <- getOption("max.print", 9999L) + } + suffix <- character() + max <- min(max, N) + if (max < N) { + out <- out[seq_len(max)] + suffix <- stringr::str_c( + " [ reached 'max' / getOption(\"max.print\") -- omitted", + N - max, "entries ]\n", + sep = " " + ) + } + print(format(out, trim = trim, digits = digits), ...) + cat(suffix) + invisible(x) +} + +#' @export +`[.percent` <- function(x, ..., drop = TRUE){ + cl <- oldClass(x) + class(x) <- NULL + out <- NextMethod("[") + class(out) <- cl + attr(out, ".digits") <- get_perc_digits(x) + out +} + +#' @export +unique.percent <- function(x, incomparables = FALSE, ...){ + cl <- oldClass(x) + class(x) <- NULL + out <- NextMethod("unique") + class(out) <- cl + attr(out, ".digits") <- get_perc_digits(x) + out +} + +#' @export +rep.percent <- function(x, ...){ + cl <- oldClass(x) + class(x) <- NULL + out <- NextMethod("rep") + class(out) <- cl + attr(out, ".digits") <- get_perc_digits(x) + out +} + +#' @export +Math.percent <- function(x, ...){ + rounding_math <- switch(.Generic, + `floor` =, + `ceiling` =, + `trunc` =, + `round` =, + `signif` = TRUE, FALSE) + x <- unclass(x) + + if (switch(.Generic, `sign` = TRUE, FALSE)){ + NextMethod(.Generic) + } else if (rounding_math){ + x <- x * 100 + if (.Generic == "round"){ + out <- do.call(round_half_up, list(x, ...)) + } else if (.Generic == "signif"){ + out <- do.call(signif_half_up, list(x, ...)) + } else { + out <- NextMethod(.Generic) + } + new_percent(out / 100, get_perc_digits(x)) + } else { + out <- NextMethod(.Generic) + new_percent(out, get_perc_digits(x)) + } +} +#' @export +Summary.percent <- function(x, ...){ + summary_math <- switch(.Generic, + `sum` =, + `prod` =, + `min` =, + `max` =, + `range` = TRUE, FALSE) + x <- unclass(x) + out <- NextMethod(.Generic) + if (summary_math){ + out <- new_percent(out, get_perc_digits(x)) + } + out +} +#' @export +mean.percent <- function(x, ...){ + new_percent(mean(unclass(x), ...), get_perc_digits(x)) +} diff --git a/R/phsmethods.R b/R/phsmethods.R index 8197566..9b6b8e9 100644 --- a/R/phsmethods.R +++ b/R/phsmethods.R @@ -10,6 +10,7 @@ #' @importFrom magrittr %>% #' @importFrom magrittr %<>% #' @importFrom rlang .data +#' @importFrom rlang %||% #' @importFrom tibble tibble #' @importFrom lifecycle deprecated NULL diff --git a/man/percent.Rd b/man/percent.Rd new file mode 100644 index 0000000..1cf1c13 --- /dev/null +++ b/man/percent.Rd @@ -0,0 +1,82 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/percent.R +\docType{data} +\name{as_percent} +\alias{as_percent} +\alias{NA_percent_} +\title{Percentages} +\format{ +An object of class \code{percent} of length 1. +} +\usage{ +as_percent(x, digits = 2) + +NA_percent_ +} +\arguments{ +\item{x}{\verb{[numeric]} vector of proportions.} + +\item{digits}{\verb{[numeric(1)]} - The number of digits that will be used for +formatting. This is by default 2 and is applied whenever \code{format()}, +\code{as.character()} and \code{print()} are called. This can also be controlled +directly via \code{format()}.} +} +\value{ +An object of class \code{percent}. +} +\description{ +\code{percent} is a lightweight S3 class allowing for pretty +printing of proportions as percentages. \cr +It aims to remove the need for creating character vectors of percentages. +} +\details{ +\subsection{Rounding}{ + +The rounding for percent vectors differs to that of base R rounding, +namely in that halves are rounded up instead of rounded to even. +This means that \code{round(x)} will round the percent vector \code{x} using +halves-up rounding (like in the janitor package). +} + +\subsection{Formatting}{ + +By default all percentages are formatted to 2 decimal places which can be +overwritten using \code{format()} or using \code{round()} if your required digits are +less than 2. It's worth noting that the digits argument in +\code{format.percent} uses decimal rounding instead of the usual +significant digit rounding that \code{format.default()} uses. +} +} +\examples{ +# Convert proportions to percentages +as_percent(seq(0, 1, 0.1)) + +# You can use round() as usual +p <- as_percent(15.56 / 100) +round(p) +round(p, digits = 1) + +p2 <- as_percent(0.0005) +signif(p2, 2) +floor(p2) +ceiling(p2) + +# We can do basic math operations as usual + +# Order of operations doesn't matter +10 * as_percent(c(0, 0.5, 2)) +as_percent(c(0, 0.5, 2)) * 10 + +as_percent(0.1) + as_percent(0.2) + +# Formatting options +format(as_percent(2.674 / 100), digits = 2, symbol = " (\%)") +# Prints nicely in data frames (and tibbles) +library(dplyr) +starwars \%>\% + count(eye_color) \%>\% + mutate(perc = as_percent(n/sum(n))) \%>\% + arrange(desc(perc)) \%>\% # We can do numeric sorting with percent vectors + mutate(perc_rounded = round(perc)) +} +\keyword{datasets} diff --git a/man/phsmethods.Rd b/man/phsmethods.Rd index 64143ac..762545f 100644 --- a/man/phsmethods.Rd +++ b/man/phsmethods.Rd @@ -2,6 +2,7 @@ % Please edit documentation in R/phsmethods.R \docType{package} \name{phsmethods} +\alias{phsmethods-package} \alias{phsmethods} \title{\code{phsmethods} package} \description{ @@ -11,3 +12,36 @@ Standard Methods for use in PHS. See the README on \href{https://github.com/Public-Health-Scotland/phsmethods#readme}{GitHub}. } +\seealso{ +Useful links: +\itemize{ + \item \url{https://github.com/Public-Health-Scotland/phsmethods} + \item \url{https://public-health-scotland.github.io/phsmethods/} + \item Report bugs at \url{https://github.com/Public-Health-Scotland/phsmethods/issues} +} + +} +\author{ +\strong{Maintainer}: Tina Fu \email{Yuyan.Fu2@phs.scot} + +Authors: +\itemize{ + \item David Caldwell \email{David.Caldwell@phs.scot} + \item Jack Hannah \email{jack.hannah2@phs.scot} + \item Ciara Gribben \email{Ciara.Gribben@phs.scot} + \item Chris Deans \email{Chris.Deans2@phs.scot} + \item Jaime Villacampa \email{Jaime.Villacampa@phs.scot} + \item Graeme Gowans \email{Graeme.Gowans@phs.scot} + \item James McMahon \email{James.McMahon@phs.scot} (\href{https://orcid.org/0000-0002-5380-2029}{ORCID}) + \item Nicolaos Christofidis \email{nicolaos.christofidis@phs.scot} +} + +Other contributors: +\itemize{ + \item Public Health Scotland \email{phs.datascience@phs.scot} [copyright holder] + \item Lucinda Lawrie \email{Lucinda.Lawrie@phs.scot} [reviewer] + \item Alice Byers [contributor] + \item Alan Yeung \email{Alan.Yeung@phs.scot} [contributor] +} + +} diff --git a/tests/testthat/test-percent.R b/tests/testthat/test-percent.R new file mode 100644 index 0000000..d2e9e42 --- /dev/null +++ b/tests/testthat/test-percent.R @@ -0,0 +1,244 @@ +test_that("as_percent", { + expect_error(as_percent(NULL)) + expect_error(as_percent("1")) + + expect_equal( + as_percent(0.1234567), + structure(0.1234567, class = "percent", .digits = 2) + ) + + expect_equal(format(as_percent(0.045), digits = 0), "5%") + expect_equal(round(as_percent(0.045)), as_percent(0.05)) +}) + +test_that("as_percent works correctly", { + expect_s3_class(as_percent(0.5), "percent") + expect_type(as_percent(0.5), "double") + + expect_equal(as.character(as_percent(50)), "5,000%") + expect_equal(as.character(as_percent(5)), "500%") + expect_equal(as.character(as_percent(0.5)), "50%") + expect_equal(as.character(as_percent(0.05)), "5%") + expect_equal(as.character(as_percent(0.005)), "0.5%") + expect_equal(as.character(as_percent(0.0005)), "0.05%") + expect_equal(as.character(as_percent(0.00005)), "0.01%") + + expect_equal(format(as_percent(50)), "5,000%") + expect_equal(format(as_percent(5)), "500%") + expect_equal(format(as_percent(0.5)), "50%") + expect_equal(format(as_percent(0.05)), "5%") + expect_equal(format(as_percent(0.005)), "0.5%") + expect_equal(format(as_percent(0.0005)), "0.05%") + expect_equal(format(as_percent(0.00005)), "0.01%") +}) + +test_that("as_percent handles non-numeric input", { + expect_error(as_percent("not a number"), regexp = "must be a vector, not a vector") + expect_error(as_percent(list(1, 2, 3)), regexp = "must be a vector, not a vector") +}) + +test_that("round_half_up handles non-numeric input", { + expect_error(round_half_up("not a number"), regexp = "must be a vector, not a vector") + expect_error(round_half_up(list(1, 2, 3)), regexp = "must be a vector, not a vector") +}) + + +test_that("signif_half_up handles non-numeric input", { + expect_error(signif_half_up("not a number"), regexp = "must be a vector, not a vector") + expect_error(signif_half_up(list(1, 2, 3)), regexp = "must be a vector, not a vector") +}) + + +test_that("signif_half_up results are as expected", { + # scalars + expect_equal( + signif_half_up(x = 12.5, digits = 2), + 13 + ) + expect_equal( + signif_half_up(x = 0), + 0 + ) + expect_equal(signif_half_up(x = -2.5, digits = 1), -3) + expect_equal( + signif_half_up(x = 123.45, digits = 4), + 123.5 + ) + expect_equal(signif_half_up(x = -123.45, digits = 4), -123.5) + # vectors + expect_equal( + signif_half_up( + x = c(12.5, 0, -2.5, 123.45, -123.45), + digits = 2 + ), + c(13, 0, -2.5, 120, -120) + ) +}) + +test_that("signif_half_up works correctly", { + expect_equal(signif_half_up(0.555, 2), 0.56) + expect_equal(signif_half_up(0.555, 1), 0.6) + expect_equal(signif_half_up(12345, 3), 12300) +}) + +test_that("rounding and recycling", { + x <- seq(-10, by = 0.05, length = 90) + dig <- c(0, 1, 2) + + # No digits + + expect_equal( + round_half_up(x, digits = NULL), + x + ) + expect_equal( + signif_half_up(x, digits = NULL), + x + ) + + # Inf digits + expect_equal( + round_half_up(x, digits = Inf), + x + ) + expect_equal( + signif_half_up(x, digits = Inf), + x + ) + + expect_equal( + round_half_up(x, digits = c(Inf, 5, Inf)), + x + ) + expect_equal( + signif_half_up(x, digits = c(Inf, 5, Inf)), + x + ) + + # 0-length inputs + expect_equal( + round_half_up(numeric(), digits = numeric()), + numeric() + ) + expect_equal( + signif_half_up(numeric(), digits = numeric()), + numeric() + ) + + expect_equal( + round_half_up(x, digits = numeric()), + numeric() + ) + + expect_equal( + signif_half_up(x, digits = numeric()), + numeric() + ) + + expect_equal( + round_half_up(numeric(), digits = 2), + numeric() + ) + + expect_equal( + signif_half_up(numeric(), digits = 2), + numeric() + ) + + + # Recycling of digits + + expect_equal( + round_half_up(x, digits = dig), + round_half_up(x, digits = rep_len(dig, 30)) + ) + expect_equal( + signif_half_up(x, digits = dig), + signif_half_up(x, digits = rep_len(dig, 30)) + ) + + # Recycling of x + + x <- seq(-10, by = 0.05, length = 7) + dig <- (sample.int(3, 70, TRUE) - 1) + + expect_equal( + round_half_up(rep_len(x, 70), digits = dig), + round_half_up(x, digits = dig) + ) + expect_equal( + signif_half_up(x, digits = dig), + signif_half_up(rep_len(x, 70), digits = dig) + ) + + + expect_equal( + as_percent(0.1234567), + structure(0.1234567, class = "percent", .digits = 2) + ) + + expect_equal(format(as_percent(0.045), digits = 0), "5%") + expect_equal(round(as_percent(0.045)), as_percent(0.05)) +}) + +test_that("as.character.percent works correctly", { + p <- as_percent(0.1234) + expect_equal(as.character(p), "12.34%") + expect_equal(as.character(p, digits = 1), "12.3%") +}) + +test_that("format.percent works correctly", { + p <- as_percent(0.1234) + expect_equal(format(p), "12.34%") + expect_equal(format(p, digits = 1), "12.3%") + expect_equal(format(p, symbol = " (%)"), "12.34 (%)") +}) + +test_that("print.percent works correctly", { + expect_output(print(as_percent(0.1234)), "12.34%") + expect_output(print(as_percent(0.567890)), "56.79%") + expect_output(print(as_percent(numeric())), "A vector of length 0") +}) + +test_that("subsetting percent objects works correctly", { + p <- as_percent(c(0.1, 0.2, 0.3)) + expect_s3_class(p[1:2], "percent") + expect_equal(p[1:2], as_percent(c(0.1, 0.2))) +}) + +test_that("unique.percent works correctly", { + p <- as_percent(c(0.1, 0.2, 0.1, 0.3)) + expect_s3_class(unique(p), "percent") + expect_equal(unique(p), as_percent(c(0.1, 0.2, 0.3))) +}) + +test_that("rep.percent works correctly", { + p <- as_percent(0.1) + expect_s3_class(rep(p, 3), "percent") + expect_equal(rep(p, 3), as_percent(c(0.1, 0.1, 0.1))) +}) + +test_that("Math.percent works correctly", { + p <- as_percent(c(0.1234, 0.5678)) + expect_equal(floor(p), as_percent(c(0.12, 0.56))) + expect_equal(ceiling(p), as_percent(c(0.13, 0.57))) + expect_equal(trunc(p), as_percent(c(0.12, 0.56))) + expect_equal(round(p), as_percent(c(0.12, 0.57))) + expect_equal(signif(p, 2), as_percent(c(0.12, 0.57))) +}) + +test_that("Summary.percent works correctly", { + p <- as_percent(c(0.1, 0.2, 0.3)) + expect_s3_class(sum(p), "percent") + expect_equal(sum(p), as_percent(0.6)) + expect_equal(prod(p), as_percent(0.006)) + expect_equal(min(p), as_percent(0.1)) + expect_equal(max(p), as_percent(0.3)) + expect_equal(range(p), as_percent(c(0.1, 0.3))) +}) + +test_that("mean.percent works correctly", { + p <- as_percent(c(0.1, 0.2, 0.3)) + expect_s3_class(mean(p), "percent") + expect_equal(mean(p), as_percent(0.2)) +})