diff --git a/.agents/skills/survival-design-routing/SKILL.md b/.agents/skills/survival-design-routing/SKILL.md index bcf919cf..f33785b8 100644 --- a/.agents/skills/survival-design-routing/SKILL.md +++ b/.agents/skills/survival-design-routing/SKILL.md @@ -21,3 +21,25 @@ description: Choose the appropriate gsDesign survival or exact-binomial workflow otherwise keep the appropriate survival design function. - Set `ratio` explicitly when randomization is specified; `ratio = 1` means equal experimental:control randomization. +- For the common four-period enrollment ramp-up, use `gamma = 1:4` for equal + relative rate increments and `R = rep(1, 4)`. The final period is extended + as needed by the selected duration solve. +- With `T` and `minfup` specified, `gsSurv()` and `gsSurvCalendar()` fix total + study and minimum follow-up duration, scale `gamma` proportionally to power + the trial, and extend the final `R` period to `T - minfup`. +- With `T = NULL` and `minfup` specified, keep `gamma` fixed and solve the + enrollment duration by extending the final `R` period. +- With both `T = NULL` and `minfup = NULL`, keep enrollment rates and duration + fixed and solve follow-up duration. Warn that this can be infeasible when + the fixed enrollment plan is always over- or under-powered. +- Use `gsSurvPower()` when enrollment, follow-up, and analysis timing are fixed + and the objective is achieved power rather than a powered sample-size plan. +- Use `toInteger()` after design derivation when integer event targets and an + allocation-compatible total enrollment are needed. +- For a `gsSurv` object, use `N` for cumulative total expected enrollment at + each analysis. Use `eNC` and `eNE` for control and experimental enrollment by + stratum. +- For an `nSurv` object, scalar `n` and scalar `N` are identical total expected + enrollment values. Accept either name without converting existing code. +- For stratified survival designs, supply matrices whose columns are strata; + align the columns of `lambdaC`, `eta`, `etaE`, and `gamma`. diff --git a/DESCRIPTION b/DESCRIPTION index fbc13d40..22a35daf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: gsDesign -Version: 3.11.0.9002 +Version: 3.11.0.9003 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 02d25f41..3bd2145b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,14 @@ ## New features +- All `gsSurv` objects now include `N`, the cumulative total expected + enrollment at each analysis. `nSurv` objects retain scalar `n` and also + return identical scalar `N` as a non-breaking alias (#299). +- Added survival enrollment-planning documentation for four-period ramp-up, + the three combinations of fixed or solved enrollment and follow-up duration, + calendar-time analyses, power sensitivity, integer conversion, and + stratified designs. `gsSurv(T = NULL, minfup = ...)` now consistently keeps + enrollment rates fixed and solves enrollment duration (#300). - 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. The plot accepts arbitrary diff --git a/R/gsSurv-nSurv.R b/R/gsSurv-nSurv.R index 60aeb291..4ade6155 100644 --- a/R/gsSurv-nSurv.R +++ b/R/gsSurv-nSurv.R @@ -232,6 +232,8 @@ #' \item{hr0}{As input.} #' \item{n}{Total expected sample size corresponding to output accrual rates #' and durations.} +#' \item{N}{Identical to \code{n}; provided as a non-breaking alias for total +#' expected sample size.} #' \item{d}{Total expected number of events under the alternate #' hypothesis.} #' \item{tol}{As input, except when not used in computations in @@ -277,6 +279,8 @@ #' \item{minfup}{As input.} #' \item{hr}{As input.} #' \item{hr0}{As input.} +#' \item{N}{A vector containing cumulative total expected enrollment at each +#' analysis.} #' \item{eNC}{Total expected sample size corresponding to output accrual rates #' and durations.} #' \item{eNE}{Total expected sample size corresponding to output accrual rates @@ -325,8 +329,10 @@ #' #' @author Keaven Anderson \email{keaven_anderson@@merck.com} #' -#' @seealso \code{vignette("gsSurvBasicExamples", package = "gsDesign")} for -#' basic survival sample size examples, \code{vignette("SurvivalOverview", +#' @seealso \code{vignette("SurvivalEnrollmentPlanning", package = "gsDesign")} +#' for enrollment ramp-up and duration planning, +#' \code{vignette("gsSurvBasicExamples", package = "gsDesign")} for basic +#' survival sample size examples, \code{vignette("SurvivalOverview", #' package = "gsDesign")} for method background, and #' \code{vignette("SeqDesignSurvival", package = "gsDesign")} for a SAS PROC #' SEQDESIGN translation example. @@ -412,6 +418,21 @@ #' ) |> #' print() #' +#' # Common four-period enrollment ramp-up. With T and minfup fixed, the +#' # relative gamma pattern is scaled to power the trial, and the final R +#' # period is extended so enrollment lasts T - minfup. +#' ramp_rate <- gsSurv( +#' T = 26, minfup = 12, +#' gamma = 1:4, R = rep(1, 4) +#' ) +#' +#' # With T = NULL and minfup fixed, gamma stays fixed and the final +#' # enrollment period is extended to obtain the required sample size. +#' ramp_duration <- gsSurv( +#' T = NULL, minfup = 12, +#' gamma = 1:4, R = rep(1, 4) +#' ) +#' #' # Vary minimum follow-up duration minfup to obtain power #' # Accrual duration R rate gamma are fixed and will not change on output. #' # Trial duration T and minimum follow-up minfup are input as NULL @@ -575,6 +596,7 @@ nSurv <- function( xx$method <- method xx$call <- match.call() xx$inputs <- input_vals + xx$N <- xx$n return(xx) } diff --git a/R/gsSurv-utils.R b/R/gsSurv-utils.R index 18ad04f0..b80d6840 100644 --- a/R/gsSurv-utils.R +++ b/R/gsSurv-utils.R @@ -67,6 +67,12 @@ validate_survival_timing_inputs <- function(R, T, minfup, call = "nSurv") { invisible(TRUE) } +# Add cumulative total enrollment at each analysis to a gsSurv object. +gsSurvAddN <- function(x) { + x$N <- rowSums(as.matrix(x$eNC)) + rowSums(as.matrix(x$eNE)) + x +} + # Construct the gsDesign portion of a single-analysis survival design without # calling gsDesign(), whose group-sequential validation requires k >= 2. gsSurvFixedDesignObject <- function( @@ -185,5 +191,5 @@ asGsSurvFixedDesign <- function( design$inputs <- inputs class(design) <- c("gsSurv", "gsDesign") - design + gsSurvAddN(design) } diff --git a/R/gsSurv.R b/R/gsSurv.R index f17f15c2..f1f690a8 100644 --- a/R/gsSurv.R +++ b/R/gsSurv.R @@ -99,13 +99,6 @@ gsSurv <- function( )$root T <- sum(R) + minfup } - # Preserve the historical Lachin-Foulkes default: with fixed follow-up and - # T = NULL, keep R fixed and vary the accrual rate. - if (method == "LachinFoulkes" && is.null(T) && !is.null(minfup) && - !is.null(R) && length(R) > 0 && - !is.null(gamma) && length(gamma) > 0) { - T <- sum(R) + minfup - } x <- nSurv( lambdaC = lambdaC, hr = hr, hr0 = hr0, eta = eta, etaE = etaE, gamma = gamma, R = R, S = S, T = T, minfup = minfup, ratio = ratio, @@ -176,7 +169,7 @@ gsSurv <- function( colnames(y$etaE) <- stratnames rownames(y$gamma) <- nameR colnames(y$gamma) <- stratnames - return(y) + return(gsSurvAddN(y)) } # gsnSurv function [sinew] ---- diff --git a/R/gsSurvCalendar.R b/R/gsSurvCalendar.R index 7f00c6e3..d0eec26f 100644 --- a/R/gsSurvCalendar.R +++ b/R/gsSurvCalendar.R @@ -33,8 +33,10 @@ #' #' @rdname gsSurvCalendar #' -#' @seealso \code{vignette("SeqDesignSurvival", package = "gsDesign")} for a -#' SAS PROC SEQDESIGN sample size translation example and +#' @seealso \code{vignette("SurvivalEnrollmentPlanning", package = "gsDesign")} +#' for enrollment ramp-up and duration planning, +#' \code{vignette("SeqDesignSurvival", package = "gsDesign")} for a SAS +#' PROC SEQDESIGN sample size translation example and #' \code{vignette("gsSurvPower", package = "gsDesign")} for power #' calculations with fixed calendar analysis assumptions. #' @@ -71,6 +73,12 @@ #' y$usTime #' # Actual calendar fraction from design after toInteger() conversion #' y$T / max(y$T) +#' +#' # Four-period enrollment ramp-up with fixed study duration and follow-up. +#' ramp_calendar <- gsSurvCalendar( +#' calendarTime = c(12, 18, 26), minfup = 12, +#' gamma = 1:4, R = rep(1, 4) +#' ) gsSurvCalendar <- function( test.type = 4, alpha = 0.025, sided = 1, beta = 0.1, astar = 0, sfu = gsDesign::sfHSD, sfupar = -4, @@ -184,5 +192,5 @@ gsSurvCalendar <- function( colnames(y$etaE) <- stratnames rownames(y$gamma) <- nameR colnames(y$gamma) <- stratnames - return(y) + return(gsSurvAddN(y)) } diff --git a/R/gsSurvPower.R b/R/gsSurvPower.R index 7410526a..c7d489fe 100644 --- a/R/gsSurvPower.R +++ b/R/gsSurvPower.R @@ -247,6 +247,7 @@ #' \item{T}{Calendar times of analyses.} #' \item{eDC, eDE}{Expected events by stratum (control, experimental).} #' \item{eNC, eNE}{Expected sample sizes by stratum (control, experimental).} +#' \item{N}{Cumulative total expected enrollment at each analysis.} #' \item{upper, lower}{Bounds and crossing probabilities.} #' \item{harm}{Harm-bound information when \code{test.type} is 7 or 8.} #' \item{en, theta}{Expected sample size summary and drift values returned by @@ -1236,5 +1237,5 @@ gsSurvPower <- function( result$beta <- 1 - result$power class(result) <- c("gsSurv", "gsDesign") - .gsSurvPower_label_output_matrices(result) + gsSurvAddN(.gsSurvPower_label_output_matrices(result)) } diff --git a/R/toInteger.R b/R/toInteger.R index 3d3a0882..2b92c4f1 100644 --- a/R/toInteger.R +++ b/R/toInteger.R @@ -137,6 +137,7 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { 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$N <- result$n result$beta <- integer_design$beta result$power <- 1 - result$beta class(result) <- class(original) @@ -402,6 +403,7 @@ toInteger <- function(x, ratio = x$ratio, roundUpFinal = TRUE) { rownames(xi$gamma) <- nameR colnames(xi$gamma) <- stratnames } + if (inherits(xi, "gsSurv")) xi <- gsSurvAddN(xi) return(xi) } diff --git a/_pkgdown.yml b/_pkgdown.yml index 41a471c6..68a244a5 100755 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -154,6 +154,7 @@ articles: contents: - SurvivalOverview - gsSurvBasicExamples + - SurvivalEnrollmentPlanning - gsSurvPower - SeqDesignSurvival - toInteger diff --git a/man/gsSurvCalendar.Rd b/man/gsSurvCalendar.Rd index 228de6db..f69b691a 100644 --- a/man/gsSurvCalendar.Rd +++ b/man/gsSurvCalendar.Rd @@ -235,6 +235,12 @@ gsBoundSummary(y) y$usTime # Actual calendar fraction from design after toInteger() conversion y$T / max(y$T) + +# Four-period enrollment ramp-up with fixed study duration and follow-up. +ramp_calendar <- gsSurvCalendar( + calendarTime = c(12, 18, 26), minfup = 12, + gamma = 1:4, R = rep(1, 4) +) } \references{ Lan KKG and DeMets DL (1983), Discrete Sequential Boundaries for Clinical @@ -250,8 +256,10 @@ Freedman LS (1982), Tables of the Number of Patients Required in Clinical Trials Using the Logrank Test. \emph{Statistics in Medicine}, 1, 121-129. } \seealso{ -\code{vignette("SeqDesignSurvival", package = "gsDesign")} for a - SAS PROC SEQDESIGN sample size translation example and +\code{vignette("SurvivalEnrollmentPlanning", package = "gsDesign")} + for enrollment ramp-up and duration planning, + \code{vignette("SeqDesignSurvival", package = "gsDesign")} for a SAS + PROC SEQDESIGN sample size translation example and \code{vignette("gsSurvPower", package = "gsDesign")} for power calculations with fixed calendar analysis assumptions. diff --git a/man/gsSurvPower.Rd b/man/gsSurvPower.Rd index a3da2d6f..6806b4d2 100644 --- a/man/gsSurvPower.Rd +++ b/man/gsSurvPower.Rd @@ -220,6 +220,7 @@ An object of class \code{c("gsSurv", "gsDesign")} containing: \item{T}{Calendar times of analyses.} \item{eDC, eDE}{Expected events by stratum (control, experimental).} \item{eNC, eNE}{Expected sample sizes by stratum (control, experimental).} +\item{N}{Cumulative total expected enrollment at each analysis.} \item{upper, lower}{Bounds and crossing probabilities.} \item{harm}{Harm-bound information when \code{test.type} is 7 or 8.} \item{en, theta}{Expected sample size summary and drift values returned by diff --git a/man/nSurv.Rd b/man/nSurv.Rd index 3950d1d7..494b58a7 100644 --- a/man/nSurv.Rd +++ b/man/nSurv.Rd @@ -342,6 +342,8 @@ analyses is still displayed.} \item{hr0}{As input.} \item{n}{Total expected sample size corresponding to output accrual rates and durations.} + \item{N}{Identical to \code{n}; provided as a non-breaking alias for total + expected sample size.} \item{d}{Total expected number of events under the alternate hypothesis.} \item{tol}{As input, except when not used in computations in @@ -387,6 +389,8 @@ analyses is still displayed.} \item{minfup}{As input.} \item{hr}{As input.} \item{hr0}{As input.} + \item{N}{A vector containing cumulative total expected enrollment at each + analysis.} \item{eNC}{Total expected sample size corresponding to output accrual rates and durations.} \item{eNE}{Total expected sample size corresponding to output accrual rates @@ -599,6 +603,21 @@ gsSurv( ) |> print() +# Common four-period enrollment ramp-up. With T and minfup fixed, the +# relative gamma pattern is scaled to power the trial, and the final R +# period is extended so enrollment lasts T - minfup. +ramp_rate <- gsSurv( + T = 26, minfup = 12, + gamma = 1:4, R = rep(1, 4) +) + +# With T = NULL and minfup fixed, gamma stays fixed and the final +# enrollment period is extended to obtain the required sample size. +ramp_duration <- gsSurv( + T = NULL, minfup = 12, + gamma = 1:4, R = rep(1, 4) +) + # Vary minimum follow-up duration minfup to obtain power # Accrual duration R rate gamma are fixed and will not change on output. # Trial duration T and minimum follow-up minfup are input as NULL @@ -693,8 +712,10 @@ Trials Using the Logrank Test. \emph{Statistics in Medicine}, 1, 121-129. \seealso{ \code{\link[stats]{uniroot}} -\code{vignette("gsSurvBasicExamples", package = "gsDesign")} for - basic survival sample size examples, \code{vignette("SurvivalOverview", +\code{vignette("SurvivalEnrollmentPlanning", package = "gsDesign")} + for enrollment ramp-up and duration planning, + \code{vignette("gsSurvBasicExamples", package = "gsDesign")} for basic + survival sample size examples, \code{vignette("SurvivalOverview", package = "gsDesign")} for method background, and \code{vignette("SeqDesignSurvival", package = "gsDesign")} for a SAS PROC SEQDESIGN translation example. diff --git a/tests/testthat/_snaps/independent-test-print.gsSurv.md b/tests/testthat/_snaps/independent-test-print.gsSurv.md index 8b86fcfa..fcd81f36 100644 --- a/tests/testthat/_snaps/independent-test-print.gsSurv.md +++ b/tests/testthat/_snaps/independent-test-print.gsSurv.md @@ -2,7 +2,7 @@ Group sequential design (method=LachinFoulkes; k=3 analyses; Two-sided asymmetric with non-binding futility) HR=0.500 vs HR0=1.000 | alpha=0.025 (sided=2) | power=90.0% - N=187.2 subjects | D=93.5 events | T=18.5 study duration | accrual=18.0 Accrual duration | minfup=0.5 minimum follow-up | ratio=1 randomization ratio (experimental/control) + N=105.2 subjects | D=92.8 events | T=105.7 study duration | accrual=105.2 Accrual duration | minfup=0.5 minimum follow-up | ratio=1 randomization ratio (experimental/control) Spending functions: Efficacy bounds derived using a Hwang-Shih-DeCani spending function with gamma = -4. @@ -12,35 +12,35 @@ Method: LachinFoulkes Analysis Value Efficacy Futility IA 1: 33% Z 3.0107 -0.2388 - N: 100 p (1-sided) 0.0013 0.5944 - Events: 32 ~HR at bound 0.3401 1.0893 - Month: 10 P(Cross) if HR=1 0.0013 0.4056 + N: 44 p (1-sided) 0.0013 0.5944 + Events: 31 ~HR at bound 0.3386 1.0897 + Month: 43 P(Cross) if HR=1 0.0013 0.4056 P(Cross) if HR=0.5 0.1412 0.0148 IA 2: 67% Z 2.5465 0.9410 - N: 150 p (1-sided) 0.0054 0.1733 - Events: 63 ~HR at bound 0.5246 0.7879 - Month: 14 P(Cross) if HR=1 0.0062 0.8347 + N: 76 p (1-sided) 0.0054 0.1733 + Events: 62 ~HR at bound 0.5233 0.7872 + Month: 75 P(Cross) if HR=1 0.0062 0.8347 P(Cross) if HR=0.5 0.5815 0.0437 Final Z 1.9992 1.9992 - N: 188 p (1-sided) 0.0228 0.0228 - Events: 94 ~HR at bound 0.6613 0.6613 - Month: 18 P(Cross) if HR=1 0.0233 0.9767 + N: 106 p (1-sided) 0.0228 0.0228 + Events: 93 ~HR at bound 0.6603 0.6603 + Month: 106 P(Cross) if HR=1 0.0233 0.9767 P(Cross) if HR=0.5 0.9000 0.1000 Key inputs (names preserved): - desc item value input - Accrual rate(s) gamma 10.401 1 - Accrual rate duration(s) R 18 18 - Control hazard rate(s) lambdaC 0.116 0.116 - Control dropout rate(s) eta 0 0 - Experimental dropout rate(s) etaE 0 etaE - Event and dropout rate duration(s) S NULL S + desc item value input + Accrual rate(s) gamma 1 1 + Accrual rate duration(s) R 105.244 18 + Control hazard rate(s) lambdaC 0.116 0.116 + Control dropout rate(s) eta 0 0 + Experimental dropout rate(s) etaE 0 etaE + Event and dropout rate duration(s) S NULL S # Test: checking hazard ratio hr0 != 1 Group sequential design (method=LachinFoulkes; k=3 analyses; Two-sided asymmetric with non-binding futility) HR=0.500 vs HR0=1.500 | alpha=0.025 (sided=2) | power=90.0% - N=75.7 subjects | D=37.8 events | T=18.5 study duration | accrual=18.0 Accrual duration | minfup=0.5 minimum follow-up | ratio=1 randomization ratio (experimental/control) + N=49.1 subjects | D=37.1 events | T=49.6 study duration | accrual=49.1 Accrual duration | minfup=0.5 minimum follow-up | ratio=1 randomization ratio (experimental/control) Spending functions: Efficacy bounds derived using a Hwang-Shih-DeCani spending function with gamma = -4. @@ -50,29 +50,29 @@ Method: LachinFoulkes Analysis Value Efficacy Futility IA 1: 33% Z 3.0107 -0.2388 - N: 42 p (1-sided) 0.0013 0.5944 - Events: 13 ~HR at bound 0.2750 1.7160 - Month: 10 P(Cross) if HR=1.5 0.0013 0.4056 + N: 24 p (1-sided) 0.0013 0.5944 + Events: 13 ~HR at bound 0.2705 1.7182 + Month: 23 P(Cross) if HR=1.5 0.0013 0.4056 P(Cross) if HR=0.5 0.1412 0.0148 IA 2: 67% Z 2.5465 0.9410 - N: 62 p (1-sided) 0.0054 0.1733 - Events: 26 ~HR at bound 0.5438 1.0310 - Month: 14 P(Cross) if HR=1.5 0.0062 0.8347 + N: 38 p (1-sided) 0.0054 0.1733 + Events: 25 ~HR at bound 0.5385 1.0273 + Month: 37 P(Cross) if HR=1.5 0.0062 0.8347 P(Cross) if HR=0.5 0.5815 0.0437 Final Z 1.9992 1.9992 - N: 76 p (1-sided) 0.0228 0.0228 - Events: 38 ~HR at bound 0.7827 0.7827 - Month: 18 P(Cross) if HR=1.5 0.0233 0.9767 + N: 50 p (1-sided) 0.0228 0.0228 + Events: 38 ~HR at bound 0.7779 0.7779 + Month: 50 P(Cross) if HR=1.5 0.0233 0.9767 P(Cross) if HR=0.5 0.9000 0.1000 Key inputs (names preserved): - desc item value input - Accrual rate(s) gamma 4.204 1 - Accrual rate duration(s) R 18 18 - Control hazard rate(s) lambdaC 0.116 0.116 - Control dropout rate(s) eta 0 0 - Experimental dropout rate(s) etaE 0 etaE - Event and dropout rate duration(s) S NULL S + desc item value input + Accrual rate(s) gamma 1 1 + Accrual rate duration(s) R 49.063 18 + Control hazard rate(s) lambdaC 0.116 0.116 + Control dropout rate(s) eta 0 0 + Experimental dropout rate(s) etaE 0 etaE + Event and dropout rate duration(s) S NULL S # Test: checking test.type > 1 diff --git a/tests/testthat/test-gsSurv-N-enrollment.R b/tests/testthat/test-gsSurv-N-enrollment.R new file mode 100644 index 00000000..0a2a29e7 --- /dev/null +++ b/tests/testthat/test-gsSurv-N-enrollment.R @@ -0,0 +1,64 @@ +expect_gsSurv_N <- function(x) { + expect_s3_class(x, "gsSurv") + expect_equal(x$N, rowSums(x$eNC) + rowSums(x$eNE)) + expect_length(x$N, x$k) +} + +test_that("nSurv returns identical scalar n and N values", { + designs <- list( + nSurv(), + nSurv( + lambdaC = matrix(log(2) / c(10, 16), nrow = 1), + gamma = matrix(c(0.6, 0.4), nrow = 1) + ) + ) + + for (design in designs) { + expect_length(design$n, 1) + expect_length(design$N, 1) + expect_identical(design$N, design$n) + expect_equal(design$N, sum(design$eNC + design$eNE)) + + integer <- toInteger(design) + expect_identical(integer$N, integer$n) + } +}) + +test_that("all gsSurv construction paths return cumulative total N", { + fixed <- gsSurv(k = 1) + sequential <- gsSurv(k = 2) + calendar <- gsSurvCalendar(calendarTime = c(12, 24)) + power <- gsSurvPower( + k = 2, + plannedCalendarTime = c(12, 24) + ) + integer <- toInteger(sequential) + + lapply( + list(fixed, sequential, calendar, power, integer), + expect_gsSurv_N + ) + expect_equal(tail(integer$N, 1), round(tail(integer$N, 1))) +}) + +test_that("gsSurv with fixed rates and follow-up solves enrollment duration", { + gamma <- 1:4 + R <- rep(1, 4) + minfup <- 12 + + design <- gsSurv( + k = 2, + lambdaC = log(2) / 12, + hr = 0.7, + T = NULL, + minfup = minfup, + gamma = gamma, + R = R + ) + + expect_identical(design$variable, "Accrual duration") + expect_equal(as.vector(design$gamma), gamma) + expect_equal(design$R[seq_len(3)], R[seq_len(3)]) + expect_gt(design$R[4], R[4]) + expect_equal(max(design$T), sum(design$R) + minfup) +}) diff --git a/vignettes/SurvivalEnrollmentPlanning.Rmd b/vignettes/SurvivalEnrollmentPlanning.Rmd new file mode 100644 index 00000000..83ef5092 --- /dev/null +++ b/vignettes/SurvivalEnrollmentPlanning.Rmd @@ -0,0 +1,257 @@ +--- +title: "Planning enrollment and follow-up for survival designs" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Planning enrollment and follow-up for survival designs} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include=FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + fig.width = 7, + fig.height = 4.5 +) +options(width = 70) +``` + +## Overview + +Survival trial planning connects three time quantities: + +- enrollment duration; +- minimum follow-up after the last participant enrolls; and +- total study duration. + +The identity is + +```text +total study duration = enrollment duration + minimum follow-up. +``` + +`gsSurv()` can solve one component of this plan while deriving a powered group +sequential design. `gsSurvCalendar()` uses the same enrollment model but fixes +analysis times on the calendar. `gsSurvPower()` answers the reverse question: +given a fixed operational plan, what power will it achieve? Finally, +`toInteger()` converts continuous expected events and enrollment to an +integer-compatible plan. + +```{r setup} +library(gsDesign) + +lambdaC <- log(2) / 12 +hr <- 0.7 + +# Four enrollment periods with equal relative rate increments. +gamma_ramp <- 1:4 +R_ramp <- rep(1, 4) +``` + +Every `nSurv` object contains identical scalar values `n` and `N` for total +expected enrollment. Every `gsSurv` object contains `N` as a vector of +cumulative total expected enrollment at each analysis. It is the row total of +the control and experimental enrollment components: + +```r +x$N == rowSums(x$eNC) + rowSums(x$eNE) +``` + +## Pattern 1: fix study duration and minimum follow-up + +This is the most common planning pattern. Specifying `T` and `minfup` fixes the +enrollment duration at `T - minfup`. The values in `gamma` describe the +relative ramp-up shape; `gsSurv()` scales all rates proportionally to power the +trial. When the supplied `R` periods do not fill the enrollment duration, the +last period is extended. + +```{r fixed-duration} +fixed_duration <- gsSurv( + k = 3, + lambdaC = lambdaC, + hr = hr, + T = 26, + minfup = 12, + gamma = gamma_ramp, + R = R_ramp +) + +data.frame( + period = seq_along(fixed_duration$R), + duration = fixed_duration$R, + rate = as.vector(fixed_duration$gamma) +) + +fixed_duration$N +``` + +Here enrollment lasts 14 months. The first three ramp-up periods last one +month each and the fourth rate continues through the remaining 11 months. + +## Pattern 2: fix rates and minimum follow-up + +Set `T = NULL` to keep `gamma` fixed and solve how long enrollment must remain +open. The final `R` period is extended to obtain the required sample size; the +earlier ramp-up periods are unchanged. + +```{r fixed-rates} +fixed_rates <- gsSurv( + k = 3, + lambdaC = lambdaC, + hr = hr, + T = NULL, + minfup = 12, + gamma = gamma_ramp, + R = R_ramp +) + +data.frame( + period = seq_along(fixed_rates$R), + duration = fixed_rates$R, + rate = as.vector(fixed_rates$gamma) +) + +c( + enrollment_duration = sum(fixed_rates$R), + minimum_follow_up = fixed_rates$minfup, + total_duration = max(fixed_rates$T) +) +``` + +Absolute rates of 1, 2, 3, and 4 participants per month are deliberately low, +so this example produces a long enrollment duration. In practice, multiply +the ramp by realistic site-level or program-level rates. + +## Pattern 3: fix enrollment and solve follow-up + +With both `T = NULL` and `minfup = NULL`, enrollment rates and their durations +are fixed. `gsSurv()` solves the follow-up duration needed to power the trial. +This option can fail when the fixed enrollment plan is over-powered even with +almost no follow-up, or under-powered regardless of follow-up. + +```{r fixed-enrollment} +fixed_enrollment <- gsSurv( + k = 3, + lambdaC = lambdaC, + hr = hr, + T = NULL, + minfup = NULL, + gamma = 50 * gamma_ramp, + R = R_ramp +) + +c( + enrollment_duration = sum(fixed_enrollment$R), + minimum_follow_up = fixed_enrollment$minfup, + total_duration = max(fixed_enrollment$T) +) +``` + +When this solve is infeasible, revise the fixed enrollment plan, target power, +or event assumptions rather than interpreting the error as a numerical failure. + +## Calendar-time analyses + +Use `gsSurvCalendar()` when interim analyses are specified as months from the +start of enrollment. The final calendar time and `minfup` imply the enrollment +duration, while the four-period ramp-up is scaled to power the trial. + +```{r calendar-design} +calendar_design <- gsSurvCalendar( + calendarTime = c(12, 18, 26), + lambdaC = lambdaC, + hr = hr, + minfup = 12, + gamma = gamma_ramp, + R = R_ramp +) + +data.frame( + analysis_month = calendar_design$T, + expected_events = calendar_design$n.I, + expected_enrollment = calendar_design$N +) +``` + +Use `gsSurv()` instead when analyses are defined by event or information +fractions rather than calendar dates. + +## Power for a fixed operational plan + +`gsSurvPower()` does not resize enrollment to hit target power. It evaluates +power for the supplied rates, durations, treatment effect, and analysis timing. +For example, the following sensitivity analysis evaluates 80% of the planned +enrollment rates at the original calendar analysis times. + +```{r power-sensitivity} +slower_enrollment <- gsSurvPower( + x = fixed_duration, + gamma = 0.8 * fixed_duration$gamma, + plannedCalendarTime = fixed_duration$T +) + +c( + planned_power = 1 - fixed_duration$beta, + slower_enrollment_power = slower_enrollment$power +) +``` + +Use `targetEvents = fixed_duration$n.I` instead of `plannedCalendarTime` when +event counts, rather than dates, remain fixed and the analysis dates may move. + +## Integer event and enrollment plans + +Design calculations use expected counts and can therefore be non-integer. +Apply `toInteger()` after deriving the design to obtain integer event targets +and a final total enrollment compatible with the randomization allocation. + +```{r integer-plan} +integer_design <- toInteger(fixed_duration) + +data.frame( + analysis = seq_len(integer_design$k), + events = integer_design$n.I, + enrollment = integer_design$N +) +``` + +The input `ratio` is experimental-to-control randomization. For example, +`ratio = 1` produces allocation-compatible even totals; `ratio = 2` produces +totals compatible with 2:1 randomization. + +## Stratified enrollment + +For a stratified design, matrix columns identify strata. Align the columns of +the control hazards, dropout rates, and enrollment rates. The example below +uses two strata with different control medians and enrollment contributions. + +```{r stratified} +lambda_strata <- matrix(log(2) / c(10, 16), nrow = 1) +gamma_strata <- cbind( + 0.6 * gamma_ramp, + 0.4 * gamma_ramp +) + +stratified_design <- gsSurv( + k = 3, + lambdaC = lambda_strata, + hr = hr, + eta = matrix(c(0.001, 0.001), nrow = 1), + T = 26, + minfup = 12, + gamma = gamma_strata, + R = R_ramp +) + +data.frame( + analysis = seq_len(stratified_design$k), + control = rowSums(stratified_design$eNC), + experimental = rowSums(stratified_design$eNE), + total = stratified_design$N +) +``` + +The same matrix conventions apply to `gsSurvCalendar()` and +`gsSurvPower()`. For final operational planning, inspect both `N` and the +stratum-specific `eNC` and `eNE` matrices before applying `toInteger()`.