simtrial

Clinical Trial Simulation

Guide users through clinical trial simulation using the simtrial R package. Use this skill when the user asks about: simulating survival trials, simfix, sim_pw_surv, cutting data at calendar or event times, weighted logrank tests, MaxCombo tests, or simulation-based power.

Clinical Trial Simulation with simtrial

Note: This skill targets simtrial >= 1.0.2 (main branch at github.com/Merck/simtrial).

API reference

  • Vendored function docs: references/llms.txt
  • Full function docs (local v1.0.2): references/llms_local.txt
  • Workflow patterns: references/code_patterns.md

Key functions

Simulation

  • sim_pw_surv() - Simulate piecewise exponential survival data (individual patient data)
  • sim_fixed_n() - Fixed-sample simulation with analysis pipeline (simpler, less flexible)
  • sim_gs_n() - Group sequential simulation (integrates with gsDesign2 designs)
  • to_sim_pw_surv() - Convert simple rate format (control rate + HR) to sim_pw_surv format

Data cutting

  • cut_data_by_date() - Cut simulated data at a calendar date
  • cut_data_by_event() - Cut simulated data at a target event count
  • get_cut_date_by_event() - Find calendar date for a target event count
  • get_analysis_date() - Get analysis date from multiple criteria (events, calendar time, follow-up)
  • create_cut() - Create a cutting function for use in sim_gs_n pipelines

Statistical tests

  • wlr() - Weighted logrank test (single weight function)
  • maxcombo() - MaxCombo test (multiple FH weight functions, correlation-adjusted p-value)
  • rmst() / rmst_two_arm() / rmst_single_arm() - Restricted mean survival time
  • milestone() - Milestone analysis (survival difference at fixed time)
  • multitest() - Apply multiple tests to one dataset
  • create_test() - Create a parameterized test function for use in pipelines

Weight functions

  • fh() - Fleming-Harrington weights (rho, gamma)
  • mb() - Magirr-Burman weights (delay period for NPH)
  • early_zero() - Early zero weight function
  • wlr_weight() - General WLR weight specification

Utilities

  • counting_process() - At-risk/event tables from survival data (for custom analyses)
  • rpwexp() - Random piecewise exponential generation
  • rpwexp_enroll() - Random piecewise enrollment times
  • fit_pwexp() - Fit piecewise exponential model
  • randomize_by_fixed_block() - Block randomization

Output

  • summary() - Summarize sim_gs_n results (power, events, timing)
  • as_gt() - Convert summary to gt table

Example datasets

  • ex1_delayed_effect through ex6_crossing - Pre-built NPH scenarios from Cross-Pharma Working Group

Workflow patterns

For detailed code templates, read references/code_patterns.md.

Topics covered: - Generating survival data with sim_pw_surv() (PH and NPH) - Data cutting (calendar, event, flexible criteria) - Weighted logrank tests (wlr()) and MaxCombo (maxcombo()) - Milestone and RMST tests - Multiple tests with multitest() and create_test() - Fixed-sample simulation with sim_fixed_n() (timing_type options) - Group sequential simulation with sim_gs_n() (multiple tests, boundary updating) - Integration with gsDesign2 designs (events from design, original_design parameter) - Weight functions (Fleming-Harrington, Magirr-Burman, early zero) - Rate format conversion with to_sim_pw_surv() - Flexible analysis timing with get_analysis_date() / create_cut() - Stratified simulations - Standalone wlr() with illness-death model ADTTE data - Example NPH datasets

Important design considerations

  • maxcombo must be used alone in sim_gs_n(): it cannot be combined with other tests in the same test list
  • to_sim_pw_surv(): Use this to convert the simpler rate format (control rate + HR) to the treatment-specific format needed by sim_pw_surv()
  • sim_gs_n() + original_design: Pass a gsDesign2 design object to get boundaries updated based on actual vs planned information fraction
  • ia_alpha_spending: Controls how alpha is spent when observed events differ from planned (“min_planned_actual” is conservative default)
  • fa_alpha_spending = "full_alpha": Spends full alpha at final analysis (default); use “info_frac” for event underrunning scenarios
  • create_cut() and create_test(): These factory functions are essential for building sim_gs_n() pipelines
  • Example datasets: Columns are id, month, evntd, trt — need renaming to tte, event, treatment for use with wlr() / maxcombo()
  • Standalone wlr(): Can be used outside sim_gs_n() with any data having tte, event, stratum, treatment columns. Returns positive Z when experimental is better. See references/code_patterns.md for illness-death model integration.

