R/etNew.R

Defines functions dim.rxEt .rxEtRebuildShell .etDoseChunk .etExpandObsChunk .etObsChunk .etDoseValidate .etDoseUntil .etWindowTime is.rxEt .etMaterialize .etEmptyDf .etPrepareSolveEvents .etPrepareGroupedSolveData .etAddSolveObsRows .etSolveObsTimes .etSolveObsValue .etGroupedSolveDataICov .etGroupedSolveDataFrameICov .etGroupedSolveSplitKey .etGroupedSolveData .etFixCmtForSolve .etExpandAddl .rxEtSyncData .newRxEt .addRowsToChunks .etAddChunk .etExpandGroupData .etShiftChunk .etMaterializeGroup .etPreviewData .formatIds .etSimulateRepresentation .etSplitGroupToChunks .etChunkIndex .etResetCountsFromGroups .etIdColumn .etPresentIds .etSetGroups .etGetGroups .etGroupChunk .etGroupIdsEqual .etGroups .etDropUnitsForChunk .etDefaultShow .rxEtEnv .rxGetHomogenous

Documented in is.rxEt

# rxEt environment and chunk helpers -------------------------------------


#' Is the homogenous event table path active?
#'
#'
#' @return logical; TRUE if homogenous event table path is active,
#'   FALSE otherwise, gets from option "rxode2.homogenous" with
#'   default TRUE
#' @noRd
#' @author Matthew L. Fidler
.rxGetHomogenous <- function() {
  isTRUE(getOption("rxode2.homogenous", TRUE))
}

#' Extract mutable .env from either new-style (data.frame subclass + .rxEtEnv attr)
#' or internal mini-rxEt (1-element named list where [[1L]] is the env).
#'
#' @param x object to extract data from
#'
#' @return environment or NULL if not found
#'
#' @noRd
.rxEtEnv <- function(x) {
  .e <- attr(x, ".rxEtEnv", exact = TRUE)
  if (!is.null(.e)) return(.e)
  .ux <- unclass(x)
  if (is.list(.ux) && ".env" %in% names(.ux)) return(.ux[[".env"]])
  if (length(.ux) >= 1L && is.environment(.ux[[1L]])) return(.ux[[1L]])
  NULL
}

#' Default show flags (controls which columns print/as.data.frame expose)
#'
#' This is kept here so that it can be specified once and referred to
#' by multiple locations.
#'
#' @return named logical vector of default show flags
#'
#' @noRd
.etDefaultShow <- function() {
  c(id = FALSE, low = FALSE, time = TRUE, high = FALSE, cmt = FALSE,
    amt = FALSE, rate = FALSE, ii = FALSE, addl = FALSE, evid = TRUE,
    ss = FALSE, dur = FALSE)
}
#' Drop the units for the check
#'
#' @param df NULL, data.frame or non-data frame object.
#'   from for internal use
#'
#' @return this function returns NULL or the non-materialized data, or
#'   the data.frame with units columns converted to numeric for
#'   internal use
#'
#' @noRd
#' @author Matthew L. Fidler
.etDropUnitsForChunk <- function(df) {
  if (is.null(df) || !is.data.frame(df)) {
    return(df)
  }
  for (.nm in names(df)) {
    if (inherits(df[[.nm]], "units")) {
      df[[.nm]] <- as.numeric(df[[.nm]])
    }
  }
  df
}
#' Get the groups from the rxEt environment
#'
#' @param envRef The environmental reference for the rxEt groups
#' @return A list of groups, where each group is a list containing 'ids' and 'data'
#' @noRd
#' @author Matthew L. Fidler
.etGroups <- function(envRef) {
  .groups <- envRef$groups
  if (is.null(.groups)) .groups <- list()
  .groups
}
#' Check if two sets of group IDs are equal
#'
#' @param x first group to check if it is equal.
#' @param y second group to check if it is equal.
#' @return logical value indicating whether the two sets of group IDs are equal
#' @noRd
#' @author Matthew L. Fidler
.etGroupIdsEqual <- function(x, y) {
  identical(sort.int(as.integer(x), method = "quick"),
            sort.int(as.integer(y), method = "quick"))
}
#' Process a chunk of data for grouping
#'
#' This function takes a data frame and an optional set of IDs, and
#' processes the data frame for grouping.
#'
#'
#' @param df data frame to process for grouping
#' @param ids optional vector of IDs to consider for grouping; if NULL, the function will
#' @return processed data frame
#' @noRd
#' @author Matthew L. Fidler
.etGroupChunk <- function(df, ids = NULL) {
  if (!is.data.frame(df)) {
    df <- .etExpandObsChunk(df)
  }
  df <- .etDropUnitsForChunk(df)
  if (!is.null(ids) && "id" %in% names(df)) {
    .id <- unique(as.integer(df$id))
    if (length(.id) == 1L && length(ids) >= 1L) {
      df$id <- NULL
    }
  }
  df
}

#' Get the groups from the rxEt environment, with fallback to chunks
#' if groups are not set
#'
#' @param envRef The environment reference group
#' @return A list of groups, where each group is a list containing
#'   'ids' and 'data'. If groups are not set, it will return an empty
#'   list or process the chunks to create groups.
#' @noRd
#' @author Matthew L. Fidler
.etGetGroups <- function(envRef) {
  .groups <- .etGroups(envRef)
  if (length(.groups) > 0L) return(.groups)

  .chunks <- envRef$chunks
  if (length(.chunks) == 0L) return(list())

  .ret <- vector("list", 0L)
  for (.i in seq_along(.chunks)) {
    .chunk <- .chunks[[.i]]
    if (is.null(.chunk)) next
    .ids <- if (!is.null(names(.chunk)) && "id" %in% names(.chunk)) {
      unique(as.integer(.chunk$id))
    } else if (length(envRef$ids) == 1L) {
      as.integer(envRef$ids)
    } else {
      as.integer(.i)
    }
    .df <- .etGroupChunk(.chunk, .ids)
    .ret[[length(.ret) + 1L]] <- list(ids = .ids, data = .df)
  }
  .ret
}

.etSetGroups <- function(envRef, groups) {
  envRef$groups <- groups
  if (length(groups) > 0L) {
    envRef$chunks <- list()
  }
  invisible(NULL)
}

.etPresentIds <- function(envRef) {
  .groups <- .etGroups(envRef)
  if (length(.groups) > 0L) {
    return(sort(unique(as.integer(unlist(lapply(.groups, `[[`, "ids"), use.names = FALSE)))))
  }
  .chunks <- envRef$chunks
  if (length(.chunks) == 0L) return(integer(0))
  .ids <- integer(0)
  for (.i in seq_along(.chunks)) {
    .chunk <- .chunks[[.i]]
    if (is.null(.chunk)) next
    if (!is.null(names(.chunk)) && "id" %in% names(.chunk)) {
      .ids <- c(.ids, unique(as.integer(.chunk$id)))
    } else if (length(envRef$ids) == 1L) {
      .ids <- c(.ids, as.integer(envRef$ids))
    } else {
      .ids <- c(.ids, as.integer(.i))
    }
  }
  sort(unique(.ids))
}

