simtte

simtte is an R package for simulating time-to-event (survival) datasets whose hazard is driven by a joint pharmacokinetic/pharmacodynamic (PK/PD) model, using mrgsolve as the ODE solver backend. The survival probability is integrated as one more ODE compartment alongside the PK/PD model’s own states, and each subject’s event time is located during integration (in-solver event detection), not resolved after the fact from a pre-simulated trajectory. Six built-in PK/PD hazard models ship out of the box, and tte_model() converts any user-supplied mrgsolve model into one usable the same way. Covariates (including a formula/design-matrix interface), between-subject variability, right/interval censoring, and realistic visit schedules all compose with any model. The original closed-form Weibull/M-spline API (sim_tte()/sim_tte_df()) remains fully supported for the cases it covers better (an arbitrary M-spline knot count, exact Weibull behavior for very small shapes, or an externally simulated survival trajectory from any source).

A companion package, simttepower (forthcoming), will build trial replication and power-analysis tooling on top of simtte.

Overview

simtte addresses two related simulation needs:

Installation

From CRAN

install.packages("simtte")

From GitHub (development version)

simtte depends on mrgsolve, which requires a working C++ compiler. Install the appropriate toolchain for your platform before proceeding:

Platform Requirement
Windows Rtools
macOS Xcode command line tools: xcode-select --install
Linux g++ via your package manager (e.g., apt install g++)
# install.packages("devtools")
# devtools::install_github("csetraynor/simtte")
install.packages("simtte", repos = c('https://csetraynor.r-universe.dev', 'https://cloud.r-project.org'))

Quick start

One short example per capability – see the vignettes for the full picture.

A built-in PK/PD hazard model, with dosing

library(simtte)

dose <- data.frame(ID = 1:50, time = 0, cmt = 1, amt = 100, evid = 1)
sim <- sim_tte_ode(model = "irm1_hazard", param = list(H0 = 0.01, beta_r = 1),
  n = 50, end = 30, delta = 2, data = dose, seed = 1)
#> Building irm1_hazard ... done.
head(sim$events)
#>   ID sim_time sim_status     sim_reason
#> 1  1 30.00000          0 administrative
#> 2  2 30.00000          0 administrative
#> 3  3 30.00000          0 administrative
#> 4  4 16.40492          1          event
#> 5  5 30.00000          0 administrative
#> 6  6 18.15148          1          event

Your own PK/PD model, converted with tte_model()

my_model <- "
$PARAM CL = 1, V = 20, KA = 1
$CMT DEPOT CENT
$ODE
dxdt_DEPOT = -KA*DEPOT;
dxdt_CENT = KA*DEPOT - (CL/V)*CENT;
"
tm <- tte_model(my_model, hazard = "H0 * exp(lp + beta * (CENT/V))",
  params = list(H0 = 0.01, beta = 0.05))
#> Building simtte_tte_model_7b992c4fe49b ... done.
sim_tm <- sim_tte_ode(model = tm, n = 50, end = 30, delta = 2, data = dose,
  seed = 1)
head(sim_tm$events)
#>   ID  sim_time sim_status     sim_reason
#> 1  1 30.000000          0 administrative
#> 2  2 30.000000          0 administrative
#> 3  3 30.000000          0 administrative
#> 4  4  8.003627          1          event
#> 5  5 30.000000          0 administrative
#> 6  6  8.918469          1          event

Covariates, via a formula and a design matrix

baseline <- data.frame(age = rnorm(50, 50, 10),
  arm = factor(rep(c("control", "treated"), 25)))
sim_cov <- sim_tte_ode(model = "weibull", param = list(mu = -1, shape = 1.5),
  n = 50, end = 20, delta = 2, covariates = baseline,
  formula = ~ age + arm, beta = c(age = 0.01, armtreated = -0.5), seed = 1)
head(sim_cov$events)
#>   ID  sim_time sim_status sim_reason
#> 1  1 1.6907000          1      event
#> 2  2 1.9528771          1      event
#> 3  3 1.0421564          1      event
#> 4  4 0.4222671          1      event
#> 5  5 1.8574621          1      event
#> 6  6 0.4012701          1      event