Code Patterns

Code Patterns for simtrial

Note: These patterns target simtrial >= 1.0.2 (main branch at github.com/Merck/simtrial).

Table of Contents

  1. Generating survival data with sim_pw_surv
  2. Data cutting
  3. Weighted logrank test (wlr)
  4. MaxCombo test
  5. Milestone and RMST tests
  6. Multiple tests with multitest
  7. Fixed-sample simulation with sim_fixed_n
  8. Group sequential simulation with sim_gs_n
  9. Integrating with gsDesign2 designs
  10. Weight functions
  11. Converting rate formats with to_sim_pw_surv
  12. Flexible analysis timing with get_analysis_date
  13. Example NPH datasets
  14. Stratified simulations

Generating survival data with sim_pw_surv

sim_pw_surv() generates individual patient data with piecewise exponential enrollment, failure, and dropout.

Basic usage (proportional hazards)

library(simtrial)

# Simulate 400 patients, 1:1 randomization
data <- sim_pw_surv(
  n = 400,
  stratum = data.frame(stratum = "All", p = 1),
  block = c(rep("control", 2), rep("experimental", 2)),
  enroll_rate = data.frame(rate = 25, duration = 16),
  fail_rate = data.frame(
    stratum = rep("All", 2),
    period = c(1, 1),
    treatment = c("control", "experimental"),
    duration = c(100, 100),
    rate = c(log(2) / 12, log(2) / 12 * 0.7)
  ),
  dropout_rate = data.frame(
    stratum = rep("All", 2),
    period = c(1, 1),
    treatment = c("control", "experimental"),
    duration = c(100, 100),
    rate = c(0.001, 0.001)
  )
)
# Returns: stratum, enroll_time, treatment, fail_time, dropout_time, cte, fail

Delayed treatment effect (non-proportional hazards)

data <- sim_pw_surv(
  n = 400,
  stratum = data.frame(stratum = "All", p = 1),
  block = c(rep("control", 2), rep("experimental", 2)),
  enroll_rate = data.frame(rate = c(10, 30), duration = c(4, 12)),
  fail_rate = data.frame(
    stratum = rep("All", 4),
    period = rep(1:2, 2),
    treatment = c(rep("control", 2), rep("experimental", 2)),
    duration = rep(c(3, 100), 2),
    rate = log(2) / c(9, 9, 9, 18)   # HR = 1 first 3 months, then 0.5
  ),
  dropout_rate = data.frame(
    stratum = rep("All", 2),
    period = c(1, 1),
    treatment = c("control", "experimental"),
    duration = c(100, 100),
    rate = c(0.001, 0.001)
  )
)

Using to_sim_pw_surv for simpler rate specification

# Define rates in the simpler format (like sim_fixed_n)
fail_rate <- data.frame(
  stratum = "All",
  duration = c(3, 100),
  fail_rate = log(2) / c(9, 18),
  hr = c(0.9, 0.6),
  dropout_rate = 0.001
)

# Convert to sim_pw_surv format
rates <- to_sim_pw_surv(fail_rate)

data <- sim_pw_surv(
  n = 400,
  enroll_rate = data.frame(rate = c(10, 30), duration = c(4, 12)),
  fail_rate = rates$fail_rate,
  dropout_rate = rates$dropout_rate
)

Data cutting

After generating data with sim_pw_surv(), cut it at a specific calendar time or event count to create an analysis dataset.

Cut by calendar date

# Cut at 36 months from study start
analysis_data <- cut_data_by_date(data, cut_date = 36)
# Returns: tte, event, stratum, treatment (class "tte_data")

Cut by event count

# Cut when 250 events observed
analysis_data <- cut_data_by_event(data, event = 250)