#' Per-row id column of the materialized table, without materializing
#'
#' Mirrors the row counts of .etMaterialize() (obs-chunk expansion plus
#' the windowed-addl expansion of .etExpandAddl(windows=TRUE)) but never
#' draws window times, so reading ev$id does not touch the RNG (#1154).
#'
#' @param et rxEt object
#' @return integer vector matching .etMaterialize(et)$id
#' @noRd
.etIdColumn <- function(et) {
  .env <- .rxEtEnv(et)
  .groups <- .etGetGroups(.env)
  if (length(.groups) == 0L) return(integer(0))
  .ids <- integer(0)
  .counts <- integer(0)
  for (.g in .groups) {
    .df <- .g$data
    if (!is.data.frame(.df)) .df <- .etExpandObsChunk(.df)
    if (!is.data.frame(.df) || nrow(.df) == 0L) next
    # rows added by the windowed-addl expansion in .etMaterialize
    .extra <- if (is.null(.df$evid) || is.null(.df$addl) || is.null(.df$low)) {
      rep.int(0L, nrow(.df))
    } else {
      ifelse(!is.na(.df$evid) & .df$evid != 0L &
               !is.na(.df$addl) & .df$addl > 0L & !is.na(.df$low),
             as.integer(.df$addl), 0L)
    }
    if (!is.null(.df$id)) {
      .rowIds <- as.integer(.df$id)
      .tab <- tapply(1L + .extra, .rowIds, sum)
      .ids <- c(.ids, as.integer(names(.tab)))
      .counts <- c(.counts, as.integer(.tab))
    } else {
      .n <- nrow(.df) + sum(.extra)
      .gIds <- as.integer(.g$ids)
      .ids <- c(.ids, .gIds)
      .counts <- c(.counts, rep.int(as.integer(.n), length(.gIds)))
    }
  }
  if (length(.ids) == 0L) return(integer(0))
  # total rows per unique id, in ascending id order (materialize sorts by id)
  .tot <- vapply(split(.counts, .ids), sum, integer(1))
  .uid <- as.integer(names(.tot))
  .ord <- order(.uid)
  rep(.uid[.ord], .tot[.ord])
}

.etResetCountsFromGroups <- function(envRef) {
  .groups <- .etGetGroups(envRef)
  if (length(.groups) == 0L) {
    envRef$nobs <- 0L
    envRef$ndose <- 0L
    return(invisible(NULL))
  }
  envRef$nobs <- as.integer(sum(vapply(.groups, function(.g) {
    sum(.g$data$evid == 0L, na.rm = TRUE) * length(.g$ids)
  }, numeric(1))))
  envRef$ndose <- as.integer(sum(vapply(.groups, function(.g) {
    sum(.g$data$evid != 0L, na.rm = TRUE) * length(.g$ids)
  }, numeric(1))))
  invisible(NULL)
}

.etChunkIndex <- function(chunks, id) {
  .idx <- as.integer(id)
  if (is.na(.idx) || .idx < 1L) {
    stop("invalid event table id", call. = FALSE)
  }
  .len <- length(chunks)
  if (.idx > .len) {
    length(chunks) <- .idx
  }
  chunks
}

.etSplitGroupToChunks <- function(chunks, group, randomType = NA_integer_) {
  .df <- .etDropUnitsForChunk(group$data)
  if (!is.data.frame(.df) || nrow(.df) == 0L || length(group$ids) == 0L) {
    return(chunks)
  }
  .hasWin <- !is.na(.df$low) & !is.na(.df$high)
  for (.id in as.integer(group$ids)) {
    .row <- .df
    if (any(.hasWin)) {
      if (!is.na(randomType) && randomType == 3L) {
        .row$time[.hasWin] <- stats::rnorm(sum(.hasWin), .row$low[.hasWin], .row$high[.hasWin])
      } else if (!is.na(randomType) && randomType == 2L) {
        .row$time[.hasWin] <- stats::runif(sum(.hasWin), .row$low[.hasWin], .row$high[.hasWin])
      }
    }
    .row$id <- .id
    chunks <- .etChunkIndex(chunks, .id)
    chunks[[.id]] <- .row
  }
  chunks
}

.etSimulateRepresentation <- function(envRef) {
  .groups <- .etGroups(envRef)
  .chunks <- envRef$chunks
  .randomType <- envRef$randomType
  .hasWin <- FALSE
  .needsChunkSplit <- length(.chunks) > 0L

  if (length(.groups) > 0L) {
    for (.g in .groups) {
      .df <- .etDropUnitsForChunk(.g$data)
      if (is.data.frame(.df) && nrow(.df) > 0L) {
        .groupHasWin <- !is.na(.df$low) & !is.na(.df$high)
        if (any(.groupHasWin)) {
          .hasWin <- TRUE
          if (length(.g$ids) > 1L) {
            .needsChunkSplit <- TRUE
          }
        }
      }
    }
  }

  if (!.hasWin && length(.chunks) > 0L) {
    for (.i in seq_along(.chunks)) {
      .df <- .etDropUnitsForChunk(.chunks[[.i]])
      if (is.data.frame(.df) && nrow(.df) > 0L && any(!is.na(.df$low) & !is.na(.df$high))) {
        .hasWin <- TRUE
        break
      }
    }
  }

  if (!.hasWin) {
    return(list(hasWin = FALSE, groups = .groups, chunks = .chunks))
  }

  if (!is.na(.randomType) && .randomType == 1L) {
    return(list(hasWin = FALSE, groups = .groups, chunks = .chunks))
  }

  if (.needsChunkSplit) {
    .outChunks <- vector("list", max(length(.chunks), suppressWarnings(max(as.integer(envRef$ids), 0L)), na.rm = TRUE))
    if (length(.groups) > 0L) {
      for (.g in .groups) {
        .outChunks <- .etSplitGroupToChunks(.outChunks, .g, .randomType)
      }
    }
    if (length(.chunks) > 0L) {
      for (.i in seq_along(.chunks)) {
        .df <- .etDropUnitsForChunk(.chunks[[.i]])
        if (!is.data.frame(.df) || nrow(.df) == 0L) next
        .hasWinI <- !is.na(.df$low) & !is.na(.df$high)
        if (any(.hasWinI)) {
          if (!is.na(.randomType) && .randomType == 3L) {
            .df$time[.hasWinI] <- stats::rnorm(sum(.hasWinI), .df$low[.hasWinI], .df$high[.hasWinI])
          } else if (!is.na(.randomType) && .randomType == 2L) {
            .df$time[.hasWinI] <- stats::runif(sum(.hasWinI), .df$low[.hasWinI], .df$high[.hasWinI])
          }
        }
        .id <- if ("id" %in% names(.df)) unique(as.integer(.df$id))[1L] else as.integer(.i)
        .outChunks <- .etChunkIndex(.outChunks, .id)
        .outChunks[[.id]] <- .df
      }
    }
    return(list(hasWin = TRUE, groups = list(), chunks = .outChunks))
  }

  .outGroups <- lapply(.groups, function(.g) {
    .df <- .etDropUnitsForChunk(.g$data)
    .hasWinG <- !is.na(.df$low) & !is.na(.df$high)
    if (any(.hasWinG)) {
      if (!is.na(.randomType) && .randomType == 3L) {
        .df$time[.hasWinG] <- stats::rnorm(sum(.hasWinG), .df$low[.hasWinG], .df$high[.hasWinG])
      } else if (!is.na(.randomType) && .randomType == 2L) {
        .df$time[.hasWinG] <- stats::runif(sum(.hasWinG), .df$low[.hasWinG], .df$high[.hasWinG])
      }
    }
    list(ids = .g$ids, data = .df)
  })
  list(hasWin = TRUE, groups = .outGroups, chunks = list())
}