Right censoring

censored <- add_censoring(sim$events, censoring = list(dist = "exponential",
  rate = 0.02), end = 30, seed = 2)
table(censored$sim_reason)
#> 
#> administrative       censored          event 
#>             29             16              5

Interval censoring from a realistic visit schedule

sched <- visit_schedule(n = 50, every = 4, end = 30, jitter = 1, seed = 1)
realized <- thin_visits(sched, p_miss = 0.1, seed = 2)
interval <- add_interval_censoring(censored, visits = realized)
head(interval[, c("sim_time", "sim_status", "sim_time_left", "sim_time_right")])
#>    sim_time sim_status sim_time_left sim_time_right
#> 1 30.000000          0     28.889351            Inf
#> 2 20.237404          0     19.353114            Inf
#> 3  7.332633          0      4.539683            Inf
#> 4 16.404917          1     15.534441       19.77223
#> 5  4.476309          0      0.000000            Inf
#> 6 18.151475          1     16.447422       19.82255

See vignette("pkpd-time-to-event", package = "simtte") for the full walkthrough, vignette("bring-your-own-model", package = "simtte") for everything tte_model() does, and vignette("censoring-and-assessment", package = "simtte") for the full censoring/visit-schedule toolkit.

Legacy API: sim_tte() / sim_tte_df()

The original closed-form Weibull/M-spline API, unchanged since CRAN 1.0.2.

Weibull Model

set.seed(42)
lp <- matrix(rnorm(50, 0, 0.5), nrow = 50)

result <- sim_tte(
  pi       = lp,
  mu       = -1,
  coefs    = 1.1,        # Weibull shape parameter
  time     = seq(0.1, 100, by = 0.1),
  type     = "weibull",
  end_time = 100
)
#> Building weibull ... done.

head(result)
#> # A tibble: 6 × 4
#>   sim_time sim_status    ID      lp
#>      <dbl>      <dbl> <dbl>   <dbl>
#> 1      0.7          1     1  0.685 
#> 2      4.8          1     2 -0.282 
#> 3      3.1          1     3  0.182 
#> 4      1.8          1     4  0.316 
#> 5      0.2          1     5  0.202 
#> 6      0.2          1     6 -0.0531

Flexible M-Spline Model

data("ms_data")

lp <- matrix(runif(nrow(ms_data$basis)), nrow = nrow(ms_data$basis))

result <- sim_tte(
  pi    = lp,
  mu    = ms_data$mu,
  basis = ms_data$basis,
  coefs = ms_data$coefs,
  time  = ms_data$time,
  type  = "ms"
)
#> Building ms ... done.

head(result)
#> # A tibble: 6 × 4
#>   sim_time sim_status    ID    lp
#>      <dbl>      <dbl> <dbl> <dbl>
#> 1     6.73          0     1 0.719
#> 2     4.35          1     2 0.324
#> 3     2.86          1     3 0.779
#> 4     1.84          1     4 0.394
#> 5     1.68          1     5 0.679
#> 6     2.84          1     6 0.776

Event-Time Interpolation

By default, sim_tte() and sim_tte_df() resolve each event time as the first reported time at which survival falls to or below a sampled uniform draw (event_time_method = "grid"). Setting event_time_method = "log_survival" refines a crossing that falls strictly between two reported time points, by linearly interpolating cumulative hazard between them, without changing which subjects are classified as events versus censored:

result_grid <- sim_tte(
  pi       = lp,
  mu       = -1,
  coefs    = 1.1,
  time     = seq(0.1, 100, by = 0.1),
  type     = "weibull",
  end_time = 100,
  event_time_method = "grid"          # default
)
#> Loading model from cache.

result_interp <- sim_tte(
  pi       = lp,
  mu       = -1,
  coefs    = 1.1,
  time     = seq(0.1, 100, by = 0.1),
  type     = "weibull",
  end_time = 100,
  event_time_method = "log_survival"  # sub-grid refinement
)
#> Loading model from cache.

See Statistical Background below for what "log_survival" assumes and when it is exact rather than approximate.