Using create_cut for pipelines

create_cut() wraps get_analysis_date() arguments into a reusable function.

# Cut at planned calendar time
cut1 <- create_cut(planned_calendar_time = 24)

# Cut at target events
cut2 <- create_cut(target_event_overall = 250)

# Cut at target events but no more than 6 months extension
cut3 <- create_cut(
  target_event_overall = 300,
  max_extension_for_target_event = 6
)

# Apply cutting function to simulated data
analysis_data <- cut1(data)

Weighted logrank test (wlr)

wlr() performs a single weighted logrank test on cut data.

# Standard logrank
result <- wlr(
  data = cut_data_by_event(data, 250),
  weight = fh(rho = 0, gamma = 0)
)
result$z  # Z-statistic (negative favors experimental)

# Fleming-Harrington (0, 0.5) - emphasizes late differences
result_fh <- wlr(
  data = cut_data_by_event(data, 250),
  weight = fh(rho = 0, gamma = 0.5)
)

# With variance estimate
result_var <- wlr(
  data = cut_data_by_event(data, 250),
  weight = fh(rho = 0, gamma = 0),
  return_variance = TRUE
)

MaxCombo test

maxcombo() combines multiple Fleming-Harrington weighted logrank tests, using the maximum test statistic with a correlation-adjusted p-value.

# MaxCombo: logrank + FH(0, 0.5)
result <- maxcombo(
  data = cut_data_by_event(data, 250),
  rho = c(0, 0),
  gamma = c(0, 0.5)
)
result$z        # Z-statistics for each test
result$p_value  # p-value (accounts for correlation)

# Three-test MaxCombo
result3 <- maxcombo(
  data = cut_data_by_event(data, 250),
  rho = c(0, 0, 1),
  gamma = c(0, 1, 1),
  return_corr = TRUE   # Also return correlation matrix
)

Milestone and RMST tests

Milestone (survival difference at fixed time)

result <- milestone(
  data = cut_data_by_event(data, 250),
  ms_time = 12,            # Compare survival at 12 months
  test_type = "naive"      # Or "log-log"
)
result$estimate  # Survival difference (experimental - control)
result$z         # Z-statistic

RMST (restricted mean survival time difference)

result <- rmst(
  data = cut_data_by_event(data, 250),
  tau = 24                 # RMST up to 24 months
)

Multiple tests with multitest

Apply several tests to the same cut dataset in one call.

# Create parameterized test functions
logrank <- create_test(wlr, weight = fh(rho = 0, gamma = 0))
fh05 <- create_test(wlr, weight = fh(rho = 0, gamma = 0.5))
rmst_test <- create_test(rmst, tau = 24)
combo <- create_test(maxcombo, rho = c(0, 0), gamma = c(0, 0.5))

# Apply all tests to one dataset
cut <- cut_data_by_event(data, 250)
results <- multitest(
  data = cut,
  logrank = logrank,
  fh05 = fh05,
  rmst = rmst_test,
  combo = combo
)
# results is a named list of test outputs

Fixed-sample simulation with sim_fixed_n

sim_fixed_n() is a self-contained pipeline: simulate data, cut, and test in one call. Simpler but less flexible than sim_gs_n().

# Basic simulation with Fleming-Harrington tests
results <- sim_fixed_n(
  n_sim = 1000,
  sample_size = 400,
  target_event = 300,
  enroll_rate = data.frame(duration = c(4, 12), rate = c(10, 30)),
  fail_rate = data.frame(
    stratum = "All",
    duration = c(3, 100),
    fail_rate = log(2) / c(9, 18),
    hr = c(0.9, 0.6),
    dropout_rate = 0.001
  ),
  total_duration = 36,
  timing_type = 1:5,
  rho_gamma = data.frame(rho = c(0, 0), gamma = c(0, 0.5))
)

# timing_type controls the cutoff method:
# 1: Planned study duration (total_duration)
# 2: Time target_event is achieved
# 3: Minimum follow-up after enrollment complete
# 4: max(planned duration, target event time)
# 5: max(target event time, minimum follow-up)

Estimating power from sim_fixed_n

