Skip to contents

Introduction

rxsim supports two equivalent ways to build the same trial.

  • Style A: Direct instantiation builds Population, Timer, Condition, and Trial objects yourself.
  • Style B: Generator API gives replicate_trial() the population and analysis generators, then lets rxsim build each Trial for you.

The two styles are equivalent when the design itself is the same: same arms, endpoint distributions, enrollment, dropout, and trigger logic. The difference is where you want control.

Prefer the direct style when you want to inspect or edit the Timer, work with fixed schedules, or debug one trial step by step. Prefer the generator style when you want many stochastic replicates, quick scenario grids, or minimal boilerplate.

The sections below use the same two-arm design in both styles:

  • placebo (pbo) versus treatment (trt)
  • sample_size = 40 with allocation = c(1, 1)
  • stochastic enrollment from rexp(n, rate = 1)
  • stochastic dropout from rexp(n, rate = 0.05)
  • continuous endpoint with mean 0 for placebo and 0.5 for treatment
  • one final analysis at full enrollment
  • 10 replicates for the operating characteristics
sample_size <- 40L
arms <- c("pbo", "trt")
allocation <- c(1, 1)
delta <- 0.5
n_reps <- 10L

enrollment <- function(n) rexp(n, rate = 1)
dropout <- function(n) rexp(n, rate = 0.05)

make_outcome_data <- function(n, mean_shift) {
  data.frame(
    id = seq_len(n),
    outcome = rnorm(n, mean = mean_shift, sd = 1),
    readout_time = 0,
    stringsAsFactors = FALSE
  )
}

final_analysis <- function(df, current_time) {
  enrolled <- subset(df, !is.na(enroll_time))
  fit <- stats::t.test(outcome ~ arm, data = enrolled)

  data.frame(
    n = nrow(enrolled),
    mean_pbo = mean(enrolled$outcome[enrolled$arm == "pbo"]),
    mean_trt = mean(enrolled$outcome[enrolled$arm == "trt"]),
    p_value = unname(fit$p.value),
    stringsAsFactors = FALSE
  )
}

make_final_condition <- function() {
  Condition$new(
    where = enroll_trigger(1.0, sample_size),
    analysis = final_analysis,
    name = "final"
  )
}

Style A: Direct instantiation

Use the direct style when you want to see every moving part. You create the plan, build the timer, size each arm, define the trigger, and assemble the trial yourself.

# 1. Draw one stochastic enrollment and dropout plan.
direct_plan <- stochastic_schedule(
  sample_size = sample_size,
  arms = arms,
  allocation = allocation,
  enrollment = enrollment,
  dropout = dropout
)

# 2. Build the timer from that plan.
direct_timer <- Timer$new(name = "direct_timer")
direct_timer$add_schedule(direct_plan)

# 3. Compute the planned arm sizes from the timer input.
direct_n_by_arm <- vapply(
  arms,
  function(arm_name) {
    as.integer(sum(direct_plan$enroll[direct_plan$arm == arm_name]))
  },
  integer(1)
)

# 4. Instantiate each arm population explicitly.
pop_pbo <- Population$new(
  name = "pbo",
  data = make_outcome_data(direct_n_by_arm[["pbo"]], mean_shift = 0)
)
pop_trt <- Population$new(
  name = "trt",
  data = make_outcome_data(direct_n_by_arm[["trt"]], mean_shift = delta)
)

# 5. Define the final analysis with a condition object.
direct_final <- make_final_condition()

# 6. Assemble the Trial object.
direct_trial <- Trial$new(
  name = "direct_trial",
  timer = direct_timer,
  population = list(pop_pbo, pop_trt),
  conditions = list(direct_final)
)

In this style, the timer is a first-class object. You can inspect the time grid, modify individual timepoints, or replace the whole schedule before running.

head(direct_plan)
#>        time arm enroll drop
#> 1 0.1983368 pbo      1    0
#> 2 1.1427231 pbo      1    0
#> 3 1.1809150 pbo      1    0
#> 4 1.6540916 pbo      1    0
#> 5 3.4317033 pbo      1    0
#> 6 5.0334307 pbo      1    0
direct_timer$get_end_timepoint()
#> [1] 764.053
# 7. Run the single direct trial.
direct_trial$run()
collect_results(direct_trial)
#>   replicate timepoint analysis  n   mean_pbo  mean_trt   p_value
#> 1         1  39.32605    final 40 -0.0557219 0.1194667 0.6202483

For many stochastic replicates, the direct pattern is usually wrapped in a small constructor function.