The rest of the 1.0.x API

sim_tte()/sim_tte_df() also come with explore_pi_tq_surv() (a prognostic-index/survival-difference exploration utility) and further Weibull/M-spline examples – see vignette("advanced-usage") for the full walkthrough.

Statistical Background

Weibull Model

The Weibull hazard at time $t$ for individual $i$ is:

$$h_i(t) = \exp(\mu + \mathbf{x}_i'\boldsymbol{\beta}) \cdot \gamma \cdot t^{\gamma - 1}$$

where $\mu$ is the intercept, $\mathbf{x}_i'\boldsymbol{\beta}$ is the linear predictor (prognostic index), and $\gamma > 0$ is the shape parameter (coefs). The corresponding survival probability $S_i(t) = \exp[-\exp(\mu + \mathbf{x}_i'\boldsymbol{\beta}) \cdot t^\gamma]$ has a closed form, so simtte evaluates it directly on the reported time grid rather than by numerically integrating the survival ODE; this is numerically robust across shape and parameter regimes (including near $t = 0$ for $\gamma < 1$, and large intercept/linear-predictor values), but it is specific to the Weibull model, not a general continuous-time event-root solver.

M-Spline (Flexible Parametric) Model

The baseline hazard is expressed as a linear combination of M-spline basis functions:

$$h_0(t) = \sum_{k} \alpha_k M_k(t)$$

This approach (Royston & Parmar, 2002) accommodates non-monotone and non-proportional hazard shapes, making it suitable for modelling immunotherapy and other treatments with delayed effects. The hazard is supplied to mrgsolve as a time-varying input evaluated at the elements of time; between two consecutive supplied time points, the hazard value at the earlier point applies (a last-observation-carried-forward convention), giving a piecewise-constant hazard on the reported grid rather than continuous re-evaluation of the M-spline basis at arbitrary times.

How event times are generated

The two engines resolve event times in genuinely different ways – this section describes each in turn.

sim_tte_ode() (default engine for PK/PD-linked and ODE models)

Survival is one more ODE compartment, p11, alongside the model’s own PK/PD states, with dxdt_p11 = -p11 * HAZ. A uniform draw $U \sim \text{Uniform}(0,1)$ is made per subject up front (one stats::runif() draw per row of idata, before the solver runs, not after a trajectory exists), and the solver is watched internally for the first step at which $p11(t) \le U$ – an in-solver root, latched at the solver’s own internal step grid, which is generally finer than, and independent of, the requested output grid (delta/add). Once a crossing is latched, the reported event time is refined further inside the one solver step that bracketed it (not between two reported trajectory rows), so refinement accuracy does not depend on how coarse delta/add are. A subject with no crossing before end is administratively censored at end. There is no event_time_method argument here – that is a sim_tte()/sim_tte_df()-only option (see below). See ?sim_tte_ode’s “Event-time refinement” section for the full mechanics and vignette("pkpd-time-to-event") for a walkthrough.

sim_tte() / sim_tte_df() (legacy 1.0.x engine)

For each individual, the package obtains $S(t)$ on the exact grid supplied via time (default seq(0, 100, by = 1), extended to end_time if necessary, and reported by mrgsolve at exactly those times via its tgrid mechanism — not mrgsolve’s own default output schedule), then draws $U \sim \text{Uniform}(0,1)$ after the trajectory exists and finds the first reported grid time $t^$ such that $S(t^) \le U$. For sim_tte(), a subject with no such $t^*$ is administratively censored at end_time; sim_tte_df() instead censors at that subject’s own last reported time (which coincides with end_time for sim_tte() by construction, but need not for an arbitrary user-supplied trajectory with per-subject time ranges). By default (event_time_method = "grid"), event-time resolution is exactly this grid-based lookup, with precision limited by the spacing of time.