# Power = proportion of simulations with z < -1.96
power <- results |>
  dplyr::filter(cut == "Planned duration" & rho == 0 & gamma == 0) |>
  dplyr::summarize(power = mean(z <= qnorm(0.025)))

Group sequential simulation with sim_gs_n

sim_gs_n() simulates a group sequential trial: generate data, cut at multiple analysis times, and apply tests.

Basic group sequential simulation

library(simtrial)
library(gsDesign2)
library(gsDesign)

# Define cutting functions for 3 analyses
ia1_cut <- create_cut(target_event_overall = 150)
ia2_cut <- create_cut(target_event_overall = 225)
fa_cut <- create_cut(target_event_overall = 300)

# Simulate with logrank test
results <- sim_gs_n(
  n_sim = 1000,
  sample_size = 400,
  enroll_rate = data.frame(duration = c(4, 12), rate = c(10, 30)),
  fail_rate = data.frame(
    stratum = "All",
    duration = c(3, 100),
    fail_rate = log(2) / c(9, 18),
    hr = c(0.9, 0.6),
    dropout_rate = 0.001
  ),
  test = wlr,
  cut = list(ia1 = ia1_cut, ia2 = ia2_cut, fa = fa_cut),
  weight = fh(rho = 0, gamma = 0)
)

MaxCombo in sim_gs_n

MaxCombo must be the only test (cannot be combined with other tests in the same call).

results_combo <- sim_gs_n(
  n_sim = 1000,
  sample_size = 400,
  enroll_rate = data.frame(duration = c(4, 12), rate = c(10, 30)),
  fail_rate = data.frame(
    stratum = "All",
    duration = c(3, 100),
    fail_rate = log(2) / c(9, 18),
    hr = c(0.9, 0.6),
    dropout_rate = 0.001
  ),
  test = maxcombo,
  cut = list(ia1 = ia1_cut, ia2 = ia2_cut, fa = fa_cut),
  rho = c(0, 0),
  gamma = c(0, 0.5)
)

Multiple WLR tests in sim_gs_n

results_multi <- sim_gs_n(
  n_sim = 1000,
  sample_size = 400,
  enroll_rate = data.frame(duration = c(4, 12), rate = c(10, 30)),
  fail_rate = data.frame(
    stratum = "All",
    duration = c(3, 100),
    fail_rate = log(2) / c(9, 18),
    hr = c(0.9, 0.6),
    dropout_rate = 0.001
  ),
  test = list(
    logrank = create_test(wlr, weight = fh(rho = 0, gamma = 0)),
    fh05 = create_test(wlr, weight = fh(rho = 0, gamma = 0.5))
  ),
  cut = list(ia1 = ia1_cut, ia2 = ia2_cut, fa = fa_cut)
)

Integrating with gsDesign2 designs

Use a gsDesign2 design to drive sample size, event targets, and boundary updates in sim_gs_n().

library(simtrial)
library(gsDesign2)
library(gsDesign)

# Enrollment and failure rate assumptions
enroll_rate <- define_enroll_rate(
  duration = c(4, 12),
  rate = c(10, 30)
)

fail_rate <- define_fail_rate(
  duration = c(3, 100),
  fail_rate = log(2) / 9,
  hr = c(0.9, 0.6),
  dropout_rate = 0.001
)

# Group sequential design
design <- gs_design_ahr(
  enroll_rate = enroll_rate,
  fail_rate = fail_rate,
  alpha = 0.025,
  beta = 0.1,
  analysis_time = c(12, 24, 36),
  info_scale = "h0_info",
  upper = gs_spending_bound,
  upar = list(sf = sfLDOF, total_spend = 0.025),
  lower = gs_b,
  lpar = rep(-Inf, 3)
)

# Create cuts from the design's planned events
cuts <- list(
  ia1 = create_cut(target_event_overall = ceiling(design$analysis$event[1])),
  ia2 = create_cut(target_event_overall = ceiling(design$analysis$event[2])),
  fa = create_cut(target_event_overall = ceiling(design$analysis$event[3]))
)

