Define Non-Time-to-Event Endpoints

knitr::opts_chunk$set(
  collapse = TRUE,
  cache = TRUE,
  cache.path = 'cache/defineNonTimeToEventEndpoints/',
  comment = '#>',
  dpi = 300,
  out.width = '100%'
)
library(dplyr)
library(TrialSimulator)
set.seed(12345)

TrialSimulator provides a flexible framework for defining and simulating a variety of clinical trial endpoints by specifying the type parameter in endpoint. This vignette covers non-time-to-event (non-TTE) endpoints, demonstrating how they can be defined, integrated into trial arms, and analyzed at pre-specified milestones. For time-to-event endpoints, please refer to the vignette Define Time-to-Event Endpoints in Clinical Trials. For longitudinal endpoints, please refer to the vignette Define Longitudinal Endpoints in Clinical Trials

This vignette demonstrates how to use the following key functions to define non-TTE endpoints. For the sake of completeness, we also demonstrates how to define arms and trial with the created endpoints

Define endpoints with random number generators

Similar to time-to-event endpoints, non-TTE endpoints can be defined using any univariate random number generator that takes n (number of observations) as its first argument. The stats package provides a set of random number generators that can be assigned to generator in endpoints, where additional arguments required by generator can be passed through .... When creating non-TTE endpoints, the argument type should be set to "non-tte", and the argument readout should be specified as a named numeric vector, indicating the time required for the endpoint to be available for analysis after patient enrollment.

In the example below, we define two types of endpoints:

## endpoints in placebo arm
tumor_cfb_pbo <- endpoint(name = 'cfb', type = 'non-tte', 
                          readout = c(cfb = 6),
                          generator = rnorm, mean = .8, sd = 3.2)
orr_pbo <- endpoint(name = 'orr', type = 'non-tte', 
                    readout = c(orr = 2),
                    generator = rbinom, size = 1, prob = .1)

## define the placebo arm
pbo <- arm(name = 'placebo')
pbo$add_endpoints(tumor_cfb_pbo, orr_pbo)

## endpoints in treatment arm
tumor_cfb_trt <- endpoint(name = 'cfb', type = 'non-tte', 
                          readout = c(cfb = 6), 
                          generator = rnorm, mean = -2.3, sd = 1.5)
orr_trt <- endpoint(name = 'orr', type = 'non-tte', 
                    readout = c(orr = 2), 
                    generator = rbinom, size = 1, prob = .25)

## define the treatment arm
trt <- arm(name = 'treatment')
trt$add_endpoints(tumor_cfb_trt, orr_trt)

With the treatment arms defined, we can proceed to create a trial. Patients are recruited at a piecewise constant rate, with an accrual pattern as follows:

We also specify a dropout process with a Weibull distribution. The dropout rates are set as follows:

These constraints are resolved using the Weibull dropout function:

$$ \begin{split} 0.15 & = & 1 - e^{-(12/\lambda)^k} \ 0.30 & = & 1 - e^{-(18/\lambda)^k} \end{split} $$

dropout_pars <- weibullDropout(c(12, 18), c(.15, .30))
dropout_pars

Using the computed scale parameter $\lambda=$ r round(dropout_pars['scale'], 3) and shape parameter $k=$ r round(dropout_pars['shape'], 3), we specify the trial setup:

accrual_rate <- data.frame(end_time = c(6, Inf), 
                           piecewise_rate = c(10, 20))

trial <- trial(
  name = 'Trial-31415', description = 'Example Clinical Trial', 
  n_patients = 420, duration = 30, 
  enroller = StaggeredRecruiter, accrual_rate = accrual_rate, 
  dropout = rweibull, scale = 30.636, shape = 1.939
)

## add arms to the trial
trial$add_arms(sample_ratio = c(1, 1), trt, pbo)
trial

Here accrual_rate is an argument of TrialSimulator::StaggeredRecruiter controlling how patients are recruited into the trial. Similarly, scale and shape are arguments of rweibull. All these arguments are passed through ... of trial().

TrialSimulator allows defining trial milestones at specific time points when data is locked for analysis. Here, we define three key milestones:

  1. Interim Analysis: Triggered when orr has been observed for 60 patients.
  2. Random Checkpoint: For illustration purpose only. Triggered when the trial has reached at least 10 months, and at least one of the following conditions is met:
    • cfb has been observed for at least 100 patients,
    • orr has been observed for at least 180 patients.
  3. Final Analysis: Occurs when the trial reaches 30 months.
interim <- milestone(name = 'interim', 
                     when = eventNumber(endpoint = 'orr', n = 60), 
                     action = doNothing)

random <- milestone(name = 'random', 
                    when = 
                      calendarTime(time = 10) & 
                      (eventNumber(endpoint = 'cfb', n = 100) | 
                         eventNumber(endpoint = 'orr', n = 180)
                       ), 
                    action = doNothing)

final <- milestone(name = 'final', 
                   when = calendarTime(time = 30), 
                   action = doNothing)

Here action = doNothing in milestone means we don't expect any action at the time of triggered milestones. In practice, instead of doNothing, custom action function can be adopted to add or remove arms (e.g., dose selection), adjust sample ratio per arm, or carry out statistical analysis based on locked data. These advanced setups are covered in other vignettes.

Next, we register the milestones with a listener and create a controller to monitor and execute the trial.

## register milestones to the listener
listener <- listener()
listener$add_milestones(interim, random, final)

## run the trial
controller <- controller(trial, listener)
controller$run()

We can inspect the dataset locked at different milestone by calling member function get_locked_data with milestone names. Ideally, this should be done within custom action function, where decision is made based on data locked at the time of a milestone.

interim_data <- trial$get_locked_data(milestone_name = 'interim')
random_data <- trial$get_locked_data(milestone_name = 'random')
final_data <- trial$get_locked_data(milestone_name = 'final')
head(interim_data)

Since cfb has a 6-month readout time, at interim analysis, some patients’ cfb values are still unavailable, appearing as NA in interim_data. However, these values become available in random_data collected at a later time point. This demonstrates how TrialSimulator properly and automatically handles endpoint availability at different milestones

not_ready_at_interim <- 
  interim_data %>% 
  dplyr::filter(is.na(cfb) & 
                  is.na(orr) & 
                  enroll_time + 6 < dropout_time) %>% 
  head() %>% 
  print()

random_data %>% 
  dplyr::filter(patient_id %in% not_ready_at_interim$patient_id) %>% 
  print()

In this example, we simulate tumor size change from baseline (cfb). However, in many trials, it is more appropriate to simulate tumor size at both baseline and follow-up separately to allow for more complex modeling, such as longitudinal or repeated measures analysis. This will be covered in another vignette.

With this flexible setup, TrialSimulator enables efficient endpoint definition, adaptive trial execution, and data monitoring—allowing users to design and simulate clinical trials tailored to specific research needs.



Try the TrialSimulator package in your browser

Any scripts or data that you put into this service are public.

TrialSimulator documentation built on Nov. 5, 2025, 7:22 p.m.