Multiscale Modelling for Digital Twins in Vaccine Trials

A three-scale R pipeline: viral dynamics → antibody kinetics → population trial outcomes

digital twin
multiscale
ODE
R
vaccine
infectious disease
Author

Jong-Hoon Kim

Published

April 22, 2026

1 Why multiple scales?

The outcome of a vaccine trial depends on processes that span at least three orders of magnitude in time and space: molecular events inside a single cell, the kinetics of an individual’s immune system, and the spread of a pathogen through a population. A multiscale digital twin is a computational stack that couples models at each of these scales so that trial-level predictions inherit uncertainty from every scale below (1).

This post builds such a stack in R. Each section implements one scale as an ODE system, connects its outputs to the next scale, and visualises the resulting dynamics. The goal is a working pipeline you can inspect, modify, and reason about.

Scale Model Key output
1 — Within-host Target cell ODEs Viral load trajectory
2 — Immunological Antigen–antibody ODEs Titre at challenge; waning rate
3 — Population SIR with heterogeneous VE Attack rate in trial arms

2 Scale 1 — Within-host viral dynamics

2.1 The target cell model

The target cell limited model (2) tracks three populations:

\[ \frac{dT}{dt} = \lambda - d_T T - \beta V T \] \[ \frac{dI}{dt} = \beta V T - \delta I \] \[ \frac{dV}{dt} = p\,I - c\,V \]

\(T\) = uninfected target cells (e.g. airway epithelial cells), \(I\) = productively infected cells, \(V\) = free virus (virions/mL). The within-host basic reproductive number is \(R_0^w = \beta T_0 p \,(c\,\delta)^{-1}\).

Code
tcm <- function(t, y, parms) {
  with(as.list(c(y, parms)), {
    dT <-  lambda - d_T * T - beta * V * T
    dI <-  beta  * V * T   - delta * I
    dV <-  p * I            - c    * V
    list(c(dT, dI, dV))
  })
}

# Parameters for a generic respiratory virus (for illustration)
# R0_within = beta * T0 * p / (c * delta) = 5e-8 * 1e7 * 50 / 5 = 5
parms0 <- c(
  lambda = 1e5,   # target-cell production (/day); T* = lambda/d_T = 1e7
  d_T    = 0.01,  # background death rate (/day)
  beta   = 5e-8,  # infection rate constant (mL/virion/day)
  delta  = 1.0,   # infected-cell clearance (/day)
  p      = 50,    # viral burst size (virions/cell/day)
  c      = 5.0    # viral clearance rate (/day)
)

y0    <- c(T = 1e7, I = 100, V = 0)   # I0 = 100 initially infected cells
times <- seq(0, 21, by = 0.05)

out0 <- as.data.frame(ode(y = y0, times = times, func = tcm, parms = parms0))

df1 <- out0 |>
  select(time, T, V) |>
  mutate(
    T = T / 1e7,           # fraction of initial target cells
    V = V / max(V)         # fraction of peak viral load
  ) |>
  pivot_longer(c(T, V), names_to = "variable", values_to = "value") |>
  mutate(variable = recode(variable,
    T = "Target cells (fraction of T₀)",
    V = "Viral load (fraction of peak)"
  ))

ggplot(df1, aes(x = time, y = value, colour = variable)) +
  geom_line(linewidth = 0.9) +
  scale_colour_manual(values = c("#E87722", "#3B6EA8")) +
  labs(x = "Days post-infection", y = "Relative value", colour = NULL) +
  theme_bw(base_size = 12) +
  theme(legend.position = "top")

Single within-host simulation. Viral load (blue) peaks around day 4–5 as infected cells expand, then collapses as target cells are depleted and virus is cleared. Target cells (orange) partially recover after the acute phase.

2.2 Effect of immune clearance rate

The infected-cell clearance rate \(\delta\) encodes the combined effect of cytotoxic T-cell killing, innate immune pressure, and apoptosis. People differ in \(\delta\): stronger cellular immunity clears infected cells faster, limiting peak viraemia.

Code
delta_vals <- seq(0.3, 3.5, by = 0.1)

