library(shiny) library(bslib) library(mrgsolve) library(dplyr) library(ggplot2) library(tidyr) # ─── mrgsolve model ──────────────────────────────────────────────────────────── # Sacituzumab govitecan (SG) PopPK model — three analytes # Source: Sathe et al., Clinical Pharmacokinetics 2024 (doi:10.1007/s40262-024-01366-3) # SG: 2-CMT, linear CL, IV Q1W×2 in 21-day cycle # Free SN-38: 2-CMT, driven by first-order release from SG (KREL) # tAB: 2-CMT, time-dependent CL (max 17% reduction over ~6 months) # Time unit: hours | SG: μg/mL | SN-38: ng/mL | tAB: μg/mL # Amounts: SG in μg, SN-38 in ng, tAB in μg model_code <- ' $PARAM @annotated // ── SG (intact ADC) structural params (reference: 70 kg, albumin 38 g/L) ── CLSG_ref : 0.133 : SG clearance (L/h) QSG_ref : 0.006 : SG inter-compartmental CL (L/h) V1SG_ref : 2.770 : SG central volume (L) V2SG_ref : 0.908 : SG peripheral volume (L) WTEXP_CL_SG : 0.508 : WT exponent on CL_SG and Q_SG WTEXP_V_SG : 0.532 : WT exponent on V1_SG and V2_SG ALB_EXP_SG : -0.355 : Albumin exponent on CL_SG // ── SN-38 structural params (reference: 70 kg) ── KREL : 0.0961 : First-order SG release rate constant (1/h) CLSN38_ref: 409.0 : SN-38 apparent clearance (L/h) QSN38_ref : 247.0 : SN-38 apparent inter-compartmental CL (L/h) V1SN38 : 49.0 : SN-38 apparent central volume (L, fixed) V2SN38 : 2177.0 : SN-38 apparent peripheral volume (L, fixed) WTEXP_CL_SN38 : 0.500 : WT exponent on CL_SN38 and Q_SN38 // ── SN-38 calibration factor ── // KREL (1/h) × A_SG (μg) × F_CONV (ng/μg) = SN-38 release (ng/h) // F_CONV calibrated to match published SN-38 Cmax ≈ 8.65 ng/mL (FDA label, 10 mg/kg, 70 kg) // Accounts for MW ratio + bioanalytical assay definition + fraction bioavailable F_CONV_cal : 87.0 : Calibrated SN-38 release conversion (ng_SN38/μg_SG) // ── tAB structural params (reference: 70 kg, albumin 38 g/L) ── CLtAB_ref : 0.016 : tAB clearance (L/h) QtAB_ref : 0.010 : tAB inter-compartmental CL (L/h) V1tAB_ref : 3.06 : tAB central volume (L) V2tAB_ref : 1.20 : tAB peripheral volume (L) WTEXP_CL_tAB : 0.372 : WT exponent on CL_tAB and Q_tAB WTEXP_V_tAB : 0.446 : WT exponent on V1_tAB and V2_tAB ALB_EXP_tAB : -0.735 : Albumin exponent on CL_tAB MAXRED : 0.167 : Max relative reduction in CL_tAB (fraction) KRED : 6.08e-4 : Rate constant for time-dependent CL reduction (1/h) // ── Patient covariates ── WT : 70.0 : Body weight (kg) MALE : 0.0 : Sex (0=female, 1=male) ALB : 38.0 : Baseline albumin (g/L) $CMT @annotated CENT_SG : SG central (μg) PERI_SG : SG peripheral (μg) CENT_SN38 : SN-38 central (ng) PERI_SN38 : SN-38 peripheral (ng) CENT_tAB : tAB central (μg) PERI_tAB : tAB peripheral (μg) $MAIN // ── SG covariate-adjusted parameters ── double CLSG = CLSG_ref * pow(WT/70.0, WTEXP_CL_SG) * pow(ALB/38.0, ALB_EXP_SG); double QSG = QSG_ref * pow(WT/70.0, WTEXP_CL_SG); double V1SG = V1SG_ref * pow(WT/70.0, WTEXP_V_SG); double V2SG = V2SG_ref * pow(WT/70.0, WTEXP_V_SG); // ── SN-38 covariate-adjusted parameters ── double CLSN38 = CLSN38_ref * pow(WT/70.0, WTEXP_CL_SN38); double QSN38 = QSN38_ref * pow(WT/70.0, WTEXP_CL_SN38); // ── tAB covariate-adjusted parameters ── double V1tAB = V1tAB_ref * pow(WT/70.0, WTEXP_V_tAB); double V2tAB = V2tAB_ref * pow(WT/70.0, WTEXP_V_tAB); double QTtAB = QtAB_ref * pow(WT/70.0, WTEXP_CL_tAB); // tAB: sex on V1: 12% higher in males double V1tAB_adj = V1tAB * (MALE > 0.5 ? (1.0 + 0.121) : 1.0); // Albumin and time-dependent CL handled in ODE // F_CONV_cal is the net conversion factor from μg_SG to ng_SN38 // Calibrated to match published FDA label Cmax ~8.65 ng/mL for 70 kg patient at 10 mg/kg double F_CONV = F_CONV_cal; $ODE // ── SG concentrations (μg/L, display as μg/mL after /1000) ── double C_SG1 = CENT_SG / V1SG; double C_SG2 = PERI_SG / V2SG; if (C_SG1 < 0.0) C_SG1 = 0.0; // ── SG ODEs ── dxdt_CENT_SG = -CLSG * C_SG1 - QSG * (C_SG1 - C_SG2); dxdt_PERI_SG = QSG * (C_SG1 - C_SG2); // ── SN-38 release (ng/h) ── double sn38_release = KREL * CENT_SG * F_CONV; if (sn38_release < 0.0) sn38_release = 0.0; // ── SN-38 concentrations (ng/L, display as ng/mL after /1000) ── double C_SN1 = CENT_SN38 / V1SN38; double C_SN2 = PERI_SN38 / V2SN38; if (C_SN1 < 0.0) C_SN1 = 0.0; // ── SN-38 ODEs ── dxdt_CENT_SN38 = sn38_release - CLSN38 * C_SN1 - QSN38 * (C_SN1 - C_SN2); dxdt_PERI_SN38 = QSN38 * (C_SN1 - C_SN2); // ── tAB time-dependent CL ── double CLtAB_ref_cov = CLtAB_ref * pow(WT/70.0, WTEXP_CL_tAB) * pow(ALB/38.0, ALB_EXP_tAB); double CLtAB = CLtAB_ref_cov * (1.0 - MAXRED * (1.0 - exp(-KRED * SOLVERTIME))); // ── tAB concentrations (μg/L, display as μg/mL after /1000) ── double C_tAB1 = CENT_tAB / V1tAB_adj; double C_tAB2 = PERI_tAB / V2tAB; if (C_tAB1 < 0.0) C_tAB1 = 0.0; // ── tAB ODEs ── dxdt_CENT_tAB = -CLtAB * C_tAB1 - QTtAB * (C_tAB1 - C_tAB2); dxdt_PERI_tAB = QTtAB * (C_tAB1 - C_tAB2); $TABLE // CENT in μg (SG/tAB) or ng (SN-38); volumes in L → conc in μg/L or ng/L // Divide by 1000 to display as μg/mL or ng/mL double CP_SG = (CENT_SG / V1SG) / 1000.0; // μg/mL double CP_SN38 = (CENT_SN38 / V1SN38) / 1000.0; // ng/mL double CP_tAB = (CENT_tAB / V1tAB_adj) / 1000.0; // μg/mL if (CP_SG < 0.0) CP_SG = 0.0; if (CP_SN38 < 0.0) CP_SN38 = 0.0; if (CP_tAB < 0.0) CP_tAB = 0.0; $CAPTURE CP_SG CP_SN38 CP_tAB ' mod <- mcode("sg_popk_3analytes", model_code, quiet = TRUE) # ─── UI ──────────────────────────────────────────────────────────────────────── ui <- page_sidebar( title = "Sacituzumab Govitecan (SG) PopPK Simulator", theme = bs_theme( version = 5, bootswatch = "flatly", primary = "#0ea5e9" ), sidebar = sidebar( title = "Patient & Dosing Parameters", width = 300, h6("Dosing", class = "text-muted mt-1 mb-1"), sliderInput("dose_mgkg", "Dose (mg/kg)", min = 6, max = 14, value = 10, step = 0.5), sliderInput("n_cycles", "Cycles to simulate (21-day)", min = 1, max = 6, value = 3, step = 1), p(class = "small text-muted mt-0", "Dosing: Days 1 & 8 of each 21-day cycle"), hr(class = "my-2"), h6("Patient Characteristics", class = "text-muted mb-1"), sliderInput("wt", "Body Weight (kg)", min = 37, max = 140, value = 70, step = 1), radioButtons("sex", "Sex", choices = c("Female" = 0, "Male" = 1), selected = 0, inline = TRUE), hr(class = "my-2"), h6("Laboratory Values", class = "text-muted mb-1"), sliderInput("alb", "Baseline Albumin (g/L)", min = 19, max = 50, value = 38, step = 1), hr(class = "my-2"), checkboxInput("log_scale", "Log scale (Y-axis)", value = FALSE) ), # ── Metric cards ───────────────────────────────────────────────────────────── layout_column_wrap( width = 1/4, fill = FALSE, card( class = "text-center border-0 shadow-sm", card_body( h6("SG Cmax", class = "text-muted mb-1"), h3(textOutput("cmax_sg", inline = TRUE), class = "text-primary fw-bold mb-0"), p("μg/mL (Cycle 1, Day 1)", class = "small text-muted mb-0") ) ), card( class = "text-center border-0 shadow-sm", card_body( h6("SN-38 Cmax", class = "text-muted mb-1"), h3(textOutput("cmax_sn38", inline = TRUE), class = "text-primary fw-bold mb-0"), p("ng/mL (Cycle 1, Day 1)", class = "small text-muted mb-0") ) ), card( class = "text-center border-0 shadow-sm", card_body( h6("SG AUC₀₋₂₁d", class = "text-muted mb-1"), h3(textOutput("auc_sg", inline = TRUE), class = "text-primary fw-bold mb-0"), p("μg·h/mL (Cycle 1)", class = "small text-muted mb-0") ) ), card( class = "text-center border-0 shadow-sm", card_body( h6("SN-38 AUC₀₋₂₁d", class = "text-muted mb-1"), h3(textOutput("auc_sn38", inline = TRUE), class = "text-primary fw-bold mb-0"), p("ng·h/mL (Cycle 1)", class = "small text-muted mb-0") ) ) ), # ── Main PK plots ───────────────────────────────────────────────────────────── layout_column_wrap( width = 1/2, fill = FALSE, card( full_screen = TRUE, height = "480px", card_header("SG (Intact ADC) Concentration"), card_body(plotOutput("plot_sg", height = "420px")) ), card( full_screen = TRUE, height = "480px", card_header("Free SN-38 (Payload) Concentration"), card_body(plotOutput("plot_sn38", height = "420px")) ) ), # ── tAB and model info ──────────────────────────────────────────────────────── layout_column_wrap( width = 1/2, fill = FALSE, card( full_screen = TRUE, height = "480px", card_header("Total Antibody (tAB) Concentration"), card_body(plotOutput("plot_tab", height = "420px")) ), card( navset_card_underline( nav_panel( "Model", div( class = "p-3", h5("Model Structure"), tags$ul( tags$li(strong("SG (intact ADC):"), " 2-CMT, linear CL, IV 3-h infusion on days 1 & 8 Q21D"), tags$li(strong("Free SN-38 (payload):"), " 2-CMT with first-order release from SG (KREL = 0.0961/h). V1 and V2 fixed from literature."), tags$li(strong("tAB (total antibody):"), " 2-CMT, time-dependent CL (max 16.7% reduction over ~6 months). Includes time after treatment and albumin effects."), tags$li(strong("Covariates on SG CL:"), " Body weight (0.508), albumin (exponent −0.355)"), tags$li(strong("Covariates on SN-38 CL/F:"), " Body weight (0.500)"), tags$li(strong("N=529 patients"), " from IMMU-132-01 (phase I/II) and ASCENT (phase III, NCT02574455)") ) ) ), nav_panel( "Params", div( class = "p-3", tags$table( class = "table table-sm table-striped", tags$thead(tags$tr(tags$th("Parameter"), tags$th("Value"), tags$th("Unit"))), tags$tbody( tags$tr(tags$td("CL_SG"), tags$td("0.133"), tags$td("L/h")), tags$tr(tags$td("Q_SG"), tags$td("0.006"), tags$td("L/h")), tags$tr(tags$td("V1_SG"), tags$td("2.770"), tags$td("L")), tags$tr(tags$td("V2_SG"), tags$td("0.908"), tags$td("L")), tags$tr(tags$td("KREL"), tags$td("0.0961"), tags$td("1/h")), tags$tr(tags$td("CL_SN38/F"), tags$td("409"), tags$td("L/h")), tags$tr(tags$td("Q_SN38/F"), tags$td("247"), tags$td("L/h")), tags$tr(tags$td("V1_SN38/F"), tags$td("49"), tags$td("L (fixed)")), tags$tr(tags$td("V2_SN38/F"), tags$td("2177"), tags$td("L (fixed)")), tags$tr(tags$td("CL_tAB"), tags$td("0.016"), tags$td("L/h")), tags$tr(tags$td("V1_tAB"), tags$td("3.06"), tags$td("L")) ) ) ) ), nav_panel( "Ref", div( class = "p-3", tags$ol( tags$li( "Sathe AG, et al. ", tags$em("Population Pharmacokinetics of Sacituzumab Govitecan in Patients with Metastatic Triple-Negative Breast Cancer and Other Solid Tumors."), " Clin Pharmacokinet. 2024;63(5):669-681. DOI: ", tags$a("10.1007/s40262-024-01366-3", href = "https://doi.org/10.1007/s40262-024-01366-3", target = "_blank") ), tags$li( "FDA Label: Sacituzumab govitecan-hziy (TRODELVY). ", tags$a("Prescribing Information", href = "https://www.accessdata.fda.gov/drugsatfda_docs/label/2023/761115s019lbl.pdf", target = "_blank") ) ) ) ) ) ) ), # ── Footer ─────────────────────────────────────────────────────────────────── div( class = "text-center text-muted small py-3 mt-2 border-top", HTML('Built with PKPDBuilder • Parameters from Sathe et al. 2024 • For research and educational use only') ) ) # ─── Server ──────────────────────────────────────────────────────────────────── server <- function(input, output, session) { sim_data <- reactive({ shiny::req(input$dose_mgkg, input$wt, input$sex, input$alb, input$n_cycles) n_cycles <- as.integer(input$n_cycles) ii_h <- 504 # 21-day cycle tinf_h <- 3.0 # 3-hour IV infusion (per label) total_h <- n_cycles * ii_h dose_ug <- input$dose_mgkg * input$wt * 1000 # mg → μg # Dosing: Days 1 & 8 of each cycle = t = 0, 168, 504, 672, 1008, ... # Day 1 of cycle k: (k-1)*504; Day 8 of cycle k: (k-1)*504 + 168 dose_times <- sort(c( seq(0, (n_cycles - 1) * ii_h, by = ii_h), # Day 1 of each cycle seq(168, (n_cycles - 1) * ii_h + 168, by = ii_h) # Day 8 of each cycle )) dose_times <- dose_times[dose_times < total_h + 168] # trim to sim window ev_list <- lapply(dose_times, function(t) { ev(amt = dose_ug, cmt = c("CENT_SG", "CENT_tAB"), time = t, rate = dose_ug / tinf_h) }) evnt <- do.call(c, ev_list) tgrid <- seq(0, total_h, by = 2) param_update <- list( WT = input$wt, MALE = as.numeric(input$sex), ALB = input$alb ) out <- mod |> param(param_update) |> mrgsim(events = evnt, tgrid = tgrid, carry_out = "evid") |> as.data.frame() |> dplyr::filter(evid == 0 | time == 0) out$time_days <- out$time / 24 out$cycle <- floor(out$time / ii_h) + 1 out }) # ── Metrics from Cycle 1 ──────────────────────────────────────────────────── metrics <- reactive({ df <- sim_data() cy1 <- dplyr::filter(df, cycle == 1) list( cmax_sg = max(cy1$CP_SG, na.rm = TRUE), cmax_sn38 = max(cy1$CP_SN38, na.rm = TRUE), auc_sg = tryCatch({ d <- dplyr::arrange(cy1, time) sum(diff(d$time) * (head(d$CP_SG, -1) + tail(d$CP_SG, -1)) / 2) }, error = function(e) NA_real_), auc_sn38 = tryCatch({ d <- dplyr::arrange(cy1, time) sum(diff(d$time) * (head(d$CP_SN38, -1) + tail(d$CP_SN38, -1)) / 2) }, error = function(e) NA_real_) ) }) output$cmax_sg <- renderText({ m <- metrics(); sprintf("%.1f", m$cmax_sg) }) output$cmax_sn38 <- renderText({ m <- metrics(); sprintf("%.2f", m$cmax_sn38) }) output$auc_sg <- renderText({ m <- metrics(); sprintf("%.0f", m$auc_sg) }) output$auc_sn38 <- renderText({ m <- metrics(); sprintf("%.0f", m$auc_sn38) }) # ── Plot helper ───────────────────────────────────────────────────────────── make_plot <- function(df, yvar, ylab, color, log_sc) { n_cyc <- max(df$cycle) vlines <- data.frame( x = c( seq(21, (n_cyc - 1) * 21, by = 21), # cycle boundaries seq(7, (n_cyc - 1) * 21 + 7, by = 21) # day 8 of each cycle ) ) vlines <- vlines[vlines$x < max(df$time_days), , drop = FALSE] p <- ggplot(df, aes(x = .data[["time_days"]], y = .data[[yvar]])) + geom_line(color = color, linewidth = 1.0) + labs(x = "Time (days)", y = ylab) + theme_bw(base_size = 13) + theme( panel.grid.minor = element_blank(), panel.border = element_rect(color = "#dee2e6"), axis.title = element_text(size = 11), axis.text = element_text(size = 10) ) if (nrow(vlines) > 0) { p <- p + geom_vline(data = vlines, aes(xintercept = .data[["x"]]), linetype = "dashed", color = "#adb5bd", linewidth = 0.4) } if (log_sc) { df_pos <- df[df[[yvar]] > 0, ] if (nrow(df_pos) > 0) { p <- p %+% df_pos + scale_y_log10() } } p } output$plot_sg <- renderPlot({ df <- sim_data() make_plot(df, "CP_SG", "SG Concentration (μg/mL)", "#0ea5e9", input$log_scale) }) output$plot_sn38 <- renderPlot({ df <- sim_data() make_plot(df, "CP_SN38", "Free SN-38 Concentration (ng/mL)", "#f97316", input$log_scale) }) output$plot_tab <- renderPlot({ df <- sim_data() make_plot(df, "CP_tAB", "tAB Concentration (μg/mL)", "#10b981", input$log_scale) }) } shinyApp(ui, server)