From faec448d1b352e0db76984bd6d0d599f2c8d2142 Mon Sep 17 00:00:00 2001 From: keaven Date: Wed, 29 Jul 2026 09:59:25 -0400 Subject: [PATCH 1/4] Add minimum median follow-up utilities (#281) --- DESCRIPTION | 2 +- NAMESPACE | 2 + NEWS.md | 3 + R/minMedianFollowUp.R | 171 ++++++++++++++++++++++++ man/minMedianFollowUp.Rd | 68 ++++++++++ tests/testthat/test-minMedianFollowUp.R | 103 ++++++++++++++ 6 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 R/minMedianFollowUp.R create mode 100644 man/minMedianFollowUp.Rd create mode 100644 tests/testthat/test-minMedianFollowUp.R diff --git a/DESCRIPTION b/DESCRIPTION index 2381c3e2..94d9286a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: gsDesign -Version: 3.10.1.9001 +Version: 3.11.0.9000 Title: Group Sequential Design Authors@R: c( person("Keaven", "Anderson", email = "keaven_anderson@merck.com", role = c("aut", "cre")), diff --git a/NAMESPACE b/NAMESPACE index ff9aba52..3bb8ee98 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -58,6 +58,7 @@ export(gsSurvPower) export(hrn2z) export(hrz2n) export(isInteger) +export(minMedianFollowUp) export(nBinomial) export(nBinomial1Sample) export(nEvents) @@ -66,6 +67,7 @@ export(nNormal) export(nSurv) export(nSurvival) export(normalGrid) +export(plotMinMedianFollowUp) export(repeatedPValueBinomialExact) export(sequentialPValue) export(sequentialPValueBinomialExact) diff --git a/NEWS.md b/NEWS.md index f74fc2e2..d94f728f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,9 @@ ## New features +- Added `minMedianFollowUp()` and `plotMinMedianFollowUp()` to compute and plot + minimum median follow-up at any calendar time from the piecewise enrollment + assumptions in an `nSurv` or `gsSurv` design (#281). - Sequential p-values, including exact-binomial repeated and sequential efficacy p-values, now support `test.type = 8` by ignoring its non-binding futility and harm bounds. `toBinomialExact()` now provides full exact diff --git a/R/minMedianFollowUp.R b/R/minMedianFollowUp.R new file mode 100644 index 00000000..367fc9d7 --- /dev/null +++ b/R/minMedianFollowUp.R @@ -0,0 +1,171 @@ +# minMedianFollowUp roxy [sinew] ---- +#' Minimum median follow-up for a survival design +#' +#' Computes minimum median follow-up at one or more calendar times under the +#' enrollment assumptions in an \code{nSurv} or \code{gsSurv} object. Minimum +#' median follow-up is defined as the time elapsed since enrollment reached +#' one-half of the enrollment accumulated by the requested calendar time. +#' After enrollment is complete, this is one-half of final planned enrollment. +#' +#' Enrollment is integrated over the piecewise-constant rates in +#' \code{x$gamma} and durations in \code{x$R}, summing rates across strata. +#' Thus, at each requested calendar time, the median enrollment time is the +#' first time at which expected cumulative enrollment reached one-half of the +#' enrollment accumulated by then. Before any enrollment has occurred, the +#' result is \code{NA_real_}. +#' +#' This definition is continuous at the time enrollment completes when the +#' enrollment rate immediately before completion is positive. The value may +#' have kinks when an enrollment rate changes. A zero-enrollment period can +#' produce a discontinuity because no subjects have enrollment times within +#' that interval. +#' +#' @param x An \code{nSurv} or \code{gsSurv} object. +#' @param calendarTime Nonnegative calendar time(s) from the start of +#' enrollment. For \code{minMedianFollowUp()}, the default is the planned +#' analysis time(s) in \code{x$T}. For \code{plotMinMedianFollowUp()}, +#' \code{NULL} creates a grid from trial start through the final planned +#' analysis time. +#' @param showAnalysisTimes Logical scalar indicating whether analysis times +#' should be marked with points. For an \code{nSurv} object, this marks the +#' final study time. +#' +#' @return \code{minMedianFollowUp()} returns a numeric vector with one value +#' for each value of \code{calendarTime}. +#' \code{plotMinMedianFollowUp()} returns a \code{ggplot} object. +#' +#' @examples +#' x <- gsSurv(gamma = 10, R = 12, T = 30, minfup = 18) +#' +#' # Minimum median follow-up at each planned analysis +#' minMedianFollowUp(x) +#' +#' # Minimum median follow-up at 12, 18, and 24 months +#' minMedianFollowUp(x, calendarTime = c(12, 18, 24)) +#' +#' # Plot its evolution through the planned analyses +#' plotMinMedianFollowUp(x) +#' +#' # The same functions work for a fixed survival design +#' x_fixed <- nSurv(gamma = 10, R = 12, T = 30, minfup = 18) +#' minMedianFollowUp(x_fixed) +#' plotMinMedianFollowUp(x_fixed) +#' +#' @export +minMedianFollowUp <- function(x, calendarTime = x$T) { + if (!inherits(x, c("nSurv", "gsSurv"))) { + stop("x must be an nSurv or gsSurv object") + } + if (!is.numeric(calendarTime) || length(calendarTime) < 1L || + anyNA(calendarTime) || any(!is.finite(calendarTime)) || + any(calendarTime < 0)) { + stop("calendarTime must contain finite, nonnegative numeric values") + } + + gamma <- accrual_gamma(x$gamma, x$R) + enrollment_rate <- rowSums(gamma) + enrollment_by_period <- enrollment_rate * x$R + total_enrollment <- sum(enrollment_by_period) + if (!is.finite(total_enrollment) || total_enrollment <= 0) { + stop("x must specify positive, finite planned enrollment") + } + + cumulative_enrollment <- cumsum(enrollment_by_period) + period_start <- c(0, head(cumsum(x$R), -1L)) + + vapply(calendarTime, function(current_time) { + accrued_duration <- pmax( + 0, + pmin(x$R, current_time - period_start) + ) + enrollment_to_date <- sum(enrollment_rate * accrued_duration) + if (enrollment_to_date <= 0) { + return(NA_real_) + } + + target <- enrollment_to_date / 2 + median_period <- which(cumulative_enrollment >= target)[1] + enrollment_before <- if (median_period == 1L) { + 0 + } else { + cumulative_enrollment[median_period - 1L] + } + median_enrollment_time <- period_start[median_period] + + (target - enrollment_before) / enrollment_rate[median_period] + + current_time - median_enrollment_time + }, numeric(1)) +} + +# plotMinMedianFollowUp function [sinew] ---- +#' @rdname minMedianFollowUp +#' @export +plotMinMedianFollowUp <- function( + x, + calendarTime = NULL, + showAnalysisTimes = TRUE +) { + if (!inherits(x, c("nSurv", "gsSurv"))) { + stop("x must be an nSurv or gsSurv object") + } + if (!is.logical(showAnalysisTimes) || length(showAnalysisTimes) != 1L || + is.na(showAnalysisTimes)) { + stop("showAnalysisTimes must be TRUE or FALSE") + } + + if (is.null(calendarTime)) { + final_time <- max(x$T) + calendarTime <- sort(unique(c( + seq(0, final_time, length.out = 201L), + x$T, + cumsum(x$R) + ))) + calendarTime <- calendarTime[calendarTime <= final_time] + } + + plot_data <- data.frame( + calendarTime = calendarTime, + minimumMedianFollowUp = minMedianFollowUp(x, calendarTime) + ) + plot_data <- plot_data[!is.na(plot_data$minimumMedianFollowUp), , drop = FALSE] + + p <- ggplot2::ggplot( + plot_data, + ggplot2::aes( + x = .data$calendarTime, + y = .data$minimumMedianFollowUp + ) + ) + + ggplot2::geom_line(linewidth = 0.8) + + ggplot2::labs( + x = "Calendar time", + y = "Minimum median follow-up" + ) + + ggplot2::theme_bw() + + if (showAnalysisTimes) { + analysis_time <- x$T[ + x$T >= min(calendarTime) & x$T <= max(calendarTime) + ] + analysis_data <- data.frame( + calendarTime = analysis_time, + minimumMedianFollowUp = minMedianFollowUp(x, analysis_time) + ) + analysis_data <- analysis_data[ + !is.na(analysis_data$minimumMedianFollowUp), + , + drop = FALSE + ] + p <- p + ggplot2::geom_point( + data = analysis_data, + mapping = ggplot2::aes( + x = .data$calendarTime, + y = .data$minimumMedianFollowUp + ), + inherit.aes = FALSE, + size = 2 + ) + } + + p +} diff --git a/man/minMedianFollowUp.Rd b/man/minMedianFollowUp.Rd new file mode 100644 index 00000000..eda7fab1 --- /dev/null +++ b/man/minMedianFollowUp.Rd @@ -0,0 +1,68 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/minMedianFollowUp.R +\name{minMedianFollowUp} +\alias{minMedianFollowUp} +\alias{plotMinMedianFollowUp} +\title{Minimum median follow-up for a survival design} +\usage{ +minMedianFollowUp(x, calendarTime = x$T) + +plotMinMedianFollowUp(x, calendarTime = NULL, showAnalysisTimes = TRUE) +} +\arguments{ +\item{x}{An \code{nSurv} or \code{gsSurv} object.} + +\item{calendarTime}{Nonnegative calendar time(s) from the start of +enrollment. For \code{minMedianFollowUp()}, the default is the planned +analysis time(s) in \code{x$T}. For \code{plotMinMedianFollowUp()}, +\code{NULL} creates a grid from trial start through the final planned +analysis time.} + +\item{showAnalysisTimes}{Logical scalar indicating whether analysis times +should be marked with points. For an \code{nSurv} object, this marks the +final study time.} +} +\value{ +\code{minMedianFollowUp()} returns a numeric vector with one value + for each value of \code{calendarTime}. + \code{plotMinMedianFollowUp()} returns a \code{ggplot} object. +} +\description{ +Computes minimum median follow-up at one or more calendar times under the +enrollment assumptions in an \code{nSurv} or \code{gsSurv} object. Minimum +median follow-up is defined as the time elapsed since enrollment reached +one-half of the enrollment accumulated by the requested calendar time. +After enrollment is complete, this is one-half of final planned enrollment. +} +\details{ +Enrollment is integrated over the piecewise-constant rates in +\code{x$gamma} and durations in \code{x$R}, summing rates across strata. +Thus, at each requested calendar time, the median enrollment time is the +first time at which expected cumulative enrollment reached one-half of the +enrollment accumulated by then. Before any enrollment has occurred, the +result is \code{NA_real_}. + +This definition is continuous at the time enrollment completes when the +enrollment rate immediately before completion is positive. The value may +have kinks when an enrollment rate changes. A zero-enrollment period can +produce a discontinuity because no subjects have enrollment times within +that interval. +} +\examples{ +x <- gsSurv(gamma = 10, R = 12, T = 30, minfup = 18) + +# Minimum median follow-up at each planned analysis +minMedianFollowUp(x) + +# Minimum median follow-up at 12, 18, and 24 months +minMedianFollowUp(x, calendarTime = c(12, 18, 24)) + +# Plot its evolution through the planned analyses +plotMinMedianFollowUp(x) + +# The same functions work for a fixed survival design +x_fixed <- nSurv(gamma = 10, R = 12, T = 30, minfup = 18) +minMedianFollowUp(x_fixed) +plotMinMedianFollowUp(x_fixed) + +} diff --git a/tests/testthat/test-minMedianFollowUp.R b/tests/testthat/test-minMedianFollowUp.R new file mode 100644 index 00000000..e8b8d312 --- /dev/null +++ b/tests/testthat/test-minMedianFollowUp.R @@ -0,0 +1,103 @@ +test_that("minMedianFollowUp handles uniform enrollment", { + x <- gsSurv( + k = 2, gamma = 10, R = 12, T = 30, minfup = 18, + lambdaC = log(2) / 6, hr = 0.7 + ) + + expect_equal(minMedianFollowUp(x, c(6, 12, 18)), c(3, 6, 12)) + expect_equal(minMedianFollowUp(x), x$T - 6) +}) + +test_that("minMedianFollowUp handles piecewise and stratified enrollment", { + x <- gsSurv( + k = 2, + gamma = matrix(c(1, 1, 3, 1), nrow = 2, byrow = TRUE), + R = c(4, 4), T = 16, minfup = 8, + lambdaC = matrix(log(2) / c(6, 9), nrow = 1), + hr = 0.7 + ) + + # At time 5, 12 subjects are enrolled and the sixth entered at time 3. + # At full enrollment (time 8), the 12th entered at time 5. + expect_equal(minMedianFollowUp(x, c(5, 8, 12)), c(2, 3, 7)) +}) + +test_that("minMedianFollowUp uses enrollment to date", { + x <- gsSurv(k = 2, gamma = 10, R = 12, T = 30, minfup = 18) + + expect_equal(minMedianFollowUp(x, c(0, 5, 6)), c(NA, 2.5, 3)) +}) + +test_that("minMedianFollowUp is continuous when enrollment completes", { + x <- gsSurv(k = 2, gamma = 10, R = 12, T = 30, minfup = 18) + epsilon <- 1e-6 + + observed <- minMedianFollowUp(x, 12 + c(-epsilon, 0, epsilon)) + expected <- c(6 - epsilon / 2, 6, 6 + epsilon) + expect_equal(observed, expected, tolerance = 1e-12) +}) + +test_that("minMedianFollowUp validates inputs", { + expect_error(minMedianFollowUp(list(), 1), "nSurv or gsSurv object") + + x <- gsSurv(k = 2, gamma = 10, R = 12, T = 30, minfup = 18) + expect_error(minMedianFollowUp(x, -1), "finite, nonnegative") + expect_error(minMedianFollowUp(x, NA_real_), "finite, nonnegative") + expect_error(minMedianFollowUp(x, Inf), "finite, nonnegative") + expect_error(minMedianFollowUp(x, character()), "finite, nonnegative") +}) + +test_that("minimum median follow-up supports nSurv objects", { + x <- nSurv(gamma = 10, R = 12, T = 30, minfup = 18) + + expect_s3_class(x, "nSurv") + expect_equal(minMedianFollowUp(x, c(6, 12, 18)), c(3, 6, 12)) + expect_equal(minMedianFollowUp(x), x$T - 6) + + p <- plotMinMedianFollowUp(x) + expect_s3_class(p, "ggplot") + expect_equal(p$layers[[2]]$data$calendarTime, x$T) + expect_equal( + p$layers[[2]]$data$minimumMedianFollowUp, + minMedianFollowUp(x) + ) +}) + +test_that("plotMinMedianFollowUp plots the trajectory and analysis times", { + x <- gsSurv( + k = 2, gamma = 10, R = 12, T = 30, minfup = 18, + lambdaC = log(2) / 6, hr = 0.7 + ) + + p <- plotMinMedianFollowUp(x) + expect_s3_class(p, "ggplot") + expect_equal(length(p$layers), 2) + expect_equal(p$layers[[2]]$data$calendarTime, x$T) + expect_equal( + p$layers[[2]]$data$minimumMedianFollowUp, + minMedianFollowUp(x) + ) + + p_line <- plotMinMedianFollowUp( + x, + calendarTime = seq(0, 18, by = 1), + showAnalysisTimes = FALSE + ) + expect_s3_class(p_line, "ggplot") + expect_equal(length(p_line$layers), 1) + expect_equal(max(p_line$data$calendarTime), 18) +}) + +test_that("plotMinMedianFollowUp validates inputs", { + x <- gsSurv(k = 2, gamma = 10, R = 12, T = 30, minfup = 18) + + expect_error(plotMinMedianFollowUp(list()), "nSurv or gsSurv object") + expect_error( + plotMinMedianFollowUp(x, showAnalysisTimes = NA), + "TRUE or FALSE" + ) + expect_error( + plotMinMedianFollowUp(x, calendarTime = -1), + "finite, nonnegative" + ) +}) From cab41879e8a7331ba588f6953fd3d0bde9dfcd44 Mon Sep 17 00:00:00 2001 From: keaven Date: Wed, 29 Jul 2026 11:45:08 -0400 Subject: [PATCH 2/4] Support fixed survival designs and follow-up plotting (#281, #289) --- DESCRIPTION | 2 +- NEWS.md | 9 +- R/gsMethods.R | 102 +++++++++++++---- R/gsSurv-nSurv.R | 4 + R/gsSurv-utils.R | 121 ++++++++++++++++++++ R/gsSurv.R | 25 +++- R/gsSurvPower.R | 110 +++++++++++------- R/minMedianFollowUp.R | 38 ++++++- R/toInteger.R | 56 +++++++-- _pkgdown.yml | 1 + man/gsBoundSummary.Rd | 2 + man/gsSurvPower.Rd | 4 + man/minMedianFollowUp.Rd | 20 +++- man/nSurv.Rd | 4 + man/toInteger.Rd | 3 + tests/testthat/test-k1-survival-designs.R | 132 ++++++++++++++++++++++ tests/testthat/test-minMedianFollowUp.R | 45 ++++++++ 17 files changed, 597 insertions(+), 81 deletions(-) create mode 100644 tests/testthat/test-k1-survival-designs.R diff --git a/DESCRIPTION b/DESCRIPTION index 94d9286a..fd11924f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: gsDesign -Version: 3.11.0.9000 +Version: 3.11.0.9001 Title: Group Sequential Design Authors@R: c( person("Keaven", "Anderson", email = "keaven_anderson@merck.com", role = c("aut", "cre")), diff --git a/NEWS.md b/NEWS.md index d94f728f..b17aaaa6 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,7 +4,10 @@ - Added `minMedianFollowUp()` and `plotMinMedianFollowUp()` to compute and plot minimum median follow-up at any calendar time from the piecewise enrollment - assumptions in an `nSurv` or `gsSurv` design (#281). + assumptions in an `nSurv` or `gsSurv` design. The plot accepts arbitrary + time-unit labels through `timename`; month and year labels default to x-axis + breaks every 6 months and 0.5 years, respectively, while other units use + automatic breaks (#281). - Sequential p-values, including exact-binomial repeated and sequential efficacy p-values, now support `test.type = 8` by ignoring its non-binding futility and harm bounds. `toBinomialExact()` now provides full exact @@ -16,6 +19,10 @@ ## Bug fixes +- Single-analysis survival designs now use a fixed-design `nSurv()` path in + `gsSurv(k = 1)` and `gsSurvPower(k = 1)`. The resulting objects work with + `toInteger()` and `gsBoundSummary()`, including alternate-alpha summaries + (#289). - Power plots for test types 7 and 8 now treat crossing the futility threshold as the union of futility-only and harm stops. The separate harm curve remains harm-only, and the mutually exclusive probabilities stored on the design are diff --git a/R/gsMethods.R b/R/gsMethods.R index c4c78e21..857f929f 100644 --- a/R/gsMethods.R +++ b/R/gsMethods.R @@ -69,7 +69,11 @@ print.gsProbability <- function(x, ...) { summary.gsDesign <- function(object, information = FALSE, timeunit = "months", ...) { out <- NULL if (object$test.type == 1) { - out <- paste(out, "One-sided group sequential design with ", sep = "") + out <- paste( + out, + if (object$k == 1) "One-sided fixed design with " else "One-sided group sequential design with ", + sep = "" + ) } else if (object$test.type == 2) { out <- paste(out, "Symmetric two-sided group sequential design with ", sep = "") } else if (object$test.type %in% c(7, 8)) { @@ -87,7 +91,12 @@ summary.gsDesign <- function(object, information = FALSE, timeunit = "months", . out <- paste(out, "non-binding futility bound, ", sep = "") } } - out <- paste(out, object$k, " analyses, ", sep = "") + out <- paste( + out, + object$k, + if (object$k == 1) " analysis, " else " analyses, ", + sep = "" + ) if (object$nFixSurv > 0) { out <- paste(out, "time-to-event outcome with sample size ", ceiling(object$nSurv), " and ", ceiling(object$n.I[object$k]), " events required, ", @@ -469,23 +478,28 @@ gsBoundSummary0 <- function( for (i in 1:length(x$theta)) pframe3 <- rbind(pframe3, data.frame("Harm" = cumsum(x$harm$prob[, i]))) pframe <- data.frame(pframe, pframe3) } - # conditional power at bound, theta=hat(theta) - cp <- data.frame(gsBoundCP(x, r = r)) - # conditional power at bound, theta=theta[1] - cp1 <- data.frame(gsBoundCP(x, theta = x$delta, r = r)) - if (x$test.type %in% c(7, 8)) { - colnames(cp) <- c("Futility", "Efficacy", "Harm") - colnames(cp1) <- c("Futility", "Efficacy", "Harm") - } else if (x$test.type > 1) { - colnames(cp) <- c("Futility", "Efficacy") - colnames(cp1) <- c("Futility", "Efficacy") + if (x$k > 1) { + # conditional power at bound, theta=hat(theta) + cp <- data.frame(gsBoundCP(x, r = r)) + # conditional power at bound, theta=theta[1] + cp1 <- data.frame(gsBoundCP(x, theta = x$delta, r = r)) + if (x$test.type %in% c(7, 8)) { + colnames(cp) <- c("Futility", "Efficacy", "Harm") + colnames(cp1) <- c("Futility", "Efficacy", "Harm") + } else if (x$test.type > 1) { + colnames(cp) <- c("Futility", "Efficacy") + colnames(cp1) <- c("Futility", "Efficacy") + } else { + colnames(cp) <- "Efficacy" + colnames(cp1) <- "Efficacy" + } + cp <- data.frame(cp, "Value" = "CP", i = seq_len(x$k - 1)) + cp1 <- data.frame(cp1, "Value" = "CP H1", i = seq_len(x$k - 1)) } else { - colnames(cp) <- "Efficacy" - colnames(cp1) <- "Efficacy" + cp <- NULL + cp1 <- NULL } - cp <- data.frame(cp, "Value" = "CP", i = 1:(x$k - 1)) - cp1 <- data.frame(cp1, "Value" = "CP H1", i = 1:(x$k - 1)) - if ("PP" %in% exclude) { + if ("PP" %in% exclude || x$k == 1) { pp <- NULL } else { # predictive probability @@ -568,11 +582,38 @@ gsBoundSummary0 <- function( statframe[statframe$Value == statframe$Value[2], ]$Analysis <- paste(Nname, ": ", N, sep = "") # add POS and predictive POS, if requested if (POS) { - ppos <- rep("", x$k) - for (i in 1:(x$k - 1)) ppos[i] <- paste("Post IA POS: ", as.character(round(100 * gsCPOS(i = i, x = x, theta = prior$z, wgts = prior$wgts), 1)), "%", sep = "") - statframe[statframe$Value == statframe$Value[nstat + 1], ]$Analysis <- ppos - statframe[nstat + 2, ]$Analysis <- ppos[1] - statframe[nstat + 1, ]$Analysis <- paste("Trial POS: ", as.character(round(100 * gsPOS(x = x, theta = prior$z, wgts = prior$wgts), 1)), "%", sep = "") + if (x$k == 1) { + trial_pos <- sum( + prior$wgts * stats::pnorm( + prior$z * sqrt(x$n.I[1]) - x$upper$bound[1] + ) + ) + statframe$Analysis[nrow(statframe)] <- paste0( + "Trial POS: ", round(100 * trial_pos, 1), "%" + ) + } else { + ppos <- rep("", x$k) + for (i in seq_len(x$k - 1)) { + ppos[i] <- paste( + "Post IA POS: ", + as.character(round(100 * gsCPOS( + i = i, x = x, theta = prior$z, wgts = prior$wgts + ), 1)), + "%", + sep = "" + ) + } + statframe[statframe$Value == statframe$Value[nstat + 1], ]$Analysis <- ppos + statframe[nstat + 2, ]$Analysis <- ppos[1] + statframe[nstat + 1, ]$Analysis <- paste( + "Trial POS: ", + as.character(round(100 * gsPOS( + x = x, theta = prior$z, wgts = prior$wgts + ), 1)), + "%", + sep = "" + ) + } } # add futility and harm columns to data frame if (x$test.type %in% c(7, 8)) { @@ -636,6 +677,8 @@ gsBoundSummary0 <- function( #' provided for LaTeX output by setting default options for #' \code{\link[xtable]{print.xtable}} when producing tables summarizing design #' bounds. +#' Single-analysis fixed designs are supported; interim-only characteristics +#' such as conditional and predictive power are omitted when \code{k = 1}. #' #' Individual transformation of z-value test statistics for interim and final #' analyses are obtained from \code{gsBValue()}, \code{gsDelta()}, @@ -1060,6 +1103,21 @@ gsAlternateAlphaDesign <- function( sfu = x$upper$sf, sfupar = x$upper$param, usTime = x$upper$sTime) { + if (x$k == 1) { + y <- x + y$alpha <- alpha + y$upper$spend <- alpha + y$upper$bound <- stats::qnorm(1 - alpha) + y$upper$prob <- matrix( + stats::pnorm( + outer(sqrt(y$n.I), y$theta) - y$upper$bound + ), + nrow = 1 + ) + y$beta <- 1 - y$upper$prob[1, length(y$theta)] + return(y) + } + test_upper <- if (is.null(x$testUpper)) rep(TRUE, x$k) else x$testUpper lower_sf <- if (is.null(x$lower) || is.null(x$lower$sf)) sfHSD else x$lower$sf lower_param <- if (is.null(x$lower) || is.null(x$lower$param)) -2 else x$lower$param diff --git a/R/gsSurv-nSurv.R b/R/gsSurv-nSurv.R index 3e442646..60aeb291 100644 --- a/R/gsSurv-nSurv.R +++ b/R/gsSurv-nSurv.R @@ -10,6 +10,10 @@ #' are also supported; see Details. #' \code{gsSurv()} combines \code{nSurv()} with \code{gsDesign()} to derive a #' group sequential design for a study with a time-to-event endpoint. +#' When \code{k = 1}, \code{gsSurv()} uses the fixed-design calculations from +#' \code{nSurv()} directly and returns a normalized single-analysis +#' \code{gsSurv} object for use with functions such as +#' \code{\link{toInteger}} and \code{\link{gsBoundSummary}}. #' #' @details #' The Lachin and Foulkes method uses both null and alternate hypothesis diff --git a/R/gsSurv-utils.R b/R/gsSurv-utils.R index f4aceae1..18ad04f0 100644 --- a/R/gsSurv-utils.R +++ b/R/gsSurv-utils.R @@ -66,3 +66,124 @@ validate_survival_timing_inputs <- function(R, T, minfup, call = "nSurv") { } invisible(TRUE) } + +# Construct the gsDesign portion of a single-analysis survival design without +# calling gsDesign(), whose group-sequential validation requires k >= 2. +gsSurvFixedDesignObject <- function( + alpha, + design_beta, + n_fix, + event_count, + delta0, + delta1, + theta_alt, + power, + sfu = sfHSD, + sfupar = -4, + sided = 1, + tol = .Machine$double.eps^0.25, + r = 18) { + z_alpha <- stats::qnorm(1 - alpha) + + if (is.character(sfu)) { + upper <- list( + sf = sfu, name = sfu, parname = "Delta", param = sfupar, sTime = 1 + ) + if (sfu %in% c("OF", "Pocock")) upper$param <- NULL + class(upper) <- "spendfn" + } else if (is.function(sfu)) { + upper <- sfu(alpha, 1, sfupar) + upper$sTime <- 1 + } else { + stop("Upper spending function mis-specified") + } + upper$spend <- alpha + upper$bound <- z_alpha + upper$prob <- matrix(c(alpha, power), nrow = 1) + + delta <- (z_alpha + stats::qnorm(1 - design_beta)) / sqrt(n_fix) + result <- list( + k = 1L, + test.type = 1L, + alpha = alpha, + sided = sided, + beta = 1 - power, + astar = 0, + delta = delta, + n.fix = n_fix, + timing = 1, + tol = tol, + r = r, + n.I = event_count, + maxn.IPlan = event_count, + nFixSurv = 0, + nSurv = 0, + endpoint = NULL, + delta1 = delta1, + delta0 = delta0, + overrun = 0, + usTime = NULL, + lsTime = NULL, + testUpper = TRUE, + testLower = FALSE, + testHarm = FALSE, + upper = upper, + lower = NULL, + theta = c(0, theta_alt), + en = rep(event_count, 2) + ) + class(result) <- "gsDesign" + result +} + +asGsSurvFixedDesign <- function( + x, + sfu = sfHSD, + sfupar = -4, + r = 18, + tol = .Machine$double.eps^0.25, + call = NULL, + inputs = NULL) { + design <- gsSurvFixedDesignObject( + alpha = x$alpha / x$sided, + design_beta = x$beta, + n_fix = x$d, + event_count = x$d, + delta0 = log(x$hr0), + delta1 = log(x$hr), + theta_alt = (stats::qnorm(1 - x$alpha / x$sided) + + stats::qnorm(x$power)) / sqrt(x$d), + power = x$power, + sfu = sfu, + sfupar = sfupar, + sided = x$sided, + tol = tol, + r = r + ) + + design$T <- x$T + design$eDC <- matrix(x$eDC, nrow = 1) + design$eDE <- matrix(x$eDE, nrow = 1) + design$eDC0 <- matrix(x$eDC0, nrow = 1) + design$eDE0 <- matrix(x$eDE0, nrow = 1) + design$eNC <- matrix(x$eNC, nrow = 1) + design$eNE <- matrix(x$eNE, nrow = 1) + design$hr <- x$hr + design$hr0 <- x$hr0 + design$R <- x$R + design$S <- x$S + design$minfup <- x$minfup + design$gamma <- x$gamma + design$ratio <- x$ratio + design$lambdaC <- x$lambdaC + design$etaC <- x$etaC + design$etaE <- x$etaE + design$variable <- x$variable + design$method <- x$method + design$power <- x$power + design$call <- call + design$inputs <- inputs + class(design) <- c("gsSurv", "gsDesign") + + design +} diff --git a/R/gsSurv.R b/R/gsSurv.R index cbef20de..f17f15c2 100644 --- a/R/gsSurv.R +++ b/R/gsSurv.R @@ -28,6 +28,23 @@ gsSurv <- function( stop("ratio must be a single positive scalar") } validate_survival_timing_inputs(R = R, T = T, minfup = minfup, call = "gsSurv") + if (identical(as.integer(k), 1L)) { + fixed <- nSurv( + lambdaC = lambdaC, hr = hr, hr0 = hr0, eta = eta, etaE = etaE, + gamma = gamma, R = R, S = S, T = T, minfup = minfup, ratio = ratio, + alpha = alpha, beta = beta, sided = sided, tol = tol, method = method + ) + input_vals$testUpper <- testUpper + return(asGsSurvFixedDesign( + fixed, + sfu = sfu, + sfupar = sfupar, + r = r, + tol = tol, + call = match.call(), + inputs = input_vals + )) + } solve_followup <- is.null(T) && is.null(minfup) if (solve_followup) { if (is.null(beta)) { @@ -264,7 +281,11 @@ print.gsSurv <- function(x, digits = 3, show_gsDesign = FALSE, show_strata = TRU x_sided <- if (!is.null(x$sided)) x$sided else if (x$test.type == 1) 1L else 2L if (is_power_calc) { - cat("Power computation for group sequential design\n") + cat(if (x$k == 1) { + "Power computation for fixed survival design\n" + } else { + "Power computation for group sequential design\n" + }) cat( "(method=", x$method, "; k=", x$k, " analyses; ", test_type_desc, ")\n", sep = "" @@ -277,7 +298,7 @@ print.gsSurv <- function(x, digits = 3, show_gsDesign = FALSE, show_strata = TRU ) } else { cat( - "Group sequential design ", + if (x$k == 1) "Fixed survival design " else "Group sequential design ", "(method=", x$method, "; k=", x$k, " analyses; ", test_type_desc, ")\n", sep = "" ) diff --git a/R/gsSurvPower.R b/R/gsSurvPower.R index 1f17f803..abffe56b 100644 --- a/R/gsSurvPower.R +++ b/R/gsSurvPower.R @@ -7,6 +7,10 @@ #' assumptions and computes the resulting power. It is meant to compute for #' a single set of assumptions at a time; different scenarios are evaluated #' with separate calls. +#' For \code{k = 1}, power is computed through the fixed-design +#' \code{nSurv(beta = NULL)} path. The returned object is normalized as a +#' single-analysis \code{gsSurv} object so it can be passed to +#' \code{\link{toInteger}} and \code{\link{gsBoundSummary}}. #' #' @details #' \strong{Accepting a gsSurv object:} @@ -389,6 +393,7 @@ gsSurvPower <- function( if (is.null(hr0)) hr0 <- 1 if (is.null(hr1)) hr1 <- hr if (is.null(eta)) eta <- 0 + if (is.null(gamma)) gamma <- 1 if (is.null(ratio)) ratio <- 1 if (is.null(R)) R <- 12 if (is.null(minfup)) minfup <- 18 @@ -513,10 +518,30 @@ gsSurvPower <- function( ) if (k == 1) { + fixed_power_fit <- nSurv( + lambdaC = rate_inputs$lambdaC, + hr = settings$hr, + hr0 = settings$hr0, + eta = rate_inputs$eta, + etaE = rate_inputs$etaE, + gamma = rate_inputs$gamma, + R = settings$R, + S = settings$S, + T = analysis_schedule$analysis_time[1], + minfup = max(0, analysis_schedule$analysis_time[1] - sum(settings$R)), + ratio = settings$ratio, + alpha = settings$alpha, + beta = NULL, + sided = 1, + tol = settings$tol, + method = settings$method + ) + settings$minfup <- fixed_power_fit$minfup bound_result <- .gsSurvPower_build_fixed_design_result( total_events = analysis_schedule$total_events, n_fix = fixed_design_events, - settings = settings + settings = settings, + power = fixed_power_fit$power ) } else { bound_result <- .gsSurvPower_compute_group_sequential_result( @@ -912,52 +937,46 @@ gsSurvPower <- function( .gsSurvPower_build_fixed_design_result <- function( total_events, n_fix, - settings) { + settings, + power = NULL) { z_alpha <- qnorm(1 - settings$alpha) theta_design <- (z_alpha + qnorm(1 - settings$beta_design)) / sqrt(n_fix) - theta_assumed <- theta_design * .gsSurvPower_compute_delta_ratio( - settings$hr, - settings$hr1, - settings - ) - drift <- theta_assumed * sqrt(total_events[1]) - power_value <- pnorm(drift - z_alpha) + if (is.null(power)) { + theta_assumed <- theta_design * .gsSurvPower_compute_delta_ratio( + settings$hr, + settings$hr1, + settings + ) + drift <- theta_assumed * sqrt(total_events[1]) + power_value <- pnorm(drift - z_alpha) + } else { + power_value <- power + theta_assumed <- (z_alpha + qnorm(power_value)) / sqrt(total_events[1]) + } - design_object <- list( - k = 1, - test.type = settings$test.type, + design_object <- gsSurvFixedDesignObject( alpha = settings$alpha, - sided = settings$sided, - n.I = total_events[1], - n.fix = n_fix, - timing = 1, - tol = settings$tol, - r = settings$r, - upper = list( - bound = z_alpha, - prob = matrix(c(settings$alpha, power_value), nrow = 1) - ), - lower = list( - bound = -20, - prob = matrix(c(1 - settings$alpha, 1 - power_value), nrow = 1) - ), - theta = c(0, theta_assumed), - en = list(en = total_events[1]), - delta = theta_design, + design_beta = settings$beta_design, + n_fix = n_fix, + event_count = total_events[1], delta0 = log(settings$hr0), delta1 = log(settings$hr1), - astar = settings$astar, - beta = 1 - power_value + theta_alt = theta_assumed, + power = power_value, + sfu = settings$sfu, + sfupar = settings$sfupar, + sided = settings$sided, + tol = settings$tol, + r = settings$r ) - class(design_object) <- "gsDesign" list( design_object = design_object, upper_bounds = design_object$upper$bound, - lower_bounds = design_object$lower$bound, + lower_bounds = numeric(0), probabilities = list( upper = list(prob = design_object$upper$prob), - lower = list(prob = design_object$lower$prob), + lower = NULL, en = design_object$en, theta = design_object$theta ) @@ -1144,7 +1163,7 @@ gsSurvPower <- function( result$etaC <- normalized_rates$etaC result$etaE <- normalized_rates$etaE result$variable <- "Power" - result$test.type <- settings$test.type + result$test.type <- if (settings$k == 1) 1L else settings$test.type result$alpha <- settings$alpha result$sided <- settings$sided result$tol <- settings$tol @@ -1155,16 +1174,27 @@ gsSurvPower <- function( result$call <- call_object result$timing <- analysis_schedule$timing result$testUpper <- .gsSurvPower_format_test_flag(settings$testUpper, settings$k) - result$testLower <- .gsSurvPower_format_test_flag(settings$testLower, settings$k) - if (settings$test.type %in% c(7, 8)) { + result$testLower <- if (settings$k == 1) { + FALSE + } else { + .gsSurvPower_format_test_flag(settings$testLower, settings$k) + } + if (result$test.type %in% c(7, 8)) { result$testHarm <- .gsSurvPower_format_test_flag(settings$testHarm, settings$k) + } else { + result$testHarm <- FALSE + result$harm <- NULL } result$upper$prob <- bound_result$probabilities$upper$prob result$upper$bound <- bound_result$upper_bounds - result$lower$prob <- bound_result$probabilities$lower$prob - result$lower$bound <- bound_result$lower_bounds - if (settings$test.type %in% c(7, 8)) { + if (result$test.type > 1) { + result$lower$prob <- bound_result$probabilities$lower$prob + result$lower$bound <- bound_result$lower_bounds + } else { + result$lower <- NULL + } + if (result$test.type %in% c(7, 8)) { result$harm$prob <- bound_result$probabilities$harm$prob } result$en <- bound_result$probabilities$en diff --git a/R/minMedianFollowUp.R b/R/minMedianFollowUp.R index 367fc9d7..2066433c 100644 --- a/R/minMedianFollowUp.R +++ b/R/minMedianFollowUp.R @@ -14,6 +14,14 @@ #' enrollment accumulated by then. Before any enrollment has occurred, the #' result is \code{NA_real_}. #' +#' The computation represents potential follow-up for all subjects randomized +#' by the requested calendar time. Conceptually, each subject contributes the +#' time from enrollment to that calendar time, regardless of whether the +#' subject would have experienced the event of interest, discontinued, or +#' dropped out. Event and dropout assumptions such as \code{x$lambdaC}, +#' \code{x$etaC}, and \code{x$etaE} therefore do not enter the calculation. +#' This is not observed follow-up among subjects who remain under observation. +#' #' This definition is continuous at the time enrollment completes when the #' enrollment rate immediately before completion is positive. The value may #' have kinks when an enrollment rate changes. A zero-enrollment period can @@ -29,6 +37,10 @@ #' @param showAnalysisTimes Logical scalar indicating whether analysis times #' should be marked with points. For an \code{nSurv} object, this marks the #' final study time. +#' @param timename Nonempty character string indicating the time unit. Month +#' and year labels (singular or plural, case-insensitive) use x-axis breaks +#' every 6 months and 0.5 years, respectively. Other units use the default +#' \code{ggplot2} breaks. #' #' @return \code{minMedianFollowUp()} returns a numeric vector with one value #' for each value of \code{calendarTime}. @@ -103,11 +115,17 @@ minMedianFollowUp <- function(x, calendarTime = x$T) { plotMinMedianFollowUp <- function( x, calendarTime = NULL, - showAnalysisTimes = TRUE + showAnalysisTimes = TRUE, + timename = "Months" ) { if (!inherits(x, c("nSurv", "gsSurv"))) { stop("x must be an nSurv or gsSurv object") } + if (!is.character(timename) || length(timename) != 1L || + is.na(timename) || !nzchar(trimws(timename))) { + stop("timename must be a nonempty character scalar") + } + timename <- trimws(timename) if (!is.logical(showAnalysisTimes) || length(showAnalysisTimes) != 1L || is.na(showAnalysisTimes)) { stop("showAnalysisTimes must be TRUE or FALSE") @@ -128,6 +146,14 @@ plotMinMedianFollowUp <- function( minimumMedianFollowUp = minMedianFollowUp(x, calendarTime) ) plot_data <- plot_data[!is.na(plot_data$minimumMedianFollowUp), , drop = FALSE] + break_interval <- switch( + tolower(timename), + month = 6, + months = 6, + year = 0.5, + years = 0.5, + NULL + ) p <- ggplot2::ggplot( plot_data, @@ -138,11 +164,17 @@ plotMinMedianFollowUp <- function( ) + ggplot2::geom_line(linewidth = 0.8) + ggplot2::labs( - x = "Calendar time", - y = "Minimum median follow-up" + x = paste0("Calendar time (", timename, ")"), + y = paste0("Minimum median follow-up (", timename, ")") ) + ggplot2::theme_bw() + if (!is.null(break_interval)) { + p <- p + ggplot2::scale_x_continuous( + breaks = seq(0, max(calendarTime), by = break_interval) + ) + } + if (showAnalysisTimes) { analysis_time <- x$T[ x$T >= min(calendarTime) & x$T <= max(calendarTime) diff --git a/R/toInteger.R b/R/toInteger.R index 20d130f1..d422d434 100644 --- a/R/toInteger.R +++ b/R/toInteger.R @@ -46,6 +46,9 @@ #' \code{x$timing * final_events}. Interim counts are constrained to be positive #' and strictly increasing. Group sequential boundaries and spending are #' recomputed with \code{gsDesign()} at the integer event counts. +#' For a single-analysis survival design, the fixed efficacy boundary is +#' retained and its power is recomputed at the integer final event count +#' without invoking multi-look group-sequential calculations. #' #' Total sample size for a survival design is then updated under a fixed #' calendar plan (same enrollment periods, study duration, and minimum @@ -159,15 +162,43 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { test_lower_arg <- if (!is.null(x$testLower)) x$testLower else TRUE test_harm_arg <- if (!is.null(x$testHarm)) x$testHarm else TRUE - xi <- gsDesign( - k = x$k, test.type = x$test.type, n.I = counts, maxn.IPlan = counts[x$k], - alpha = x$alpha, beta = x$beta, astar = x$astar, - delta = x$delta, delta1 = x$delta1, delta0 = x$delta0, endpoint = x$endpoint, - sfu = x$upper$sf, sfupar = x$upper$param, sfl = lower_sf, sflpar = lower_par, - sfharm = sfharm_arg, sfharmparam = sfharmparam_arg, - lsTime = x$lsTime, usTime = x$usTime, - testUpper = test_upper_arg, testLower = test_lower_arg, testHarm = test_harm_arg - ) + if (x$k == 1) { + design_beta <- 1 - stats::pnorm( + x$delta * sqrt(x$n.fix) - stats::qnorm(1 - x$alpha) + ) + fixed_power <- stats::pnorm( + x$theta[length(x$theta)] * sqrt(counts[1]) - + stats::qnorm(1 - x$alpha) + ) + fixed <- gsSurvFixedDesignObject( + alpha = x$alpha, + design_beta = design_beta, + n_fix = x$n.fix, + event_count = counts[1], + delta0 = x$delta0, + delta1 = x$delta1, + theta_alt = x$theta[length(x$theta)], + power = fixed_power, + sfu = x$upper$sf, + sfupar = x$upper$param, + sided = if (!is.null(x$sided)) x$sided else 1, + tol = x$tol, + r = x$r + ) + xi <- x + for (nm in names(fixed)) xi[[nm]] <- fixed[[nm]] + class(xi) <- class(x) + } else { + xi <- gsDesign( + k = x$k, test.type = x$test.type, n.I = counts, maxn.IPlan = counts[x$k], + alpha = x$alpha, beta = x$beta, astar = x$astar, + delta = x$delta, delta1 = x$delta1, delta0 = x$delta0, endpoint = x$endpoint, + sfu = x$upper$sf, sfupar = x$upper$param, sfl = lower_sf, sflpar = lower_par, + sfharm = sfharm_arg, sfharmparam = sfharmparam_arg, + lsTime = x$lsTime, usTime = x$usTime, + testUpper = test_upper_arg, testLower = test_lower_arg, testHarm = test_harm_arg + ) + } if (max(abs(xi$n.I - counts)) > .01) warning("toInteger: check n.I input versus output") xi$n.I <- counts # ensure these are integers as they became real in gsDesign call # Non-binding futility designs have x$test.type either 4 or 6 @@ -189,10 +220,12 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { build_nsurv <- function(N_target) { # Update enrollment rates to achieve new sample size in same time inflateN <- N_target / N_continuous + calendar_minfup <- max(0, max(x$T) - sum(x$R)) # Following is adapted from gsSurv() to construct gsSurv object xx <- nSurv( lambdaC = x$lambdaC, hr = x$hr, hr0 = x$hr0, eta = x$etaC, etaE = x$etaE, - gamma = x$gamma * inflateN, R = x$R, S = x$S, T = max(x$T), minfup = x$minfup, ratio = x$ratio, + gamma = x$gamma * inflateN, R = x$R, S = x$S, T = max(x$T), + minfup = calendar_minfup, ratio = x$ratio, alpha = x$alpha, beta = NULL, sided = 1, tol = x$tol ) xx$tol <- x$tol @@ -283,7 +316,7 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { eNC <- NULL eNE <- NULL T <- NULL - for (i in 1:(x$k - 1)) { + for (i in seq_len(x$k - 1)) { xx <- tEventsIA(z, xi$timing[i], tol = x$tol) T <- c(T, xx$T) eDC <- rbind(eDC, xx$eDC) @@ -312,6 +345,7 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { xi$etaE <- z$etaE xi$variable <- x$variable xi$tol <- x$tol + if (!is.null(x$power)) xi$power <- 1 - xi$beta class(xi) <- c("gsSurv", "gsDesign") nameR <- nameperiod(cumsum(xi$R)) stratnames <- paste("Stratum", seq_len(ncol(xi$lambdaC))) diff --git a/_pkgdown.yml b/_pkgdown.yml index e798df10..41a471c6 100755 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -56,6 +56,7 @@ reference: - nEventsIA - tEventsIA - toInteger + - minMedianFollowUp - nSurvival - print.nSurvival - title: Vaccine/Prevention Efficacy diff --git a/man/gsBoundSummary.Rd b/man/gsBoundSummary.Rd index 62f6a617..f5a10b9a 100644 --- a/man/gsBoundSummary.Rd +++ b/man/gsBoundSummary.Rd @@ -208,6 +208,8 @@ R Markdown output or output to a variety of file types. \code{xprint()} is provided for LaTeX output by setting default options for \code{\link[xtable]{print.xtable}} when producing tables summarizing design bounds. +Single-analysis fixed designs are supported; interim-only characteristics +such as conditional and predictive power are omitted when \code{k = 1}. Individual transformation of z-value test statistics for interim and final analyses are obtained from \code{gsBValue()}, \code{gsDelta()}, diff --git a/man/gsSurvPower.Rd b/man/gsSurvPower.Rd index d0556db8..a3da2d6f 100644 --- a/man/gsSurvPower.Rd +++ b/man/gsSurvPower.Rd @@ -244,6 +244,10 @@ size to achieve target power, \code{gsSurvPower()} takes fixed design assumptions and computes the resulting power. It is meant to compute for a single set of assumptions at a time; different scenarios are evaluated with separate calls. +For \code{k = 1}, power is computed through the fixed-design +\code{nSurv(beta = NULL)} path. The returned object is normalized as a +single-analysis \code{gsSurv} object so it can be passed to +\code{\link{toInteger}} and \code{\link{gsBoundSummary}}. } \details{ \strong{Accepting a gsSurv object:} diff --git a/man/minMedianFollowUp.Rd b/man/minMedianFollowUp.Rd index eda7fab1..1a2d5280 100644 --- a/man/minMedianFollowUp.Rd +++ b/man/minMedianFollowUp.Rd @@ -7,7 +7,12 @@ \usage{ minMedianFollowUp(x, calendarTime = x$T) -plotMinMedianFollowUp(x, calendarTime = NULL, showAnalysisTimes = TRUE) +plotMinMedianFollowUp( + x, + calendarTime = NULL, + showAnalysisTimes = TRUE, + timename = "Months" +) } \arguments{ \item{x}{An \code{nSurv} or \code{gsSurv} object.} @@ -21,6 +26,11 @@ analysis time.} \item{showAnalysisTimes}{Logical scalar indicating whether analysis times should be marked with points. For an \code{nSurv} object, this marks the final study time.} + +\item{timename}{Nonempty character string indicating the time unit. Month +and year labels (singular or plural, case-insensitive) use x-axis breaks +every 6 months and 0.5 years, respectively. Other units use the default +\code{ggplot2} breaks.} } \value{ \code{minMedianFollowUp()} returns a numeric vector with one value @@ -42,6 +52,14 @@ first time at which expected cumulative enrollment reached one-half of the enrollment accumulated by then. Before any enrollment has occurred, the result is \code{NA_real_}. +The computation represents potential follow-up for all subjects randomized +by the requested calendar time. Conceptually, each subject contributes the +time from enrollment to that calendar time, regardless of whether the +subject would have experienced the event of interest, discontinued, or +dropped out. Event and dropout assumptions such as \code{x$lambdaC}, +\code{x$etaC}, and \code{x$etaE} therefore do not enter the calculation. +This is not observed follow-up among subjects who remain under observation. + This definition is continuous at the time enrollment completes when the enrollment rate immediately before completion is positive. The value may have kinks when an enrollment rate changes. A zero-enrollment period can diff --git a/man/nSurv.Rd b/man/nSurv.Rd index 146b941a..3950d1d7 100644 --- a/man/nSurv.Rd +++ b/man/nSurv.Rd @@ -441,6 +441,10 @@ Schoenfeld (1981), Freedman (1982), and Bernstein and Lagakos (1989) methods are also supported; see Details. \code{gsSurv()} combines \code{nSurv()} with \code{gsDesign()} to derive a group sequential design for a study with a time-to-event endpoint. +When \code{k = 1}, \code{gsSurv()} uses the fixed-design calculations from +\code{nSurv()} directly and returns a normalized single-analysis +\code{gsSurv} object for use with functions such as +\code{\link{toInteger}} and \code{\link{gsBoundSummary}}. } \details{ The Lachin and Foulkes method uses both null and alternate hypothesis diff --git a/man/toInteger.Rd b/man/toInteger.Rd index 425b4719..0d41b4db 100644 --- a/man/toInteger.Rd +++ b/man/toInteger.Rd @@ -60,6 +60,9 @@ tolerance), then derives interim integer event targets from \code{x$timing * final_events}. Interim counts are constrained to be positive and strictly increasing. Group sequential boundaries and spending are recomputed with \code{gsDesign()} at the integer event counts. +For a single-analysis survival design, the fixed efficacy boundary is +retained and its power is recomputed at the integer final event count +without invoking multi-look group-sequential calculations. Total sample size for a survival design is then updated under a fixed calendar plan (same enrollment periods, study duration, and minimum diff --git a/tests/testthat/test-k1-survival-designs.R b/tests/testthat/test-k1-survival-designs.R new file mode 100644 index 00000000..224f3c0b --- /dev/null +++ b/tests/testthat/test-k1-survival-designs.R @@ -0,0 +1,132 @@ +test_that("gsSurv k=1 uses fixed nSurv sizing", { + args <- list( + lambdaC = log(2) / 8, + hr = 0.7, + eta = 0.01, + gamma = 12, + R = 10, + T = 22, + minfup = 12, + ratio = 1.5, + alpha = 0.025, + beta = 0.1 + ) + + fixed <- do.call(nSurv, args) + design <- do.call(gsSurv, c(list(k = 1), args)) + + expect_s3_class(design, "gsSurv") + expect_s3_class(design, "gsDesign") + expect_equal(design$k, 1) + expect_equal(design$test.type, 1) + expect_equal(design$n.I, fixed$d) + expect_equal(design$n.fix, fixed$d) + expect_equal(design$beta, fixed$beta) + expect_equal(design$upper$prob[1, ], c(0.025, fixed$power)) + expect_null(design$lower) + expect_equal(dim(design$eDC), c(1, ncol(fixed$lambdaC))) + expect_match(summary(design), "fixed design with 1 analysis") +}) + +test_that("gsSurv k=1 supports fixed power calculations", { + args <- list( + lambdaC = log(2) / 8, + hr = 0.7, + gamma = 12, + R = 10, + T = 22, + minfup = 12, + beta = NULL + ) + + fixed <- do.call(nSurv, args) + design <- do.call(gsSurv, c(list(k = 1), args)) + + expect_equal(design$power, fixed$power) + expect_equal(design$beta, 1 - fixed$power) + expect_equal(design$n.I, fixed$d) +}) + +test_that("gsSurvPower k=1 agrees with nSurv fixed power", { + power_design <- gsSurvPower( + k = 1, + lambdaC = log(2) / 8, + hr = 0.7, + eta = 0.01, + gamma = 12, + R = 10, + ratio = 1.5, + plannedCalendarTime = 22, + minfup = 12 + ) + fixed <- nSurv( + lambdaC = log(2) / 8, + hr = 0.7, + eta = 0.01, + gamma = 12, + R = 10, + T = 22, + minfup = 12, + ratio = 1.5, + beta = NULL + ) + + expect_s3_class(power_design, "gsSurv") + expect_equal(power_design$test.type, 1) + expect_equal(power_design$n.I, fixed$d) + expect_equal(power_design$power, fixed$power) + expect_equal(power_design$beta, 1 - fixed$power) + expect_null(power_design$lower) + expect_false(power_design$testLower) +}) + +test_that("gsSurvPower k=1 has usable defaults", { + design <- gsSurvPower(k = 1, plannedCalendarTime = 18) + + expect_s3_class(design, "gsSurv") + expect_equal(unname(design$gamma), matrix(1)) + expect_equal(design$minfup, 6) + expect_true(design$power > 0 && design$power < 1) +}) + +test_that("toInteger supports single-analysis survival designs", { + designs <- list( + gsSurv(k = 1), + gsSurvPower(k = 1, gamma = 10, R = 12, plannedCalendarTime = 18) + ) + + for (design in designs) { + integer_design <- toInteger(design) + + expect_s3_class(integer_design, "gsSurv") + expect_equal(integer_design$k, 1) + expect_equal(integer_design$test.type, 1) + expect_equal(integer_design$n.I, round(integer_design$n.I)) + expect_true(integer_design$n.I >= round(design$n.I)) + expect_equal(nrow(integer_design$eDC), 1) + expect_true(inherits(gsBoundSummary(integer_design), "gsBoundSummary")) + } +}) + +test_that("gsBoundSummary omits interim-only rows for k=1", { + design <- gsSurv(k = 1) + + summary_default <- gsBoundSummary(design) + summary_full <- gsBoundSummary(design, exclude = NULL) + summary_alpha <- gsBoundSummary(design, alpha = c(0.01, 0.05)) + summary_pos <- gsBoundSummary(design, POS = TRUE) + + expect_s3_class(summary_default, "gsBoundSummary") + expect_s3_class(summary_pos, "gsBoundSummary") + expect_true(any(grepl("Trial POS:", summary_pos$Analysis, fixed = TRUE))) + expect_false(any(c("CP", "CP H1", "PP") %in% summary_full$Value)) + expect_equal( + names(summary_alpha), + c("Analysis", "Value", "\u03b1=0.025", "\u03b1=0.01", "\u03b1=0.05") + ) + expect_equal( + unname(as.numeric(summary_alpha[summary_alpha$Value == "Z", 3:5])), + qnorm(1 - c(0.025, 0.01, 0.05)), + tolerance = 1e-4 + ) +}) diff --git a/tests/testthat/test-minMedianFollowUp.R b/tests/testthat/test-minMedianFollowUp.R index e8b8d312..4d8bf513 100644 --- a/tests/testthat/test-minMedianFollowUp.R +++ b/tests/testthat/test-minMedianFollowUp.R @@ -88,6 +88,39 @@ test_that("plotMinMedianFollowUp plots the trajectory and analysis times", { expect_equal(max(p_line$data$calendarTime), 18) }) +test_that("plotMinMedianFollowUp uses unit-specific x-axis breaks", { + x_months <- nSurv(gamma = 10, R = 12, T = 30, minfup = 18) + p_months <- plotMinMedianFollowUp(x_months) + + expect_equal( + p_months$scales$get_scales("x")$breaks, + seq(0, 30, by = 6) + ) + expect_equal(p_months$labels$x, "Calendar time (Months)") + expect_equal(p_months$labels$y, "Minimum median follow-up (Months)") + + x_years <- nSurv(gamma = 120, R = 1, T = 2.5, minfup = 1.5) + p_years <- plotMinMedianFollowUp(x_years, timename = "Years") + + expect_equal( + p_years$scales$get_scales("x")$breaks, + seq(0, 2.5, by = 0.5) + ) + expect_equal(p_years$labels$x, "Calendar time (Years)") + expect_equal(p_years$labels$y, "Minimum median follow-up (Years)") + + p_month <- plotMinMedianFollowUp(x_months, timename = "Month") + expect_equal( + p_month$scales$get_scales("x")$breaks, + seq(0, 30, by = 6) + ) + + p_weeks <- plotMinMedianFollowUp(x_months, timename = "Weeks") + expect_null(p_weeks$scales$get_scales("x")) + expect_equal(p_weeks$labels$x, "Calendar time (Weeks)") + expect_equal(p_weeks$labels$y, "Minimum median follow-up (Weeks)") +}) + test_that("plotMinMedianFollowUp validates inputs", { x <- gsSurv(k = 2, gamma = 10, R = 12, T = 30, minfup = 18) @@ -100,4 +133,16 @@ test_that("plotMinMedianFollowUp validates inputs", { plotMinMedianFollowUp(x, calendarTime = -1), "finite, nonnegative" ) + expect_error( + plotMinMedianFollowUp(x, timename = ""), + "nonempty character scalar" + ) + expect_error( + plotMinMedianFollowUp(x, timename = c("Months", "Years")), + "nonempty character scalar" + ) + expect_error( + plotMinMedianFollowUp(x, timename = 1), + "nonempty character scalar" + ) }) From 45f7eea8d3101e49d23aada7e26e0934bf930659 Mon Sep 17 00:00:00 2001 From: keaven Date: Thu, 30 Jul 2026 06:26:43 -0400 Subject: [PATCH 3/4] Support integer fixed survival designs (#289) --- NEWS.md | 5 +- R/gsMethods.R | 16 ++++--- R/toInteger.R | 47 +++++++++++++++++-- man/toInteger.Rd | 14 ++++-- .../test-independent-test-toInteger.R | 29 +++++++++++- tests/testthat/test-k1-survival-designs.R | 4 +- 6 files changed, 99 insertions(+), 16 deletions(-) diff --git a/NEWS.md b/NEWS.md index b17aaaa6..245453b9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -22,7 +22,10 @@ - Single-analysis survival designs now use a fixed-design `nSurv()` path in `gsSurv(k = 1)` and `gsSurvPower(k = 1)`. The resulting objects work with `toInteger()` and `gsBoundSummary()`, including alternate-alpha summaries - (#289). + and use all alpha at the sole analysis without displaying an irrelevant + spending function in `summary()`. An `nSurv()` object can now also be passed + directly to `toInteger()` and is returned as an `nSurv` object with integer + event and sample-size targets (#289). - Power plots for test types 7 and 8 now treat crossing the futility threshold as the union of futility-only and harm stops. The separate harm curve remains harm-only, and the mutually exclusive probabilities stored on the design are diff --git a/R/gsMethods.R b/R/gsMethods.R index 857f929f..8a39d814 100644 --- a/R/gsMethods.R +++ b/R/gsMethods.R @@ -124,14 +124,18 @@ summary.gsDesign <- function(object, information = FALSE, timeunit = "months", . sep = "" ) } - if (object$test.type == 2) { - out <- paste(out, ". Bounds derived using a ", sep = "") + if ("gsSurv" %in% class(object) && object$k == 1) { + out <- paste0(out, ".") } else { - out <- paste(out, ". Efficacy bounds derived using a", sep = "") + if (object$test.type == 2) { + out <- paste(out, ". Bounds derived using a ", sep = "") + } else { + out <- paste(out, ". Efficacy bounds derived using a", sep = "") + } + out <- paste(out, " ", summary(object$upper), ".", sep = "") + if (object$test.type > 2) out <- paste(out, " Futility bounds derived using a ", summary(object$lower), ".", sep = "") + if (object$test.type %in% c(7, 8)) out <- paste(out, " Harm bounds derived using a ", summary(object$harm), ".", sep = "") } - out <- paste(out, " ", summary(object$upper), ".", sep = "") - if (object$test.type > 2) out <- paste(out, " Futility bounds derived using a ", summary(object$lower), ".", sep = "") - if (object$test.type %in% c(7, 8)) out <- paste(out, " Harm bounds derived using a ", summary(object$harm), ".", sep = "") return(out) } diff --git a/R/toInteger.R b/R/toInteger.R index d422d434..3d3a0882 100644 --- a/R/toInteger.R +++ b/R/toInteger.R @@ -1,7 +1,7 @@ #' Translate group sequential design to integer events (survival designs) #' or sample size (other designs) #' -#' @param x An object of class \code{gsDesign} or \code{gsSurv}. +#' @param x An object of class \code{gsDesign}, \code{gsSurv}, or \code{nSurv}. #' @param ratio Usually corresponds to experimental:control sample size ratio. #' If an integer is provided, rounding is done to a multiple of #' \code{ratio + 1}. See details. @@ -17,8 +17,10 @@ #' See details. #' #' @return Output is an object of the same class as input \code{x}; i.e., -#' \code{gsDesign} with integer vector for \code{n.I} or \code{gsSurv} -#' with integer vector \code{n.I} and integer total sample size. See details. +#' \code{gsDesign} with integer vector for \code{n.I}, \code{gsSurv} +#' with integer vector \code{n.I} and integer total sample size, or +#' \code{nSurv} with integer \code{d} and integer total sample size \code{n}. +#' See details. #' #' @details #' It is useful to explicitly provide the argument \code{ratio} when a @@ -39,6 +41,12 @@ #' For 3:2 randomization, \code{ratio = 4} would ensure rounding sample size #' to a multiple of 5. #' +#' An \code{nSurv} object is converted through the corresponding +#' single-analysis \code{gsSurv} representation and returned as an +#' \code{nSurv} object. Its required event count \code{d} is rounded in the +#' same way as the final event count for a \code{gsSurv} object, and its total +#' sample size \code{n} is rounded according to \code{ratio}. +#' #' For a \code{gsSurv} object, \code{x$n.I} is an event-count schedule. #' \code{toInteger()} rounds the final planned event count (up when #' \code{roundUpFinal = TRUE}; otherwise to nearest integer, with a 0.01 @@ -102,7 +110,38 @@ #' # with final event count rounded up by default. #' toInteger(x) toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { - if (!inherits(x, "gsDesign")) stop("must have class gsDesign as input") + is_nsurv <- inherits(x, "nSurv") && !inherits(x, "gsDesign") + if (!inherits(x, "gsDesign") && !is_nsurv) { + stop("must have class gsDesign or nSurv as input") + } + if (is_nsurv) { + original <- x + x <- asGsSurvFixedDesign( + x, + tol = .Machine$double.eps^0.25, + call = x$call, + inputs = x$inputs + ) + integer_design <- toInteger( + x, + ratio = ratio, + roundUpFinal = roundUpFinal + ) + result <- original + event_fields <- c("eDC", "eDE", "eDC0", "eDE0", "eNC", "eNE") + for (nm in event_fields) result[[nm]] <- as.vector(integer_design[[nm]]) + plan_fields <- c( + "lambdaC", "etaC", "etaE", "gamma", "R", "S", "T", "minfup", + "variable", "method" + ) + for (nm in plan_fields) result[[nm]] <- integer_design[[nm]] + result$d <- integer_design$n.I[1] + result$n <- sum(result$eNC + result$eNE) + result$beta <- integer_design$beta + result$power <- 1 - result$beta + class(result) <- class(original) + return(result) + } if (!(isInteger(ratio) && ratio >= 0)){ message("toInteger: rounding done to nearest integer since ratio was not specified as postive integer .") ratio <- 0 diff --git a/man/toInteger.Rd b/man/toInteger.Rd index 0d41b4db..ff350138 100644 --- a/man/toInteger.Rd +++ b/man/toInteger.Rd @@ -8,7 +8,7 @@ or sample size (other designs)} toInteger(x, ratio = x$ratio, roundUpFinal = TRUE) } \arguments{ -\item{x}{An object of class \code{gsDesign} or \code{gsSurv}.} +\item{x}{An object of class \code{gsDesign}, \code{gsSurv}, or \code{nSurv}.} \item{ratio}{Usually corresponds to experimental:control sample size ratio. If an integer is provided, rounding is done to a multiple of @@ -27,8 +27,10 @@ See details.} } \value{ Output is an object of the same class as input \code{x}; i.e., - \code{gsDesign} with integer vector for \code{n.I} or \code{gsSurv} - with integer vector \code{n.I} and integer total sample size. See details. + \code{gsDesign} with integer vector for \code{n.I}, \code{gsSurv} + with integer vector \code{n.I} and integer total sample size, or + \code{nSurv} with integer \code{d} and integer total sample size \code{n}. + See details. } \description{ Translate group sequential design to integer events (survival designs) @@ -53,6 +55,12 @@ randomization ratio. For 3:2 randomization, \code{ratio = 4} would ensure rounding sample size to a multiple of 5. +An \code{nSurv} object is converted through the corresponding +single-analysis \code{gsSurv} representation and returned as an +\code{nSurv} object. Its required event count \code{d} is rounded in the +same way as the final event count for a \code{gsSurv} object, and its total +sample size \code{n} is rounded according to \code{ratio}. + For a \code{gsSurv} object, \code{x$n.I} is an event-count schedule. \code{toInteger()} rounds the final planned event count (up when \code{roundUpFinal = TRUE}; otherwise to nearest integer, with a 0.01 diff --git a/tests/testthat/test-independent-test-toInteger.R b/tests/testthat/test-independent-test-toInteger.R index 020890a5..e8851525 100644 --- a/tests/testthat/test-independent-test-toInteger.R +++ b/tests/testthat/test-independent-test-toInteger.R @@ -70,6 +70,33 @@ test_that("toInteger() handles gsSurv object integer conversion correctly", { expect_gte(result_nearest_n + 1e-5, result_nearest$n.I[result_nearest$k]) }) +test_that("toInteger() handles nSurv objects", { + x <- nSurv( + lambdaC = log(2) / 8, + hr = 0.7, + eta = 0.01, + gamma = 12, + R = 10, + T = 22, + minfup = 12, + ratio = 1, + alpha = 0.025, + beta = 0.1 + ) + + result <- toInteger(x) + result_nearest <- toInteger(x, roundUpFinal = FALSE) + + expect_s3_class(result, "nSurv") + expect_false(inherits(result, "gsDesign")) + expect_equal(result$d, ceiling(x$d)) + expect_equal(result_nearest$d, round(x$d)) + expect_equal(result$n %% 2, 0) + expect_equal(result$n, sum(result$eNC + result$eNE)) + expect_equal(result$power, 1 - result$beta) + expect_identical(result$call, x$call) +}) + test_that("toInteger() handles edge case where no rounding is needed", { x <- gsDesign(k = 3, test.type = 1, alpha = 0.05, beta = 0.2, n.fix = 150) @@ -125,7 +152,7 @@ test_that("toInteger() prints a message for invalid ratio values", { test_that("toInteger() throws an error when input is not a gsDesign object", { invalid_object <- data.frame(a = 1, b = 2) # Not a gsDesign object - expect_error(toInteger(invalid_object), "must have class gsDesign as input") + expect_error(toInteger(invalid_object), "must have class gsDesign or nSurv as input") }) EXTREMEZ_TI <- 20 diff --git a/tests/testthat/test-k1-survival-designs.R b/tests/testthat/test-k1-survival-designs.R index 224f3c0b..df1ec124 100644 --- a/tests/testthat/test-k1-survival-designs.R +++ b/tests/testthat/test-k1-survival-designs.R @@ -13,7 +13,7 @@ test_that("gsSurv k=1 uses fixed nSurv sizing", { ) fixed <- do.call(nSurv, args) - design <- do.call(gsSurv, c(list(k = 1), args)) + design <- do.call(gsSurv, c(list(k = 1, sfu = sfPower, sfupar = 3), args)) expect_s3_class(design, "gsSurv") expect_s3_class(design, "gsDesign") @@ -22,10 +22,12 @@ test_that("gsSurv k=1 uses fixed nSurv sizing", { expect_equal(design$n.I, fixed$d) expect_equal(design$n.fix, fixed$d) expect_equal(design$beta, fixed$beta) + expect_equal(design$upper$spend, args$alpha) expect_equal(design$upper$prob[1, ], c(0.025, fixed$power)) expect_null(design$lower) expect_equal(dim(design$eDC), c(1, ncol(fixed$lambdaC))) expect_match(summary(design), "fixed design with 1 analysis") + expect_false(grepl("spending function", summary(design), fixed = TRUE)) }) test_that("gsSurv k=1 supports fixed power calculations", { From dd2d68481540d5793eb2774c04f39f7d9b2239e8 Mon Sep 17 00:00:00 2001 From: keaven Date: Thu, 30 Jul 2026 09:42:41 -0400 Subject: [PATCH 4/4] Preserve exact survival targets (#290, #294) --- DESCRIPTION | 2 +- NEWS.md | 6 +++++ R/gsMethods.R | 16 ++++++++---- R/gsSurvPower.R | 41 +++++++++++++++++++++++++++--- R/gsUtilities.R | 10 ++++++++ tests/testthat/test-gsSurvPower.R | 42 +++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 10 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index fd11924f..fbc13d40 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: gsDesign -Version: 3.11.0.9001 +Version: 3.11.0.9002 Title: Group Sequential Design Authors@R: c( person("Keaven", "Anderson", email = "keaven_anderson@merck.com", role = c("aut", "cre")), diff --git a/NEWS.md b/NEWS.md index 245453b9..02d25f41 100644 --- a/NEWS.md +++ b/NEWS.md @@ -19,6 +19,12 @@ ## Bug fixes +- `gsSurvPower()` now retains exact event totals when `targetEvents` determines + an analysis, rather than exposing small root-finding residuals that could + make `gsBoundSummary()` round an integer event target up by one (#294). +- Survival sample-size outputs now normalize machine-precision representations + of integers before applying display rounding, so `gsSurvPower()` and + `gsBoundSummary()` preserve exact arm and total sample sizes (#290). - Single-analysis survival designs now use a fixed-design `nSurv()` path in `gsSurv(k = 1)` and `gsSurvPower(k = 1)`. The resulting objects work with `toInteger()` and `gsBoundSummary()`, including alternate-alpha summaries diff --git a/R/gsMethods.R b/R/gsMethods.R index 8a39d814..15e30b54 100644 --- a/R/gsMethods.R +++ b/R/gsMethods.R @@ -103,11 +103,14 @@ summary.gsDesign <- function(object, information = FALSE, timeunit = "months", . sep = "" ) } else if ("gsSurv" %in% class(object)) { + experimental_n <- gsRoundNearInteger(rowSums(object$eNE)) + control_n <- gsRoundNearInteger(rowSums(object$eNC)) out <- paste(out, "time-to-event outcome with sample size ", - ifelse(object$ratio == 1, 2 * ceiling(rowSums(object$eNE))[object$k], - (ceiling(rowSums(object$eNE)) + ceiling(rowSums(object$eNC)))[object$k] + ifelse(object$ratio == 1, 2 * ceiling(experimental_n)[object$k], + (ceiling(experimental_n) + ceiling(control_n))[object$k] ), - " and ", ceiling(object$n.I[object$k]), " events required, ", + " and ", ceiling(gsRoundNearInteger(object$n.I[object$k])), + " events required, ", sep = "" ) } else if (information) { @@ -578,8 +581,11 @@ gsBoundSummary0 <- function( } } else { nstat <- 4 - statframe[statframe$Value == statframe$Value[3], ]$Analysis <- paste("Events:", ceiling(x$n.I)) - if (x$ratio == 1) N <- 2 * ceiling(rowSums(x$eNE)) else N <- ceiling(rowSums(x$eNE)) + ceiling(rowSums(x$eNC)) + event_counts <- gsRoundNearInteger(x$n.I) + statframe[statframe$Value == statframe$Value[3], ]$Analysis <- paste("Events:", ceiling(event_counts)) + experimental_n <- gsRoundNearInteger(rowSums(x$eNE)) + control_n <- gsRoundNearInteger(rowSums(x$eNC)) + if (x$ratio == 1) N <- 2 * ceiling(experimental_n) else N <- ceiling(experimental_n) + ceiling(control_n) Time <- round(x$T, tdigits) statframe[statframe$Value == statframe$Value[4], ]$Analysis <- paste(timename, ": ", as.character(Time), sep = "") } diff --git a/R/gsSurvPower.R b/R/gsSurvPower.R index abffe56b..7410526a 100644 --- a/R/gsSurvPower.R +++ b/R/gsSurvPower.R @@ -773,13 +773,21 @@ gsSurvPower <- function( } if (objective(search_upper_bound) < 0) { warning("Target ", round(target), " events may not be achievable") - return(search_upper_bound) + return(list(time = search_upper_bound, achievable = FALSE)) } - if (objective(0.001) >= 0) return(0.001) - uniroot(objective, c(0.001, search_upper_bound), tol = tol)$root + if (objective(0.001) >= 0) { + return(list(time = 0.001, achievable = TRUE)) + } + list( + time = uniroot( + objective, c(0.001, search_upper_bound), tol = tol + )$root, + achievable = TRUE + ) } analysis_time <- numeric(analysis_count) + target_determines_analysis <- rep(FALSE, analysis_count) for (analysis_index in seq_len(analysis_count)) { floor_times <- numeric(0) @@ -809,7 +817,10 @@ gsSurvPower <- function( floor_time <- if (length(floor_times) > 0) max(floor_times) else 0.001 if (!is.na(total_event_targets[analysis_index])) { - event_time <- find_time_for_events(total_event_targets[analysis_index]) + event_solution <- find_time_for_events( + total_event_targets[analysis_index] + ) + event_time <- event_solution$time if (event_time <= floor_time) { analysis_time[analysis_index] <- floor_time } else if (!is.na(max_extension[analysis_index])) { @@ -821,6 +832,7 @@ gsSurvPower <- function( analysis_time[analysis_index] <- event_time } } else { + event_solution <- NULL analysis_time[analysis_index] <- floor_time } @@ -837,6 +849,10 @@ gsSurvPower <- function( analysis_time[analysis_index - 1] + max_extension[analysis_index] ) } + target_determines_analysis[analysis_index] <- + !is.null(event_solution) && + event_solution$achievable && + analysis_time[analysis_index] == event_solution$time } control_events <- experimental_events <- NULL @@ -850,7 +866,24 @@ gsSurvPower <- function( experimental_enrollment <- rbind(experimental_enrollment, expected_counts$eNE) } + # Retain exact event targets instead of exposing small root-solver residuals. + # Adjust one component so component counts remain consistent with the total. + target_rows <- which(target_determines_analysis) + if (length(target_rows) > 0) { + last_stratum <- ncol(experimental_events) + for (analysis_index in target_rows) { + residual <- total_event_targets[analysis_index] - + sum(control_events[analysis_index, ]) - + sum(experimental_events[analysis_index, ]) + experimental_events[analysis_index, last_stratum] <- + experimental_events[analysis_index, last_stratum] + residual + } + } + + control_enrollment <- gsRoundNearInteger(control_enrollment) + experimental_enrollment <- gsRoundNearInteger(experimental_enrollment) total_events <- rowSums(control_events) + rowSums(experimental_events) + total_events[target_rows] <- total_event_targets[target_rows] list( analysis_time = analysis_time, diff --git a/R/gsUtilities.R b/R/gsUtilities.R index 7385b024..c92040a9 100644 --- a/R/gsUtilities.R +++ b/R/gsUtilities.R @@ -231,6 +231,16 @@ checkVector <- function(x, isType = "numeric", ..., length = NULL) { # isInteger function [sinew] ---- isInteger <- function(x) all(is.numeric(x)) && all(round(x, 0) == x) +# Replace floating-point representations of integers with the exact integer +# value while leaving genuinely fractional values unchanged. +gsRoundNearInteger <- function(x, tol = sqrt(.Machine$double.eps)) { + rounded <- round(x) + close <- is.finite(x) & + abs(x - rounded) <= tol * pmax(1, abs(x)) + x[close] <- rounded[close] + x +} + checkMD5 <- function(package = "gsDesign", dir) { if (missing(dir)) { dir <- find.package(package, quiet = TRUE) diff --git a/tests/testthat/test-gsSurvPower.R b/tests/testthat/test-gsSurvPower.R index 2a6688f4..a5285022 100644 --- a/tests/testthat/test-gsSurvPower.R +++ b/tests/testthat/test-gsSurvPower.R @@ -53,6 +53,48 @@ test_that("gsSurvPower works with targetEvents", { expect_equal(pwr$n.I, events) }) +test_that("gsSurvPower preserves integer sample size and event targets", { + target_events <- c(150, 200, 350) + pwr <- gsSurvPower( + k = 3, + test.type = 1, + alpha = 0.025, + sided = 1, + sfu = sfLDOF, + sfupar = NULL, + spending = "information", + lambdaC = log(2) / 15, + hr = 0.7, + hr0 = 1, + eta = 0.001, + gamma = c(1, 2, 3, 4) * 500 / + sum(c(1, 2, 3, 4) * c(2, 2, 2, 6)), + R = c(2, 2, 2, 6), + targetEvents = target_events, + minfup = 18, + ratio = 1.5, + testUpper = TRUE, + testLower = FALSE, + testHarm = FALSE, + method = "LachinFoulkes" + ) + + expect_identical(pwr$n.I, target_events) + expect_equal(rowSums(pwr$eDC) + rowSums(pwr$eDE), target_events) + expect_identical(as.vector(pwr$eNC), rep(200, 3)) + expect_identical(as.vector(pwr$eNE), rep(300, 3)) + + summary_analysis <- gsBoundSummary(pwr)$Analysis + expect_identical( + summary_analysis[grepl("^N:", summary_analysis)], + rep("N: 500", 3) + ) + expect_identical( + summary_analysis[grepl("^Events:", summary_analysis)], + paste("Events:", target_events) + ) +}) + test_that("gsSurvPower works without x (all parameters specified)", { pwr <- gsSurvPower( k = 2, test.type = 1, alpha = 0.025, sided = 1,