sweep_res <- lapply(delta_vals, function(d) {
  p_i        <- parms0
  p_i["delta"] <- d
  out_i      <- as.data.frame(
    ode(y = y0, times = times, func = tcm, parms = p_i)
  )
  above    <- out_i$V > 1
  duration <- if (any(above)) diff(range(out_i$time[above])) else 0
  data.frame(delta = d, peak_V = max(out_i$V), duration = duration)
})
sw <- bind_rows(sweep_res)

p_pk <- ggplot(sw, aes(x = delta, y = peak_V / 1e6)) +
  geom_line(colour = "#3B6EA8", linewidth = 0.9) +
  geom_point(colour = "#3B6EA8", size = 1.8) +
  labs(x = expression(paste("Clearance rate ", delta, " (/day)")),
       y = expression(paste("Peak viral load (", 10^6, " virions/mL)"))) +
  theme_bw(base_size = 12)

p_dur <- ggplot(sw, aes(x = delta, y = duration)) +
  geom_line(colour = "#E87722", linewidth = 0.9) +
  geom_point(colour = "#E87722", size = 1.8) +
  labs(x = expression(paste("Clearance rate ", delta, " (/day)")),
       y = "Detectable infection (days)") +
  theme_bw(base_size = 12)

p_pk + p_dur

Effect of varying the infected-cell clearance rate δ on peak viral load (left) and the duration virus is detectable above 1 virion/mL (right). The nonlinear collapse above δ ≈ 1.5 /day illustrates why a modest boost in cellular immunity can dramatically reduce viraemia.

This parameter sweep motivates the virtual patient concept: if individuals draw \(\delta\) from a distribution rather than a single value, the resulting viral load trajectories span a wide range, producing the population-level heterogeneity that Scale 2 and Scale 3 must represent.


3 Scale 2 — Vaccine-induced antibody kinetics

3.1 Antigen-driven antibody model

After vaccination, antigen is cleared while simultaneously driving B-cell differentiation and antibody secretion. A two-compartment model captures the essentials:

\[ \frac{dAg}{dt} = -k_{Ag}\,Ag \] \[ \frac{dAb}{dt} = r_{Ab}\,Ag - d_{Ab}\,Ab \]

\(Ag\) = antigen (normalised dose units), \(Ab\) = serum antibody titre (AU/mL). With \(Ag(0)=1\), \(Ab(0)=0\), the analytical solution is:

\[ Ab(t) = \frac{r_{Ab}}{k_{Ag}-d_{Ab}}\bigl(e^{-d_{Ab}t}-e^{-k_{Ag}t}\bigr) \]

Titre peaks at \(t^{*} = \ln(k_{Ag}/d_{Ab})\,/\,(k_{Ag}-d_{Ab})\), typically 10–21 days, then wanes as \(e^{-d_{Ab}t}\) dominates.

Code
Ab_t <- function(t, r_Ab = 5, d_Ab = 0.01, k_Ag = 0.3) {
  r_Ab / (k_Ag - d_Ab) * (exp(-d_Ab * t) - exp(-k_Ag * t))
}

t_vac <- seq(0, 180, by = 0.5)
df2a  <- data.frame(time = t_vac, titre = Ab_t(t_vac))

ggplot(df2a, aes(x = time, y = titre)) +
  annotate("rect", xmin = 55, xmax = 65, ymin = -Inf, ymax = Inf,
           fill = "#3B6EA8", alpha = 0.12) +
  geom_line(colour = "#3B6EA8", linewidth = 0.9) +
  annotate("text", x = 60, y = max(df2a$titre) * 0.95,
           label = "Challenge\nwindow", size = 3.2, hjust = 0.5,
           colour = "#3B6EA8") +
  labs(x = "Days post-vaccination", y = "Antibody titre (AU/mL)") +
  theme_bw(base_size = 12)

Antibody kinetics for a single vaccinee. Titre peaks near day 14 and wanes slowly over months. The shaded band marks the 60-day challenge window used in Scale 3.

3.2 Virtual patient cohort

Two parameters capture the main axes of inter-individual variation:

  • \(r_{Ab}\): immune responsiveness (B-cell clone size, adjuvant response)
  • \(d_{Ab}\): waning rate (long-lived plasma-cell survival, isotype class)

