library(shiny)
library(bslib)
library(mrgsolve)
library(dplyr)
library(ggplot2)
# ── mrgsolve PK/PD model ─────────────────────────────────────────────────────
model_code <- '
$PARAM @annotated
Vmax : 207.0 : Max DPD-mediated elimination rate (mg/h, at BSA 1.73 m2)
Km : 0.70 : Michaelis-Menten half-saturation constant (mg/L)
V1 : 10.0 : Central volume of distribution (L)
V2 : 15.0 : Peripheral volume of distribution (L)
Q : 5.0 : Inter-compartmental clearance (L/h)
BSA : 1.73 : Body surface area (m2)
DPD_ACT : 1.0 : DPD activity fraction (1.0 = normal; 0.5 = deficiency)
Circ0 : 5000 : Baseline ANC (cells/uL)
Slope : 0.009 : Linear drug-effect slope on proliferating cells (L/mg)
MTT : 120 : Mean transit time of neutrophil maturation (h)
Gamma : 0.27 : Feedback power exponent
$CMT @annotated
CENT : Central compartment (mg)
PERI : Peripheral compartment (mg)
PROL : Proliferating cell precursors
TRAN1 : Maturation transit compartment 1
TRAN2 : Maturation transit compartment 2
TRAN3 : Maturation transit compartment 3
CIRC : Circulating neutrophils (cells/uL)
$MAIN
double Vmaxi = Vmax * DPD_ACT * (BSA / 1.73);
double ktr = 4.0 / MTT;
PROL_0 = Circ0;
TRAN1_0 = Circ0;
TRAN2_0 = Circ0;
TRAN3_0 = Circ0;
CIRC_0 = Circ0;
$ODE
double CPo = CENT / V1;
double CPp = PERI / V2;
double CIRCsafe = (CIRC > 10.0) ? CIRC : 10.0;
dxdt_CENT = -(Vmaxi * CPo) / (Km + CPo) - Q * (CPo - CPp);
dxdt_PERI = Q * (CPo - CPp);
double Edrug = Slope * CPo;
if (Edrug > 0.95) Edrug = 0.95;
double feedback = pow(Circ0 / CIRCsafe, Gamma);
dxdt_PROL = ktr * PROL * (1.0 - Edrug) * feedback - ktr * PROL;
dxdt_TRAN1 = ktr * PROL - ktr * TRAN1;
dxdt_TRAN2 = ktr * TRAN1 - ktr * TRAN2;
dxdt_TRAN3 = ktr * TRAN2 - ktr * TRAN3;
dxdt_CIRC = ktr * TRAN3 - ktr * CIRC;
$TABLE
double CP = CENT / V1;
double ANC = CIRC;
$CAPTURE @annotated
CP : Plasma 5-FU concentration (mg/L)
ANC : Absolute neutrophil count (cells/uL)
'
mod <- mcode("fu5_pkpd", model_code)
# ── Theme ────────────────────────────────────────────────────────────────────
app_theme <- bs_theme(
version = 5, bootswatch = "flatly", primary = "#8b5cf6"
) |> bs_add_rules("
.metric-card { background: #f8f9fa; border-radius: 8px; padding: 15px; margin: 5px;
text-align: center; border: 1px solid #dee2e6; }
.metric-value { font-size: 24px; font-weight: bold; color: #2c3e50; }
.metric-label { font-size: 12px; color: #7f8c8d; margin-top: 4px; }
.metric-success .metric-value { color: #10b981; }
.metric-warning .metric-value { color: #f59e0b; }
.metric-primary .metric-value { color: #8b5cf6; }
.metric-info .metric-value { color: #0dcaf0; }
.auc-badge { display: inline-block; padding: 2px 9px; border-radius: 20px;
font-size: 10px; font-weight: 700; margin-left: 4px; }
.auc-ok { background: #d1fae5; color: #065f46; }
.auc-low { background: #fef3c7; color: #92400e; }
.auc-high { background: #fee2e2; color: #991b1b; }
.ref-box { background: #f0f4ff; border-left: 4px solid #8b5cf6;
padding: 14px 18px; border-radius: 4px; margin-top: 10px; font-size: 13px; }
.footer-bar { text-align: center; padding: 16px; margin-top: 8px;
border-top: 1px solid #e9ecef; color: #6c757d; font-size: 12px; }
")
# ── Regimen presets ──────────────────────────────────────────────────────────
regimen_presets <- list(
"FOLFOX6 (q2w)" = list(dose = 2400, dur = 46, ii = 14),
"FOLFIRI (q2w)" = list(dose = 2400, dur = 46, ii = 14),
"LV5FU2 (q2w)" = list(dose = 600, dur = 22, ii = 14),
"Continuous (5-day)" = list(dose = 1000, dur = 120, ii = 28),
"Custom" = list(dose = 2000, dur = 46, ii = 14)
)
# ── UI ───────────────────────────────────────────────────────────────────────
ui <- page_sidebar(
title = "5-Fluorouracil (5-FU) PK/PD Simulator",
theme = app_theme,
sidebar = sidebar(
title = "Simulation Settings", width = 340,
h6("Chemotherapy Regimen"),
selectInput("regimen", NULL,
choices = names(regimen_presets),
selected = "FOLFOX6 (q2w)"),
hr(),
h6("Dosing Parameters"),
sliderInput("bsa", "Body Surface Area (m\u00b2)", min = 1.2, max = 2.4, value = 1.73, step = 0.01),
sliderInput("dose_per_m2", "5-FU Dose (mg/m\u00b2)", min = 400, max = 4000, value = 2400, step = 100),
sliderInput("inf_dur", "Infusion Duration (h)", min = 2, max = 120, value = 46, step = 1),
sliderInput("n_cycles", "Number of Cycles", min = 1, max = 6, value = 3, step = 1),
sliderInput("cycle_ii", "Cycle Interval (days)", min = 7, max = 28, value = 14, step = 7),
hr(),
h6("Pharmacogenomics"),
sliderInput("dpd_act", "DPD Activity (fraction of normal)",
min = 0.3, max = 1.5, value = 1.0, step = 0.05),
helpText("\u26a0\ufe0f DPD deficiency (<0.7) \u2192 severe toxicity risk at standard doses"),
hr(),
checkboxInput("log_scale", "Log scale (concentration axis)", value = FALSE)
),
layout_column_wrap(
width = 1/4, fill = FALSE,
div(class = "metric-card metric-success",
div(class = "metric-value", textOutput("cmax")),
div(class = "metric-label", "Cmax (mg/L)")),
div(class = "metric-card metric-primary",
div(class = "metric-value", textOutput("auc_c1")),
div(class = "metric-label", htmlOutput("auc_label"))),
div(class = "metric-card metric-warning",
div(class = "metric-value", textOutput("nadir_anc")),
div(class = "metric-label", "Nadir ANC (cells/\u03bcL)")),
div(class = "metric-card metric-info",
div(class = "metric-value", textOutput("t_half")),
div(class = "metric-label", "Effective t\u00bd (h)"))
),
navset_card_underline(
full_screen = TRUE,
nav_panel("Pharmacokinetics",
plotOutput("pkPlot", height = "470px")),
nav_panel("Myelosuppression (Friberg)",
plotOutput("ancPlot", height = "470px")),
nav_panel("Model Information", div(style = "padding:12px;",
h4("5-Fluorouracil (5-FU) \u2014 2-CMT Michaelis-Menten + Friberg PK/PD Model"),
tags$hr(),
h5("Pharmacokinetic Model"),
tags$p("Structure: 2-compartment with Michaelis-Menten (saturable) elimination.
5-FU is primarily catabolized by hepatic DPD (\u226580% of elimination).
Capacity-limited kinetics mean small dose changes cause disproportionate AUC changes
\u2014 the pharmacological basis for TDM."),
tags$table(class="table table-sm table-bordered",
tags$thead(tags$tr(tags$th("Parameter"), tags$th("Typical Value"), tags$th("Description"))),
tags$tbody(
tags$tr(tags$td("Vmax"), tags$td("207 mg/h per 1.73 m\u00b2"), tags$td("Max DPD-mediated elimination")),
tags$tr(tags$td("Km"), tags$td("0.70 mg/L"), tags$td("Half-saturation constant")),
tags$tr(tags$td("V1"), tags$td("10 L"), tags$td("Central volume")),
tags$tr(tags$td("V2"), tags$td("15 L"), tags$td("Peripheral volume")),
tags$tr(tags$td("Q"), tags$td("5 L/h"), tags$td("Inter-compartmental CL"))
)
),
tags$hr(),
h5("Myelosuppression Model (Friberg Semi-Physiological)"),
tags$p("Proliferating cells \u2192 3 maturation transit compartments \u2192 circulating neutrophils.
Drug linearly inhibits proliferation. Feedback from circulating cell count
drives compensatory response."),
tags$table(class="table table-sm table-bordered",
tags$thead(tags$tr(tags$th("Parameter"), tags$th("Value"), tags$th("Description"))),
tags$tbody(
tags$tr(tags$td("Circ\u2080"), tags$td("5,000 cells/\u03bcL"), tags$td("Baseline ANC")),
tags$tr(tags$td("MTT"), tags$td("120 h"), tags$td("Mean transit time")),
tags$tr(tags$td("Slope"), tags$td("0.009 L/mg"), tags$td("Linear drug effect")),
tags$tr(tags$td("\u03b3"), tags$td("0.27"), tags$td("Feedback exponent"))
)
),
tags$hr(),
h5("Therapeutic Drug Monitoring (TDM)"),
tags$p(HTML("Target AUC: 20\u201330 mg\u00b7h/L per infusion cycle.
IATDMCT (2018) strongly recommends TDM of 5-FU in clinical practice.")),
tags$ul(
tags$li(tags$strong("< 20 mg\u00b7h/L:"), " Underdosing \u2014 dose escalation recommended"),
tags$li(tags$strong("20\u201330 mg\u00b7h/L:"), " Therapeutic \u2014 optimal exposure-efficacy"),
tags$li(tags$strong("> 30 mg\u00b7h/L:"), " Overdosing \u2014 excess toxicity, dose reduction required")
)
)),
nav_panel("References", div(class = "ref-box",
tags$h5("\U0001f4da Primary References"),
tags$ol(
tags$li(tags$strong("Kobuchi S & Ito Y. (2020)"),
" Pharmacometrics approaches for personalized 5-FU-based chemotherapy (Review). ",
tags$em("Anticancer Research"), " 40:6585\u20136597."),
tags$li(tags$strong("Terret C et al. (2000)"),
" Dose and time dependencies of 5-fluorouracil pharmacokinetics. ",
tags$em("Clin Pharmacol Ther"), " 68:270\u2013279."),
tags$li(tags$strong("Friberg LE et al. (2002)"),
" Model of chemotherapy-induced myelosuppression. ",
tags$em("J Clin Oncol"), " 20:4713\u20134721."),
tags$li(tags$strong("Gamelin E et al. (1998)"),
" Individual fluorouracil dose adjustment based on pharmacokinetic follow-up. ",
tags$em("J Clin Oncol"), " 16:2099\u20132110."),
tags$li(tags$strong("Kobuchi S et al. (2016)"),
" Population PK-PD model for myelosuppression in 5-FU-treated rats. ",
tags$em("Eur J Drug Metab Pharmacokinet"), " 42:703\u2013714.")
),
tags$h5("\U0001f489 Clinical Context"),
tags$ul(
tags$li(tags$strong("Drug class:"), " Fluoropyrimidine antimetabolite"),
tags$li(tags$strong("Indications:"), " Colorectal, gastric, breast, pancreatic cancer"),
tags$li(tags$strong("Key toxicities:"), " Myelosuppression, mucositis, hand-foot syndrome, diarrhea"),
tags$li(tags$strong("Pharmacogenomics:"), " DPYD variants predict DPD deficiency \u2014 screening recommended before treatment"),
tags$li(tags$strong("TDM:"), " IATDMCT strongly recommends TDM in clinical practice (2018)")
)
))
),
div(class = "footer-bar",
"Powered by ",
tags$a(href = "https://www.pkpdbuilder.com", target = "_blank",
style = "color: #8b5cf6; font-weight: 600;", "PKPDBuilder.com"),
" \u2022 Based on: Kobuchi & Ito (2020), Anticancer Research 40:6585\u20136597", br(),
tags$span(style = "font-size: 10px;",
"For research and educational use only. Not for clinical decision-making.")
)
)
# ── Server ────────────────────────────────────────────────────────────────────
server <- function(input, output, session) {
# Regimen presets
observeEvent(input$regimen, {
p <- regimen_presets[[input$regimen]]
updateSliderInput(session, "dose_per_m2", value = p$dose)
updateSliderInput(session, "inf_dur", value = p$dur)
updateSliderInput(session, "cycle_ii", value = p$ii)
}, ignoreInit = TRUE)
sim_data <- reactive({
shiny::req(input$dose_per_m2, input$bsa, input$inf_dur, input$n_cycles, input$cycle_ii, input$dpd_act)
dose_total <- input$dose_per_m2 * input$bsa
cycle_h <- input$cycle_ii * 24
n_cyc <- as.integer(input$n_cycles)
dur_h <- input$inf_dur
rate_val <- dose_total / dur_h
# Build event table: one infusion per cycle
ev_list <- lapply(seq_len(n_cyc) - 1, function(i) {
ev(amt = dose_total, rate = rate_val, cmt = 1, time = i * cycle_h)
})
ev_combined <- Reduce("+", ev_list)
end_time <- (n_cyc - 1) * cycle_h + max(dur_h + 72, cycle_h)
mod |>
param(BSA = input$bsa, DPD_ACT = input$dpd_act) |>
ev(ev_combined) |>
mrgsim(end = end_time, delta = 0.25) |>
as.data.frame()
})
# PK metrics ---------------------------------------------------------------
output$cmax <- renderText({
d <- sim_data()
sprintf("%.2f", max(d$CP, na.rm = TRUE))
})
output$auc_c1 <- renderText({
d <- sim_data()
dur_h <- input$inf_dur
d1 <- dplyr::filter(d, time >= 0, time <= dur_h + 48)
auc <- sum(diff(d1$time) * (head(d1$CP, -1) + tail(d1$CP, -1)) / 2)
sprintf("%.1f", auc)
})
output$auc_label <- renderUI({
d <- sim_data()
dur_h <- input$inf_dur
d1 <- dplyr::filter(d, time >= 0, time <= dur_h + 48)
auc <- sum(diff(d1$time) * (head(d1$CP, -1) + tail(d1$CP, -1)) / 2)
badge_class <- if (auc < 20) "auc-badge auc-low" else if (auc > 30) "auc-badge auc-high" else "auc-badge auc-ok"
badge_label <- if (auc < 20) "Underdosed" else if (auc > 30) "Overdosed" else "Therapeutic"
tagList(
span("AUC Cycle 1 (mg\u00b7h/L)"),
span(class = badge_class, badge_label)
)
})
output$nadir_anc <- renderText({
d <- sim_data()
sprintf("%.0f", min(d$ANC, na.rm = TRUE))
})
output$t_half <- renderText({
d <- sim_data()
cp <- d$CP[d$CP > 0.01]
if (length(cp) < 2) return("—")
# Estimate from terminal slope during last cycle decay
dur_h <- input$inf_dur
cycle_h <- input$cycle_ii * 24
last_cyc <- (input$n_cycles - 1) * cycle_h
decay <- dplyr::filter(d, time > last_cyc + dur_h, time < last_cyc + dur_h + 48, CP > 0.001)
if (nrow(decay) < 4) return("—")
fit <- tryCatch(
lm(log(CP) ~ time, data = decay),
error = function(e) NULL
)
if (is.null(fit)) return("—")
k <- -coef(fit)[["time"]]
if (k <= 0) return("—")
sprintf("%.1f", log(2) / k)
})
# PK Plot ------------------------------------------------------------------
output$pkPlot <- renderPlot({
d <- sim_data()
target_lo <- 20; target_hi <- 30
p <- ggplot(d, aes(x = time, y = CP)) +
annotate("rect", xmin = -Inf, xmax = Inf,
ymin = target_lo, ymax = target_hi,
fill = "#10b981", alpha = 0.12) +
geom_hline(yintercept = c(target_lo, target_hi),
linetype = "dashed", color = "#10b981", alpha = 0.6) +
geom_line(color = "#8b5cf6", linewidth = 0.8) +
annotate("text", x = max(d$time) * 0.02, y = target_hi * 1.05,
label = "TDM target AUC zone: 20\u201330 mg\u00b7h/L",
hjust = 0, size = 3.5, color = "#10b981") +
labs(x = "Time (h)",
y = "5-FU Concentration (mg/L)",
title = sprintf("5-FU %.0f mg/m\u00b2 over %gh \u00d7 %d cycles (BSA %.2f m\u00b2, DPD %.0f%%)",
input$dose_per_m2, input$inf_dur,
input$n_cycles, input$bsa, input$dpd_act * 100)) +
theme_minimal(base_size = 14) +
theme(plot.title = element_text(size = 12))
if (input$log_scale) {
p <- p + scale_y_log10(
limits = c(1e-3, max(d$CP[d$CP > 0], na.rm = TRUE) * 2)
)
}
p
})
# ANC Plot -----------------------------------------------------------------
output$ancPlot <- renderPlot({
d <- sim_data()
nadir_t <- d$time[which.min(d$ANC)]
nadir_v <- min(d$ANC, na.rm = TRUE)
sev_grade <- dplyr::case_when(
nadir_v < 500 ~ "Grade 4 (Severe)",
nadir_v < 1000 ~ "Grade 3",
nadir_v < 1500 ~ "Grade 2",
nadir_v < 2000 ~ "Grade 1",
TRUE ~ "No toxicity"
)
grade_col <- dplyr::case_when(
nadir_v < 500 ~ "#dc2626",
nadir_v < 1000 ~ "#f59e0b",
nadir_v < 1500 ~ "#facc15",
TRUE ~ "#10b981"
)
ggplot(d, aes(x = time, y = ANC)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = 500,
fill = "#fee2e2", alpha = 0.5) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 500, ymax = 1000,
fill = "#fef3c7", alpha = 0.5) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 1000, ymax = 1500,
fill = "#fefce8", alpha = 0.4) +
geom_hline(yintercept = c(500, 1000, 1500),
linetype = "dotted", color = "grey50", alpha = 0.7) +
annotate("text", x = max(d$time) * 0.98, y = 250, label = "Grade 4 ANC <500", hjust = 1, size = 3, color = "#dc2626") +
annotate("text", x = max(d$time) * 0.98, y = 750, label = "Grade 3 ANC <1000", hjust = 1, size = 3, color = "#d97706") +
annotate("text", x = max(d$time) * 0.98, y = 1250, label = "Grade 2 ANC <1500", hjust = 1, size = 3, color = "#ca8a04") +
geom_line(color = "#3b82f6", linewidth = 0.8) +
geom_point(aes(x = nadir_t, y = nadir_v), color = grade_col, size = 4) +
annotate("label", x = nadir_t, y = nadir_v + max(d$ANC) * 0.08,
label = paste0("Nadir: ", round(nadir_v), " cells/\u03bcL\n", sev_grade),
size = 3.3, fill = "white", color = grade_col) +
labs(x = "Time (h)",
y = "ANC (cells/\u03bcL)",
title = "Friberg Semi-Physiological Myelosuppression Model \u2014 Neutrophil Dynamics") +
theme_minimal(base_size = 14) +
theme(plot.title = element_text(size = 12))
})
}
shinyApp(ui = ui, server = server)