.formatIds <- function(ids) {
  ids <- sort(unique(as.integer(ids)))
  if (length(ids) == 0L) return(NA_character_)
  if (length(ids) == 1L) return(ids)
  if (all(diff(ids) == 1L)) {
    return(paste0(ids[1L], ":", ids[length(ids)]))
  }
  paste(ids, collapse = ",")
}

.etPreviewData <- function(envRef, subset = c("all", "dosing", "sampling")) {
  subset <- match.arg(subset)
  .groups <- .etGroups(envRef)
  if (length(.groups) == 0L) {
    .mat <- .etMaterialize(structure(list(env = envRef), class = "rxEt"))
    if (nrow(.mat) == 0L) return(NULL)
    if (subset == "dosing") {
      .mat <- .mat[.mat$evid != 0L, , drop = FALSE]
    } else if (subset == "sampling") {
      .mat <- .mat[.mat$evid == 0L, , drop = FALSE]
    }
    if (nrow(.mat) == 0L) return(NULL)
    rownames(.mat) <- seq_len(nrow(.mat))
    return(.mat)
  }

  .ret <- vector("list", 0L)
  .meta <- vector("list", 0L)
  for (.i in seq_along(.groups)) {
    .g <- .groups[[.i]]
    .df <- .etDropUnitsForChunk(.g$data)
    if (subset == "dosing") {
      .df <- .df[.df$evid != 0L, , drop = FALSE]
    } else if (subset == "sampling") {
      .df <- .df[.df$evid == 0L, , drop = FALSE]
    }
    if (nrow(.df) == 0L) next
    if (subset == "all") {
      .formattedId <- if (length(.g$ids) == 1L) as.integer(.g$ids) else .formatIds(.g$ids)
      .df <- cbind(id = .formattedId, .df)
    }
    .ret[[length(.ret) + 1L]] <- .df
    .meta[[length(.meta) + 1L]] <- list(
      ids = as.integer(.g$ids),
      nSub = length(.g$ids),
      nRow = nrow(.df)
    )
  }
  if (length(.ret) == 0L) return(NULL)
  .out <- as.data.frame(data.table::rbindlist(.ret, fill = TRUE))
  rownames(.out) <- seq_len(nrow(.out))
  attr(.out, "rxEtPreviewGroups") <- .meta
  attr(.out, "rxEtShow") <- envRef$show
  attr(.out, "rxEtExtraCols") <- .etExtraCols(envRef) # nolint
  attr(.out, "rxEtMarkedCols") <- names(.out)
  .tu <- envRef$units["time"]
  if (!is.na(.tu) && nchar(.tu) > 0 && requireNamespace("units", quietly = TRUE)) {
    for (.col in c("time", "ii", "low", "high", "dur")) {
      if (.col %in% names(.out)) {
        .out[[.col]] <- units::set_units(.out[[.col]], .tu, mode = "standard")
      }
    }
  }
  # class last: `[[<-` on a marked frame drops the display marking
  class(.out) <- c("rxEtPreview", class(.out))
  .out
}

.etMaterializeGroup <- function(group) {
  .df <- .etDropUnitsForChunk(group$data)
  if (!is.data.frame(.df) || nrow(.df) == 0L || length(group$ids) == 0L) {
    return(.etEmptyDf())
  }
  .n <- nrow(.df)
  .idx <- rep(seq_len(.n), times = length(group$ids))
  .id <- rep(as.integer(group$ids), each = .n)
  .ret <- .df[.idx, , drop = FALSE]
  .ret$id <- .id
  .ret
}

.etShiftChunk <- function(df, timeDelta) {
  .df <- .etDropUnitsForChunk(df)
  if (!is.data.frame(.df) || nrow(.df) == 0L || identical(timeDelta, 0)) {
    return(.df)
  }
  .df$time <- .df$time + timeDelta
  if (!is.null(.df$low) && any(!is.na(.df$low))) {
    .df$low[!is.na(.df$low)] <- .df$low[!is.na(.df$low)] + timeDelta
  }
  if (!is.null(.df$high) && any(!is.na(.df$high))) {
    .df$high[!is.na(.df$high)] <- .df$high[!is.na(.df$high)] + timeDelta
  }
  if (!is.null(.df$ii)) .df$ii[is.na(.df$ii)] <- 0.0
  .df
}

.etExpandGroupData <- function(df, envRef) {
  .df <- .etDropUnitsForChunk(df)
  if (!is.data.frame(.df) || nrow(.df) == 0L) {
    return(.df)
  }
  .tmp <- .df
  .tmp$id <- 1L
  .expanded <- .etExpandAddl(.tmp, envRef)
  .expanded$id <- NULL
  .expanded
}

#' Add rows of a data.frame to the ID-indexed chunks list
#'
#' Assigns id column and appends to \code{chunks[[id]]} for each ID in
#' .ids.
#'
#' @param .envRef mutable env from rxEt
#'
#' @param .df data.frame of rows to add (no 'id' column yet)
#'
#' @param .ids integer vector of ids; NULL/empty defaults to 1L
#'
#' @noRd
.etAddChunk <- function(envRef, df, ids = NULL) {
  .rt <- attr(df, ".randomType")
  if (!is.null(.rt) && !is.na(.rt)) {
    if (is.na(envRef$randomType) || .rt > envRef$randomType)
      envRef$randomType <- .rt
    if (!isTRUE(envRef$show["low"])) {
      envRef$show["low"]  <- TRUE
      envRef$show["high"] <- TRUE
    }
  }
  if (!is.data.frame(df)) {
    df <- .etExpandObsChunk(df)
  }
  if (nrow(df) == 0) {
    return(invisible(NULL))
  }
  if (is.null(ids) || length(ids) == 0L) {
    ids <-  1L
  } else {
    ids <- as.integer(ids)
  }
  .posIds <- ids[ids > 0L]
  if (length(.posIds) == 0L) return(invisible(NULL))
  if (!"id" %in% names(df)) {
    .groups <- .etGetGroups(envRef)
    .chunk <- .etGroupChunk(df, .posIds)
    if (length(.groups) > 0L) {
      .newGroups <- vector("list", 0L)
      .remainingIds <- sort.int(as.integer(.posIds), method = "quick")
      for (.g in .groups) {
        .overlap <- intersect(.g$ids, .remainingIds)
        if (length(.overlap) == 0L) {
          .newGroups[[length(.newGroups) + 1L]] <- .g
          next
        }
        .keepIds <- setdiff(.g$ids, .overlap)
        if (length(.keepIds) > 0L) {
          .newGroups[[length(.newGroups) + 1L]] <- list(ids = .keepIds, data = .g$data)
        }
        .newGroups[[length(.newGroups) + 1L]] <- list(
          ids = sort.int(as.integer(.overlap), method = "quick"),
          data = as.data.frame(data.table::rbindlist(list(.g$data, .chunk), fill = TRUE))
        )
        .remainingIds <- setdiff(.remainingIds, .overlap)
      }
      if (length(.remainingIds) > 0L) {
        .newGroups[[length(.newGroups) + 1L]] <- list(ids = .remainingIds, data = .chunk)
      }
      .etSetGroups(envRef, .newGroups)
      return(invisible(NULL))
    }
    if (length(.posIds) > 1L) {
      .match <- which(vapply(.groups, function(.g) .etGroupIdsEqual(.g$ids, .posIds), logical(1)))
      if (length(.match) == 1L) {
        .groups[[.match]]$data <- as.data.frame(
          data.table::rbindlist(list(.groups[[.match]]$data, .chunk), fill = TRUE)
        )
      } else {
        .groups[[length(.groups) + 1L]] <- list(ids = sort.int(as.integer(.posIds), method = "quick"),
                                                data = .chunk)
      }
      .etSetGroups(envRef, .groups)
      return(invisible(NULL))
    }
  }
  for (.i in .posIds) {
    .row <- .etDropUnitsForChunk(df)
    .row$id <- .i
    if (.i <= length(envRef$chunks)) {
      .cur <- envRef$chunks[[.i]] # units already dropped
      envRef$chunks[[.i]] <- as.data.frame(data.table::rbindlist(list(.cur, .row), fill = TRUE))
    } else {
      envRef$chunks[[.i]] <- .row
    }
  }
  invisible(NULL)
}