build_direct_trial <- function(name) {
  # 1. Generate a fresh stochastic plan for this replicate.
  plan <- stochastic_schedule(
    sample_size = sample_size,
    arms = arms,
    allocation = allocation,
    enrollment = enrollment,
    dropout = dropout
  )

  # 2. Turn that plan into a timer.
  timer <- Timer$new(name = paste0(name, "_timer"))
  timer$add_schedule(plan)

  # 3. Size each arm from the generated plan.
  n_by_arm <- vapply(
    arms,
    function(arm_name) {
      as.integer(sum(plan$enroll[plan$arm == arm_name], na.rm = TRUE))
    },
    integer(1)
  )

  # 4. Instantiate populations explicitly.
  populations <- list(
    Population$new(
      name = "pbo",
      data = make_outcome_data(n_by_arm[["pbo"]], mean_shift = 0)
    ),
    Population$new(
      name = "trt",
      data = make_outcome_data(n_by_arm[["trt"]], mean_shift = delta)
    )
  )

  # 5. Create a fresh condition object for this replicate.
  conditions <- list(make_final_condition())

  # 6. Return the assembled trial.
  Trial$new(
    name = name,
    timer = timer,
    population = populations,
    conditions = conditions
  )
}
direct_trials <- lapply(seq_len(n_reps), function(i) {
  build_direct_trial(paste0("direct_", i))
})

run_trials(direct_trials)
direct_results <- collect_results(direct_trials)
head(direct_results)
#>   replicate timepoint analysis  n    mean_pbo  mean_trt    p_value
#> 1         1  45.97723    final 40  0.22031537 0.6831231 0.11616961
#> 2         2  38.68791    final 40 -0.12834383 0.6033173 0.03610186
#> 3         3  39.78981    final 40  0.17085927 0.6229233 0.13717783
#> 4         4  40.73727    final 40 -0.07649800 0.3610610 0.12120162
#> 5         5  42.68275    final 40  0.02060517 0.3789782 0.29638352
#> 6         6  44.00494    final 40  0.24331892 0.3731780 0.71147851

Style B: Generator API

Use the generator API when you want the same design with less setup. You define functions for the arm populations and analyses, then let replicate_trial() build the timers, conditions, and trials internally.

# 1. Define one population generator per arm.
population_generators <- list(
  pbo = function(n) make_outcome_data(n, mean_shift = 0),
  trt = function(n) make_outcome_data(n, mean_shift = delta)
)

# 2. Define the analysis generator with a condition object.
analysis_generators <- list(
  final = list(
    trigger = enroll_trigger(1.0, sample_size),
    analysis = final_analysis
  )
)

The trigger must be a trigger object such as enroll_trigger(1.0, sample_size). No quotation helpers are needed.

# 3. Ask rxsim to build 10 independent trials.
generator_trials <- replicate_trial(
  trial_name = "generator",
  sample_size = sample_size,
  arms = arms,
  allocation = allocation,
  enrollment = enrollment,
  dropout = dropout,
  analysis_generators = analysis_generators,
  population_generators = population_generators,
  n = n_reps
)

# 4. Run all generated trials.
run_trials(generator_trials)
generator_results <- collect_results(generator_trials)
head(generator_results)
#>   replicate timepoint analysis  n    mean_pbo   mean_trt      p_value
#> 1         1  41.31080    final 40  0.14790964 0.64055125 0.1843726111
#> 2         2  42.57187    final 40 -0.08040526 0.46229123 0.0748187763
#> 3         3  36.74258    final 40 -0.37301982 0.44163311 0.0074422896
#> 4         4  32.38417    final 40 -0.03727242 0.04886914 0.8133832280
#> 5         5  44.70138    final 40 -0.48426483 0.48936266 0.0175744028
#> 6         6  41.63317    final 40 -0.35693944 0.80935517 0.0004009703

This style is shorter because replicate_trial() handles the timer construction, population sizing, and condition creation for every replicate.

When to use Style A

Prefer direct instantiation when you want to work directly with the underlying objects.

  • Manually manipulate the Timer before running.
  • Use deterministic schedules from deterministic_schedule() or a hand-built plan.
  • Debug a single trial by inspecting timer, population, and locked_data.
  • Build multi-stage designs where the schedule is easier to express directly, such as the seamless pattern in Example 5.

When to use Style B

Prefer the generator API when the design follows the standard workflow that replicate_trial() already knows how to build.

  • Run scenario grids generated with expand_grid().
  • Simulate standard stochastic enrollment and dropout with minimal setup.
  • Write scripts that need many replicates and little boilerplate.
  • Keep design changes local to a few small generator functions.

Mixing the styles

A common workflow is to start with the direct style for one trial, inspect it, then scale out with run_trials() once you are happy with the setup.

# 1. Build one trial directly so you can inspect it.
mixed_trial <- build_direct_trial("mixed_1")
mixed_trial$timer$timelist

# 2. Extend the same direct pattern to more replicates.
mixed_trials <- c(
  list(mixed_trial),
  lapply(2:5, function(i) build_direct_trial(paste0("mixed_", i)))
)

# 3. Run them together with the usual batch helper.
run_trials(mixed_trials)

If you intentionally want the same fixed timer and populations in each replicate, clone_trial() is also useful. That pattern is most natural for deterministic schedules, not for stochastic enrollment.

# Deep copies with independent timer and population state.
clones <- clone_trial(mixed_trial, n = 2)
names(clones)
#> NULL

Next steps