We sample 500 virtual vaccinees from log-normal distributions and extract each individual’s titre at day 60 — a plausible mid-trial challenge time.

Code
set.seed(42)
n_vp <- 500

r_Ab_i <- exp(rnorm(n_vp, log(5),    0.55))  # ~55% CV in responsiveness
d_Ab_i <- exp(rnorm(n_vp, log(0.01), 0.40))  # ~40% CV in waning rate

titre_60 <- pmax(
  mapply(Ab_t, t = 60, r_Ab = r_Ab_i, d_Ab = d_Ab_i),
  0.01   # numerical floor
)

ggplot(data.frame(titre = titre_60), aes(x = titre)) +
  geom_histogram(bins = 50, fill = "#3B6EA8", colour = "white",
                 linewidth = 0.2) +
  scale_x_log10() +
  labs(x = "Antibody titre at day 60 (AU/mL, log scale)", y = "Count") +
  theme_bw(base_size = 12)

Distribution of antibody titres at the 60-day challenge window across 500 virtual patients. The log scale reveals the right-skewed distribution typical of clinical immunogenicity data. Variation arises from individual differences in immune responsiveness and antibody waning.

The distribution is approximately log-normal, consistent with observed immunogenicity data for licensed vaccines. The two-fold range from 5th to 95th percentile reflects the biological reality that the same vaccine produces widely varying responses across individuals.


4 Scale 3 — Population trial outcome

4.1 From titre to individual protection

The antibody–protection relationship is well described by a Hill function:

\[ \text{VE}_i = \frac{T_i^k}{T_i^k + \text{EC}_{50}^k} \]

where \(T_i\) is individual \(i\)’s titre at challenge, \(\text{EC}_{50}\) is the titre giving 50% protection, and \(k\) governs steepness. Setting \(\text{EC}_{50} = \text{median}(T_i)\) ensures that the median virtual patient has 50% protection — a conservative but transparent calibration choice.

4.2 Analytic attack rate

For an SIR epidemic, the final attack rate satisfies the implicit equation:

\[ 1 - \text{AR} = \exp\!\bigl(-R_0^{\text{eff}}\cdot\text{AR}\bigr), \qquad R_0^{\text{eff}} = R_0\,(1 - \bar{\text{VE}}) \]

where \(\bar{\text{VE}} = n^{-1}\sum_i \text{VE}_i\). This avoids running one ODE system per trial replicate while preserving the nonlinear epidemic threshold behaviour.

Code
EC50 <- median(titre_60)
k    <- 2
VEi  <- titre_60^k / (titre_60^k + EC50^k)

ar_solve <- function(mean_VE, R0 = 2.5) {
  R0_eff <- R0 * (1 - mean_VE)
  if (R0_eff <= 1) return(0)
  uniroot(function(ar) 1 - ar - exp(-R0_eff * ar),
          interval = c(1e-8, 1 - 1e-8))$root
}

set.seed(7)
n_trial <- 200
n_rep   <- 1000

vacc_ar <- replicate(n_rep, {
  idx  <- sample(n_vp, n_trial, replace = FALSE)
  R0_i <- rnorm(1, 2.5, 0.15)
  ar_solve(mean(VEi[idx]), R0_i)
})

placebo_ar <- replicate(n_rep, {
  ar_solve(0, rnorm(1, 2.5, 0.15))
})

p_ve <- ggplot(data.frame(VE = VEi), aes(x = VE)) +
  geom_histogram(bins = 40, fill = "#E87722", colour = "white",
                 linewidth = 0.2) +
  labs(x = "Individual VE", y = "Count") +
  theme_bw(base_size = 12)

df3 <- bind_rows(
  data.frame(arm = "Vaccine", ar = vacc_ar),
  data.frame(arm = "Placebo", ar = placebo_ar)
)

p_ar <- ggplot(df3, aes(x = ar, fill = arm, colour = arm)) +
  geom_density(alpha = 0.35, linewidth = 0.7) +
  scale_fill_manual(values  = c(Vaccine = "#E87722", Placebo = "#3B6EA8")) +
  scale_colour_manual(values = c(Vaccine = "#E87722", Placebo = "#3B6EA8")) +
  labs(x = "Attack rate", y = "Density", fill = NULL, colour = NULL) +
  theme_bw(base_size = 12) +
  theme(legend.position = "top")