#' Merge a data.frame (with id column) into an ID-indexed chunks list
#'
#' Used by etSeq/etRbind when accumulating materialized data.frames.
#'
#' @param chunks list indexed by ID integer value
#'
#' @param df data.frame with 'id' column already set
#'
#' @return updated chunks
#'
#' @noRd
.addRowsToChunks <- function(chunks, df) {
  if (nrow(df) == 0L) return(chunks)
  .ids <- unique(as.integer(df$id))
  for (.i in .ids) {
    .rows <- .etDropUnitsForChunk(df[df$id == .i, , drop = FALSE])
    if (.i <= length(chunks)) {
      .cur <- chunks[[.i]] # units already dropped
      chunks[[.i]] <- as.data.frame(data.table::rbindlist(list(.cur, .rows), fill = TRUE))
    } else {
      chunks[[.i]] <- .rows
    }
  }
  chunks
}

# rxEt object construction ------------------------------------------------

#' Create a new empty rxEt object
#'
#' @param amountUnits character dose unit, e.g. "mg"
#'
#' @param timeUnits character time unit, e.g. "hours"
#'
#' @return rxEt object
#'
#' @noRd
.newRxEt <- function(amountUnits = NA_character_, timeUnits = NA_character_) {
  .env <- new.env(parent = emptyenv())
  .env$chunks     <- list()
  .env$groups     <- list()
  .env$units      <- c(dosing = amountUnits, time = timeUnits)
  .env$show       <- .etDefaultShow()
  .env$ids        <- 1L
  .env$nobs       <- 0L
  .env$ndose      <- 0L
  .env$randomType <- NA_integer_
  .env$canResize  <- TRUE
  .env$extraCols  <- character(0)
  .env$methods <- .etBuildMethods(.env)
  .obj <- list(
    id = integer(0), low = numeric(0), time = numeric(0), high = numeric(0),
    cmt = integer(0), amt = numeric(0), rate = numeric(0), ii = numeric(0),
    addl = integer(0), evid = integer(0), ss = integer(0), dur = numeric(0)
  )
  attr(.obj, "class")     <- c("rxEt", "data.frame")
  attr(.obj, "row.names") <- integer(0)
  attr(.obj, ".rxEtEnv")  <- .env
  .obj
}

#' Sync the materialized data.frame shell with the rxEt environment
#'
#' @param x rxEt object
#'
#' @return materialized synced rxEt data frame
#'
#' @noRd
.rxEtSyncData <- function(x) {
  .env <- .rxEtEnv(x)
  if (!is.environment(.env)) return(x)
  .nTot <- .env$nobs + .env$ndose
  .ret <- if (.nTot <= 1000L) .etMaterialize(x) else .etEmptyDf()
  attr(.ret, ".rxEtEnv") <- .env
  .rt <- .env$randomType
  .cls <- c("rxEt", "data.frame")
  if (!is.null(.rt) && !is.na(.rt)) {
    .cls <- structure(.cls, ".rxode2.lst" = list(randomType = as.integer(.rt)))
  }
  class(.ret) <- .cls
  .ret
}

# rxEt materialization and dose expansion --------------------------------

#' Expand addl doses into individual records
#'
#' @param df materialized data.frame from .etMaterialize()
#'
#' @param env environment for randomType and show flags
#'
#' @param windows logical; if TRUE, only expand windows (low/high) by
#'   ii on each subsequent dose based on random type in env
#'
#' @return data.frame with addl expanded into individual dose records
#'
#' @noRd
.etExpandAddl <- function(df, env=NULL, windows=FALSE) {
  if (windows) {
    .doseRows <- df[df$evid != 0L & df$addl > 0L & !is.na(df$low), , drop = FALSE]
  } else {
    .doseRows <- df[df$evid != 0L & df$addl > 0L, , drop = FALSE]
  }
  if (nrow(.doseRows) == 0L) return(df)
  if (is.environment(env) &&
        exists("randomType", envir = env, inherits = FALSE)) {
    .rt <- env$randomType
    if (is.na(.rt)) .rt <- 1L
  } else {
    .rt <- 1
  }
  .extras <- vector("list", nrow(.doseRows))
  for (.i in seq_len(nrow(.doseRows))) {
    .row   <- .doseRows[.i, ]
    .n     <- .row$addl
    .extra <- .row[rep(1L, .n), , drop = FALSE]
    # This works for normal and uniform cases
    if (!is.na(.row$low)) {
      .extra$low <- .row$low + seq_len(.n) * .row$ii
    }
    if (.rt == 3) {
      # normal; sd remains the same on expansion
      .extra$time <- vapply(seq_len(.n), function(i) {
        stats::rnorm(1L, .extra$low[i], .extra$high[i])
      }, numeric(1), USE.NAMES = FALSE)
    } else if (!is.na(.row$high)) {
      # uniform or window expand window by ii on each subsequent dose
      .extra$high <- .row$high + seq_len(.n) * .row$ii
      .extra$time <- vapply(seq_len(.n), function(i) {
        stats::runif(1L, .extra$low[i], .extra$high[i])
      }, numeric(1), USE.NAMES = FALSE)
    } else {
      .extra$time <- .row$time + seq_len(.n) * .row$ii
    }


    .extra$high <- .row$high + seq_len(.n) * .row$ii
    .extra$addl <- 0L
    .extra$ii   <- 0.0
    .extras[[.i]] <- .extra
  }
  df$addl[df$evid != 0L] <- 0L
  df$ii[df$evid   != 0L] <- 0.0
  .combined <- do.call(rbind, c(list(df), .extras))
  .evidSort <- ifelse(.combined$evid == 3L, -1L, as.integer(.combined$evid))
  .combined[.order3(.combined$id, .combined$time, .evidSort), ] # nolint
}

