diff --git a/.gitignore b/.gitignore
index d76efe5c..28eb76c2 100755
--- a/.gitignore
+++ b/.gitignore
@@ -63,7 +63,7 @@ inst/Learn/_freeze/
data-raw/Runs/
tests/testthat/Runs/
Examples
-!inst/Examples/
+inst/Examples/
Experimental
other/
1
@@ -85,3 +85,4 @@ src/Makevars.win
# Old files
Archived
+
diff --git a/Cargo.lock b/Cargo.lock
index 80e95efd..070e26b2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1380,9 +1380,8 @@ dependencies = [
[[package]]
name = "pmcore"
-version = "0.25.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56359b47b12acc12b49b61e98c2405f8750e586a3241e8417bdfa5c59d6aa71d"
+version = "0.25.1"
+source = "git+https://github.com/LAPKB/PMcore.git?branch=fix%2Fpmetrics-analytical#8e99683e1d33fafb7c6eb56227aa353b530c48a7"
dependencies = [
"anyhow",
"argmin",
@@ -2085,9 +2084,9 @@ dependencies = [
[[package]]
name = "typenum"
-version = "1.19.0"
+version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucd-trie"
@@ -2148,11 +2147,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
-version = "1.0.2+wasi-0.2.9"
+version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -2161,7 +2160,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.51.0",
]
[[package]]
@@ -2362,6 +2361,12 @@ dependencies = [
"wit-bindgen-rust-macro",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
diff --git a/DESCRIPTION b/DESCRIPTION
index 0f28a973..4121d777 100755
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -77,7 +77,7 @@ Encoding: UTF-8
VignetteBuilder: knitr
LazyData: true
LazyDataCompression: xz
-Config/rextendr/version: 0.4.2
+Config/rextendr/version: 0.5.0
Config/testthat/edition: 3
Config/Needs/website:
r-lib/pkgdown,
diff --git a/NAMESPACE b/NAMESPACE
index 26f7df4d..df0c318d 100755
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -17,6 +17,7 @@ S3method(plot,PM_pta)
S3method(plot,PM_sim)
S3method(plot,PM_valid)
S3method(plot,PMvalid)
+S3method(plot,bd)
S3method(print,PM_compare)
S3method(print,PMerr)
S3method(print,PMnpc)
@@ -98,6 +99,8 @@ export(add_smooth)
export(additive)
export(all_is_numeric)
export(apps)
+export(bd)
+export(bd_post)
export(check_updates)
export(clear_build)
export(cli_ask)
@@ -183,6 +186,7 @@ importFrom(dplyr,group_by)
importFrom(dplyr,group_map)
importFrom(dplyr,if_else)
importFrom(dplyr,inner_join)
+importFrom(dplyr,left_join)
importFrom(dplyr,mutate)
importFrom(dplyr,n)
importFrom(dplyr,nest_by)
@@ -196,6 +200,7 @@ importFrom(dplyr,rowwise)
importFrom(dplyr,select)
importFrom(dplyr,slice)
importFrom(dplyr,slice_head)
+importFrom(dplyr,slice_max)
importFrom(dplyr,slice_tail)
importFrom(dplyr,starts_with)
importFrom(dplyr,summarize)
diff --git a/R/PM_bestdose.R b/R/PM_bestdose.R
new file mode 100644
index 00000000..b11b978a
--- /dev/null
+++ b/R/PM_bestdose.R
@@ -0,0 +1,1247 @@
+#' @title
+#' Object to contain BestDose optimization results
+#'
+#' @description
+#' `r lifecycle::badge("experimental")`
+#'
+#' This object is created after a successful BestDose optimization run.
+#' BestDose finds optimal dosing regimens to achieve target drug concentrations
+#' or AUC values using Bayesian optimization.
+#'
+#' @importFrom dplyr filter left_join group_by slice_max select ungroup bind_rows arrange pull group_map mutate
+#' @importFrom plotly plot_ly add_lines add_markers layout
+#' @importFrom purrr list_rbind
+#' @export
+bd <- R6::R6Class(
+ "bd",
+ public = list(
+ #' @field past PM_data object containing past patient data used in the optimization (if applicable)
+ past = NULL,
+ #' @field past_pred PM_sim object containing model predictions for the past data
+ past_pred = NULL,
+ #' @field future PM_data object containing future data used in the optimization
+ future = NULL,
+ #' @field future_pred PM_sim object containing model predictions for the future data
+ future_pred = NULL,
+ #' @field result List containing optimization results, including optimal doses, predictions, and objective function value
+ result = NULL,
+ #' @field posterior The `bd_post` object used to compute the posterior distribution (if applicable)
+ posterior = NULL,
+ #' @field prior_weight The prior weight (lambda) used in the optimization (if applicable)
+ prior_weight = NULL,
+ #' @field start Start specification provided by user (numeric hours or datetime string)
+ start = NULL,
+ #' @field start_offset Resolved numeric offset (hours) used internally; NULL means use target times as-is
+ start_offset = 0,
+ #' @field future_requested_doses Numeric vector of originally requested future doses (0 = optimize, non-zero = fixed)
+ future_requested_doses = NULL,
+ #' @field future_target_info List with target_type, target, and target_time from the original future specification
+ future_target_info = NULL,
+ #' @description
+ #' Initialize a `bd` object by running a one-shot BestDose optimization.
+ #' Creates the posterior internally, then optimizes. For reusing the posterior
+ #' across multiple optimizations, use `bd_post$new()` followed by `$optimize()`.
+ #' @param model PM_model object or path to compiled model
+ #' @param prior Prior parameter value distributions for the model, can be a [PM_result], [PM_final], or path to `theta.csv` file.
+ #' @param past_data PM_data object or path to CSV file with past patient data (optional). Omitting this will plan a new regimen for the patient
+ #' based on the population prior without any individualization other than inclusion of any covariates which are in the model.
+ #' @param future The future regimen to optimize. It can be one of three things:
+ #' * A list describing future dosing/target setup with elements:
+ #' - `dose` The amount to administer (required). Use `0` to optimize that dose to achieve the target; any other value is treated as fixed.
+ #' Can be a scalar or a numeric vector. If a vector is provided, each element corresponds to each dose event in order.
+ #' - `frequency` How often to administer dose(s) (default 24 hours). Can be a scalar or numeric vector; when vectorized,
+ #' element `i` is the interval after dose `i` and before dose `i+1`.
+ #' - `route` Either 0 for oral/bolus, or a value >0 indicating infusion duration. Can be a scalar or numeric vector;
+ #' when vectorized, element `i` applies to dose `i`.
+ #' - `number` The total number of doses to administer in the future (default 1).
+ #' If `number` is smaller than `max(length(dose), length(frequency), length(route))`, it is increased to that maximum and an informational message is shown.
+ #' If any of `dose`, `frequency`, or `route` are shorter than `number`, their last value is recycled to length `number`.
+ #' - `target` The target value to achieve after every dose (required).
+ #' - `target_type` The target type (default `"concentration"`). Must be one of `"concentration"`, `"auc"`, or `"time"`.
+ #' `"concentration"` targets a concentration at `target_time`; `"auc"` targets an AUC from dose time to `target_time`;
+ #' `"time"` uses `target_time` as a proportion between 0 and 1 of each dose interval.
+ #' - `target_time` For `target_type = "concentration"` or `"auc"`, the time after each dose at which to evaluate the target,
+ #' default 24 hours if unspecified. For `target_type = "time"`, this must be a single numeric proportion between 0 and 1,
+ #' and each target observation time is set to `round(frequency[i] * target_time)` hours after dose `i`.
+ #' - `covariates` Named list of covariates in the model with values to use. If there are no covariates in the model, this can be omitted.
+ #' * A `PM_data` object containing the future regimen and target observations. Dose events should be included with `evid=1` and target observations should be included with `evid=0` and `out` equal to the target value.
+ #' When `future` is supplied this way, concentration targets are assumed.
+ #' * A path to a CSV file containing the future regimen and target observations in the same format as described for the `PM_data` object above.
+ #' When `future` is supplied this way, concentration targets are assumed.
+ #' @param dose_range List with 'min' and 'max' elements defining the dose search range (default: 0 to 1000)
+ #' @param prior_weight Numeric between 0 and 1 indicating the balance between the prior and the posterior in the optimization (default: 0.5).
+ #' If 1, the model prior parameter
+ #' value distribution will be used as the sole basis for optimization. If 0, only the Bayesian posterior parameter distribution will be used,
+ #' which incorporates the patient history. Values between 0 and 1 will weight the contribution of the prior vs posterior parameter distributions in the optimization.
+ #' Choose values closer to 1 when the patient history is sparse or not believed to be very informative, and values closer to 0 when the patient history is rich and believed to be highly informative.
+ #' @param start Start time for the future regimen. Can be either:
+ #' * `NULL`: use the supplied future/target times as-is, without appending them after past data.
+ #' * Numeric hours (default `0`): relative to the last event date/time in past data, or if no past data,
+ #' relative to the next local clock hour.
+ #' * Date-time character string: interpreted as an absolute start date/time and converted to an internal hour offset.
+ #' Ambiguous formats (e.g. `01/02/26`) are resolved using the `date_format` Pmetrics option
+ #' (set via [setPMoptions]), which defaults to `%m/%d/%y` for US locales and `%d/%m/%y` otherwise.
+ #' Accepted formats include:
+ #' - `YYYY-mm-dd HH:MM[:SS]`
+ #' - `mm/dd/[YY]YY HH:MM[:SS]` or `mm-dd-[YY]YY HH:MM[:SS]`
+ #' - `dd/mm/[YY]YY HH:MM[:SS]` or `dd-mm-[YY]YY HH:MM[:SS]`
+ #' @param max_cycles Maximum number of optimization cycles for computing the posterior (default: 500)
+ #' @param settings List of additional settings for posterior computation (optional)
+ #' @param posterior `bd_post` object to use instead of computing a new one (optional).
+ #' When provided, `prior`, `model`, `past_data`, `max_cycles`, and `settings` are ignored, as they are already included in the `posterior` object.
+ #' @param quiet Logical indicating whether to suppress verbose simulation output (default: FALSE)
+ #' @return A `bd` object containing the optimization results and associated information
+
+ initialize = function(model = NULL,
+ prior = NULL,
+ past_data = NULL,
+ future = NULL,
+ dose_range = list(min = 0, max = 1000),
+ prior_weight = 0.5,
+ start = 0,
+ max_cycles = 500,
+ settings = NULL,
+ posterior = NULL,
+ quiet = FALSE,
+ simulate = FALSE) {
+ if (!is.null(future)) {
+ if (is.list(future)) {
+ future_spec <- private$.build_future_data(future)
+ future_data <- future_spec$data
+ target_type <- future_spec$target_type
+ self$future_target_info <- list(
+ target_type = tolower(as.character(future$target_type %||% "concentration")),
+ target = future$target,
+ target_time = future$target_time
+ )
+ } else if (inherits(future, "PM_data") || is.character(future)) {
+ future_data <- PM_data$new(future, quiet = TRUE)
+ target_type <- "concentration"
+ self$future_target_info <- list(target_type = "concentration", target = NA, target_time = NA)
+ } else {
+ cli::cli_abort("{.arg future} must be either a list describing the future setup, a PM_data object, or a path to a CSV file.")
+ }
+ } else {
+ cli::cli_abort("A {.arg future} plan must be supplied.")
+ }
+
+ # Capture originally requested doses before optimization may mutate the PM_data object
+ if (!is.null(future_data$standard_data)) {
+ orig_evid1 <- future_data$standard_data |> dplyr::filter(evid == 1)
+ self$future_requested_doses <- if (nrow(orig_evid1) > 0) orig_evid1$dose else numeric(0)
+ }
+
+ if (is.null(posterior)) {
+ if (is.null(prior) || is.null(model)) {
+ cli::cli_abort("Both {.arg prior} and {.arg model} are required when {.arg posterior} is not provided.")
+ }
+ posterior <- bd_post$new(
+ prior = prior,
+ model = model,
+ past_data = past_data,
+ max_cycles = max_cycles,
+ settings = settings,
+ quiet = quiet
+ )
+ }
+
+ start_info <- private$.resolve_start(start, posterior$past)
+
+ raw <- private$.optimize(posterior, future_data, dose_range, prior_weight, target_type, start_info$start_offset, quiet)
+
+ private$.set_result(future_data, raw, posterior, prior_weight)
+ self$past <- posterior$past
+ self$start <- start_info$start
+ self$start_offset <- start_info$start_offset
+
+ if (!is.null(self$past) && !is.null(self$start_offset) && self$start_offset > 0) {
+ private$.extend_past_to_start(self$start_offset)
+ }
+
+ if (simulate) {
+ cli::cli_alert("Simulating predictions for past and future data...")
+ private$.sim_past()
+ private$.sim_future()
+ }
+
+ invisible(self)
+ },
+
+ #' @description
+ #' Print summary of BestDose results
+ print = function() {
+ cat("BestDose Optimization Results\n")
+ cat("==============================\n\n")
+ dose_str <- paste(sprintf("%.2f", self$doses), collapse = ", ")
+ cat(sprintf("Optimal doses: [%s] mg\n", dose_str))
+ cat(sprintf("Objective function: %.10f\n", self$objf))
+ cat(sprintf("ln(Objective): %.4f\n", log(self$objf)))
+ cat(sprintf("Method: %s\n", self$method))
+ cat(sprintf("Status: %s\n", self$status))
+ if (!is.null(self$prior_weight)) {
+ cat(sprintf("Prior weight: %.2f\n", self$prior_weight))
+ }
+ if (!is.null(self$start)) {
+ if (is.character(self$start)) {
+ cat(sprintf("Start: %s\n", self$start))
+ } else {
+ cat(sprintf("Start offset (h): %.2f\n", self$start_offset))
+ }
+ }
+ cat(sprintf("\nNumber of predictions: %d\n", nrow(self$result$predictions)))
+ if (!is.null(self$result$auc_predictions)) {
+ cat(sprintf("Number of AUC predictions: %d\n", nrow(self$result$auc_predictions)))
+ }
+ invisible(self)
+ },
+ #' @description
+ #' Save results to RDS file
+ #' @param filename Path to save file. Default: "bestdose_result.rds"
+ save = function(filename = "bestdose_result.rds") {
+ saveRDS(self, filename)
+ cli::cli_alert_success("Results saved to {filename}")
+ invisible(self)
+ },
+ #' @description
+ #' Plot observed and predicted concentrations over time for both past and future data
+ #' @param ... Additional arguments passed to plot.bd function
+ plot = function(...) {
+ plot.bd(self, ...)
+ },
+ #' @description
+ #' Generate an HTML report for a BestDose result
+ #' @param ... Parameters passed to the internal BestDose report generator.
+ report = function(...) {
+ bd_report(self, ...)
+ }
+ ),
+ private = list(
+ .build_future_data = function(future) {
+ if (is.null(future$dose) || is.null(future$target)) {
+ cli::cli_abort(c(
+ "x" = "Future setup must include both {.code dose} and {.code target}.",
+ "i" = "Provide {.code future = list(dose = ..., target = ...)}."
+ ))
+ }
+
+ # Apply defaults
+ future$target_type <- future$target_type %||% "concentration"
+ future$frequency <- future$frequency %||% 24
+ future$route <- future$route %||% 0
+ future$number <- future$number %||% 1
+
+ # Determine effective number from vector lengths of dose, frequency, route
+ n_from_vectors <- max(
+ length(future$dose),
+ length(future$frequency),
+ length(future$route)
+ )
+ if (future$number < n_from_vectors) {
+ cli::cli_inform(c(
+ "!" = "{.arg number} ({future$number}) is less than the maximum length of
+ {.code dose}, {.code frequency}, or {.code route} ({n_from_vectors}).",
+ ">" = "Setting {.arg number} to {n_from_vectors}."
+ ))
+ future$number <- n_from_vectors
+ }
+ n <- as.integer(future$number)
+
+ # Recycle the last element of x to reach length n
+ recycle_last <- function(x, n) {
+ len <- length(x)
+ if (len >= n) {
+ return(x[seq_len(n)])
+ }
+ c(x, rep(x[len], n - len))
+ }
+ doses <- recycle_last(future$dose, n)
+ freqs <- recycle_last(future$frequency, n)
+ routes <- recycle_last(future$route, n)
+ target_type <- tolower(as.character(future$target_type[[1]]))
+
+ if (!target_type %in% c("concentration", "auc", "time")) {
+ cli::cli_abort("{.arg future$target_type} must be one of: concentration, auc, time")
+ }
+
+ if (target_type == "time") {
+ if (is.null(future$target_time) ||
+ length(future$target_time) != 1 ||
+ !is.numeric(future$target_time) ||
+ is.na(future$target_time) ||
+ future$target_time < 0 ||
+ future$target_time > 1) {
+ cli::cli_abort("For {.code future$target_type = 'time'}, {.arg future$target_time} must be a single numeric value between 0 and 1.")
+ }
+ target_offsets <- round(freqs * future$target_time)
+ rust_target_type <- "concentration"
+ } else {
+ future$target_time <- future$target_time %||% 24
+ if (length(future$target_time) != 1 ||
+ !is.numeric(future$target_time) ||
+ is.na(future$target_time) ||
+ future$target_time < 0) {
+ cli::cli_abort("{.arg future$target_time} must be a single non-negative numeric value.")
+ }
+ target_offsets <- rep(as.numeric(future$target_time), n)
+ rust_target_type <- if (target_type == "auc") "auc_from_zero" else "concentration"
+ }
+
+ # Cumulative dose times: dose 1 at t=0, dose i at sum of preceding frequencies
+ dose_times <- c(0, cumsum(head(freqs, n - 1)))
+
+ future_data <- PM_data$new()
+
+ # Add each dose event individually
+ for (i in seq_len(n)) {
+ dose_args <- list(
+ id = 1, time = dose_times[i], evid = 1,
+ dose = doses[i], out = NA,
+ dur = routes[i],
+ validate = FALSE
+ )
+ do.call(future_data$addEvent, dose_args)
+ }
+
+ # Add covariates if specified
+ if (!is.null(future$covariates) && length(future$covariates) > 0) {
+ cov_args <- c(list(id = 1, validate = FALSE), future$covariates)
+ do.call(future_data$addEvent, cov_args)
+ }
+
+ # Add observation events at target_time after each dose
+ for (i in seq_len(n)) {
+ obs_time <- dose_times[i] + target_offsets[i]
+ obs_args <- list(
+ id = 1, time = obs_time, evid = 0,
+ dose = NA, out = future$target,
+ validate = (i == n)
+ )
+ do.call(future_data$addEvent, obs_args)
+ }
+ list(data = future_data, target_type = rust_target_type)
+ },
+ .optimize = function(posterior, future_data, dose_range, prior_weight, target_type, start_offset, quiet = FALSE) {
+ if (is.null(posterior$handle)) {
+ cli::cli_abort(c(
+ "x" = "bd_post object is not properly initialized.",
+ "i" = "Create a new {.cls bd_post} with {.code bd_post$new()}."
+ ))
+ }
+
+ if (!target_type %in% c("concentration", "auc_from_zero")) {
+ cli::cli_abort("Internal {.arg target_type} must be one of: concentration, auc_from_zero")
+ }
+
+ if (prior_weight < 0 || prior_weight > 1) {
+ cli::cli_abort("{.arg prior_weight} must be between 0 and 1")
+ }
+
+ if (is.null(dose_range$min) || is.null(dose_range$max)) {
+ cli::cli_abort("{.arg dose_range} must have both 'min' and 'max' elements")
+ }
+
+ if (dose_range$min >= dose_range$max) {
+ cli::cli_abort("{.arg dose_range$min} must be less than {.arg dose_range$max}")
+ }
+
+ future_data_path <- bestdose_parse_data(future_data)
+
+ res <- bestdose_optimize(
+ posterior$handle,
+ future_data_path,
+ start_offset,
+ dose_range$min,
+ dose_range$max,
+ bias_weight = prior_weight,
+ target_type
+ )
+
+ if (is.character(res)) {
+ cli::cli_abort(res)
+ }
+
+ res
+ },
+ .set_result = function(future, result, posterior, prior_weight) {
+ if (!is.null(future)) {
+ self$future <- if (inherits(future, "PM_data")) future else PM_data$new(future, quiet = TRUE)
+ }
+ self$result <- result
+ self$posterior <- posterior
+ self$prior_weight <- prior_weight
+ invisible(self)
+ },
+ .resolve_start = function(start, past) {
+ if (is.null(start)) {
+ start_offset <- NULL
+ } else if (is.numeric(start) && length(start) == 1 && !is.na(start)) {
+ start_offset <- as.numeric(start)
+ } else if (is.character(start) && length(start) == 1 && nzchar(start)) {
+ start_datetime <- private$.parse_datetime(start)
+ if (is.na(start_datetime)) {
+ cli::cli_abort(c(
+ "x" = "Unable to parse {.arg start} datetime string.",
+ "i" = "Use an ISO-like value such as {.code '2026-02-01 14:00:00'}."
+ ))
+ }
+
+ has_past <- !is.null(past) && !is.null(past$data) && nrow(past$data) > 0
+ if (has_past) {
+ ref_datetime <- private$.reference_datetime(past)
+ start_offset <- as.numeric(difftime(start_datetime, ref_datetime, units = "hours"))
+ } else {
+ # Without past data, a datetime string defines the plotting origin itself.
+ # Keep optimization on the future timeline by using a zero internal offset.
+ start_offset <- 0
+ }
+ } else {
+ cli::cli_abort("{.arg start} must be NULL, a single numeric value, or a date-time character string.")
+ }
+
+ if (!is.null(start_offset) && (!is.finite(start_offset) || start_offset < 0)) {
+ cli::cli_abort("{.arg start} resolves to a negative or invalid offset relative to the reference time.")
+ }
+
+ list(start = start, start_offset = start_offset)
+ },
+ .reference_datetime = function(past) {
+ if (!is.null(past) && !is.null(past$data) && nrow(past$data) > 0 &&
+ all(c("date", "time") %in% names(past$data))) {
+ dt_raw <- paste(as.character(past$data$date), as.character(past$data$time))
+ dt_vals <- vapply(
+ dt_raw,
+ function(x) private$.parse_datetime(x),
+ FUN.VALUE = as.POSIXct(NA_real_, origin = "1970-01-01", tz = Sys.timezone())
+ )
+
+ if (!all(is.na(dt_vals))) {
+ if (!is.null(past$standard_data) && "time" %in% names(past$standard_data) &&
+ nrow(past$standard_data) == length(dt_vals)) {
+ idx <- which.max(past$standard_data$time)
+ if (length(idx) == 1 && !is.na(dt_vals[idx])) {
+ return(dt_vals[idx])
+ }
+ }
+ return(max(dt_vals, na.rm = TRUE))
+ }
+ }
+
+ now <- Sys.time()
+ ceiling_hour <- as.POSIXct(
+ format(now, "%Y-%m-%d %H:00:00"),
+ format = "%Y-%m-%d %H:%M:%S",
+ tz = Sys.timezone()
+ )
+ if (ceiling_hour <= now) ceiling_hour <- ceiling_hour + 3600
+ ceiling_hour
+ },
+ .parse_datetime = function(x) {
+ # Get user-preferred date format from Pmetrics options; fall back to locale
+ user_date_fmt <- getPMoptions("date_format", warn = FALSE, quiet = TRUE)
+ if (!is.character(user_date_fmt) || !nzchar(user_date_fmt)) {
+ user_date_fmt <- if (grepl("en_US", Sys.getlocale("LC_TIME"), fixed = TRUE)) "%m/%d/%y" else "%d/%m/%y"
+ }
+ user_4yr <- sub("%y", "%Y", user_date_fmt, fixed = TRUE)
+ formats <- unique(c(
+ paste(user_4yr, "%H:%M:%S"),
+ paste(user_4yr, "%H:%M"),
+ paste(user_date_fmt, "%H:%M:%S"),
+ paste(user_date_fmt, "%H:%M"),
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%d %H:%M",
+ "%m/%d/%Y %H:%M:%S",
+ "%m/%d/%Y %H:%M",
+ "%m/%d/%y %H:%M:%S",
+ "%m/%d/%y %H:%M",
+ "%m-%d-%Y %H:%M",
+ "%m-%d-%Y %H:%M:%S",
+ "%m-%d-%y %H:%M",
+ "%m-%d-%y %H:%M:%S",
+ "%d/%m/%Y %H:%M:%S",
+ "%d/%m/%Y %H:%M",
+ "%d/%m/%y %H:%M:%S",
+ "%d/%m/%y %H:%M",
+ "%d-%m-%Y %H:%M:%S",
+ "%d-%m-%Y %H:%M",
+ "%d-%m-%y %H:%M:%S",
+ "%d-%m-%y %H:%M"
+ ))
+ for (fmt in formats) {
+ dt <- suppressWarnings(as.POSIXct(x, format = fmt, tz = Sys.timezone()))
+ if (!is.na(dt)) {
+ return(dt)
+ }
+ }
+ suppressWarnings(as.POSIXct(x, tz = Sys.timezone()))
+ },
+ .extend_past_to_start = function(start_offset) {
+ if (is.null(self$past) || is.null(self$past$standard_data) || nrow(self$past$standard_data) == 0) {
+ return(invisible(NULL))
+ }
+
+ past_std <- self$past$standard_data
+ last_rows <- past_std |>
+ dplyr::group_by(id) |>
+ dplyr::slice_max(time, n = 1, with_ties = FALSE) |>
+ dplyr::ungroup()
+
+ new_rows <- last_rows
+ new_rows$time <- new_rows$time + start_offset
+ if ("evid" %in% names(new_rows)) new_rows$evid <- 0
+ if ("dose" %in% names(new_rows)) new_rows$dose <- NA_real_
+ if ("dur" %in% names(new_rows)) new_rows$dur <- NA_real_
+ if ("addl" %in% names(new_rows)) new_rows$addl <- NA_real_
+ if ("ii" %in% names(new_rows)) new_rows$ii <- NA_real_
+ if ("out" %in% names(new_rows)) new_rows$out <- NA_real_
+
+ self$past$standard_data <- dplyr::bind_rows(past_std, new_rows) |>
+ dplyr::arrange(id, time)
+
+ invisible(self)
+ },
+ .sim_past = function() {
+ if (is.null(self$past)) {
+ return(invisible(NULL))
+ }
+ sim1 <- PM_sim$new(
+ poppar = as.data.frame(self$posterior$theta),
+ model = self$posterior$model_info$model,
+ data = self$past,
+ predInt = 1,
+ limits = NA,
+ quiet = TRUE
+ )
+ self$past_pred <- sim1
+ invisible(self)
+ },
+ .sim_future = function() {
+ if (is.null(self$future)) {
+ return(invisible(NULL))
+ }
+ # Replace placeholder doses with optimized values (use tail to skip past fixed doses)
+ future_sim_data <- self$future$standard_data
+ n_future_doses <- sum(future_sim_data$evid != 0)
+ future_sim_data$dose[future_sim_data$evid != 0] <- tail(self$result$doses, n_future_doses)
+ self$future$standard_data <- future_sim_data
+
+ sim2 <- PM_sim$new(
+ poppar = as.data.frame(self$posterior$theta),
+ model = self$posterior$model_info$model,
+ data = self$future,
+ predInt = 1,
+ limits = NA,
+ quiet = TRUE
+ )
+ self$future_pred <- sim2
+ invisible(self)
+ }
+ ),
+ active = list(
+ #' @field doses Optimal doses found by BestDose optimization
+ doses = function() {
+ self$result$doses
+ },
+ #' @field objf Objective function value at the optimal doses
+ objf = function() {
+ self$result$objf
+ },
+ #' @field method Optimization method used
+ method = function() {
+ self$result$method
+ },
+ #' @field status Status of the optimization run
+ status = function() {
+ self$result$status
+ }
+ )
+)
+
+#' @title
+#' Compute a reusable BestDose posterior
+#'
+#' @description
+#' `r lifecycle::badge("experimental")`
+#'
+#' Use `bd_post` to compute the Bayesian posterior once from
+#' prior population data and patient history, then call `$optimize()` multiple
+#' times with different targets, dose ranges, or bias weights.
+#'
+#' @export
+bd_post <- R6::R6Class(
+ "bd_post",
+ public = list(
+ #' @field handle Memory pointer to the computed posterior (opaque to users)
+ handle = NULL,
+ #' @field theta Matrix of support points in the posterior distribution
+ theta = NULL,
+ #' @field theta_dim Dimensions of the theta matrix
+ theta_dim = NULL,
+ #' @field param_names Names of the parameters in the posterior
+ param_names = NULL,
+ #' @field posterior_weights Weights of the posterior support points
+ posterior_weights = NULL,
+ #' @field population_weights Weights of the population support points
+ population_weights = NULL,
+ #' @field past PM_data object containing past patient data used in the posterior computation (if applicable)
+ past = NULL,
+ #' @field model_info Information about the model used
+ model_info = NULL,
+ #' @field settings Settings used for the posterior computation
+ settings = NULL,
+ #' @description
+ #' Initialize the `bd_post` object by computing the posterior distribution from the given prior, model, and past data
+ #' @param prior Prior information for the model, can be a PM_result, PM_final, or path to theta.csv
+ #' @param model PM_model object or path to compiled model
+ #' @param past_data PM_data object or path to CSV file with past patient data (optional)
+ #' @param max_cycles Maximum number of optimization cycles for computing the posterior (default: 500)
+ #' @param settings List of additional settings for posterior computation (optional)
+ #' @param quiet Logical indicating whether to suppress verbose simulation output (default: FALSE)
+ #' @return A `bd_post` object containing the computed posterior distribution and associated information
+
+ initialize = function(prior,
+ model,
+ past_data = NULL,
+ max_cycles = 500,
+ settings = NULL,
+ quiet = FALSE) {
+ prior_path <- bestdose_parse_prior(prior)
+ model_info <- bestdose_parse_model(model)
+ past_data_path <- if (!is.null(past_data)) bestdose_parse_data(past_data) else NULL
+
+ if (is.null(settings)) {
+ model_for_settings <- if (!is.null(model_info$model)) model_info$model else model
+ settings <- bestdose_default_settings(prior, model_for_settings, max_cycles = max_cycles)
+ }
+
+ prep <- bestdose_prepare(
+ model_path = model_info$path,
+ prior_path = prior_path,
+ past_data_path = past_data_path,
+ params = settings,
+ kind = model_info$kind
+ )
+
+ if (is.character(prep)) {
+ cli::cli_abort(prep)
+ }
+
+ dim <- as.integer(prep$theta_dim)
+ theta_matrix <- matrix(prep$theta_values, nrow = dim[1], ncol = dim[2])
+ colnames(theta_matrix) <- prep$param_names
+
+ self$handle <- prep$handle
+ self$theta <- theta_matrix
+ self$theta_dim <- dim
+ self$param_names <- prep$param_names
+ self$posterior_weights <- prep$posterior_weights
+ self$population_weights <- prep$population_weights
+ self$past <- if (!is.null(past_data)) PM_data$new(past_data, quiet = TRUE) else NULL
+ self$model_info <- model_info
+ self$settings <- settings
+ private$.quiet <- quiet
+
+ cli::cli_alert_success("BestDose posterior computed with {dim[1]} support points")
+ },
+ #' @description
+ #' Run optimization and return a `bd` result object
+ #' @param target Future regimen/target specification. Accepts the same forms as the `future` argument to `bd$new()`:
+ #' a list with elements such as `dose`, `frequency`, `route`, `number`, `target`, `target_type`, and `target_time`;
+ #' a `PM_data` object; or a path to a CSV file.
+ #' @param dose_range List with 'min' and 'max' elements defining the dose search range (default: 0 to 1000)
+ #' @param prior_weight Numeric between 0 and 1 indicating the weight of bias in the optimization (default: 0.5)
+ #' @param start Start time for future regimen. Can be either `NULL`, numeric hours (default: `0`), or a date-time
+ #' character string. Ambiguous formats are resolved using the `date_format` Pmetrics option
+ #' (see [setPMoptions]). Accepted string formats:
+ #' - `YYYY-mm-dd HH:MM[:SS]`
+ #' - `mm/dd/[YY]YY HH:MM[:SS]` or `mm-dd-[YY]YY HH:MM[:SS]`
+ #' - `dd/mm/[YY]YY HH:MM[:SS]` or `dd-mm-[YY]YY HH:MM[:SS]`
+ #' @param quiet Logical indicating whether to suppress verbose simulation output. If NULL, uses the quiet setting from posterior computation (default: NULL)
+ #' @return A `bd` object containing the optimization results
+ optimize = function(target,
+ dose_range = list(min = 0, max = 1000),
+ prior_weight = 0.5,
+ start = 0,
+ quiet = NULL) {
+ if (is.null(quiet)) quiet <- private$.quiet
+ bd$new(
+ future = target,
+ dose_range = dose_range,
+ prior_weight = prior_weight,
+ start = start,
+ posterior = self,
+ quiet = quiet
+ )
+ }
+ ),
+ private = list(
+ .quiet = FALSE,
+ finalize = function() {
+ self$handle <- NULL
+ }
+ )
+)
+
+#' @export
+bd$load <- function(filename = "bestdose_result.rds") {
+ if (!file.exists(filename)) {
+ cli::cli_abort("File not found: {filename}")
+ }
+ readRDS(filename)
+}
+
+
+############ HELPER FUNCTIONS ############
+
+bestdose_parse_prior <- function(prior) {
+ if (inherits(prior, "PM_result")) {
+ theta_path <- file.path(prior$rundir, "outputs", "theta.csv")
+ if (!file.exists(theta_path)) {
+ cli::cli_abort("theta.csv not found in PM_result outputs")
+ }
+ theta_path
+ } else if (inherits(prior, "PM_final")) {
+ temp_path <- tempfile(fileext = ".csv")
+ bestdose_write_prior_csv(prior, temp_path)
+ temp_path
+ } else if (is.character(prior)) {
+ if (!file.exists(prior)) {
+ cli::cli_abort("Prior file not found: {prior}")
+ }
+ prior
+ } else {
+ cli::cli_abort("prior must be PM_result, PM_final, or path to theta.csv")
+ }
+}
+
+bestdose_write_prior_csv <- function(prior, path) {
+ df <- as.data.frame(prior$popPoints)
+ df$prob <- prior$popProb
+ write.csv(df, path, row.names = FALSE, quote = FALSE)
+}
+
+bestdose_parse_model <- function(model) {
+ if (inherits(model, "PM_model")) {
+ compiled_path <- model$binary_path
+ if (is.null(compiled_path) || !file.exists(compiled_path)) {
+ cli::cli_abort("Model must be compiled first. Use model$compile()")
+ }
+
+ kind <- if (!is.null(model$model_list$analytical) && model$model_list$analytical) {
+ "analytical"
+ } else {
+ "ode"
+ }
+
+ list(path = compiled_path, kind = kind, model = model)
+ } else if (is.character(model)) {
+ if (!file.exists(model)) {
+ cli::cli_abort("Model file not found: {model}")
+ }
+ kind <- if (grepl("analytical", model, ignore.case = TRUE)) {
+ "analytical"
+ } else {
+ "ode"
+ }
+ list(path = model, kind = kind, model = NULL)
+ } else {
+ cli::cli_abort("model must be PM_model or path to compiled model")
+ }
+}
+
+bestdose_parse_data <- function(data) {
+ if (inherits(data, "PM_data")) {
+ temp_path <- tempfile(fileext = ".csv")
+ write.csv(data$standard_data, temp_path, row.names = FALSE, quote = FALSE)
+ temp_path
+ } else if (is.character(data)) {
+ if (!file.exists(data)) {
+ cli::cli_abort("Data file not found: {data}")
+ }
+ data
+ } else {
+ cli::cli_abort("data must be PM_data or path to CSV file")
+ }
+}
+
+bestdose_default_settings <- function(prior, model, max_cycles = 500) {
+ param_ranges <- lapply(model$model_list$pri, function(x) {
+ c(x$min, x$max)
+ })
+ names(param_ranges) <- tolower(names(param_ranges))
+
+ list(
+ algorithm = "NPAG",
+ ranges = param_ranges,
+ error_models = lapply(model$model_list$err, function(x) x$flatten()),
+ max_cycles = max_cycles,
+ points = 2028,
+ seed = 22,
+ prior = "prior.csv",
+ idelta = 0.25,
+ tad = 0.0
+ )
+}
+
+
+#' Plot BestDose predictions
+#'
+#' @description
+#' Plot observed and predicted concentrations over time for both past and future data,
+#' with options to apply a multiplier to the concentrations, and customize the plot appearance. The top 5 posterior support
+#' points are highlighted with distinct colors.
+#'
+#' @param x A `bd` object containing the best dose predictions.
+#' @param mult Numeric multiplier to apply to the concentrations (default: 1, no scaling).
+#' @param outeq Numeric value of outeq to filter observations for plotting (default: 1).
+#' @param quiet Logical indicating whether to suppress messages (default: FALSE).
+#' @param legend Logical indicating whether to display a legend (default: TRUE).
+#' @param log Logical indicating whether to use a logarithmic scale for the y-axis (default: FALSE).
+#' @param grid Logical indicating whether to display a grid (default: FALSE).
+#' @param future_region Logical indicating whether to display the divider line and shaded region for future data (default: FALSE).
+#' @param xlab Label for the x-axis.
+#' @param ylab Label for the y-axis.
+#' @param title Title of the plot.
+#' @param xlim Limits for the x-axis.
+#' @param ylim Limits for the y-axis.
+#' @param print Logical indicating whether to print the plot (default: TRUE).
+#' @param ... Additional arguments passed to the plotting function.
+#'
+#' @return Invisibly returns a plotly object.
+#'
+#' @export
+#' @method plot bd
+plot.bd <- function(x, mult = 1, outeq = 1,
+ quiet = FALSE, legend = TRUE, log = FALSE,
+ grid = FALSE, future_region = FALSE,
+ xlab = "Time", ylab = "Concentration",
+ title = NULL, xlim = NULL, ylim = NULL, print = TRUE, ...) {
+ if (!inherits(x, "bd")) {
+ stop("Object must be of class 'bd'")
+ }
+
+ # Print target/future data for debugging
+ if (!quiet) {
+ cli::cli_h3("Target/Future data (for debugging)")
+ if (!is.null(x$future) && !is.null(x$future$standard_data)) {
+ print(x$future$standard_data)
+ } else {
+ cli::cli_alert_info("No future data available.")
+ }
+ }
+
+ # Check if we have future predictions (past is optional)
+ if (is.null(x$future_pred)) {
+ cli::cli_abort(c(
+ "x" = "Future predictions are required for plotting.",
+ "i" = "BestDose object must have {.code future_pred}."
+ ))
+ }
+
+ # Get starting date/time for x-axis datetime conversion
+ start_datetime <- bd_plot_start_datetime(x)
+
+ # Compute a single, consistent future_time_offset (in hours) for plotting.
+ # This mirrors the Rust optimization logic:
+ # with past: future starts at max past time (past is optionally extended to start)
+ # without past: future starts at start_offset hours from plot origin
+ future_time_offset <- bd_future_time_offset(x)
+ has_past <- !is.null(x$past) && !is.null(x$past$standard_data) && nrow(x$past$standard_data) > 0
+
+ if (!quiet) {
+ cli::cli_alert_info("start = {deparse(x$start)}, start_offset = {x$start_offset %||% 0}, future_time_offset = {future_time_offset} h")
+ }
+
+ # Extract observation data from both simulations
+ past_obs <- NULL
+ if (!is.null(x$past_pred)) {
+ past_obs <- x$past_pred$data$obs |>
+ dplyr::filter(outeq == !!outeq)
+ }
+
+ future_obs <- x$future_pred$data$obs |>
+ dplyr::filter(outeq == !!outeq)
+
+
+ # Adjust future simulation prediction times using the unified offset
+ future_obs_adjusted <- future_obs |>
+ dplyr::mutate(time = time + future_time_offset)
+
+ # Combine past and future observations
+ if (!is.null(past_obs) && nrow(past_obs) > 0) {
+ combined_obs <- dplyr::bind_rows(
+ past_obs |> dplyr::mutate(source = "past"),
+ future_obs_adjusted |> dplyr::mutate(source = "future")
+ ) |>
+ dplyr::arrange(id, nsim, time) |>
+ dplyr::mutate(datetime = start_datetime + time * 3600)
+ } else {
+ combined_obs <- future_obs_adjusted |>
+ dplyr::mutate(source = "future") |>
+ dplyr::arrange(id, nsim, time) |>
+ dplyr::mutate(datetime = start_datetime + time * 3600)
+ }
+
+ # Apply multiplier
+ combined_obs$out <- combined_obs$out * mult
+
+ # Handle log scale
+ if (log) {
+ if (any(combined_obs$out <= 0, na.rm = TRUE)) {
+ if (!quiet) {
+ cat("Values <= 0 omitted from log plot.\n")
+ }
+ combined_obs$out[combined_obs$out <= 0] <- NA
+ }
+ }
+
+ # Remove NA values (but keep intentional break rows for gap rendering)
+ combined_obs <- combined_obs |> dplyr::filter(!is.na(out) | source == "break")
+
+ # Extract actual observations from past and future data
+ past_data_obs <- NULL
+ future_data_obs <- NULL
+ past_doses <- NULL
+ future_doses <- NULL
+
+ if (!is.null(x$past)) {
+ past_data_obs <- x$past$standard_data |>
+ dplyr::filter(evid == 0, outeq == !!outeq) |>
+ dplyr::select(id, time, out)
+
+ past_data_obs$out <- past_data_obs$out * mult
+
+ if (log && any(past_data_obs$out <= 0, na.rm = TRUE)) {
+ past_data_obs$out[past_data_obs$out <= 0] <- NA
+ }
+
+ past_data_obs <- past_data_obs |> dplyr::filter(!is.na(out))
+
+ past_doses <- x$past$standard_data |>
+ dplyr::filter(evid == 1) |>
+ dplyr::select(id, time, dur, dose)
+ }
+
+ if (!is.null(x$future)) {
+ future_data_obs <- x$future$standard_data |>
+ dplyr::filter(evid == 0, outeq == !!outeq) |>
+ dplyr::select(id, time, out)
+
+ # Use the same unified future_time_offset for consistency
+ future_data_obs <- future_data_obs |>
+ dplyr::mutate(time = time + future_time_offset)
+
+ future_data_obs$out <- future_data_obs$out * mult
+
+ if (log && any(future_data_obs$out <= 0, na.rm = TRUE)) {
+ future_data_obs$out[future_data_obs$out <= 0] <- NA
+ }
+
+ future_data_obs <- future_data_obs |> dplyr::filter(!is.na(out))
+
+ future_doses <- bd_future_dose_events(x)
+ if (!is.null(future_doses) && nrow(future_doses) > 0) {
+ future_doses <- future_doses |>
+ dplyr::select(id, time, dur, dose)
+ }
+ }
+
+ # Initialize plot
+ p <- plotly::plot_ly()
+
+ # Get posterior weights and robustly map them to nsim values
+ posterior_weights <- as.numeric(x$posterior$posterior_weights)
+ top5_colors <- c("#FF0000", "#0000FF", "#008000", "#800080", "#FFA500")
+
+ unique_nsim <- sort(unique(combined_obs$nsim))
+ nsim_keys <- as.character(unique_nsim)
+
+ sim_index_by_nsim <- setNames(seq_along(unique_nsim), nsim_keys)
+
+ weight_by_nsim <- setNames(rep(NA_real_, length(nsim_keys)), nsim_keys)
+ for (k in nsim_keys) {
+ idx <- sim_index_by_nsim[[k]]
+ if (!is.na(idx) && idx >= 1 && idx <= length(posterior_weights)) {
+ weight_by_nsim[[k]] <- posterior_weights[idx]
+ }
+ }
+
+ # Identify top 5 nsim keys by weight (largest to smallest)
+ ranked_keys <- names(sort(weight_by_nsim, decreasing = TRUE, na.last = NA))
+ top_keys <- ranked_keys[seq_len(min(5, length(ranked_keys)))]
+
+ color_by_nsim <- setNames(rep("#cccccc", length(nsim_keys)), nsim_keys)
+ legend_name_by_nsim <- setNames(rep(NA_character_, length(nsim_keys)), nsim_keys)
+ for (i in seq_along(top_keys)) {
+ k <- top_keys[i]
+ color_by_nsim[[k]] <- top5_colors[i]
+ legend_name_by_nsim[[k]] <- sprintf("%.4e", weight_by_nsim[[k]])
+ }
+
+ legend_added <- setNames(rep(FALSE, length(nsim_keys)), nsim_keys)
+ id_values <- unique(combined_obs$id)
+ non_top_keys <- setdiff(nsim_keys, top_keys)
+
+ # 1) Plot non-top lines first (light grey, no legend)
+ for (nsim_key in non_top_keys) {
+ weight_val <- weight_by_nsim[[nsim_key]]
+ for (id_val in id_values) {
+ this_sim <- combined_obs |>
+ dplyr::filter(id == id_val, as.character(nsim) == nsim_key) |>
+ dplyr::arrange(datetime)
+
+ if (nrow(this_sim) > 0) {
+ p <- p |>
+ plotly::add_lines(
+ x = ~datetime, y = ~out, data = this_sim,
+ line = list(color = "#cccccc", width = 0.8),
+ opacity = 0.6,
+ name = NULL,
+ legendgroup = NULL,
+ hovertemplate = sprintf(
+ "Time: %%{x}
Out: %%{y}
Weight: %s
Out: %%{y}
Weight: %s
Out: %{y}
Out: %{y}
Dose: %{customdata[0]}
%{customdata[1]}
%{text}
Dose: %{customdata[0]}
%{customdata[1]}
%{text}
Intercept = %.3f
Slope = %.3f",
+ summary(fit)$r.squared,
+ stats::coef(fit)[1],
+ stats::coef(fit)[2]
+ )
+ }
+
+ p <- plotly::plot_ly(
+ data = data, x = ~pred, y = ~obs, type = "scatter", mode = "markers",
+ marker = list(size = 9, color = "rgba(214, 86, 69, 0.75)", line = list(color = "rgba(120, 40, 30, 0.9)", width = 1)),
+ hovertemplate = "Predicted: %{x}
Observed: %{y}
%Bias: %{x:.2f}
%Imprecision: %{y:.2f}
%Bias: %{x:.2f}
%Imprecision: %{y:.2f}
%Bias: %{x:.2f}
%Imprecision: %{y:.2f}
Amount: ", df$dose_amt,
+ "
Date: ", df$date_label,
+ "
Time: ", df$time_label,
+ "
Post-dose AUC: ", sprintf("%.2f", df$postdose_auc)
+ ),
+ hoverinfo = "text",
+ textposition = "none"
+ )
+
+ # cumulative line split into past / future to change color
+ past_idx <- which(df$source == "Past")
+ future_idx <- which(df$source != "Past")
+
+ if (length(past_idx) > 0) {
+ p <- p |> plotly::add_trace(
+ x = df$datetime[past_idx], y = df$cumulative_auc[past_idx],
+ type = "scatter", mode = "lines+markers", name = "Cumulative (Past)",
+ line = list(color = "black"), marker = list(color = "black"), yaxis = "y2", showlegend = FALSE,
+ text = paste0(
+ "Dose: ", df$dose_idx[past_idx],
+ "
Amount: ", df$dose_amt[past_idx],
+ "
Date: ", df$date_label[past_idx],
+ "
Time: ", df$time_label[past_idx],
+ "
Cumulative AUC: ", sprintf("%.2f", df$cumulative_auc[past_idx])
+ ),
+ hoverinfo = "text"
+ )
+ }
+
+ if (length(future_idx) > 0) {
+ p <- p |> plotly::add_trace(
+ x = df$datetime[future_idx], y = df$cumulative_auc[future_idx],
+ type = "scatter", mode = "lines+markers", name = "Cumulative (Future)",
+ line = list(color = "red"), marker = list(color = "red"), yaxis = "y2", showlegend = FALSE,
+ text = paste0(
+ "Dose: ", df$dose_idx[future_idx],
+ "
Amount: ", df$dose_amt[future_idx],
+ "
Date: ", df$date_label[future_idx],
+ "
Time: ", df$time_label[future_idx],
+ "
Cumulative AUC: ", sprintf("%.2f", df$cumulative_auc[future_idx])
+ ),
+ hoverinfo = "text"
+ )
+ }
+
+ # bridge segment from last past to first future cumulative point
+ if (length(past_idx) > 0 && length(future_idx) > 0) {
+ i_last_past <- max(past_idx)
+ i_first_future <- min(future_idx)
+ p <- p |> plotly::add_trace(
+ x = c(df$datetime[i_last_past], df$datetime[i_first_future]),
+ y = c(df$cumulative_auc[i_last_past], df$cumulative_auc[i_first_future]),
+ type = "scatter", mode = "lines", yaxis = "y2",
+ line = list(color = "black", width = 2),
+ showlegend = FALSE,
+ hoverinfo = "skip"
+ )
+ }
+
+ # x-axis tick text: dose number only
+ ticktext <- paste0("Dose ", df$dose_idx)
+
+ p |> plotly::layout(
+ xaxis = list(title = "", tickvals = df$datetime, ticktext = ticktext),
+ yaxis = list(title = "Post-dose AUC", zeroline = FALSE),
+ yaxis2 = list(
+ overlaying = "y", side = "right",
+ title = list(text = "Cumulative AUC", standoff = 40),
+ automargin = TRUE,
+ zeroline = FALSE
+ ),
+ margin = list(t = 90),
+ bargap = 0.2,
+ showlegend = FALSE
+ )
+}
+
+bd_pd_table <- function(x, outeq = 1, start_datetime = NULL) {
+ if (is.null(x$future) || is.null(x$future$standard_data)) {
+ return(NULL)
+ }
+
+ target_rows <- x$future$standard_data |>
+ dplyr::filter(evid == 0, outeq == !!outeq)
+ if (nrow(target_rows) == 0) {
+ return(NULL)
+ }
+
+ target_time <- as.numeric(target_rows$time[1])
+ target_value <- as.numeric(target_rows$out[1])
+ if (!is.finite(target_time) || !is.finite(target_value)) {
+ return(NULL)
+ }
+
+ past_doses <- bd_past_dose_events(x)
+ future_doses <- bd_future_dose_events(x)
+ if (!is.null(future_doses) && nrow(future_doses) > 0) {
+ future_doses <- future_doses |>
+ dplyr::mutate(source = "Future")
+ }
+
+ doses <- dplyr::bind_rows(past_doses, future_doses)
+ if (is.null(doses) || nrow(doses) == 0) {
+ return(NULL)
+ }
+
+ doses <- doses |>
+ dplyr::arrange(time) |>
+ dplyr::mutate(eval_time = time + target_time)
+
+ offset <- bd_future_time_offset(x)
+ past_sim <- NULL
+ future_sim <- NULL
+
+ if (!is.null(x$past_pred) && !is.null(x$past_pred$data) && !is.null(x$past_pred$data$obs)) {
+ past_sim <- x$past_pred$data$obs |>
+ dplyr::filter(outeq == !!outeq)
+ }
+ if (!is.null(x$future_pred) && !is.null(x$future_pred$data) && !is.null(x$future_pred$data$obs)) {
+ future_sim <- x$future_pred$data$obs |>
+ dplyr::filter(outeq == !!outeq) |>
+ dplyr::mutate(time = time + offset)
+ }
+
+ sim <- dplyr::bind_rows(past_sim, future_sim)
+ if (is.null(sim) || nrow(sim) == 0) {
+ return(NULL)
+ }
+
+ sim <- sim |>
+ dplyr::mutate(nsim = as.character(nsim))
+
+ weights <- bd_weight_table(x, sim$nsim) |>
+ dplyr::mutate(nsim = as.character(nsim))
+
+ sim_split <- split(sim, sim$nsim)
+ nsim_keys <- intersect(names(sim_split), weights$nsim)
+ if (length(nsim_keys) == 0) {
+ return(NULL)
+ }
+
+ eval_times <- doses$eval_time
+ success_mat <- matrix(FALSE, nrow = length(nsim_keys), ncol = length(eval_times))
+ w <- numeric(length(nsim_keys))
+
+ for (j in seq_along(nsim_keys)) {
+ key <- nsim_keys[j]
+ d <- sim_split[[key]] |>
+ dplyr::arrange(time)
+ d <- d[!is.na(d$time) & !is.na(d$out), , drop = FALSE]
+ if (nrow(d) < 2) next
+
+ pred_at <- stats::approx(
+ x = d$time,
+ y = d$out,
+ xout = eval_times,
+ rule = 2,
+ ties = mean
+ )$y
+
+ success <- pred_at >= target_value
+ success_mat[j, ] <- as.logical(success)
+
+ w_row <- weights$weight[weights$nsim == key]
+ w[j] <- if (length(w_row) == 0 || !is.finite(w_row[1])) 0 else as.numeric(w_row[1])
+ }
+
+ if (sum(w, na.rm = TRUE) <= 0) {
+ return(NULL)
+ }
+
+ cum_prob <- vapply(seq_along(eval_times), function(i) {
+ stats::weighted.mean(as.numeric(success_mat[, i]), w = w, na.rm = TRUE)
+ }, numeric(1))
+
+ out <- doses |>
+ dplyr::transmute(
+ dose_number = dplyr::row_number(),
+ time = time,
+ source = source,
+ cumulative_probability = cum_prob
+ )
+
+ bd_add_datetime_column(out, start_datetime)
+}
+
+bd_add_datetime_column <- function(data, start_datetime) {
+ if (is.null(data) || nrow(data) == 0 || is.null(start_datetime) || is.na(start_datetime) || !"time" %in% names(data)) {
+ return(data)
+ }
+
+ data |>
+ dplyr::mutate(datetime = format(start_datetime + time * 3600, "%Y-%m-%d %H:%M:%S")) |>
+ dplyr::relocate(datetime, .after = time)
+}
+
+bd_normalize_weights <- function(w) {
+ w <- as.numeric(w)
+ w[!is.finite(w)] <- 0
+ s <- sum(w, na.rm = TRUE)
+ if (!is.finite(s) || s <= 0) {
+ return(rep(0, length(w)))
+ }
+ w / s
+}
+
+bd_parameter_shift_data <- function(x, bins = 25) {
+ if (is.null(x$posterior) || is.null(x$posterior$theta)) {
+ return(NULL)
+ }
+
+ theta <- as.data.frame(x$posterior$theta)
+ if (nrow(theta) == 0 || ncol(theta) == 0) {
+ return(NULL)
+ }
+
+ param_names <- colnames(theta)
+ if (is.null(param_names) || !length(param_names)) {
+ param_names <- x$posterior$param_names
+ colnames(theta) <- param_names
+ }
+
+ prior_w <- bd_normalize_weights(x$posterior$population_weights)
+ post_w <- bd_normalize_weights(x$posterior$posterior_weights)
+
+ n_points <- nrow(theta)
+ if (length(prior_w) != n_points) prior_w <- rep(1 / n_points, n_points)
+ if (length(post_w) != n_points) post_w <- rep(1 / n_points, n_points)
+
+ bins <- max(5L, as.integer(bins))
+
+ long <- lapply(seq_along(param_names), function(i) {
+ p <- param_names[i]
+ vals <- as.numeric(theta[[p]])
+
+ ok <- is.finite(vals)
+ if (!any(ok)) {
+ return(NULL)
+ }
+
+ vals_ok <- vals[ok]
+ prior_ok <- bd_normalize_weights(prior_w[ok])
+ post_ok <- bd_normalize_weights(post_w[ok])
+
+ if (length(vals_ok) == 1 || diff(range(vals_ok, na.rm = TRUE)) == 0) {
+ bin_idx <- rep(1L, length(vals_ok))
+ breaks <- range(vals_ok, na.rm = TRUE)
+ if (length(breaks) < 2 || !is.finite(diff(breaks)) || diff(breaks) == 0) {
+ breaks <- c(vals_ok[1] - 0.5, vals_ok[1] + 0.5)
+ }
+ bins_use <- 1L
+ } else {
+ breaks <- seq(min(vals_ok, na.rm = TRUE), max(vals_ok, na.rm = TRUE), length.out = bins + 1L)
+ bin_idx <- cut(vals_ok, breaks = breaks, include.lowest = TRUE, labels = FALSE)
+ bins_use <- bins
+ }
+
+ bin_tbl <- tibble::tibble(
+ parameter = p,
+ bin = as.integer(bin_idx),
+ value = vals_ok,
+ prior_weight = prior_ok,
+ post_weight = post_ok
+ ) |>
+ dplyr::group_by(parameter, bin) |>
+ dplyr::summarise(
+ value_low = min(value, na.rm = TRUE),
+ value_high = max(value, na.rm = TRUE),
+ value_mid = stats::weighted.mean(value, w = post_weight, na.rm = TRUE),
+ prior_prob = sum(prior_weight, na.rm = TRUE),
+ post_prob = sum(post_weight, na.rm = TRUE),
+ .groups = "drop"
+ )
+
+ all_bins <- tibble::tibble(bin = seq_len(bins_use))
+ out <- all_bins |>
+ dplyr::left_join(bin_tbl, by = dplyr::join_by(bin)) |>
+ dplyr::mutate(
+ parameter = p,
+ prior_prob = dplyr::coalesce(prior_prob, 0),
+ post_prob = dplyr::coalesce(post_prob, 0),
+ delta_prob = post_prob - prior_prob,
+ bin_pct = (bin - 0.5) / bins_use * 100,
+ q_label = sprintf("Q%02d", bin)
+ )
+
+ out
+ }) |>
+ dplyr::bind_rows()
+
+ if (is.null(long) || nrow(long) == 0) {
+ return(NULL)
+ }
+
+ summary_tbl <- long |>
+ dplyr::group_by(parameter) |>
+ dplyr::summarise(
+ total_variation = 0.5 * sum(abs(delta_prob), na.rm = TRUE),
+ max_shift = max(abs(delta_prob), na.rm = TRUE),
+ .groups = "drop"
+ ) |>
+ dplyr::arrange(dplyr::desc(total_variation), dplyr::desc(max_shift))
+
+ means_tbl <- tibble::tibble(
+ parameter = param_names,
+ prior_mean = vapply(seq_along(param_names), function(i) {
+ stats::weighted.mean(as.numeric(theta[[i]]), w = prior_w, na.rm = TRUE)
+ }, numeric(1)),
+ posterior_mean = vapply(seq_along(param_names), function(i) {
+ stats::weighted.mean(as.numeric(theta[[i]]), w = post_w, na.rm = TRUE)
+ }, numeric(1))
+ ) |>
+ dplyr::mutate(mean_shift = posterior_mean - prior_mean)
+
+ summary_tbl <- summary_tbl |>
+ dplyr::left_join(means_tbl, by = dplyr::join_by(parameter))
+
+ long <- long |>
+ dplyr::mutate(
+ parameter = factor(parameter, levels = summary_tbl$parameter)
+ )
+
+ list(long = long, summary = summary_tbl, bins = bins)
+}
+
+bd_parameter_shift_heatmap <- function(shift_data) {
+ if (is.null(shift_data) || is.null(shift_data$long) || nrow(shift_data$long) == 0) {
+ return(NULL)
+ }
+
+ df <- shift_data$long |>
+ dplyr::arrange(parameter, bin)
+
+ df <- df |>
+ dplyr::mutate(
+ x_value = dplyr::coalesce(value_mid, (value_low + value_high) / 2)
+ )
+
+ df_prior <- df |>
+ dplyr::filter(is.finite(x_value), is.finite(prior_prob), prior_prob > 0)
+
+ df_post <- df |>
+ dplyr::filter(is.finite(x_value), is.finite(post_prob), post_prob > 0)
+
+ gg <- ggplot2::ggplot() +
+ ggplot2::geom_point(
+ data = df_prior,
+ ggplot2::aes(x = x_value, y = prior_prob),
+ color = "grey55",
+ size = 2.3,
+ shape = 16
+ ) +
+ ggplot2::geom_point(
+ data = df_post,
+ ggplot2::aes(x = x_value, y = post_prob),
+ color = "red",
+ alpha = 0.5,
+ size = 2.3,
+ shape = 16
+ ) +
+ ggplot2::facet_wrap(~parameter, scales = "free") +
+ ggplot2::scale_x_continuous(breaks = scales::pretty_breaks(n = 6)) +
+ ggplot2::scale_y_continuous(breaks = scales::pretty_breaks(n = 5)) +
+ ggplot2::labs(
+ title = "Marginal parameter distributions: prior (gray) vs posterior (red)",
+ x = "Parameter value",
+ y = "Probability"
+ ) +
+ ggplot2::theme_minimal(base_size = 12) +
+ ggplot2::theme(
+ legend.position = "none",
+ panel.grid.minor = ggplot2::element_blank()
+ )
+
+ plotly::ggplotly(gg)
+}
+
+bd_report_build <- function(x, outeq = 1) {
+ start_datetime <- bd_plot_start_datetime(x)
+ future_offset <- bd_future_time_offset(x)
+
+ overview <- tibble::tibble(
+ item = c("Status", "Method", "Objective", "log(Objective)", "Prior weight", "Start", "Start offset (h)", "Future starts at (h)"),
+ value = c(
+ x$status %||% NA_character_,
+ x$method %||% NA_character_,
+ signif(x$objf %||% NA_real_, 6),
+ if (is.null(x$objf) || is.na(x$objf) || x$objf <= 0) NA_real_ else signif(log(x$objf), 6),
+ x$prior_weight %||% NA_real_,
+ paste(x$start %||% NA_character_, collapse = ", "),
+ x$start_offset %||% NA_real_,
+ future_offset
+ )
+ )
+
+ past_last_dose <- NULL
+ past_last_obs <- NULL
+ if (!is.null(x$past) && !is.null(x$past$standard_data) && nrow(x$past$standard_data) > 0) {
+ past_last_dose <- x$past$standard_data |>
+ dplyr::filter(evid == 1) |>
+ dplyr::arrange(dplyr::desc(time)) |>
+ dplyr::slice(1) |>
+ dplyr::select(time, dose, dur, input)
+
+ past_last_obs <- x$past$standard_data |>
+ dplyr::filter(evid == 0, outeq == !!outeq) |>
+ dplyr::arrange(dplyr::desc(time)) |>
+ dplyr::slice(1) |>
+ dplyr::select(time, out, outeq)
+ }
+
+ # Original requested doses (0 = Optimized, non-zero = Fixed), captured before optimization
+ original_requested_doses <- x$future_requested_doses
+
+ future_doses <- bd_future_dose_events(x)
+ if (!is.null(future_doses) && nrow(future_doses) > 0) {
+ n_fd <- nrow(future_doses)
+ status_vec <- rep("Fixed", n_fd)
+ if (!is.null(original_requested_doses) && length(original_requested_doses) >= n_fd) {
+ status_vec <- dplyr::if_else(
+ as.numeric(original_requested_doses[seq_len(n_fd)]) == 0,
+ "Optimized", "Fixed"
+ )
+ }
+ future_doses <- future_doses |>
+ dplyr::select(time, dose, dur, input) |>
+ dplyr::mutate(status = status_vec) |>
+ bd_add_datetime_column(start_datetime)
+ }
+
+ future_targets <- NULL
+ if (!is.null(x$future) && !is.null(x$future$standard_data)) {
+ future_targets <- x$future$standard_data |>
+ dplyr::filter(evid == 0, outeq == !!outeq) |>
+ dplyr::mutate(time = time + future_offset) |>
+ dplyr::select(time, out, outeq) |>
+ bd_add_datetime_column(start_datetime)
+ }
+
+ future_predictions <- x$result$predictions
+ if (!is.null(future_predictions) && nrow(future_predictions) > 0) {
+ future_predictions <- future_predictions |>
+ dplyr::filter(outeq == !!(outeq - 1)) |>
+ bd_add_datetime_column(start_datetime)
+ }
+
+ auc_table <- bd_auc_table(x, outeq = outeq, start_datetime = start_datetime)
+ auc_plot <- bd_auc_plot(auc_table)
+ pd_table <- bd_pd_table(x, outeq = outeq, start_datetime = start_datetime)
+
+ pd_target_summary <- NULL
+ if (!is.null(x$future_target_info)) {
+ ti <- x$future_target_info
+ tt <- ti$target_type %||% "concentration"
+ tgt <- ti$target
+ ttm <- ti$target_time
+ if (!is.null(tt) && tt == "time") {
+ pct <- if (!is.null(ttm) && is.numeric(ttm) && !is.na(ttm)) round(ttm * 100) else NA
+ pd_target_summary <- paste0(
+ "Therapeutic target: Time above ", tgt,
+ " for ", pct, "% of each dosing interval."
+ )
+ } else {
+ type_label <- if (!is.null(tt) && tt == "auc") "AUC" else "Concentration"
+ ttm_display <- if (!is.null(ttm) && length(ttm) == 1 && is.numeric(ttm) && !is.na(ttm)) ttm else 24
+ pd_target_summary <- paste0(
+ "Therapeutic target: ", type_label, " of ", tgt,
+ ", ", ttm_display, " hours after each dose."
+ )
+ }
+ }
+
+ past_fit <- bd_fit_data(x, source = "past", outeq = outeq)
+ future_fit <- bd_fit_data(x, source = "future", outeq = outeq)
+
+ past_fit <- bd_add_datetime_column(past_fit, start_datetime)
+ future_fit <- bd_add_datetime_column(future_fit, start_datetime)
+
+ past_fit_metrics <- bd_fit_metrics(past_fit)
+ future_fit_metrics <- bd_fit_metrics(future_fit)
+
+ posterior_table <- as.data.frame(x$posterior$theta)
+ posterior_table$weight <- as.numeric(x$posterior$posterior_weights)
+ posterior_table$population_weight <- as.numeric(x$posterior$population_weights)
+
+ posterior_summary <- tibble::tibble(
+ parameter = x$posterior$param_names,
+ weighted_mean = vapply(seq_along(x$posterior$param_names), function(i) {
+ stats::weighted.mean(x$posterior$theta[, i], x$posterior$posterior_weights, na.rm = TRUE)
+ }, numeric(1)),
+ weighted_median = vapply(seq_along(x$posterior$param_names), function(i) {
+ vals <- x$posterior$theta[, i]
+ wts <- x$posterior$posterior_weights
+ ord <- order(vals)
+ vals <- vals[ord]
+ wts <- wts[ord] / sum(wts)
+ vals[which.max(cumsum(wts) >= 0.5)]
+ }, numeric(1)),
+ weighted_sd = vapply(seq_along(x$posterior$param_names), function(i) {
+ vals <- x$posterior$theta[, i]
+ mu <- stats::weighted.mean(vals, x$posterior$posterior_weights, na.rm = TRUE)
+ sqrt(stats::weighted.mean((vals - mu)^2, x$posterior$posterior_weights, na.rm = TRUE))
+ }, numeric(1))
+ )
+
+ shift_data <- bd_parameter_shift_data(x, bins = 25)
+
+ list(
+ overview = overview,
+ plot = plot.bd(x, quiet = TRUE, print = FALSE, future_region = FALSE),
+ past_rainbow_plot = bd_fit_rainbow_plot_single(past_fit_metrics, color = "black", name = "Past"),
+ future_rainbow_plot = bd_fit_rainbow_plot_single(future_fit_metrics, color = "red", name = "Future"),
+ past_last_dose = bd_add_datetime_column(past_last_dose, start_datetime),
+ past_last_obs = bd_add_datetime_column(past_last_obs, start_datetime),
+ future_doses = future_doses,
+ future_targets = future_targets,
+ future_predictions = future_predictions,
+ auc_plot = auc_plot,
+ auc_table = auc_table,
+ pd_table = pd_table,
+ pd_target_summary = pd_target_summary,
+ past_fit = past_fit,
+ future_fit = future_fit,
+ past_fit_metrics = past_fit_metrics,
+ future_fit_metrics = future_fit_metrics,
+ past_fit_plot = bd_fit_plot(past_fit, "Past observations vs predictions"),
+ future_fit_plot = bd_fit_plot(future_fit, "Future targets vs predictions"),
+ posterior_summary = posterior_summary,
+ posterior_table = posterior_table,
+ parameter_shift_plot = bd_parameter_shift_heatmap(shift_data),
+ parameter_shift_summary = if (!is.null(shift_data)) shift_data$summary else NULL,
+ model_summary = paste(capture.output(print(x$posterior$model_info$model)), collapse = "\n")
+ )
+}
+
+bd_report <- function(x, path, show = TRUE, quiet = TRUE, title = "BestDose Report") {
+ if (!inherits(x, "bd")) {
+ cli::cli_abort(c("x" = "This function expects a {.cls bd} object."))
+ }
+
+ template_file <- system.file("report/templates/bestdose.Rmd", package = "Pmetrics")
+ if (!nzchar(template_file) || !file.exists(template_file)) {
+ cli::cli_abort(c("x" = "BestDose report template was not found in the package."))
+ }
+
+ out_path <- if (missing(path)) tempdir() else normalizePath(path, winslash = "/", mustWork = FALSE)
+ fs::dir_create(out_path)
+
+ rmarkdown::render(
+ input = template_file,
+ output_file = file.path(out_path, "bestdose_report.html"),
+ params = list(bd = x, title = title),
+ clean = TRUE,
+ quiet = quiet
+ )
+
+ out_file <- file.path(out_path, "bestdose_report.html")
+ if (file.exists(out_file)) {
+ if (show) {
+ pander::openFileInOS(out_file)
+ }
+ return(invisible(1))
+ }
+
+ invisible(-1)
+}
diff --git a/R/Pmetrics-package.R b/R/Pmetrics-package.R
index ad023234..14d94a7d 100755
--- a/R/Pmetrics-package.R
+++ b/R/Pmetrics-package.R
@@ -10,6 +10,7 @@
#' @aliases Pmetrics
#' @author Michael Neely MD
#' \url{http://www.lapk.org}
+#' @useDynLib Pmetrics, .registration = TRUE
#' @keywords internal
#' "_PACKAGE"
#'
diff --git a/R/extendr-wrappers.R b/R/extendr-wrappers.R
index 20be6617..b38258d3 100755
--- a/R/extendr-wrappers.R
+++ b/R/extendr-wrappers.R
@@ -1,22 +1,12 @@
-# Generated by extendr: Do not edit by hand
+#' Simulates the first subject in the data set using the model at the given path.
# nolint start
-#
-# This file was created with the following call:
-# .Call("wrap__make_Pmetrics_wrappers", use_symbols = TRUE, package_name = "Pmetrics")
-
-#' @usage NULL
-#' @useDynLib Pmetrics, .registration = TRUE
-NULL
-
-#' Simulates the first subject in the data set using the model at the given path.
#' @param data_path Path to the data file.
#' @param model_path Path to the compiled model file.
#' @param spp One support point as a numeric vector with probabiltity.
#' @param kind Kind of model, which can either be "ODE" or "Analytical".
#' @return Simulation results.
-#' @keywords internal
#'@export
simulate_one <- function(data_path, model_path, spp, kind) .Call(wrap__simulate_one, data_path, model_path, spp, kind)
@@ -26,7 +16,6 @@ simulate_one <- function(data_path, model_path, spp, kind) .Call(wrap__simulate_
#' @param theta Data frame of support points.
#' @param kind Kind of model, which can either be "ODE" or "Analytical".
#' @return Simulation results.
-#' @keywords internal
#' @export
simulate_all <- function(data_path, model_path, theta, kind) .Call(wrap__simulate_all, data_path, model_path, theta, kind)
@@ -37,20 +26,17 @@ simulate_all <- function(data_path, model_path, theta, kind) .Call(wrap__simulat
#' @param template_path Path to the template directory.
#' @param kind Kind of model, which can either be "ODE" or "Analytical".
#' @return Result of the compilation process.
-#' @keywords internal
#' @export
compile_model <- function(model_path, output_path, params, template_path, kind) .Call(wrap__compile_model, model_path, output_path, params, template_path, kind)
#' Dummy function to cache compilation artifacts.
#' @param template_path Path to the template directory.
#' @return Path to the build directory.
-#' @keywords internal
#' @export
dummy_compile <- function(template_path) .Call(wrap__dummy_compile, template_path)
#' Checks if Cargo is installed on the system.
#' @return TRUE if Cargo is installed, FALSE otherwise.
-#' @keywords internal
#' @export
is_cargo_installed <- function() .Call(wrap__is_cargo_installed)
@@ -61,7 +47,6 @@ is_cargo_installed <- function() .Call(wrap__is_cargo_installed)
#' @param output_path Path to save the fitting results.
#' @param kind Kind of model, which can either be "ODE" or "Analytical".
#' @return Result of the fitting process.
-#' @keywords internal
#' @export
fit <- function(model_path, data, params, output_path, kind) .Call(wrap__fit, model_path, data, params, output_path, kind)
@@ -69,13 +54,11 @@ fit <- function(model_path, data, params, output_path, kind) .Call(wrap__fit, mo
#' @param model_path Path to the compiled model file.
#' @param kind Kind of model, which can either be "ODE" or "Analytical".
#' @return List of model parameters.
-#' @keywords internal
#' @export
model_parameters <- function(model_path, kind) .Call(wrap__model_parameters, model_path, kind)
#' Retrieves the temporary path used for building models.
#' @return Temporary build path.
-#' @keywords internal
#' @export
temporary_path <- function() .Call(wrap__temporary_path)
@@ -84,5 +67,9 @@ temporary_path <- function() .Call(wrap__temporary_path)
#' @export
setup_logs <- function() .Call(wrap__setup_logs)
+bestdose_prepare <- function(model_path, prior_path, past_data_path, params, kind) .Call(wrap__bestdose_prepare, model_path, prior_path, past_data_path, params, kind)
+
+bestdose_optimize <- function(handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type) .Call(wrap__bestdose_optimize, handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type)
+
# nolint end
diff --git a/_pkgdown.yml b/_pkgdown.yml
index b3a9865d..847f6714 100755
--- a/_pkgdown.yml
+++ b/_pkgdown.yml
@@ -51,6 +51,8 @@ reference:
- PM_load
- PM_result
- PM_valid
+ - bd
+ - bd_post
- title: Project management
desc: Functions for project management and workflow
- contents:
diff --git a/inst/Examples/Rscript/bestdose_simple_test.R b/inst/Examples/Rscript/bestdose_simple_test.R
new file mode 100644
index 00000000..58cd4cce
--- /dev/null
+++ b/inst/Examples/Rscript/bestdose_simple_test.R
@@ -0,0 +1,148 @@
+devtools::load_all()
+
+mod_onecomp <- PM_model$new(
+ pri = list(
+ ke = ab(0.001, 3.0),
+ v = ab(25.0, 250.0)
+ ),
+ eqn = function() {
+ dx[1] <- -ke * X[1] + B[1]
+ },
+ out = function() {
+ Y[1] <- X[1] / v
+ },
+ err = list(
+ additive(0, c(0, 0.20, 0, 0))
+ )
+)
+
+past_file <- "inst/Examples/src/bestdose_past.csv"
+target_file <- "inst/Examples/src/bestdose_target.csv"
+prior_file <- "inst/Examples/src/bestdose_prior.csv"
+
+cat("Using PMcore prior file\n")
+
+posterior <- bd_post$new(
+ prior = prior_file,
+ model = mod_onecomp,
+ past_data = PM_data$new(past_file, quiet = TRUE),
+ max_cycles = 500,
+ quiet = TRUE
+)
+
+cat("\nPosterior theta (first 5 rows):\n")
+print(head(posterior$theta, 5))
+cat("\nPosterior weights (first 10):\n")
+print(head(posterior$posterior_weights, 10))
+
+prior_weights <- seq(0, 1, by = 0.1)
+for (lambda in prior_weights) {
+ r <- posterior$optimize(
+ target = target_file,
+ dose_range = list(min = 0, max = 300),
+ prior_weight = lambda, # ought to be called "prior_weight"
+ start = NULL
+ )
+ cat(sprintf(
+ "Prior weight: %.2f\t\tOptimal dose: [%s]\t\tCost: %.6f\t\tln Cost: %.4f\t\tMethod: %s\n",
+ lambda,
+ paste(r$doses, collapse = ", "),
+ r$objf,
+ log(r$objf),
+ r$method
+ ))
+}
+
+# # Last result predictions
+# r <- posterior$optimize(
+# target = target_file,
+# dose_range = list(min = 0, max = 300),
+# prior_weight = 0.0
+# )
+# cat("\nConcentration-time predictions for prior_weight=0.0:\n")
+# preds <- r$result$predictions
+# for (j in seq_len(nrow(preds))) {
+# p <- preds[j, ]
+# cat(sprintf(
+# "Time: %.2f h, Observed: %.2f, (Pop Mean: %.4f, Pop Median: %.4f, Post Mean: %.4f, Post Median: %.4f)\n",
+# p$time, p$obs, p$pop_mean, p$pop_median, p$post_mean, p$post_median
+# ))
+# }
+
+
+# # ===== One-shot API =====
+# bd1 <- bd$new(
+# prior = prior_file,
+# model = mod_onecomp,
+# past_data = PM_data$new(past_file, quiet = TRUE),
+# max_cycles = 500,
+# future = PM_data$new(target_file, quiet = TRUE),
+# dose_range = list(min = 0, max = 5000),
+# prior_weight = 0,
+# start = 0
+# )
+
+# bd1
+
+# bd1$plot()
+
+# bd2 <- bd$new(
+# prior = prior_file,
+# model = mod_onecomp,
+# max_cycles = 500,
+# future = target_file,
+# dose_range = list(min = 0, max = 300),
+# prior_weight = 0.0
+# )
+
+# bd2
+
+
+
+# bd2$plot()
+
+# plot(bd1)
+
+
+# ## adding ability to specify future as argument to bd$new() instead of target file.
+# ## This will allow for more flexible future specifications and avoid the need for a separate target file.
+
+# future_list <- list(dose = c(120,0),
+# frequency = 12,
+# route = 0,
+# number = 3,
+# target_time = 11.5,
+# target = 0.3,
+# target_type = "concentration")
+
+# future_list <- list(dose = 320,
+# frequency = 12,
+# route = 0,
+# number = 3,
+# target_time = 0.6,
+# target = 0.3,
+# target_type = "time")
+
+# #AUC target - crashing as of 3/13/26
+# future_list <- list(dose = 320,
+# frequency = 12,
+# route = 0,
+# number = 3,
+# target_time = 11.5,
+# target = 10,
+# target_type = "auc")
+
+
+# bd_new <- bd$new(
+# prior = prior_file,
+# model = mod_onecomp,
+# past_data = PM_data$new(past_file, quiet = TRUE),
+# max_cycles = 50,
+# future = future_list,
+# dose_range = list(min = 0, max = 5000),
+# prior_weight = 0,
+# start = "02/02/26 00:00",
+# )
+
+# # bd_new$plot()
+# bd_new$report()
diff --git a/inst/Examples/Rscript/run_bestdose_comparison.R b/inst/Examples/Rscript/run_bestdose_comparison.R
new file mode 100644
index 00000000..f6888324
--- /dev/null
+++ b/inst/Examples/Rscript/run_bestdose_comparison.R
@@ -0,0 +1,71 @@
+devtools::load_all()
+
+mod_onecomp <- PM_model$new(
+ pri = list(
+ ke = ab(0.001, 3.0),
+ v = ab(25.0, 250.0)
+ ),
+ eqn = function() {
+ dx[1] <- -ke * X[1] + B[1]
+ },
+ out = function() {
+ Y[1] <- X[1] / v
+ },
+ err = list(
+ additive(0, c(0, 0.20, 0, 0))
+ )
+)
+
+past_file <- "inst/Examples/src/bestdose_past.csv"
+target_file <- "inst/Examples/src/bestdose_target.csv"
+prior_file <- "inst/Examples/src/bestdose_prior.csv"
+
+cat("Using PMcore prior file\n")
+
+posterior <- bd_post$new(
+ prior = prior_file,
+ model = mod_onecomp,
+ past_data = PM_data$new(past_file, quiet = TRUE),
+ max_cycles = 500,
+ quiet = TRUE
+)
+
+cat("\nPosterior theta (first 5 rows):\n")
+print(head(posterior$theta, 5))
+cat("\nPosterior weights (first 10):\n")
+print(head(posterior$posterior_weights, 10))
+
+prior_weights <- seq(0, 1, by = 0.1)
+for (lambda in prior_weights) {
+ r <- posterior$optimize(
+ target = target_file,
+ dose_range = list(min = 0, max = 300),
+ prior_weight = lambda,
+ start = NULL
+ )
+ cat(sprintf(
+ "Prior weight: %.2f\t\tOptimal dose: [%.4f, %.4f]\t\tCost: %.6f\t\tln Cost: %.4f\t\tMethod: %s\n",
+ lambda,
+ r$doses[1], r$doses[2],
+ r$objf,
+ log(r$objf),
+ r$method
+ ))
+}
+
+# Last result predictions
+r <- posterior$optimize(
+ target = target_file,
+ dose_range = list(min = 0, max = 300),
+ prior_weight = 1.0,
+ start = NULL
+)
+cat("\nConcentration-time predictions for prior_weight=1.0:\n")
+preds <- r$result$predictions
+for (j in seq_len(nrow(preds))) {
+ p <- preds[j, ]
+ cat(sprintf(
+ "Time: %.2f h, Observed: %.2f, (Pop Mean: %.4f, Pop Median: %.4f, Post Mean: %.4f, Post Median: %.4f)\n",
+ p$time, p$obs, p$pop_mean, p$pop_median, p$post_mean, p$post_median
+ ))
+}
diff --git a/inst/Examples/Rscript/test_future_vectors.R b/inst/Examples/Rscript/test_future_vectors.R
new file mode 100644
index 00000000..ecb2cdd8
--- /dev/null
+++ b/inst/Examples/Rscript/test_future_vectors.R
@@ -0,0 +1,91 @@
+devtools::load_all(quiet = TRUE)
+
+mod_onecomp <- PM_model$new(
+ pri = list(ke = ab(0.001, 3.0), v = ab(25.0, 250.0)),
+ eqn = function() { dx[1] <- -ke * X[1] + B[1] },
+ out = function() { Y[1] <- X[1] / v },
+ err = list(additive(0, c(0, 0.20, 0, 0)))
+)
+
+# Test 1: scalar (legacy behavior)
+cat("--- Test 1: scalar dose/freq/route, number=3 ---\n")
+bd1 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = 1000, frequency = 12, route = 0, number = 3, target_time = 11.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("doses:", paste(bd1$future$data$dose[!is.na(bd1$future$data$dose)], collapse = ","), "\n")
+cat("times:", paste(bd1$future$data$time[!is.na(bd1$future$data$dose)], collapse = ","), "\n\n")
+
+# Test 2: vector dose (3 elements, number=3)
+cat("--- Test 2: vector dose c(500,750,1000), number=3 ---\n")
+bd2 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = c(500, 750, 1000), frequency = 12, route = 0, number = 3, target_time = 11.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("doses:", paste(bd2$future$data$dose[!is.na(bd2$future$data$dose)], collapse = ","), "\n")
+cat("times:", paste(bd2$future$data$time[!is.na(bd2$future$data$dose)], collapse = ","), "\n\n")
+
+# Test 3: number < vector length - should inform and set number=3
+cat("--- Test 3: number=2 < dose length 3 (should inform, expand to 3) ---\n")
+bd3 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = c(500, 750, 1000), frequency = 12, route = 0, number = 2, target_time = 11.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("doses:", paste(bd3$future$data$dose[!is.na(bd3$future$data$dose)], collapse = ","), "\n\n")
+
+# Test 4: short dose vector recycled (dose length 2, number=4 -> last value 1000 recycled)
+cat("--- Test 4: dose c(500,1000), number=4 (recycle last -> 500,1000,1000,1000) ---\n")
+bd4 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = c(500, 1000), frequency = 12, route = 0, number = 4, target_time = 11.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("doses:", paste(bd4$future$data$dose[!is.na(bd4$future$data$dose)], collapse = ","), "\n\n")
+
+# Test 5: vector frequency (variable intervals)
+cat("--- Test 5: dose=1000, freq=c(8,12,24), number=3 ---\n")
+bd5 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = 1000, frequency = c(8, 12, 24), route = 0, number = 3, target_time = 7.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("doses:", paste(bd5$future$data$dose[!is.na(bd5$future$data$dose)], collapse = ","), "\n")
+cat("times:", paste(bd5$future$data$time[!is.na(bd5$future$data$dose)], collapse = ","), "\n\n")
+
+# Test 6: target_type = time (proportional target_time per interval)
+cat("--- Test 6: target_type='time', freq=c(8,12,24), target_time=0.5 ---\n")
+bd6 <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = 1000, frequency = c(8, 12, 24), route = 0, number = 3, target_time = 0.5, target = 0.3, target_type = "time"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+cat("obs times:", paste(bd6$future$data$time[is.na(bd6$future$data$dose)], collapse = ","), "\n\n")
+
+cat("All tests completed.\n")
diff --git a/inst/Examples/Rscript/test_status_column.R b/inst/Examples/Rscript/test_status_column.R
new file mode 100644
index 00000000..079c8c70
--- /dev/null
+++ b/inst/Examples/Rscript/test_status_column.R
@@ -0,0 +1,35 @@
+devtools::load_all(quiet = TRUE)
+
+mod_onecomp <- PM_model$new(
+ pri = list(ke = ab(0.001, 3.0), v = ab(25.0, 250.0)),
+ eqn = function() { dx[1] <- -ke * X[1] + B[1] },
+ out = function() { Y[1] <- X[1] / v },
+ err = list(additive(0, c(0, 0.20, 0, 0)))
+)
+
+# Mixed: dose 1 = fixed (1000), dose 2 = optimize (0), dose 3 = fixed (1500)
+bd_obj <- bd$new(
+ prior = "inst/Examples/src/bestdose_prior.csv",
+ model = mod_onecomp,
+ past_data = PM_data$new("inst/Examples/src/bestdose_past.csv", quiet = TRUE),
+ max_cycles = 5,
+ future = list(dose = c(1000, 0, 1500), frequency = 12, route = 0, number = 3,
+ target_time = 11.5, target = 0.3, target_type = "concentration"),
+ dose_range = list(min = 0, max = 5000), prior_weight = 0, start = "02/02/26 00:00",
+ quiet = TRUE
+)
+
+# Debug: check what standard_data looks like for future evid==1 rows
+cat("x$future$standard_data evid==1 rows:\n")
+sd <- bd_obj$future$standard_data
+print(sd[sd$evid == 1, ])
+
+report <- Pmetrics:::bd_report_build(bd_obj)
+cat("future_doses status column:\n")
+print(report$future_doses[, c("dose", "status")])
+
+out <- file.path(tempdir(), "bd-report-status-test")
+dir.create(out, showWarnings = FALSE, recursive = TRUE)
+status <- bd_obj$report(path = out, show = FALSE, quiet = TRUE)
+cat("\nReport status:", status, "\n")
+cat("HTML exists:", file.exists(file.path(out, "bestdose_report.html")), "\n")
diff --git a/inst/Examples/src/bestdose_past.csv b/inst/Examples/src/bestdose_past.csv
new file mode 100644
index 00000000..a367c173
--- /dev/null
+++ b/inst/Examples/src/bestdose_past.csv
@@ -0,0 +1,9 @@
+id,evid,date,time,dur,dose,addl,ii,input,out,outeq,c0,c1,c2,c3
+1,1,2/1/26,00:00:00,0,150,0,0,1,.,.,.,.,.,.
+1,0,2/1/26,02:00:00,.,.,.,.,.,0.759050698,1,.,.,.,.
+1,0,2/1/26,04:00:00,.,.,.,.,.,0.38408517,1,.,.,.,.
+1,0,2/1/26,06:00:00,.,.,.,.,.,0.194349887,1,.,.,.,.
+1,1,2/1/26,12:00:00,0,75,0,0,1,.,.,.,.,.,.
+1,0,2/1/26,14:00:00,.,.,.,.,.,0.392266578,1,.,.,.,.
+1,0,2/1/26,16:00:00,.,.,.,.,.,0.198489739,1,.,.,.,.
+1,0,2/1/26,18:00:00,.,.,.,.,.,0.100437251,1,.,.,.,.
\ No newline at end of file
diff --git a/inst/Examples/src/bestdose_prior.csv b/inst/Examples/src/bestdose_prior.csv
new file mode 100644
index 00000000..bead3957
--- /dev/null
+++ b/inst/Examples/src/bestdose_prior.csv
@@ -0,0 +1,50 @@
+ke,v,prob
+0.08736658442020416,103.9818823337555,0.06405457820397967
+1.0041342623233795,188.76309752464294,0.019607843140974805
+0.10507606186866761,140.8398061990738,0.019607843150332413
+0.3220049516916275,68.28039824962616,0.019607843051963586
+0.3493796042442322,75.56532084941864,0.01960746473012483
+0.04363290762901306,85.5460512638092,0.019607220383661656
+0.3312444769859314,92.81326055526733,0.019607147791086518
+0.09591740503311158,70.4040265083313,0.019606957258885387
+0.3085237291574478,92.42967247962952,0.019739312212632944
+0.3319534166574478,118.09373497962952,0.01963231510029087
+0.06250557525157929,107.03847944736481,0.019671831895535216
+0.06885235497951508,76.48277580738068,0.019704372081019293
+0.104416601395607,91.46958649158478,0.0196439196378291
+0.11730292952060699,78.46177399158478,0.019596592693258374
+0.06250557525157929,95.56875288486481,0.019558583620767178
+0.013994081234931946,178.73696267604828,0.01973719361931262
+0.07190197534561157,66.9762921333313,0.01951179533204779
+0.3406021231412888,99.86292123794556,0.0195017935173522
+0.02050767450332642,140.53142726421356,0.019496909863886087
+0.28171865940093993,140.80943822860718,0.02006966089395463
+0.08736658442020416,127.7562963962555,0.019975556860205728
+0.283145404958725,125.71949124336243,0.021760196860831894
+0.2902425238609314,94.35134649276733,0.020471376913953784
+0.3010685295820236,128.3288460969925,0.019431925066036485
+0.044804392004013066,99.5206606388092,0.021064943923371642
+0.09146677973270416,92.7318823337555,0.01938595576577497
+0.2925854926109314,128.32107305526733,0.018981650341481797
+0.28490263152122497,92.49683499336243,0.018729335263653696
+0.09826037378311157,111.1413311958313,0.022513324580852458
+0.08170614981651307,97.3233950138092,0.022071606402980712
+0.2925854926109314,105.42556524276733,0.02188055586035111
+0.036757444095611574,109.3395733833313,0.01813223949068482
+0.09240295190811158,113.7780499458313,0.01634642552944466
+0.1311608946800232,96.91233038902283,0.020745654510324375
+0.3540655417442322,86.77137553691864,0.03911312316643254
+0.3132096666574478,115.76463341712952,0.030954606713449555
+0.07658791284561157,113.1188702583313,0.00868552053488122
+0.2908282660484314,112.85232305526733,0.014634851770971591
+0.05615650746822358,130.38859486579895,0.0012110863756026463
+0.08678084223270416,87.3705542087555,0.00043536953019302594
+0.057327991843223576,130.87199330329895,0.004552363447102815
+0.08736658442020416,87.4144995212555,0.0002790654992634474
+0.07658791284561157,113.1628155708313,0.026588971195136306
+0.3540655417442322,86.81532084941864,0.0001850932799277153
+0.3132096666574478,115.80857872962952,0.008825717688183198
+0.2908282660484314,112.80837774276733,0.03942289250196689
+0.1311608946800232,96.86838507652283,0.018470031787835278
+0.05615650746822357,130.43254017829895,0.05302517206894798
+0.08678084223270416,87.4144995212555,0.0349542088912624
diff --git a/inst/Examples/src/bestdose_prior_pmcore.csv b/inst/Examples/src/bestdose_prior_pmcore.csv
new file mode 100644
index 00000000..bead3957
--- /dev/null
+++ b/inst/Examples/src/bestdose_prior_pmcore.csv
@@ -0,0 +1,50 @@
+ke,v,prob
+0.08736658442020416,103.9818823337555,0.06405457820397967
+1.0041342623233795,188.76309752464294,0.019607843140974805
+0.10507606186866761,140.8398061990738,0.019607843150332413
+0.3220049516916275,68.28039824962616,0.019607843051963586
+0.3493796042442322,75.56532084941864,0.01960746473012483
+0.04363290762901306,85.5460512638092,0.019607220383661656
+0.3312444769859314,92.81326055526733,0.019607147791086518
+0.09591740503311158,70.4040265083313,0.019606957258885387
+0.3085237291574478,92.42967247962952,0.019739312212632944
+0.3319534166574478,118.09373497962952,0.01963231510029087
+0.06250557525157929,107.03847944736481,0.019671831895535216
+0.06885235497951508,76.48277580738068,0.019704372081019293
+0.104416601395607,91.46958649158478,0.0196439196378291
+0.11730292952060699,78.46177399158478,0.019596592693258374
+0.06250557525157929,95.56875288486481,0.019558583620767178
+0.013994081234931946,178.73696267604828,0.01973719361931262
+0.07190197534561157,66.9762921333313,0.01951179533204779
+0.3406021231412888,99.86292123794556,0.0195017935173522
+0.02050767450332642,140.53142726421356,0.019496909863886087
+0.28171865940093993,140.80943822860718,0.02006966089395463
+0.08736658442020416,127.7562963962555,0.019975556860205728
+0.283145404958725,125.71949124336243,0.021760196860831894
+0.2902425238609314,94.35134649276733,0.020471376913953784
+0.3010685295820236,128.3288460969925,0.019431925066036485
+0.044804392004013066,99.5206606388092,0.021064943923371642
+0.09146677973270416,92.7318823337555,0.01938595576577497
+0.2925854926109314,128.32107305526733,0.018981650341481797
+0.28490263152122497,92.49683499336243,0.018729335263653696
+0.09826037378311157,111.1413311958313,0.022513324580852458
+0.08170614981651307,97.3233950138092,0.022071606402980712
+0.2925854926109314,105.42556524276733,0.02188055586035111
+0.036757444095611574,109.3395733833313,0.01813223949068482
+0.09240295190811158,113.7780499458313,0.01634642552944466
+0.1311608946800232,96.91233038902283,0.020745654510324375
+0.3540655417442322,86.77137553691864,0.03911312316643254
+0.3132096666574478,115.76463341712952,0.030954606713449555
+0.07658791284561157,113.1188702583313,0.00868552053488122
+0.2908282660484314,112.85232305526733,0.014634851770971591
+0.05615650746822358,130.38859486579895,0.0012110863756026463
+0.08678084223270416,87.3705542087555,0.00043536953019302594
+0.057327991843223576,130.87199330329895,0.004552363447102815
+0.08736658442020416,87.4144995212555,0.0002790654992634474
+0.07658791284561157,113.1628155708313,0.026588971195136306
+0.3540655417442322,86.81532084941864,0.0001850932799277153
+0.3132096666574478,115.80857872962952,0.008825717688183198
+0.2908282660484314,112.80837774276733,0.03942289250196689
+0.1311608946800232,96.86838507652283,0.018470031787835278
+0.05615650746822357,130.43254017829895,0.05302517206894798
+0.08678084223270416,87.4144995212555,0.0349542088912624
diff --git a/inst/Examples/src/bestdose_target.csv b/inst/Examples/src/bestdose_target.csv
new file mode 100644
index 00000000..79883dbe
--- /dev/null
+++ b/inst/Examples/src/bestdose_target.csv
@@ -0,0 +1,9 @@
+id,evid,time,dur,dose,addl,ii,input,out,outeq,c0,c1,c2,c3
+1,1,0,0,0,0,0,1,.,.,.,.,.,.
+1,0,2,.,.,.,.,.,0.759050697604428,1,.,.,.,.
+1,0,4,.,.,.,.,.,0.384085169721793,1,.,.,.,.
+1,0,6,.,.,.,.,.,0.194349887386702,1,.,.,.,.
+1,1,12,0,0,0,0,1,.,.,.,.,.,.
+1,0,14,.,.,.,.,.,0.392266577540038,1,.,.,.,.
+1,0,16,.,.,.,.,.,0.198489739204705,1,.,.,.,.
+1,0,18,.,.,.,.,.,0.100437250648841,1,.,.,.,.
diff --git a/inst/Examples/src/bestdose_target_pmcore.csv b/inst/Examples/src/bestdose_target_pmcore.csv
new file mode 100644
index 00000000..79883dbe
--- /dev/null
+++ b/inst/Examples/src/bestdose_target_pmcore.csv
@@ -0,0 +1,9 @@
+id,evid,time,dur,dose,addl,ii,input,out,outeq,c0,c1,c2,c3
+1,1,0,0,0,0,0,1,.,.,.,.,.,.
+1,0,2,.,.,.,.,.,0.759050697604428,1,.,.,.,.
+1,0,4,.,.,.,.,.,0.384085169721793,1,.,.,.,.
+1,0,6,.,.,.,.,.,0.194349887386702,1,.,.,.,.
+1,1,12,0,0,0,0,1,.,.,.,.,.,.
+1,0,14,.,.,.,.,.,0.392266577540038,1,.,.,.,.
+1,0,16,.,.,.,.,.,0.198489739204705,1,.,.,.,.
+1,0,18,.,.,.,.,.,0.100437250648841,1,.,.,.,.
diff --git a/man/bd.Rd b/man/bd.Rd
new file mode 100644
index 00000000..66fdf525
--- /dev/null
+++ b/man/bd.Rd
@@ -0,0 +1,243 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/PM_bestdose.R
+\name{bd}
+\alias{bd}
+\title{Object to contain BestDose optimization results}
+\description{
+\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}}
+
+This object is created after a successful BestDose optimization run.
+BestDose finds optimal dosing regimens to achieve target drug concentrations
+or AUC values using Bayesian optimization.
+}
+\section{Public fields}{
+\if{html}{\out{