# Simulate with boundary updating from original design
results <- sim_gs_n(
  n_sim = 1000,
  sample_size = ceiling(max(design$analysis$n)),
  enroll_rate = data.frame(
    duration = design$enroll_rate$duration,
    rate = design$enroll_rate$rate
  ),
  fail_rate = data.frame(
    stratum = "All",
    duration = fail_rate$duration,
    fail_rate = fail_rate$fail_rate,
    hr = fail_rate$hr,
    dropout_rate = fail_rate$dropout_rate
  ),
  test = wlr,
  cut = cuts,
  original_design = design,
  weight = fh(rho = 0, gamma = 0)
)

# Summarize results
results |> summary() |> as_gt()

Alpha spending options

# How interim alpha is spent when observed events differ from planned
results <- sim_gs_n(
  ...,
  original_design = design,
  ia_alpha_spending = "min_planned_actual",  # Default: min of planned and actual info fraction
  fa_alpha_spending = "full_alpha"           # Default: spend full alpha at final analysis
)

# Alternative: spend by actual info fraction at FA (for underrunning)
results <- sim_gs_n(
  ...,
  original_design = design,
  ia_alpha_spending = "actual",
  fa_alpha_spending = "info_frac"
)

Weight functions

Fleming-Harrington family

fh(rho = 0, gamma = 0)    # Standard logrank
fh(rho = 0, gamma = 0.5)  # Emphasizes late differences
fh(rho = 0, gamma = 1)    # Stronger late emphasis (Peto-logrank-like)
fh(rho = 1, gamma = 0)    # Emphasizes early differences (Prentice-Wilcoxon)
fh(rho = 1, gamma = 1)    # Moderate weight all around

Magirr-Burman weights (for delayed effects)

mb(delay = 4)              # Down-weight first 4 months
mb(delay = 4, w_max = 2)  # Cap maximum weight at 2
mb(delay = Inf, w_max = 2) # Magirr (2021) recommendation

Early zero weight

early_zero(early_period = 4)  # Zero weight before 4 months

Converting rate formats with to_sim_pw_surv

sim_fixed_n() and sim_gs_n() use a simpler rate format (control rate + HR). sim_pw_surv() uses treatment-specific rates. to_sim_pw_surv() converts between them.

# Simple format (control rate + HR)
fail_rate_simple <- data.frame(
  stratum = "All",
  duration = c(3, 100),
  fail_rate = log(2) / c(9, 18),
  hr = c(0.9, 0.6),
  dropout_rate = 0.001
)

# Convert to sim_pw_surv format
rates <- to_sim_pw_surv(fail_rate_simple)
rates$fail_rate    # Treatment-specific failure rates
rates$dropout_rate # Treatment-specific dropout rates

# Use in sim_pw_surv
data <- sim_pw_surv(
  n = 400,
  enroll_rate = data.frame(rate = 25, duration = 16),
  fail_rate = rates$fail_rate,
  dropout_rate = rates$dropout_rate
)

Flexible analysis timing with get_analysis_date

get_analysis_date() determines analysis timing from multiple criteria. create_cut() wraps it for use in pipelines.

Calendar-time-driven analysis

cut <- create_cut(planned_calendar_time = 36)

Event-driven analysis

cut <- create_cut(target_event_overall = 300)

Event-driven with maximum extension

# Target 300 events, but extend at most 6 months beyond planned time
cut <- create_cut(
  target_event_overall = 300,
  max_extension_for_target_event = 42  # Absolute max calendar time
)

Minimum follow-up

# At least 12 months follow-up after last enrollment
cut <- create_cut(min_followup = 12)

Combining criteria

# Analysis at the later of target events or minimum follow-up
cut <- create_cut(
  target_event_overall = 300,
  min_followup = 12
)

# Minimum time between analyses
cut_ia2 <- create_cut(
  target_event_overall = 225,
  min_time_after_previous_analysis = 6
)

Per-stratum event targets

cut <- create_cut(
  target_event_per_stratum = c("BM+" = 100, "BM-" = 150)
)

Example NPH datasets

simtrial includes pre-built datasets from the Cross-Pharma Non-Proportional Hazards Working Group.