Setting event_time_method = "log_survival" refines a crossing that falls strictly between two reported grid points $(t_i, S_i)$ and $(t_{i+1}, S_{i+1})$, by linearly interpolating cumulative hazard $H(t) = -\log S(t)$ between them and solving for the time at which $H(t) = -\log U$. This is equivalent to assuming the hazard is constant over $[t_i, t_{i+1})$: for the M-spline model, whose hazard genuinely is piecewise constant on the reported grid (see above), this recovers the event time implied by that discretized hazard exactly; for a continuously varying hazard (the Weibull model with $\gamma \neq 1$, or a custom mechanistic model), it is an approximation whose accuracy improves as time is refined. event_time_method never changes which subjects are classified as events versus censored, and never changes the reported time for censoring or for a crossing already present at the first reported observation — it only provides sub-grid event-time resolution for an interior crossing, and is not a general continuous-time root solver. event_time_method does not exist on sim_tte_ode(), which refines every event time via the in-solver bracket described above regardless of the output grid.

Custom Survival Trajectories

sim_tte_df() accepts a user-supplied survival trajectory (e.g. from a custom mrgsolve model) subject to a validated contract: time values must be finite, non-negative, and strictly increasing per subject (duplicate or unsorted times are rejected, not silently repaired); survival values are normalized to $[0, 1]$ (values within a small floating-point tolerance of the boundary are clamped, values further outside are rejected) and must be non-increasing per subject within the same tolerance; and subjects may have different reported time ranges, each subject being censored at their own final reported time if no crossing occurs.

Function Reference

Function Description
sim_tte_ode() In-solver joint PK/PD and time-to-event simulation (built-in library models or a converted user model)
sim_tte_ode_models() List the built-in library model names and their between-subject-variability targets
tte_model() Convert a user-supplied mrgsolve PK/PD model for use with sim_tte_ode()
add_censoring() Apply independent right censoring to any simulated events data frame
censoring_rate_for() Solve for a censoring-distribution parameter hitting a target censoring fraction
draw_censoring_times() Draw directly from a censoring/delay distribution spec
add_interval_censoring() Map a (possibly right-censored) outcome onto a visit-schedule interval
visit_schedule() Generate a per-subject visit schedule (fixed spacing + jitter)
thin_visits() Missed visits and assessment dropout on a schedule (non-informative)
visit_schedule_informative() A visit schedule that reacts to the simulated event time (informative)
sim_tte() Simulate a survival dataset from Weibull or M-spline model (legacy API)
sim_tte_df() Apply inverse transform sampling to custom mrgsolve output (legacy API)
explore_pi_tq_surv() Explore survival differences across prognostic index values
simtte_example_model() / simtte_example_models() Bundled example PK/PD-hazard models for sim_tte_df()
ms_data Bundled example M-spline basis, coefficients, and time grid

Output Structure

sim_tte_ode() returns a list whose $events is a data frame with ID, sim_time, sim_status (1 = event, 0 = censored), and sim_reason ("event"/"censored"/"administrative"), plus interval bounds if a visit schedule was used. The legacy sim_tte()/sim_tte_df() functions return a plain data frame:

Column Type Description
sim_time numeric Simulated event or censoring time
sim_status integer Event indicator: 1 = event, 0 = censored
ID integer Subject identifier
lp numeric Log prognostic index (linear predictor)

Vignettes

Four vignettes provide deeper guidance:

vignette("pkpd-time-to-event",       package = "simtte")
vignette("bring-your-own-model",     package = "simtte")
vignette("censoring-and-assessment", package = "simtte")
vignette("introduction",             package = "simtte")
vignette("advanced-usage",           package = "simtte")

Citation

If you use simtte in published research, please cite:

Traynor C (2026). simtte: Simulate Bespoke Time-to-Event Models Using
ODEs. R package version 1.1.0.
https://CRAN.R-project.org/package=simtte

References

Bender R, Augustin T, Blettner M (2005). Generating survival times to simulate Cox proportional hazards models. Statistics in Medicine, 24(11), 1713–1723. doi:10.1002/sim.2059

Royston P, Parmar MKB (2002). Flexible parametric proportional-hazards and proportional-odds models for censored survival data, with application to prognostic modelling and estimation of treatment effects. Statistics in Medicine, 21(15), 2175–2197. doi:10.1002/sim.1203

License

GPL (>= 2)