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)))
})