p_ve + p_ar

Scale 3 outputs. Left: individual VE distribution across the 500 virtual vaccinees, arising directly from the titre distribution in Scale 2. Right: attack-rate densities across 1000 bootstrapped trial replicates (200 participants per arm). The vaccinated arm (orange) is shifted far left of the placebo arm (blue), with additional spread from VE heterogeneity.

5 Cross-scale sensitivity analysis

A key virtue of a multiscale pipeline is that you can trace how uncertainty at one scale propagates to outcomes at another. Here we sweep the antibody waning rate \(d_{Ab}\) (Scale 2) across a physiologically plausible range and ask: how does it affect the attack rate in the vaccinated arm (Scale 3)?

Code
d_Ab_range <- exp(seq(log(0.002), log(0.08), length.out = 50))

cross <- lapply(d_Ab_range, function(d) {
  t_i   <- max(Ab_t(60, r_Ab = 5, d_Ab = d), 0.01)
  ve_i  <- t_i^k / (t_i^k + EC50^k)
  ar_i  <- ar_solve(ve_i, R0 = 2.5)
  data.frame(d_Ab = d, titre = t_i, ar = ar_i)
})
cr <- bind_rows(cross)

placebo_ref <- ar_solve(0, 2.5)

p_cs1 <- ggplot(cr, aes(x = d_Ab, y = titre)) +
  geom_line(colour = "#3B6EA8", linewidth = 0.9) +
  scale_x_log10() +
  labs(x = expression(paste("Waning rate ", d[Ab], " (/day)")),
       y = "Titre at day 60 (AU/mL)") +
  theme_bw(base_size = 12)

p_cs2 <- ggplot(cr, aes(x = d_Ab, y = ar)) +
  geom_hline(yintercept = placebo_ref, linetype = "dashed",
             colour = "grey50") +
  geom_line(colour = "#E87722", linewidth = 0.9) +
  annotate("text", x = 0.003, y = placebo_ref * 1.03,
           label = "Placebo", size = 3.2, colour = "grey40", hjust = 0) +
  scale_x_log10() +
  labs(x = expression(paste("Waning rate ", d[Ab], " (/day)")),
       y = "Attack rate (vaccinated arm)") +
  theme_bw(base_size = 12)

p_cs1 + p_cs2

Cross-scale propagation: how the Scale 2 waning rate d_Ab drives the Scale 3 trial outcome. Left: titre at the 60-day challenge window collapses as waning accelerates. Right: corresponding attack rate in the vaccinated arm rises toward the placebo level (grey dashed line, R₀ = 2.5). The inflection near d_Ab ≈ 0.01 /day (antibody half-life ∼ 70 days) marks the critical waning threshold for this vaccine and challenge scenario.

When \(d_{Ab}\) is small (slow waning), titres remain above EC\(_{50}\) at day 60 and the vaccinated arm is well protected. As waning accelerates, titres fall below EC\(_{50}\) and trial attack rate converges to the placebo level. This curve is directly actionable for trial design: it identifies the minimum waning rate that the vaccine must achieve to preserve measurable efficacy at a given challenge time.


6 Discussion

6.1 What makes this a digital twin?

Three features distinguish this pipeline from a conventional transmission model:

  1. Individualisation. Each virtual patient has their own \((r_{Ab},\, d_{Ab})\), producing a titre distribution rather than a point estimate. The trial outcome inherits this individual-level variability directly.

  2. Explicit scale coupling. Scale 2 outputs feed into Scale 3 analytically, and the cross-scale analysis runs the pipeline in reverse — from a target attack rate down to the required titre and then to the required waning rate. Bidirectional reasoning is what separates a digital twin from a forward-only simulation.

  3. Counterfactual control arm. The placebo arm is generated by setting \(\overline{\text{VE}} = 0\) in the same SIR model. This counterfactual is the regulatory anchor that sponsors submit to FDA/EMA under the MIDD program (3).