# Canonical column order matching C++ etEmpty()
.etColOrder <- c("id", "low", "time", "high", "cmt", "amt", "rate", "ii", "addl", "evid", "ss", "dur")

#' Convert character cmt column to integer for C++ solver
#'
#' The C++ etTran code handles integer cmt as direct compartment indices,
#' but character cmt values are looked up as compartment NAMES. Numeric
#' strings like "2" do NOT match compartment names ("amt2"), so they get
#' assigned as extra compartments beyond state.size(). This function converts
#' all-convertible character cmt columns to integer before passing to C++.
#'
#' "(default)" and "(obs)" map to compartment 1; NA stays NA_integer_.
#' If any value is a non-numeric, non-sentinel string (a compartment name),
#' the column stays character so C++ name lookup works normally.
#'
#' @param df materialized data.frame from .etMaterialize()
#'
#' @return df with cmt column possibly converted to integer
#'
#' @noRd
.etFixCmtForSolve <- function(df) {
  .cmt <- df[["cmt"]]
  if (is.null(.cmt) || !is.character(.cmt)) return(df)
  .isNA      <- is.na(.cmt)
  .isSentinel <- !.isNA & (.cmt == "(default)" | .cmt == "(obs)")
  .numericOk  <- !.isNA & !.isSentinel & suppressWarnings(!is.na(as.integer(.cmt)))
  if (all(.isNA | .isSentinel | .numericOk)) {
    .int <- integer(length(.cmt))
    .int[.isNA]       <- NA_integer_
    .int[.isSentinel] <- 1L
    .int[.numericOk]  <- as.integer(.cmt[.numericOk])
    df[["cmt"]] <- .int
  }
  df
}

.etGroupedSolveData <- function(x) {
  .env <- .rxEtEnv(x)
  .groups <- .etGetGroups(.env)
  if (length(.groups) == 0L) {
    return(NULL)
  }
  .dat <- vector("list", 0L)
  .ids <- vector("list", 0L)
  for (.i in seq_along(.groups)) {
    .g <- .groups[[.i]]
    .df <- .etFixCmtForSolve(.etDropUnitsForChunk(.g$data))
    if (!is.data.frame(.df) || nrow(.df) == 0L || length(.g$ids) == 0L) next
    .df$id <- rep.int(.i, nrow(.df))
    .dat[[length(.dat) + 1L]] <- .df
    .ids[[length(.ids) + 1L]] <- as.integer(.g$ids)
  }
  if (length(.dat) == 0L) {
    return(.etEmptyDf())
  }
  .out <- as.data.frame(data.table::rbindlist(.dat, fill = TRUE, use.names = TRUE))
  if ("evid" %in% names(.out)) {
    .doseIdx <- which(.out$evid != 0L)
    if (length(.doseIdx) > 0L) {
      for (.col in c("rate", "ii", "dur")) {
        if (.col %in% names(.out)) {
          .vals <- .out[[.col]]
          .naInDose <- is.na(.vals[.doseIdx])
          if (any(.naInDose)) {
            .vals[.doseIdx[.naInDose]] <- 0.0
            .out[[.col]] <- .vals
          }
        }
      }
      for (.col in c("addl", "ss")) {
        if (.col %in% names(.out)) {
          .vals <- .out[[.col]]
          .naInDose <- is.na(.vals[.doseIdx])
          if (any(.naInDose)) {
            .vals[.doseIdx[.naInDose]] <- 0L
            .out[[.col]] <- .vals
          }
        }
      }
    }
  }
  if ("time" %in% names(.out) && "id" %in% names(.out)) {
    .evidSort <- if ("evid" %in% names(.out)) {
      ifelse(!is.na(.out$evid) & .out$evid == 3L, -1L, as.integer(.out$evid))
    } else {
      rep(0L, nrow(.out))
    }
    .ord <- order(.out$id, as.numeric(.out$time), .evidSort)
    .out <- .out[.ord, ]
  }
  attr(.out, "rxHomGroups") <- .ids
  attr(.out, "rxHomIdLevels") <- as.character(unlist(.ids, use.names = FALSE))
  .tu <- .env$units["time"]
  if (!is.na(.tu) && nchar(.tu) > 0 && requireNamespace("units", quietly = TRUE)) {
    .out[["time"]] <- units::set_units(.out[["time"]], .tu, mode = "standard")
  }
  .out
}

.etGroupedSolveSplitKey <- function(df) {
  if (!is.data.frame(df) || ncol(df) == 0L) {
    return(rep.int("1", if (is.data.frame(df)) nrow(df) else 0L))
  }
  do.call(interaction, c(unname(df), list(drop = TRUE, lex.order = TRUE)))
}

.etGroupedSolveDataFrameICov <- function(events, iCov, keep = NULL, modelParams = character(0)) {
  .groups <- attr(events, "rxHomGroups", exact = TRUE)
  if (!is.data.frame(events) || is.null(.groups) || !inherits(iCov, "data.frame")) {
    return(NULL)
  }
  .idCol <- which(tolower(names(iCov)) == "id")
  if (length(.idCol) != 1L) {
    return(NULL)
  }
  .idName <- names(iCov)[.idCol]
  .keep <- as.character(keep)
  .modelParams <- as.character(modelParams)
  .icovIdRaw <- if (is.factor(iCov[[.idName]])) as.character(iCov[[.idName]]) else iCov[[.idName]]
  .icovId <- suppressWarnings(as.integer(.icovIdRaw))
  if (length(.icovId) != nrow(iCov) || anyNA(.icovId)) {
    return(NULL)
  }
  if (anyDuplicated(.icovId)) {
    return(NULL)
  }
  .eventId <- suppressWarnings(as.integer(if (is.factor(events$id)) as.character(events$id) else events$id))
  if (length(.eventId) != nrow(events) || anyNA(.eventId)) {
    return(NULL)
  }
  .eventRows <- vector("list", 0L)
  .icovRows <- vector("list", 0L)
  .outGroups <- vector("list", 0L)
  .outId <- 1L
  for (.i in seq_along(.groups)) {
    .idsInGroup <- as.integer(.groups[[.i]])
    .idx <- match(.idsInGroup, .icovId)
    if (anyNA(.idx)) {
      return(NULL)
    }
    .subIc <- iCov[.idx, , drop = FALSE]
    .splitCols <- setdiff(names(.subIc), .idName)
    .splitNeeded <- unique(c(tolower(.modelParams), tolower(.keep)))
    .splitCols <- .splitCols[tolower(.splitCols) %in% .splitNeeded]
    .splitKey <- if (length(.splitCols) == 0L) {
      factor(rep.int("1", nrow(.subIc)))
    } else {
      .etGroupedSolveSplitKey(.subIc[.splitCols])
    }
    .split <- split(seq_len(nrow(.subIc)), .splitKey, drop = TRUE)
    .df <- events[.eventId == .i, , drop = FALSE]
    if (!is.data.frame(.df) || nrow(.df) == 0L) {
      return(NULL)
    }
    for (.grpIdx in .split) {
      .ids <- as.integer(.idsInGroup[.grpIdx])
      .row <- .df
      .row$id <- rep.int(.outId, nrow(.row))
      .eventRows[[length(.eventRows) + 1L]] <- .row
      .icovRow <- .subIc[.grpIdx[1L], , drop = FALSE]
      .icovRow[[.idName]] <- .outId
      .icovRows[[length(.icovRows) + 1L]] <- .icovRow
      .outGroups[[length(.outGroups) + 1L]] <- .ids
      .outId <- .outId + 1L
    }
  }
  if (length(.eventRows) == 0L) {
    return(NULL)
  }
  .events <- as.data.frame(data.table::rbindlist(.eventRows, fill = TRUE, use.names = TRUE))
  .iCovOut <- as.data.frame(data.table::rbindlist(.icovRows, fill = TRUE, use.names = TRUE))
  .iCovOut[[.idName]] <- suppressWarnings(as.integer(.iCovOut[[.idName]]))
  if (anyNA(.iCovOut[[.idName]])) {
    return(NULL)
  }
  attr(.events, "rxHomGroups") <- .outGroups
  attr(.events, "rxHomIdLevels") <- as.character(unlist(.outGroups, use.names = FALSE))
  list(
    events = .events,
    iCov = .iCovOut
  )
}

