library(shiny) library(bslib) library(mrgsolve) library(dplyr) library(ggplot2) # ── Trastuzumab 2-CMT IV Population PK model ───────────────────────────── # Source: Bruno R et al. (2005) Cancer Chemother Pharmacol # DOI: 10.1007/s00280-005-1026-z # 476 patients, 3249 serum samples — Phase I/II/III — NONMEM FO # Q = K12 * V1 = 0.164 * 2.95 = 0.484 L/day # V2 = Q / K21 = 0.484 / 0.101 = 4.79 L # Covariates: WT on V1 (power, exp=0.556), ECD on CL & V1 (exp), MET on CL (linear) model_code <- ' $PARAM @annotated CL_tv : 0.225 : Typical clearance (L/day) V1_tv : 2.95 : Typical central volume (L) Q : 0.484 : Inter-compartmental clearance (L/day) V2 : 4.79 : Peripheral volume (L) WT : 65 : Body weight (kg) ECD : 8.23 : Baseline serum HER2 ECD (ng/mL) MET : 0 : Metastatic sites >=4 (0=no, 1=yes) $CMT @annotated CENT : Central compartment (mg) PERIPH : Peripheral compartment (mg) $MAIN // Power-function covariate model (Bruno et al. 2005, NONMEM parameterization) // ECD capped at 200 ng/mL per paper; reference ECD=8.23, reference WT=65 kg double ECD_eff = (ECD > 200.0) ? 200.0 : ((ECD < 0.01) ? 0.01 : ECD); double CLi = CL_tv * pow(ECD_eff / 8.23, 0.041) * (1.0 + 0.221 * MET); double V1i = V1_tv * pow(WT / 65.0, 0.556) * pow(ECD_eff / 8.23, 0.105); double V2i = V2; double Qi = Q; $ODE dxdt_CENT = -(CLi / V1i) * CENT - (Qi / V1i) * CENT + (Qi / V2i) * PERIPH; dxdt_PERIPH = (Qi / V1i) * CENT - (Qi / V2i) * PERIPH; $TABLE double CP = CENT / V1i; $CAPTURE CP CLi V1i ' mod <- mcode("trastuzumab", model_code) # ── Regimen definitions ─────────────────────────────────────────────────── REGIMENS <- list( q1w_std = list(label = "4\u21922 mg/kg Q1W (standard, FDA-approved)", ld = 4, md = 2, ii_wk = 1, approved = TRUE), q2w_4_2 = list(label = "4\u21922 mg/kg Q2W", ld = 4, md = 2, ii_wk = 2, approved = FALSE), q3w_4_2 = list(label = "4\u21922 mg/kg Q3W", ld = 4, md = 2, ii_wk = 3, approved = FALSE), q3w_std = list(label = "8\u21926 mg/kg Q3W (standard, FDA-approved)", ld = 8, md = 6, ii_wk = 3, approved = TRUE), q4w_8_6 = list(label = "8\u21926 mg/kg Q4W", ld = 8, md = 6, ii_wk = 4, approved = FALSE), q6w_8_6 = list(label = "8\u21926 mg/kg Q6W", ld = 8, md = 6, ii_wk = 6, approved = FALSE) ) REGIMEN_CHOICES <- setNames(names(REGIMENS), sapply(REGIMENS, `[[`, "label")) COMPARISON_COLORS <- c( q1w_std = "#8b5cf6", q2w_4_2 = "#10b981", q3w_4_2 = "#f59e0b", q3w_std = "#ef4444", q4w_8_6 = "#3b82f6", q6w_8_6 = "#ec4899" ) # ── 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-primary .metric-value { color: #8b5cf6; } .metric-info .metric-value { color: #0dcaf0; } .ref-box { background: #f0f4ff; border-left: 4px solid #8b5cf6; padding: 12px 16px; border-radius: 4px; margin-top: 10px; font-size: 13px; } .ref-box a { color: #8b5cf6; } .badge-approved { background: #d1fae5; color: #065f46; border-radius: 4px; padding: 2px 6px; font-size: 10px; font-weight: 600; } .badge-invest { background: #fef3c7; color: #92400e; border-radius: 4px; padding: 2px 6px; font-size: 10px; font-weight: 600; } ") # ── UI ──────────────────────────────────────────────────────────────────── ui <- page_sidebar( title = "Trastuzumab (Herceptin\u00ae) PopPK Simulator", theme = app_theme, sidebar = sidebar( title = "Simulation Settings", width = 360, # ── Mode selector ────────────────────────────────────────────────── radioButtons("sim_mode", "Simulation Mode", choices = c("Single Regimen" = "single", "Compare Regimens" = "compare"), selected = "single", inline = TRUE ), hr(), # ── Single mode ──────────────────────────────────────────────────── conditionalPanel("input.sim_mode == 'single'", h6("Dosing Regimen"), selectInput("regimen", "Regimen", choices = REGIMEN_CHOICES, selected = "q1w_std") ), # ── Comparison mode ──────────────────────────────────────────────── conditionalPanel("input.sim_mode == 'compare'", h6("Select Regimens to Compare"), checkboxGroupInput("cmp_regimens", NULL, choices = REGIMEN_CHOICES, selected = c("q1w_std", "q3w_std") ) ), sliderInput("n_weeks", "Duration (weeks)", min = 4, max = 52, value = 24, step = 4), hr(), h6("Patient Characteristics"), sliderInput("wt", "Weight (kg)", min = 40, max = 120, value = 65, step = 2), sliderInput("ecd", "Baseline ECD (ng/mL)", min = 2, max = 50, value = 8, step = 1), checkboxInput("met", "\u2265 4 Metastatic Sites", value = FALSE), hr(), checkboxInput("log_scale", "Log Scale (Y-axis)", value = FALSE) ), # ── Metric cards (single mode only) ─────────────────────────────────── conditionalPanel("input.sim_mode == 'single'", 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 (\u03bcg/mL) \u2014 SS") ), div(class = "metric-card", uiOutput("ctrough_display") ), div(class = "metric-card metric-primary", div(class = "metric-value", textOutput("auc")), div(class = "metric-label", "AUCss (\u03bcg\u00b7day/mL)") ), div(class = "metric-card metric-info", div(class = "metric-value", textOutput("thalf")), div(class = "metric-label", "Terminal t\u00bd (days)") ) ) ), # ── PK plot card ────────────────────────────────────────────────────── card( full_screen = TRUE, height = "520px", card_body(plotOutput("pkPlot", height = "470px")) ), # ── Comparison summary table ────────────────────────────────────────── conditionalPanel("input.sim_mode == 'compare'", card( card_header("Regimen Comparison \u2014 Steady-State Metrics"), card_body(tableOutput("cmpTable")) ) ), # ── Info tabs ───────────────────────────────────────────────────────── navset_card_underline( nav_panel("Model Information", markdown(" ## Trastuzumab \u2014 2-Compartment IV Population PK Model **Drug:** Trastuzumab (Herceptin\u00ae) \u2014 recombinant humanized anti-HER2 IgG1\u03ba monoclonal antibody **Indication:** HER2-positive metastatic breast cancer **Model type:** Two-compartment linear PK with zero-order IV infusion input **Dataset:** 476 patients, 3,249 serum samples across 4 clinical studies (Phase I/II/III) **Software:** NONMEM V, first-order method (FO) ### Population PK Parameter Estimates | Parameter | Estimate | 95% CI | |-----------|---------|--------| | CL (L/day) | 0.225 | 0.213\u20130.238 | | V\u2081 (L) | 2.95 | 2.67\u20133.27 | | K\u2081\u2082 (day\u207b\u00b9) | 0.164 | 0.135\u20130.191 | | K\u2082\u2081 (day\u207b\u00b9) | 0.101 | 0.0826\u20130.122 | | Q (L/day) | 0.484 | derived | | V\u2082 (L) | 4.79 | derived | | t\u00bd terminal (days) | 28.5 | 25.5\u201332.8 | | IIV CL (%) | 43 | 39\u201347 | | IIV V\u2081 (%) | 29 | 21\u201338 | | Residual error (%) | 23 | 21\u201324 | ### Covariate Relationships | Covariate | Parameter | Coefficient | Effect | |-----------|----------|------------|--------| | Weight | V\u2081 | 0.556 (power) | V\u2081 \u00d7 (WT/65)^0.556 | | ECD | CL | 0.041 (power) | CL \u00d7 (min(ECD,200)/8.23)^0.041 | | ECD | V\u2081 | 0.105 (power) | V\u2081 \u00d7 (min(ECD,200)/8.23)^0.105 | | \u22654 Metastatic Sites | CL | 0.221 (linear) | CL \u00d7 1.221 | ### Dosing Regimens Available | Regimen | LD | MD | Interval | Status | |---------|----|----|----------|--------| | Q1W standard | 4 mg/kg | 2 mg/kg | 1 week | FDA-approved | | Q2W | 4 mg/kg | 2 mg/kg | 2 weeks | Investigational | | Q3W | 4 mg/kg | 2 mg/kg | 3 weeks | Investigational | | Q3W standard | 8 mg/kg | 6 mg/kg | 3 weeks | FDA-approved | | Q4W | 8 mg/kg | 6 mg/kg | 4 weeks | Investigational | | Q6W | 8 mg/kg | 6 mg/kg | 6 weeks | Investigational | ### Therapeutic Target - **Ctrough \u2265 20 \u03bcg/mL** at steady state (target in pivotal Phase III trials) - **Steady state:** Reached after ~5 half-lives (\u2248142 days; ~20 weeks) - **High IIV:** CV\u224843% for CL \u2014 exposure monitoring may be warranted ") ), nav_panel("References", div(class = "ref-box", tags$h5("\U0001f4da Primary Reference"), tags$p( "Bruno R, Washington CB, Lu JF, Lieberman G, Banken L, Klein P. (2005) ", tags$strong("Population pharmacokinetics of trastuzumab in patients with HER2+ metastatic breast cancer."), " ", tags$em("Cancer Chemother Pharmacol"), ". doi:10.1007/s00280-005-1026-z" ), tags$h5("\U0001f4d6 Additional References"), tags$ol( tags$li("Cobleigh MA et al. (1999) Multinational study of the efficacy and safety of humanized anti-HER2 monoclonal antibody in women who have HER2-overexpressing metastatic breast cancer. J Clin Oncol. 17:2639\u20132648."), tags$li("Slamon DJ et al. (2001) Use of chemotherapy plus a monoclonal antibody against HER2 for metastatic breast cancer. N Engl J Med. 344:783\u2013792."), tags$li("Lu JF et al. (2010) Clinical pharmacokinetics of trastuzumab in patients with HER2-positive metastatic breast cancer: population analysis with covariate estimation. J Clin Pharmacol. 50(6):640\u2013652.") ), tags$h5("\U0001f48a Drug Information"), tags$ul( tags$li(tags$strong("Brand name:"), " Herceptin\u00ae (Genentech / Roche)"), tags$li(tags$strong("Class:"), " HER2-targeted monoclonal antibody (IgG1\u03ba)"), tags$li(tags$strong("Mechanism:"), " Inhibits HER2 receptor dimerization; activates ADCC"), tags$li(tags$strong("FDA approval:"), " 1998 (HER2+ MBC); both Q1W and Q3W schedules"), tags$li(tags$strong("Route:"), " IV infusion, 30\u201390 min per cycle"), tags$li(tags$strong("MW:"), " ~148,000 Da") ) ) ) ), # ── Footer ──────────────────────────────────────────────────────────── div( style = "text-align:center; padding:20px; margin-top:30px; border-top:1px solid #e9ecef; color:#6c757d; font-size:12px;", "Powered by ", tags$a(href = "https://www.pkpdbuilder.com", target = "_blank", style = "color:#8b5cf6; font-weight:500;", "PKPDBuilder.com"), " \u2022 Built by Sunny \u2600\ufe0f (Husain Attarwala's AI Assistant)", tags$br(), tags$span(style = "font-size:10px;", "For research and educational purposes only. Not for clinical decision-making.") ) ) # ── Server ──────────────────────────────────────────────────────────────── server <- function(input, output, session) { # ---- Helper: covariate-adjusted PK params ---- get_params <- function(wt = 65, ecd = 8.23, met = 0) { ECD_eff <- min(max(ecd, 0.01), 200) CLi <- 0.225 * (ECD_eff / 8.23)^0.041 * (1 + 0.221 * met) V1i <- 2.95 * (wt / 65)^0.556 * (ECD_eff / 8.23)^0.105 list(CL = CLi, V1 = V1i, Q = 0.484, V2 = 4.79) } # ---- Helper: build dose events from regimen ---- make_doses_from_reg <- function(reg_id, wt, n_wk) { reg <- REGIMENS[[reg_id]] ii_d <- reg$ii_wk * 7 tinf_d <- 0.5 / 24 # 30-min infusion in days ld_mg <- reg$ld * wt md_mg <- reg$md * wt n_md <- max(0L, as.integer(floor((n_wk * 7 - 0.01) / ii_d))) ev(time = 0, amt = ld_mg, rate = ld_mg / tinf_d, cmt = 1) + ev(time = ii_d, amt = md_mg, rate = md_mg / tinf_d, cmt = 1, ii = ii_d, addl = max(0L, n_md - 1L)) } # ---- Helper: run sim for one regimen ---- run_sim <- function(reg_id, wt, ecd, met, n_wk) { mod %>% param(WT = wt, ECD = ecd, MET = met) %>% ev(make_doses_from_reg(reg_id, wt, n_wk)) %>% mrgsim(end = n_wk * 7, delta = 0.25) %>% as.data.frame() %>% mutate(time_weeks = time / 7, regimen = reg_id) } # ---- Reactive: current inputs (shared) ---- base_inputs <- reactive({ list(wt = input$wt, ecd = input$ecd, met = as.integer(input$met), n_wk = input$n_weeks) }) # ---- Single-mode sim ---- sim_data <- reactive({ shiny::req(input$regimen, input$wt, input$ecd, input$n_weeks) b <- base_inputs() run_sim(input$regimen, b$wt, b$ecd, b$met, b$n_wk) }) # ---- Comparison-mode sims ---- sim_data_cmp <- reactive({ shiny::req(input$cmp_regimens, input$wt, input$ecd, input$n_weeks) b <- base_inputs() ids <- input$cmp_regimens if (length(ids) == 0) return(NULL) bind_rows(lapply(ids, run_sim, wt = b$wt, ecd = b$ecd, met = b$met, n_wk = b$n_wk)) }) # ---- SS helper ---- ss_window <- function(d, n_wk) dplyr::filter(d, time_weeks >= n_wk * 0.75) ss_data <- reactive({ ss_window(sim_data(), input$n_weeks) }) # ---- Single-mode metrics ---- output$cmax <- renderText({ sprintf("%.1f", max(ss_data()$CP, na.rm = TRUE)) }) output$ctrough_display <- renderUI({ val <- min(ss_data()$CP, na.rm = TRUE) col <- if (val >= 20) "#10b981" else "#ef4444" lbl <- if (val >= 20) "\u2713 On target (\u226520)" else "\u26a0 Below target (<20)" tagList( div(style = paste0("font-size:24px;font-weight:bold;color:", col, ";"), sprintf("%.1f", val)), div(class = "metric-label", "Ctrough (\u03bcg/mL) \u2014 SS"), div(style = paste0("font-size:10px;color:", col, ";margin-top:3px;"), lbl) ) }) output$auc <- renderText({ d <- ss_data() reg <- REGIMENS[[input$regimen]] ii_d <- reg$ii_wk * 7 last_t <- max(d$time, na.rm = TRUE) d_ii <- dplyr::filter(d, time >= last_t - ii_d) if (nrow(d_ii) < 2) return("\u2014") auc <- sum(diff(d_ii$time) * (head(d_ii$CP,-1) + tail(d_ii$CP,-1)) / 2, na.rm = TRUE) sprintf("%.0f", auc) }) output$thalf <- renderText({ p <- get_params(input$wt, input$ecd, as.integer(input$met)) a <- (p$CL + p$Q) / p$V1 + p$Q / p$V2 b <- 4 * p$CL * p$Q / (p$V1 * p$V2) disc <- a^2 - b if (disc < 0) return("\u2014") lam_z <- (a - sqrt(disc)) / 2 sprintf("%.1f", log(2) / lam_z) }) # ---- Comparison summary table ---- output$cmpTable <- renderTable({ d_all <- sim_data_cmp() shiny::req(d_all) n_wk <- input$n_weeks rows <- lapply(unique(d_all$regimen), function(rid) { d <- dplyr::filter(d_all, regimen == rid) dss <- ss_window(d, n_wk) reg <- REGIMENS[[rid]] ii_d <- reg$ii_wk * 7 last_t <- max(d$time, na.rm = TRUE) d_ii <- dplyr::filter(d, time >= last_t - ii_d) auc <- if (nrow(d_ii) >= 2) sum(diff(d_ii$time) * (head(d_ii$CP,-1) + tail(d_ii$CP,-1)) / 2, na.rm = TRUE) else NA_real_ ctrough <- min(dss$CP, na.rm = TRUE) data.frame( Regimen = reg$label, `Cmax SS (µg/mL)` = round(max(dss$CP, na.rm = TRUE), 1), `Ctrough SS (µg/mL)` = round(ctrough, 1), `AUCss (µg·day/mL)` = round(auc, 0), `Target Met` = ifelse(ctrough >= 20, "\u2713 Yes", "\u2717 No"), check.names = FALSE, stringsAsFactors = FALSE ) }) bind_rows(rows) }, sanitize.text.function = identity) # ---- Main PK plot (single + comparison) ---- output$pkPlot <- renderPlot({ nw <- input$n_weeks log_scale <- input$log_scale brk <- if (nw <= 8) 1 else if (nw <= 24) 4 else 8 if (input$sim_mode == "single") { d <- sim_data() reg <- REGIMENS[[input$regimen]] ttl <- sprintf("Trastuzumab %s | WT=%.0f kg | ECD=%.0f ng/mL%s", reg$label, input$wt, input$ecd, if (isTRUE(input$met)) " | \u22654 Met" else "") y_max <- max(d$CP, na.rm = TRUE) p <- ggplot(if (log_scale) dplyr::filter(d, CP > 0) else d, aes(x = time_weeks, y = CP)) + geom_line(color = "#8b5cf6", linewidth = 0.9) } else { d_all <- sim_data_cmp() shiny::req(d_all) ids <- unique(d_all$regimen) colors <- COMPARISON_COLORS[ids] ttl <- sprintf("Trastuzumab Regimen Comparison | WT=%.0f kg | ECD=%.0f ng/mL%s", input$wt, input$ecd, if (isTRUE(input$met)) " | \u22654 Met" else "") lbl_map <- setNames(sapply(ids, function(i) REGIMENS[[i]]$label), ids) d_plot <- if (log_scale) dplyr::filter(d_all, CP > 0) else d_all d_plot$reg_label <- lbl_map[d_plot$regimen] y_max <- max(d_plot$CP, na.rm = TRUE) p <- ggplot(d_plot, aes(x = time_weeks, y = CP, color = reg_label)) + geom_line(linewidth = 0.85) + scale_color_manual(values = setNames(unname(colors), lbl_map[ids]), name = "Regimen") + theme(legend.position = "bottom", legend.text = element_text(size = 10), legend.title = element_text(size = 10, face = "bold")) } # Add target line + shading if (!log_scale) { p <- p + annotate("rect", xmin = -Inf, xmax = Inf, ymin = 20, ymax = Inf, fill = "#10b981", alpha = 0.06) + geom_hline(yintercept = 20, linetype = "dashed", color = "#10b981", linewidth = 0.7) + annotate("text", x = nw * 0.01, y = 20 + max(sim_data()$CP, na.rm = TRUE) * 0.03, label = "Target Ctrough \u2265 20 \u03bcg/mL", hjust = 0, size = 3.5, color = "#10b981") + labs(y = "Serum Concentration (\u03bcg/mL)", title = ttl) } else { p <- p + geom_hline(yintercept = 20, linetype = "dashed", color = "#10b981", linewidth = 0.7) + annotate("text", x = nw * 0.01, y = 23, label = "Target Ctrough \u2265 20 \u03bcg/mL", hjust = 0, size = 3.5, color = "#10b981") + scale_y_log10() + labs(y = "Serum Concentration (\u03bcg/mL, log scale)", title = ttl) } p + scale_x_continuous(name = "Time (weeks)", breaks = seq(0, ceiling(nw), by = brk)) + theme_minimal(base_size = 14) + theme(plot.title = element_text(size = 12, face = "bold"), panel.grid.minor = element_blank()) }) } shinyApp(ui = ui, server = server)