From e1024046a667a663fe7fa5016abf5213fb90eb24 Mon Sep 17 00:00:00 2001 From: Yujie Zhao <43153957+LittleBeannie@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:41:30 +0000 Subject: [PATCH 1/4] fix: round collect_n_subject display values --- NAMESPACE | 2 + NEWS.md | 2 + R/collect_n_subject.R | 15 +++- R/rounding.R | 123 +++++++++++++++++++++++++++++++ _pkgdown.yml | 4 + man/collect_n_subject.Rd | 5 ++ man/format_number.Rd | 31 ++++++++ man/round_half_away_from_zero.Rd | 36 +++++++++ tests/testthat/test-rounding.R | 91 +++++++++++++++++++++++ 9 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 R/rounding.R create mode 100644 man/format_number.Rd create mode 100644 man/round_half_away_from_zero.Rd create mode 100644 tests/testthat/test-rounding.R diff --git a/NAMESPACE b/NAMESPACE index cc4245b..4f50053 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -32,6 +32,7 @@ export(define_observation) export(define_parameter) export(define_plan) export(define_population) +export(format_number) export(get_label) export(meta_adam) export(meta_add_total) @@ -44,6 +45,7 @@ export(meta_split) export(n_subject) export(outdata) export(plan) +export(round_half_away_from_zero) export(spec_analysis_population) export(spec_call_program) export(spec_filename) diff --git a/NEWS.md b/NEWS.md index d4dbdf5..ad9a960 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # metalite 0.1.4 +- Add SAS-compatible rounding and fixed-decimal formatting helpers, and use + them for `collect_n_subject()` display values. - Fix bug of `n_subject()` for empty factor. - Add default mapping for subject level analysis. - Update GitHub Actions workflows. diff --git a/R/collect_n_subject.R b/R/collect_n_subject.R index edbd8c6..17d0d31 100644 --- a/R/collect_n_subject.R +++ b/R/collect_n_subject.R @@ -132,6 +132,11 @@ meta_remove_blank_group <- function(meta, #' @param decimal_places_summary Number of decimal places to be displayed in statistical summary values (Mean, SD, Median, Min, Max, Q1 and Q3). Default is 1. #' @param decimal_places_percent Number of decimal places to be displayed in percentage values. Default is 1. #' +#' @details +#' Summary statistics and percentages are calculated without rounding, then +#' rounded once at the display boundary with [round_half_away_from_zero()]. +#' Decimal ties are rounded away from zero and trailing zeros are retained. +#' #' @return A list containing number of subjects and its subset condition. #' #' @export @@ -245,7 +250,7 @@ collect_n_subject <- function(meta, q1 = stats::quantile(x, probs = 0.25, na.rm = TRUE, type = quantile_method, names = FALSE), q3 = stats::quantile(x, probs = 0.75, na.rm = TRUE, type = quantile_method, names = FALSE) ) - value <- formatC(value, format = "f", digits = decimal_places_summary) + value <- format_number(value, digits = decimal_places_summary) c(gluestick("{value[['mean']]} ({value[['sd']]})"), gluestick("{value[['median']]} [{value[['min']]}, {value[['max']]}]"), gluestick("{value[['q1']]} to {value[['q3']]}")) }) pop_num <- data.frame( @@ -260,7 +265,9 @@ collect_n_subject <- function(meta, pop_tmp <- pop_n for (i in seq(names(pop_n))) { if ("integer" %in% class(pop_n[[i]])) { - pct <- formatC(pop_n[[i]] / pop_all[[i]] * 100, format = "f", digits = decimal_places_percent, width = 5) + pct <- format_number(pop_n[[i]] / pop_all[[i]] * 100, + digits = decimal_places_percent, width = 5 + ) pop_tmp[[i]] <- gluestick("{pop_n[[i]]} ({pct}%)") } } @@ -301,7 +308,9 @@ collect_n_subject <- function(meta, for (i in seq(names(pop_tmp))) { if ("integer" %in% class(pop_tmp[[i]])) { - pct <- formatC(pop_tmp[[i]] / pop_all[[i]] * 100, format = "f", digits = decimal_places_percent, width = 5) + pct <- format_number(pop_tmp[[i]] / pop_all[[i]] * 100, + digits = decimal_places_percent, width = 5 + ) pop_tmp[[i]] <- gluestick("{pop_tmp[[i]]} ({pct}%)") } } diff --git a/R/rounding.R b/R/rounding.R new file mode 100644 index 0000000..d1e2650 --- /dev/null +++ b/R/rounding.R @@ -0,0 +1,123 @@ +# Copyright (c) 2023 Merck & Co., Inc., Rahway, NJ, USA and its affiliates. +# All rights reserved. +# +# This file is part of the metalite program. +# +# metalite is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +#' Round half away from zero +#' +#' Round numeric values to a given number of decimal places, with decimal +#' ties (for example, 1.25 at `digits = 1`) rounded half away from zero. +#' This differs from [base::round()], which uses round-to-even for ties. +#' Values that round to zero, including small negative values, return positive +#' zero so formatted output does not display negative zero. +#' +#' To account for floating-point representation, values within +#' `sqrt(.Machine$double.eps)` below a tie at the requested precision are +#' treated as ties. The implementation is adapted from `roundSAS()` in +#' [pharmaverse/tidytlg](https://github.com/pharmaverse/tidytlg/blob/5f169c76428976f53d2af9e7fe52460348ef6cb7/R/roundSAS.R). +#' +#' @param x A numeric vector, matrix, array, or data frame with only numeric +#' columns. +#' @param digits A finite, integer-valued scalar giving the number of decimal +#' places. Negative values round to positions left of the decimal point. +#' +#' @return A numeric object with the same dimensions, dimension names, and +#' names as `x`. A data frame input returns a data frame. +#' +#' @export +#' +#' @examples +#' round_half_away_from_zero(c(1.25, -1.25), digits = 1) +#' round_half_away_from_zero(c(-0.04, NA), digits = 1) +round_half_away_from_zero <- function(x, digits = 0) { + if (!is.numeric(digits) || length(digits) != 1L || is.na(digits) || + !is.finite(digits) || digits %% 1 != 0) { + stop("`digits` must be one finite integer.", call. = FALSE) + } + + if (is.data.frame(x)) { + if (!all(vapply(x, is.numeric, logical(1)))) { + stop("All columns of `x` must be numeric.", call. = FALSE) + } + + x[] <- lapply(x, function(col) round_half_away_from_zero(col, digits = digits)) + return(x) + } + + if (!is.numeric(x)) { + stop("`x` must be numeric.", call. = FALSE) + } + + n <- names(x) + d <- dim(x) + dn <- dimnames(x) + + posneg <- sign(x) + z <- abs(x) * 10^digits + z <- z + 0.5 + sqrt(.Machine$double.eps) + z <- trunc(z) + z <- z / 10^digits + z <- ifelse(!is.na(z) & z > 0, z * posneg, z) + + dim(z) <- d + dimnames(z) <- dn + names(z) <- n + + z +} + +#' Format numbers with fixed decimal places +#' +#' Round with [round_half_away_from_zero()] and format with a fixed number of +#' decimal places. Decimal ties round half away from zero, and values that +#' round to zero display as positive zero (for example, `"0.0"` rather than +#' `"-0.0"`). +#' +#' @param x A numeric vector. +#' @param digits A non-negative, integer-valued scalar giving the number of +#' decimal places. +#' @param width `NULL`, or a non-negative, integer-valued scalar giving the +#' minimum field width passed to [base::formatC()]. The default, `NULL`, does +#' not set a minimum width. +#' +#' @return A character vector containing the formatted values. +#' +#' @export +#' +#' @examples +#' format_number(c(1.25, -1.25), digits = 1) +#' format_number(c(6.25, -0.04), digits = 1, width = 5) +format_number <- function(x, digits = 1, width = NULL) { + if (!is.numeric(digits) || length(digits) != 1L || is.na(digits) || + !is.finite(digits) || digits %% 1 != 0 || digits < 0) { + stop("`digits` must be one non-negative integer.", call. = FALSE) + } + if (!is.null(width) && + (!is.numeric(width) || length(width) != 1L || is.na(width) || + !is.finite(width) || width %% 1 != 0 || width < 0)) { + stop("`width` must be NULL or one non-negative integer.", call. = FALSE) + } + + x <- round_half_away_from_zero(x, digits = digits) + + out <- if (is.null(width)) { + formatC(x, digits = digits, format = "f") + } else { + formatC(x, digits = digits, format = "f", width = width) + } + + out +} diff --git a/_pkgdown.yml b/_pkgdown.yml index dda709a..f32c533 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -85,6 +85,10 @@ reference: - title: Outdata contents: - "outdata" +- title: Formatting + contents: + - "round_half_away_from_zero" + - "format_number" - title: Utilities contents: - "default_apply" diff --git a/man/collect_n_subject.Rd b/man/collect_n_subject.Rd index 931f1d6..9da38e5 100644 --- a/man/collect_n_subject.Rd +++ b/man/collect_n_subject.Rd @@ -59,6 +59,11 @@ A list containing number of subjects and its subset condition. \description{ Collect number of subjects and its subset condition } +\details{ +Summary statistics and percentages are calculated without rounding, then +rounded once at the display boundary with \code{\link[=round_half_away_from_zero]{round_half_away_from_zero()}}. +Decimal ties are rounded away from zero and trailing zeros are retained. +} \examples{ suppressWarnings( meta <- meta_example() |> diff --git a/man/format_number.Rd b/man/format_number.Rd new file mode 100644 index 0000000..88ce257 --- /dev/null +++ b/man/format_number.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rounding.R +\name{format_number} +\alias{format_number} +\title{Format numbers with fixed decimal places} +\usage{ +format_number(x, digits = 1, width = NULL) +} +\arguments{ +\item{x}{A numeric vector.} + +\item{digits}{A non-negative, integer-valued scalar giving the number of +decimal places.} + +\item{width}{\code{NULL}, or a non-negative, integer-valued scalar giving the +minimum field width passed to \code{\link[base:formatC]{base::formatC()}}. The default, \code{NULL}, does +not set a minimum width.} +} +\value{ +A character vector containing the formatted values. +} +\description{ +Round with \code{\link[=round_half_away_from_zero]{round_half_away_from_zero()}} and format with a fixed number of +decimal places. Decimal ties round half away from zero, and values that +round to zero display as positive zero (for example, \code{"0.0"} rather than +\code{"-0.0"}). +} +\examples{ +format_number(c(1.25, -1.25), digits = 1) +format_number(c(6.25, -0.04), digits = 1, width = 5) +} diff --git a/man/round_half_away_from_zero.Rd b/man/round_half_away_from_zero.Rd new file mode 100644 index 0000000..8dc649b --- /dev/null +++ b/man/round_half_away_from_zero.Rd @@ -0,0 +1,36 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rounding.R +\name{round_half_away_from_zero} +\alias{round_half_away_from_zero} +\title{Round half away from zero} +\usage{ +round_half_away_from_zero(x, digits = 0) +} +\arguments{ +\item{x}{A numeric vector, matrix, array, or data frame with only numeric +columns.} + +\item{digits}{A finite, integer-valued scalar giving the number of decimal +places. Negative values round to positions left of the decimal point.} +} +\value{ +A numeric object with the same dimensions, dimension names, and +names as \code{x}. A data frame input returns a data frame. +} +\description{ +Round numeric values to a given number of decimal places, with decimal +ties (for example, 1.25 at \code{digits = 1}) rounded half away from zero. +This differs from \code{\link[base:round]{base::round()}}, which uses round-to-even for ties. +Values that round to zero, including small negative values, return positive +zero so formatted output does not display negative zero. +} +\details{ +To account for floating-point representation, values within +\code{sqrt(.Machine$double.eps)} below a tie at the requested precision are +treated as ties. The implementation is adapted from \code{roundSAS()} in +\href{https://github.com/pharmaverse/tidytlg/blob/5f169c76428976f53d2af9e7fe52460348ef6cb7/R/roundSAS.R}{pharmaverse/tidytlg}. +} +\examples{ +round_half_away_from_zero(c(1.25, -1.25), digits = 1) +round_half_away_from_zero(c(-0.04, NA), digits = 1) +} diff --git a/tests/testthat/test-rounding.R b/tests/testthat/test-rounding.R new file mode 100644 index 0000000..2ed4676 --- /dev/null +++ b/tests/testthat/test-rounding.R @@ -0,0 +1,91 @@ +test_that("round_half_away_from_zero handles ties and signed zero", { + expect_equal( + round_half_away_from_zero(c(1.25, -1.25), digits = 1), + c(1.3, -1.3) + ) + expect_equal(round_half_away_from_zero(1 / 16 * 100, digits = 1), 6.3) + expect_identical(round_half_away_from_zero(NA_real_, digits = 1), NA_real_) + + rounded_zero <- round_half_away_from_zero(-0.04, digits = 1) + expect_equal(rounded_zero, 0) + expect_equal(1 / rounded_zero, Inf) +}) + +test_that("round_half_away_from_zero preserves supported input shapes", { + x <- matrix(c(1.25, -1.25), nrow = 1, dimnames = list("row", c("a", "b"))) + expect_equal( + round_half_away_from_zero(x, digits = 1), + matrix(c(1.3, -1.3), nrow = 1, dimnames = list("row", c("a", "b"))) + ) + + x_df <- data.frame(a = 1.25, b = -1.25) + expect_equal( + round_half_away_from_zero(x_df, digits = 1), + data.frame(a = 1.3, b = -1.3) + ) +}) + +test_that("rounding helpers validate formatting arguments", { + expect_error(round_half_away_from_zero("1.25", digits = 1), "must be numeric") + expect_error(round_half_away_from_zero(1.25, digits = 1.5), "finite integer") + expect_error(round_half_away_from_zero(data.frame(a = 1, b = "2")), "must be numeric") + expect_error(format_number(1.25, digits = -1), "non-negative integer") + expect_error(format_number(1.25, width = 1.5), "non-negative integer") +}) + +test_that("format_number uses fixed decimals without negative zero", { + expect_equal( + format_number(c(1.25, -1.25, 1 / 16 * 100, -0.04), digits = 1), + c("1.3", "-1.3", "6.3", "0.0") + ) + expect_equal(format_number(c(6.25, -0.04), digits = 1, width = 5), c(" 6.3", " 0.0")) +}) + +test_that("collect_n_subject rounds summaries and percentages at display", { + population <- data.frame( + ID = seq_len(32), + TRT = factor(rep(c("Positive", "Negative"), each = 16)), + NUM = c(rep(1.25, 15), NA, rep(-1.25, 15), NA), + CAT = factor(rep(c("Yes", rep("No", 15)), 2), levels = c("Yes", "No")) + ) + + meta <- meta_adam(population) + meta$population$pop <- adam_mapping( + name = "pop", id = "ID", group = "TRT" + ) + meta$parameter$num <- adam_mapping( + name = "num", var = "NUM", label = "Numeric" + ) + meta$parameter$cat <- adam_mapping( + name = "cat", var = "CAT", label = "Categorical" + ) + + numeric_table <- collect_n_subject( + meta, "pop", "num", display_total = FALSE + )$table + categorical_table <- collect_n_subject( + meta, "pop", "cat", display_total = FALSE + )$table + + expect_equal( + unname(unlist( + numeric_table[numeric_table$name == "Mean (SD)", c("Positive", "Negative")], + use.names = FALSE + )), + c("1.3 (0.0)", "-1.3 (0.0)") + ) + expect_equal( + unname(unlist( + numeric_table[numeric_table$name == "Missing", c("Positive", "Negative")], + use.names = FALSE + )), + c("1 ( 6.3%)", "1 ( 6.3%)") + ) + expect_equal( + unname(unlist( + categorical_table[categorical_table$name == "Yes", c("Positive", "Negative")], + use.names = FALSE + )), + c("1 ( 6.3%)", "1 ( 6.3%)") + ) +}) From fea8b4a24ea2a019de9643b2b464a50db33bf5c3 Mon Sep 17 00:00:00 2001 From: elong0527 Date: Tue, 8 Sep 2026 02:46:30 +0000 Subject: [PATCH 2/4] Style code (GHA) --- R/meta_inherit.R | 9 +++++---- tests/testthat/test-independent-testing-define.R | 1 - tests/testthat/test-independent-testing-meta_check.R | 2 -- tests/testthat/test-rounding.R | 6 ++++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/R/meta_inherit.R b/R/meta_inherit.R index de48cab..4ad597f 100644 --- a/R/meta_inherit.R +++ b/R/meta_inherit.R @@ -34,10 +34,11 @@ #' #' @export meta_inherit <- function( - meta, - inherit, - name, - overwrite = FALSE) { + meta, + inherit, + name, + overwrite = FALSE +) { mapping <- list() for (i in seq_along(name)) { x <- collect_adam_mapping(inherit, name[i]) diff --git a/tests/testthat/test-independent-testing-define.R b/tests/testthat/test-independent-testing-define.R index f6c483a..d7c3a2a 100644 --- a/tests/testthat/test-independent-testing-define.R +++ b/tests/testthat/test-independent-testing-define.R @@ -69,7 +69,6 @@ test_that("warning if one of name is not in the plan data frame of meta define_o }) - test_that("meta_adam class object with list population contains in the object at define_observation", { expect_equal(names(z)[4], "observation") }) diff --git a/tests/testthat/test-independent-testing-meta_check.R b/tests/testthat/test-independent-testing-meta_check.R index 201175b..7e0a1d9 100644 --- a/tests/testthat/test-independent-testing-meta_check.R +++ b/tests/testthat/test-independent-testing-meta_check.R @@ -4,7 +4,6 @@ test_that("variable 'RACE' checking", { }) - test_that("variable 'AEDECOD' checking in population", { expect_error(meta_check_var(meta_example(), var = "AEDECOD", type = c("population"))) }) @@ -15,7 +14,6 @@ test_that("variable 'AEDECOD' checking in observation", { }) - test_that("variable 'BMIBL' checking in population or observation", { expect_error(meta_check_var(meta_example(), var = "BMIBL", type = c("population", "observation"))) }) diff --git a/tests/testthat/test-rounding.R b/tests/testthat/test-rounding.R index 2ed4676..e64028c 100644 --- a/tests/testthat/test-rounding.R +++ b/tests/testthat/test-rounding.R @@ -61,10 +61,12 @@ test_that("collect_n_subject rounds summaries and percentages at display", { ) numeric_table <- collect_n_subject( - meta, "pop", "num", display_total = FALSE + meta, "pop", "num", + display_total = FALSE )$table categorical_table <- collect_n_subject( - meta, "pop", "cat", display_total = FALSE + meta, "pop", "cat", + display_total = FALSE )$table expect_equal( From 91333c09f3243ff31edf7070dc17909ae1a5c73c Mon Sep 17 00:00:00 2001 From: elong0527 Date: Tue, 8 Sep 2026 03:32:53 +0000 Subject: [PATCH 3/4] fix: avoid overflow in rounding helper --- R/rounding.R | 9 +++++---- tests/testthat/test-rounding.R | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/R/rounding.R b/R/rounding.R index d1e2650..952d7c1 100644 --- a/R/rounding.R +++ b/R/rounding.R @@ -66,10 +66,11 @@ round_half_away_from_zero <- function(x, digits = 0) { dn <- dimnames(x) posneg <- sign(x) - z <- abs(x) * 10^digits - z <- z + 0.5 + sqrt(.Machine$double.eps) - z <- trunc(z) - z <- z / 10^digits + z <- abs(x) + finite <- is.finite(z) + tolerance <- sqrt(.Machine$double.eps) * 10^-digits + z[finite] <- z[finite] + pmin(tolerance, .Machine$double.xmax - z[finite]) + z <- round(z, digits = digits) z <- ifelse(!is.na(z) & z > 0, z * posneg, z) dim(z) <- d diff --git a/tests/testthat/test-rounding.R b/tests/testthat/test-rounding.R index e64028c..f45cd7b 100644 --- a/tests/testthat/test-rounding.R +++ b/tests/testthat/test-rounding.R @@ -11,6 +11,11 @@ test_that("round_half_away_from_zero handles ties and signed zero", { expect_equal(1 / rounded_zero, Inf) }) +test_that("round_half_away_from_zero does not overflow finite values", { + expect_identical(round_half_away_from_zero(1e308, digits = 1), 1e308) + expect_identical(round_half_away_from_zero(1, digits = 309), 1) +}) + test_that("round_half_away_from_zero preserves supported input shapes", { x <- matrix(c(1.25, -1.25), nrow = 1, dimnames = list("row", c("a", "b"))) expect_equal( From a8645f40a3d78eeffc1e55c9273f89489fdcb7fb Mon Sep 17 00:00:00 2001 From: elong0527 Date: Tue, 8 Sep 2026 04:13:42 +0000 Subject: [PATCH 4/4] fix: replace meta print snapshot with explicit output checks Snapshot files are excluded from the built tarball via .Rbuildignore, so R CMD check always reported a new snapshot and errored. Explicit grepl checks verify the same printed sections without _snaps. --- tests/testthat/test-independent-testing-printmeta_adam.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-independent-testing-printmeta_adam.R b/tests/testthat/test-independent-testing-printmeta_adam.R index 1940894..92df93f 100644 --- a/tests/testthat/test-independent-testing-printmeta_adam.R +++ b/tests/testthat/test-independent-testing-printmeta_adam.R @@ -37,5 +37,12 @@ meta <- meta_adam( test_that("meta print", { - testthat::expect_snapshot(meta |> print()) + out <- capture.output(print(meta)) + expect_true(any(grepl("ADaM metadata", out, fixed = TRUE))) + expect_true(any(grepl("Population data with 254 subjects", out, fixed = TRUE))) + expect_true(any(grepl("Observation data with 1191 records", out, fixed = TRUE))) + expect_true(any(grepl("Analysis plan with 1 plans", out, fixed = TRUE))) + expect_true(any(grepl("'apat'", out, fixed = TRUE))) + expect_true(any(grepl("'wk12'", out, fixed = TRUE))) + expect_true(any(grepl("'ae_summary'", out, fixed = TRUE))) })