.etGroupedSolveDataICov <- function(x, iCov, keep = NULL, modelParams = character(0)) {
  .env <- .rxEtEnv(x)
  .groups <- .etGetGroups(.env)
  if (length(.groups) == 0L) {
    return(NULL)
  }
  .etGroupedSolveDataFrameICov(.etGroupedSolveData(x), iCov,
                               keep = keep, modelParams = modelParams)
}

.etSolveObsValue <- function(x, name) {
  if (is.null(x)) {
    return(NULL)
  }
  if (length(x) != 1L) {
    stop(sprintf("'%s' must be of length 1", name), call. = FALSE)
  }
  as.numeric(x)[1L]
}

.etSolveObsTimes <- function(events, ctl = NULL) {
  .from <- .etSolveObsValue(ctl$from, "from")
  if (is.null(.from)) {
    .from <- 0.0
  }
  .to <- .etSolveObsValue(ctl$to, "to")
  if (is.null(.to)) {
    .maxTime <- suppressWarnings(max(as.numeric(events$time), na.rm = TRUE))
    if (!is.finite(.maxTime)) {
      .maxTime <- .from
    }
    .to <- .maxTime + 24
  }
  .by <- .etSolveObsValue(ctl$by, "by")
  .lengthOut <- .etSolveObsValue(ctl$length.out, "length.out")
  if (!is.null(.lengthOut)) {
    .lengthOut <- as.integer(.lengthOut)
    if (!is.null(.by)) {
      stop("cannot use both 'by' and 'length.out' for rxode2 simulations", call. = FALSE)
    }
    .by <- (.to - .from)/(.lengthOut - 1)
  } else if (is.null(.by)) {
    .lengthOut <- 200L
    .by <- (.to - .from)/(.lengthOut - 1)
  } else {
    .lengthOut <- as.integer((.to - .from)/.by + 1.0)
  }
  .by * seq.int(0L, .lengthOut - 1L) + .from
}

.etAddSolveObsRows <- function(events, obsTimes) {
  .groups <- attr(events, "rxHomGroups", exact = TRUE)
  .idLevels <- attr(events, "rxHomIdLevels", exact = TRUE)
  .events <- .etFixCmtForSolve(events)
  .events$time <- as.numeric(.events$time)
  .ids <- sort(unique(as.integer(.events$id)))
  .obs <- vector("list", length(.ids))
  for (.i in seq_along(.ids)) {
    .obs[[.i]] <- data.frame(
      id = rep.int(.ids[.i], length(obsTimes)),
      time = obsTimes,
      evid = 0L
    )
  }
  .out <- as.data.frame(data.table::rbindlist(c(list(.events), .obs), fill = TRUE, use.names = TRUE))
  if (!is.null(.groups)) {
    attr(.out, "rxHomGroups") <- .groups
  }
  if (!is.null(.idLevels)) {
    attr(.out, "rxHomIdLevels") <- .idLevels
  }
  .out
}

.etPrepareGroupedSolveData <- function(events, ctl = NULL) {
  .groups <- attr(events, "rxHomGroups", exact = TRUE)
  .events <- .etFixCmtForSolve(events)
  if (is.null(.groups)) {
    return(.events)
  }
  if (nrow(.events) == 0L) {
    return(.events)
  }
  if ("evid" %in% names(.events) && any(.events$evid == 0L, na.rm = TRUE)) {
    return(.events)
  }
  .etAddSolveObsRows(.events, .etSolveObsTimes(.events, ctl))
}

.etPrepareSolveEvents <- function(events, ctl = NULL) {
  if (!is.rxEt(events)) {
    return(.etPrepareGroupedSolveData(events, ctl))
  }
  .env <- .rxEtEnv(events)
  if (is.null(ctl$iCov) && length(.etGroups(.env)) > 0L) {
    return(.etPrepareGroupedSolveData(.etGroupedSolveData(events), ctl))
  }
  if (isTRUE(.env$nobs == 0L)) {
    return(.etFixCmtForSolve(.etMaterialize(events)))
  }
  .etFixCmtForSolve(.etMaterialize(events))
}

#' Empty data.frame skeleton matching materialized format
#'
#' @return empty data.frame with canonical columns and types
#' @noRd
.etEmptyDf <- function() {
  data.frame(
    id    = integer(0),
    low   = numeric(0),
    time  = numeric(0),
    high  = numeric(0),
    cmt   = character(0),
    amt   = numeric(0),
    rate  = numeric(0),
    ii    = numeric(0),
    addl  = integer(0),
    evid  = integer(0),
    ss    = integer(0),
    dur   = numeric(0),
    stringsAsFactors = FALSE
  )
}