6.2 Limitations of this illustration

  • Decoupled Scale 1. In a full multiscale twin, the viral dynamics model (Scale 1) would set initial conditions for Scale 2: the antigen dose reaching lymph nodes is a function of the viral inoculum and the innate immune response at the injection site. Coupling these requires a more elaborate interfacing layer, as implemented in the mRNA vaccine QSP models of Dasti et al. (4).

  • Steady-state immunity. The model fixes challenge at a single time point (day 60). A realistic twin would draw challenge time from the epidemic curve — itself a Scale 3 output — creating a feedback loop that the present pipeline ignores.

  • Homogeneous mixing. The SIR final-size approximation assumes random contact. Age structure, household clustering, or healthcare-worker exposure would each shift the attack rate curve and require additional scales (5).

6.3 Extensions

The pipeline is modular by design: swapping Scale 1 from a generic respiratory model to an influenza-specific or SARS-CoV-2-specific parameterisation (2) requires changing only that block. Similarly, Scale 3 can be upgraded from a homogeneous SIR to an age-stratified SEIR without touching Scales 1 or 2. This modularity — formalised by Masison et al. (1) — is what makes multiscale digital twins tractable despite their apparent complexity.

Physics-informed neural networks (6) offer a complementary direction: rather than specifying between-scale mappings analytically, a neural network trained on multi-scale longitudinal data can learn them from observations, at the cost of interpretability but with potentially higher accuracy when mechanistic knowledge is incomplete.


References

1.
Masison J, Beezley J, Carlson Y, et al. A modular computational framework for medical digital twins. Proceedings of the National Academy of Sciences. 2021;118(20):e2024287118. doi:10.1073/pnas.2024287118
2.
Perelson AS, Neumann AU, Markowitz M, Leonard JM, Ho DD. HIV-1 dynamics in vivo: Virion clearance rate, infected cell life-span, and viral generation time. Science. 1996;271(5255):1582–6. doi:10.1126/science.271.5255.1582
3.
U.S. Food and Drug Administration. Model-informed drug development: Paired meeting program for new drugs and biological products [Internet]. Guidance for Industry; 2020. Available from: https://www.fda.gov/media/133986/download
4.
Dasti L et al. A multiscale quantitative systems pharmacology model for the development and optimization of mRNA vaccines. CPT: Pharmacometrics & Systems Pharmacology. 2025. doi:10.1002/psp4.70041
5.
Niarakis A et al. Immune digital twins for complex human pathologies: Applications, limitations, and challenges. npj Systems Biology and Applications. 2024;10:141. doi:10.1038/s41540-024-00450-5
6.
Raissi M, Perdikaris P, Karniadakis GE. Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations. Journal of Computational Physics. 2019;378:686–707. doi:10.1016/j.jcp.2018.10.045
R version 4.5.3 (2026-03-11 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_United States.utf8 
[2] LC_CTYPE=English_United States.utf8   
[3] LC_MONETARY=English_United States.utf8
[4] LC_NUMERIC=C                          
[5] LC_TIME=English_United States.utf8    

time zone: Asia/Seoul
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] patchwork_1.3.2 tidyr_1.3.2     dplyr_1.2.0     ggplot2_4.0.2  
[5] deSolve_1.42   

loaded via a namespace (and not attached):
 [1] vctrs_0.7.2        cli_3.6.5          knitr_1.51         rlang_1.1.7       
 [5] xfun_0.57          otel_0.2.0         purrr_1.2.1        generics_0.1.4    
 [9] S7_0.2.1           jsonlite_2.0.0     labeling_0.4.3     glue_1.8.0        
[13] htmltools_0.5.9    scales_1.4.0       rmarkdown_2.31     grid_4.5.3        
[17] tibble_3.3.1       evaluate_1.0.5     fastmap_1.2.0      yaml_2.3.12       
[21] lifecycle_1.0.5    compiler_4.5.3     RColorBrewer_1.1-3 pkgconfig_2.0.3   
[25] htmlwidgets_1.6.4  farver_2.1.2       digest_0.6.39      R6_2.6.1          
[29] tidyselect_1.2.1   pillar_1.11.1      magrittr_2.0.4     withr_3.0.2       
[33] tools_4.5.3        gtable_0.3.6