# Delayed treatment effect
data(ex1_delayed_effect)
# Columns: id, month (tte), evntd (event indicator), trt (0=ctrl, 1=exp)

data(ex2_delayed_effect)  # Another delayed effect scenario
data(ex3_cure_with_ph)    # Cure model with proportional hazards
data(ex4_belly)           # "Belly-shaped" survival curves
data(ex5_widening)        # Widening survival curves
data(ex6_crossing)        # Crossing survival curves

# Use with wlr (need column renaming)
library(dplyr)
analysis_data <- ex1_delayed_effect |>
  rename(tte = month, event = evntd, treatment = trt) |>
  mutate(
    treatment = ifelse(treatment == 1, "experimental", "control"),
    stratum = "All"
  )
wlr(analysis_data, weight = fh(rho = 0, gamma = 0))

Stratified simulations

Generating stratified data

data <- sim_pw_surv(
  n = 600,
  stratum = data.frame(stratum = c("BM+", "BM-"), p = c(0.4, 0.6)),
  block = c(rep("control", 2), rep("experimental", 2)),
  enroll_rate = data.frame(rate = 30, duration = 20),
  fail_rate = data.frame(
    stratum = rep(c("BM+", "BM+", "BM-", "BM-"), each = 1),
    period = rep(1, 4),
    treatment = rep(c("control", "experimental"), 2),
    duration = rep(100, 4),
    rate = c(log(2)/12, log(2)/12 * 0.6,   # BM+: HR = 0.6
             log(2)/12, log(2)/12 * 0.85)   # BM-: HR = 0.85
  ),
  dropout_rate = data.frame(
    stratum = rep(c("BM+", "BM-"), each = 2),
    period = rep(1, 4),
    treatment = rep(c("control", "experimental"), 2),
    duration = rep(100, 4),
    rate = rep(0.001, 4)
  )
)

# Cut and test with stratified WLR
cut <- cut_data_by_event(data, event = 350)
wlr(cut, weight = fh(rho = 0, gamma = 0))

Stratified sim_fixed_n

results <- sim_fixed_n(
  n_sim = 1000,
  sample_size = 600,
  target_event = 350,
  stratum = data.frame(stratum = c("BM+", "BM-"), p = c(0.4, 0.6)),
  enroll_rate = data.frame(duration = c(4, 16), rate = c(10, 30)),
  fail_rate = data.frame(
    stratum = rep(c("BM+", "BM-"), each = 2),
    duration = rep(c(3, 100), 2),
    fail_rate = log(2) / 12,
    hr = c(0.6, 0.6, 0.85, 0.85),
    dropout_rate = 0.001
  ),
  total_duration = 36,
  timing_type = 1:2,
  rho_gamma = data.frame(rho = 0, gamma = 0)
)

Standalone wlr with illness-death model data

wlr() can be used standalone (outside sim_gs_n()) with data from the illness-death model. The key is mapping ADTTE columns to the wlr() format.

Column mapping

wlr() expects columns: tte, event, stratum, treatment

# From illness-death ADTTE data (columns: AVAL, CNSR, STRATUM, TRT)
logrank_z <- function(data) {
  d <- data.frame(
    tte = data$AVAL,
    event = 1L - data$CNSR,
    stratum = data$STRATUM,
    treatment = data$TRT
  )
  if (sum(d$event) < 2) return(NA_real_)
  wlr(d, weight = fh(rho = 0, gamma = 0))$z
}

Sign convention

wlr() returns positive Z when experimental is better (more events in control than expected under null). This matches the convention needed for sequentialPValue() and graphical testing.

Usage in simulation loops

adtte_18 <- cut_illness_death(sim_data, cut_date = 18)
z_pfs_18 <- logrank_z(adtte_18[adtte_18$PARAMCD == "PFS", ])
z_os_18 <- logrank_z(adtte_18[adtte_18$PARAMCD == "OS", ])

# Collect event counts for spending time
ev_pfs_18 <- sum(1L - adtte_18$CNSR[adtte_18$PARAMCD == "PFS"])
ev_os_18 <- sum(1L - adtte_18$CNSR[adtte_18$PARAMCD == "OS"])