#' Materialize all chunks into a sorted data.frame
#'
#' Chunks is a list indexed by ID integer value; \code{chunks[[i]]} is
#' a data.frame for ID i (with 'id' column set), or NULL if no records
#' for that ID.
#'
#' @param et rxEt object
#'
#' @return data.frame with canonical columns, sorted by (id, time,
#'   evid-adjusted)
#'
#' @noRd
.etMaterialize <- function(et) {
  id <- NULL # for R cmd check since data.table uses id := ...
  .env <- .rxEtEnv(et)
  .groups <- .etGetGroups(.env)

  if (length(.groups) > 0L) {
    .nonNull <- Filter(function(.g) !is.null(.g$data) && nrow(.g$data) > 0L, .groups)
    if (length(.nonNull) == 0L) return(.etEmptyDf())
    .dtList <- lapply(.nonNull, function(.g) {
      .df <- .g$data
      if (!is.data.frame(.df)) {
        .df <- .etExpandObsChunk(.df)
      }
      if ("id" %in% names(.df)) {
        return(data.table::as.data.table(.df))
      }
      .n <- nrow(.df)
      if (.n == 0L || length(.g$ids) == 0L) {
        return(data.table::as.data.table(.etEmptyDf()))
      }
      .idx <- rep.int(seq_len(.n), times = length(.g$ids))
      .id <- rep(as.integer(.g$ids), each = .n)
      .ret <- data.table::as.data.table(.df[.idx, , drop = FALSE])
      .ret[, id := .id]
      .ret
    })
    .dt <- data.table::rbindlist(.dtList, fill = TRUE, use.names = TRUE)
  } else {
    .chunks <- .env$chunks

    if (length(.chunks) == 0L) return(.etEmptyDf())

    # Each element is a data.frame for one ID; filter NULL entries
    .nonNull <- Filter(Negate(is.null), .chunks)
    if (length(.nonNull) == 0L) return(.etEmptyDf())

    # rbindlist: fast sparse bind across ID data.frames
    .dt <- data.table::rbindlist(.nonNull, fill = TRUE, use.names = TRUE)
  }

  # ---- Fill column defaults ----
  if (is.null(.dt[["id"]]))   data.table::set(.dt, j = "id",   value = 1L)
  if (is.null(.dt[["evid"]])) data.table::set(.dt, j = "evid", value = 0L)
  if (is.null(.dt[["low"]]))  data.table::set(.dt, j = "low",  value = NA_real_)
  if (is.null(.dt[["high"]])) data.table::set(.dt, j = "high", value = NA_real_)
  if (is.null(.dt[["cmt"]]))  data.table::set(.dt, j = "cmt",  value = "(default)")

  .isDose <- .dt$evid != 0L
  if (is.null(.dt[["amt"]]))  data.table::set(.dt, j = "amt",  value = NA_real_)
  if (is.null(.dt[["rate"]])) data.table::set(.dt, j = "rate", value = ifelse(.isDose, 0.0, NA_real_))
  if (is.null(.dt[["ii"]]))   data.table::set(.dt, j = "ii",   value = ifelse(.isDose, 0.0, NA_real_))
  if (is.null(.dt[["addl"]])) data.table::set(.dt, j = "addl", value = ifelse(.isDose, 0L,  NA_integer_))
  if (is.null(.dt[["ss"]]))   data.table::set(.dt, j = "ss",   value = ifelse(.isDose, 0L,  NA_integer_))
  if (is.null(.dt[["dur"]]))  data.table::set(.dt, j = "dur",  value = ifelse(.isDose, 0.0, NA_real_))

  # Patch NA cells in dose rows for numeric dose fields
  .doseIdx <- which(.dt$evid != 0L)
  if (length(.doseIdx) > 0L) {
    for (.col in c("rate", "ii", "dur")) {
      if (.col %in% names(.dt)) {
        .vals <- .dt[[.col]]
        .naInDose <- is.na(.vals[.doseIdx])
        if (any(.naInDose)) {
          .vals[.doseIdx[.naInDose]] <- 0.0
          data.table::set(.dt, j = .col, value = .vals)
        }
      }
    }
    for (.col in c("addl", "ss")) {
      if (.col %in% names(.dt)) {
        .vals <- .dt[[.col]]
        .naInDose <- is.na(.vals[.doseIdx])
        if (any(.naInDose)) {
          .vals[.doseIdx[.naInDose]] <- 0L
          data.table::set(.dt, j = .col, value = .vals)
        }
      }
    }
  }

  # Sort: id ASC, time ASC, evid=3 sorts before others at same (id, time)
  .evidSort <- ifelse(.dt$evid == 3L, -1L, as.integer(.dt$evid))
  .ord <- .order3(.dt$id, .dt$time, .evidSort) # nolint
  .dt <- .dt[.ord, ]

  # Ensure canonical column order
  .missing <- setdiff(.etColOrder, names(.dt))
  for (.col in .missing) data.table::set(.dt, j = .col, value = NA)
  data.table::setcolorder(.dt, .etColOrder)

  .df <- as.data.frame(.dt)
  .df <- .etExpandAddl(.df, env = .env, windows = TRUE)

  # Apply units to columns so C++ solver can propagate them to output
  .tu <- .env$units["time"]
  .du <- .env$units["dosing"]
  .hasTimeU <- !is.na(.tu) && nchar(.tu) > 0
  .hasDoseU <- !is.na(.du) && nchar(.du) > 0
  if (.hasTimeU || .hasDoseU) {
    if (.hasTimeU) {
      for (.col in c("time", "ii", "low", "high", "dur")) {
        .df[[.col]] <- units::set_units(.df[[.col]], .tu, mode = "standard")
      }
    }
    if (.hasDoseU) {
      .df[["amt"]] <- units::set_units(.df[["amt"]], .du, mode = "standard")
      if (.hasTimeU) {
        .df[["rate"]] <- units::set_units(.df[["rate"]], paste0(.du, "/", .tu), mode = "standard")
      }
    }
  }
  .df
}

#' Check if object is an rxEt event table
#'
#' @param x object to test
#'
#' @return logical
#'
#' @export
is.rxEt <- function(x) {
  if (!inherits(x, "rxEt")) return(FALSE)
  .env <- .rxEtEnv(x)
  is.environment(.env)
}

# rxEt chunk builders and shell rebuilders -------------------------------

#' @noRd
.etWindowTime <- function(time, allow1 = FALSE) {
  .randomType <- NA_integer_
  if (inherits(time, "list")) {
    .nw <- length(time)
    .low  <- numeric(.nw)
    .mid  <- numeric(.nw)
    .high <- numeric(.nw)
    # This is for the normal variability case that is list(c(4, 2, NA))
    .hasNormal <- any(vapply(time,
                             function(.w) {
                               length(.w) == 3L && is.na(.w[3L])
                             }, logical(1L)))
    .has3 <- any(vapply(time,
                        function(.w) {
                          length(.w) == 3L && !is.na(.w[3L])
                        }, logical(1L)))
    for (.i in seq_len(.nw)) {
      .w <- time[[.i]]
      if (length(.w) == 1L) {
        if (!allow1) stop("each window must be c(low, high) or c(low, mid, high)", call. = FALSE)
        .low[.i]  <- NA_real_
        .mid[.i]  <- as.numeric(.w)
        .high[.i] <- NA_real_
      } else if (length(.w) == 2L) {
        if (.hasNormal) {
          .low[.i]  <- .w[1]
          .mid[.i]  <- stats::rnorm(1, .w[1], .w[2])
          .high[.i] <- .w[2]
        } else if (.has3) {
          stop("Cannot mix 2 and 3 element windows", call. = FALSE)
        } else {
          if (.w[1] > .w[2]) {
            stop("window bounds must be ordered c(low, high)", call. = FALSE)
          }
          .low[.i]  <- .w[1]
          .mid[.i]  <- stats::runif(1, .w[1], .w[2])
          .high[.i] <- .w[2]
        }
      } else if (length(.w) == 3L) {
        if (is.na(.w[3L])) {
          .low[.i]  <- .w[1]
          .mid[.i]  <- stats::rnorm(1, .w[1], .w[2])
          .high[.i] <- .w[2]
        } else {
          if (.w[1] > .w[2] || .w[2] > .w[3]) stop("window bounds must be ordered c(low, mid, high)", call. = FALSE)
          .low[.i]  <- .w[1]
          .mid[.i]  <- .w[2]
          .high[.i] <- .w[3]
        }
      } else {
        stop("each window must be c(low, high) or c(low, mid, high)", call. = FALSE)
      }
    }
    if (.hasNormal) {
      .randomType <- 3L
    } else if (.has3) {
      .randomType <- 1L
    } else {
      .randomType <- 2L
    }
    return(list(time = .mid, low = .low, high = .high, randomType = .randomType))
  }
  list(time = as.numeric(time), low = NA_real_, high = NA_real_, randomType = NA_integer_)
}

#' @noRd
.etDoseUntil <- function(until, time, ii, addl, isList) {
  if (is.null(until)) return(addl)
  if (ii <= 0) stop("'until' requires a positive 'ii'", call. = FALSE)
  .tmp <- until - time - ii
  if (any(.tmp > 0, na.rm = TRUE)) {
    .ratio <- .tmp / ii
    .ceil <- ceiling(.ratio)
    addl <- ifelse(.ceil == .ratio, .ratio + 1, .ceil)
    addl <- as.integer(addl)
  } else {
    addl <- 0L
    if (!isList) {
      warning("'time'+'ii' is greater than 'until', no additional doses added", call. = FALSE)
    }
  }
  if (any(addl < 0L, na.rm = TRUE)) {
    warning("'until' is before 'time'; setting addl=0", call. = FALSE)
    addl[addl < 0L] <- 0L
  }
  addl
}

#' @noRd
.etDoseValidate <- function(amt, ii, addl, ss, rate) {
  if (ii > 0 && ss == 0L && all(addl == 0L, na.rm = TRUE)) {
    warning(sprintf(
      "'ii' requires non zero additional doses ('addl') or steady state dosing ('ii': %f, 'ss': %d; 'addl': %d), reset 'ii' to zero", # nolint
      ii, ss, max(addl, na.rm = TRUE)
    ), call. = FALSE)
    ii <- 0.0
  }

  if (any(addl > 0L, na.rm = TRUE) && ii == 0.0)
    stop("'addl' > 0 requires a positive inter-dose interval ('ii')", call. = FALSE)

  if (ss > 0L) {
    if (rate < -1.0 && ii == 0.0)
      stop("cannot use duration flag (rate=-2) with steady-state dosing", call. = FALSE)
    if (ss == 2L && ii == 0.0)
      stop("ss=2 requires a positive inter-dose interval ('ii')", call. = FALSE)
    if (rate > 0 && ii > 0 && amt == 0)
      stop("cannot combine constant infusion (rate>0) with dose interval (ii>0) for steady-state; use ii=0 for constant infusion SS", call. = FALSE) # nolint
  }
  ii
}

#' Build an observation chunk list (no id column; caller uses .etAddChunk)

#'
#' Returns a sparse named list where scalar fields (evid) are stored once and
#' vector fields (time) hold per-row values. Callers use length(.df$time) for
#' row count, not nrow().
#'
#' @param time numeric vector of sample times, OR a list of c(low,high)
#'   or c(low,mid,high) windows.
#'
#' @param cmt compartment name or number (optional scalar)
#'
#' @param id integer vector of ids to attach (optional)
#'
#' @return sparse named list with evid=0L scalar and time vector
#'
#' @noRd
.etObsChunk <- function(time, cmt = NULL, id = NULL) {
  .time <- .etWindowTime(time)
  .df <- list(evid = 0L, time = .time$time, low = .time$low, high = .time$high)
  if (!is.null(cmt)) .df$cmt <- cmt
  if (!is.null(id))  .df$id  <- as.integer(id)
  attr(.df, ".randomType") <- .time$randomType
  .df
}

#' Expand a sparse obs chunk list to a full data.frame
#'
#' Replicates scalar fields to match the length of the time vector.
#' @param .sparse sparse list from .etObsChunk
#' @return data.frame with all columns same length
#' @noRd
.etExpandObsChunk <- function(.sparse) {
  .n <- length(.sparse$time)
  as.data.frame(
    lapply(.sparse, function(.v) if (length(.v) == 1L) rep(.v, .n) else .v),
    stringsAsFactors = FALSE
  )
}

#' Build a dosing chunk data.frame (no id column; caller uses .etAddChunk)
#'
#' @param time numeric start time(s)
#' @param amt numeric dose amount(s)
#' @param evid integer event ID (default 1L)
#' @param cmt compartment name/number (default "(default)")
#' @param ii inter-dose interval (default 0)
#' @param addl additional doses (default 0L)
#' @param ss steady-state flag (default 0L)
#' @param rate infusion rate (default 0)
#' @param dur infusion duration (default 0.0)
#' @param until time of last dose; overrides addl
#' @param nbr.doses number of doses (legacy); sets addl = nbr.doses - 1
#' @param dosing.interval inter-dose interval (legacy alias for ii)
#' @return data.frame
#' @noRd
.etDoseChunk <- function(time = 0, amt, evid = 1L, cmt = "(default)",
                         ii = 0.0, addl = 0L, ss = 0L, rate = 0.0, dur = 0.0,
                         until = NULL, nbr.doses = NULL, dosing.interval = NULL) {
  if (!is.null(dosing.interval)) ii <- as.numeric(dosing.interval)
  if (!is.null(nbr.doses))       addl <- as.integer(nbr.doses) - 1L

  .time <- .etWindowTime(time, allow1 = TRUE)
  .timeDose <- .time$time
  .lowDose <- .time$low
  .highDose <- .time$high
  .randomType <- .time$randomType

  addl <- .etDoseUntil(until, .timeDose, ii, addl, inherits(time, "list"))
  ii <- .etDoseValidate(amt, ii, addl, ss, rate)

  if (dur > 0 && rate == 0.0) {
    rate <- amt / dur
    dur  <- 0.0
  }

  if (length(amt) > 1L || length(time) > 1L) {
    if (length(.timeDose) == 1L) .timeDose <- rep(.timeDose, length(amt))
    if (length(amt)  == 1L) amt  <- rep(amt,  length(.timeDose))
    if (length(.timeDose) != length(amt)) stop("'time' and 'amt' must have the same length", call. = FALSE)
  }

  .res <- data.frame(
    time = as.numeric(.timeDose), amt  = as.numeric(amt),
    evid = as.integer(evid), cmt  = as.character(cmt),
    ii   = as.numeric(ii),   addl = as.integer(addl),
    ss   = as.integer(ss),   rate = as.numeric(rate),
    dur  = as.numeric(dur),
    low  = as.numeric(.lowDose),
    high = as.numeric(.highDose),
    stringsAsFactors = FALSE
  )
  attr(.res, ".randomType") <- .randomType
  .res
}

#' Rebuild an rxEt shell after data-frame style mutation
#'
#' @param x rxEt object
#'
#' @param df materialized data.frame from .etMaterialize() after mutation
#'
#' @noRd
.rxEtRebuildShell <- function(x, df) {
  .env0 <- .rxEtEnv(x)
  .et <- .newRxEt(amountUnits = .env0$units["dosing"], timeUnits = .env0$units["time"])
  .env <- .rxEtEnv(.et)
  if (nrow(df) > 0L) {
    .env$methods$import.EventTable(df)
  }
  .env$show[names(.env0$show)] <- .env$show[names(.env0$show)] | .env0$show
  .env$randomType <- .env0$randomType
  .env$canResize <- .env0$canResize
  .etAddExtraCols(.env, .etExtraCols(.env0)) # nolint
  .et
}

#' @export
dim.rxEt <- function(x) {
  dim(as.data.frame(x, all = TRUE))
}

Try the rxode2 package in your browser

Any scripts or data that you put into this service are public.

rxode2 documentation built on July 28, 2026, 5:08 p.m.