Nothing
# Automatic model diagrams (#10)
#
# The model's differential equations are parsed (not regex matched) into a
# graph of compartments (nodes) and flows (edges); the graph is then laid out
# following pharmacometric conventions and drawn with one of several engines.
#' Build a compartment graph from a model's differential equations
#'
#' The differential equations are parsed into additive terms. A term that
#' is subtracted from one compartment and added (identically) to another is
#' mass transfer between the two compartments. A remaining subtracted term
#' that contains the compartment's own amount is an elimination (output); a
#' remaining term that depends on other compartments is an interaction that
#' does not transport mass (for example an effect compartment or a
#' pharmacodynamic stimulation/inhibition); a remaining added term that does
#' not depend on any other compartment is a (zero-order) input.
#' Dependencies through intermediate variables (like `cp <- central/v`) are
#' followed. A compartment that drives a transfer between two other
#' compartments (like an enzyme) is drawn as an interaction with the
#' destination. Production or loss driven only by another compartment
#' (like `ke0*cp` in an effect compartment) is represented by the interaction
#' arrow alone, without a separate input/output arrow.
#'
#' @details
#'
#' Some limitations on how equations must be written:
#'
#' - Mass transfer is only detected when the same term (up to reordering of
#' the factors of a product) is subtracted from the source and added to the
#' destination, e.g. `d/dt(depot) <- -ka*depot` and
#' `d/dt(central) <- ka*depot - ...`. Scaled transfer (like a
#' stoichiometric or volume conversion in only one of the equations) is
#' shown as an elimination plus an interaction.
#'
#' - `linCmt()` models are converted to ODEs with `rxode2::linToOde()`,
#' which requires a version of 'rxode2' that provides it.
#'
#' @param object model to diagram: a model function, an `rxode2` user
#' interface (`rxUi`) object, a compiled `rxode2` model or a fitted
#' `nlmixr2` object.
#' @param dosing optional character vector naming the dosing compartments.
#' When `NULL` the dosing compartments are detected from the dosing records
#' in `data` (a dataset without dose records has no dosing compartment);
#' when there is no data, or it has no `evid`/`amt` columns, the first
#' compartment (the default `rxode2` dosing compartment) is used.
#' @param data optional dataset used to detect the dosing compartments (from
#' the dosing records' `cmt`). For fitted models this defaults to the data
#' the model was fit with.
#' @return a `nlmixr2ModelGraph` object; a list with:
#'
#' - `nodes`: data frame with the compartment `name`, its `role`
#' (`"dosing"`, `"central"`, `"peripheral"`, `"transit"`, `"metabolite"`,
#' `"effect"` or `"other"`), whether it is `dosing` and the layout
#' coordinates `x` and `y`, and an `annotation` with the compartment's
#' dosing properties (`lag`, `F`, `rate`, `dur`; `""` when there are none),
#' which the diagrams show next to the compartment.
#'
#' - `edges`: data frame with `from`, `to` (`NA` for inputs/eliminations),
#' `type` (`"transfer"`, `"elimination"`, `"input"` or `"interaction"`),
#' `sign` (`1` when the term is added, `-1` when it is subtracted; for
#' interactions `1` is stimulation, `-1` inhibition and `0` an effect whose
#' direction cannot be determined from the equations, e.g. through
#' `ifelse()` or a conditionally assigned variable),
#' `bidirectional` (for transfers) and `label` (the model term(s)).
#' @export
#' @author Matthew L. Fidler
#' @family model diagrams
#' @examples
#' \donttest{
#' one.cmt <- function() {
#' ini({
#' tka <- 0.45
#' tcl <- 1
#' tv <- 3.45
#' add.sd <- 0.7
#' })
#' model({
#' ka <- exp(tka)
#' cl <- exp(tcl)
#' v <- exp(tv)
#' d/dt(depot) <- -ka * depot
#' d/dt(central) <- ka * depot - cl / v * central
#' cp <- central / v
#' cp ~ add(add.sd)
#' })
#' }
#' modelGraph(one.cmt)
#' }
modelGraph <- function(object, dosing = NULL, data = NULL) {
.info <- .mdModelInfo(object)
if (is.null(data)) {
data <- .info$data
}
.states <- .info$states
if (length(.states) == 0L) {
stop("the model has no differential equations to diagram", call. = FALSE)
}
if (is.null(dosing)) {
dosing <- .mdDosingFromData(data, .states, .info$order)
# without dosing information use rxode2's default dosing compartment; a
# dataset without dose records has no dosing compartment
if (is.null(dosing)) dosing <- .states[1]
} else {
if (!is.character(dosing)) {
stop("'dosing' must be a character vector of compartment names", call. = FALSE)
}
.bad <- setdiff(dosing, .states)
if (length(.bad) > 0L) {
stop("'dosing' compartment(s) not in the model: ", paste(.bad, collapse = ", "), call. = FALSE)
}
}
.edges <- .mdClassifyTerms(.info$terms, .states)
.nodes <- .mdLayout(.states, .edges, dosing)
.nodes$annotation <- .info$annotation
structure(list(nodes = .nodes, edges = .edges), class = "nlmixr2ModelGraph")
}
#' Automatic model diagram
#'
#' Draws a compartment diagram of a model from its differential equations
#' (see [modelGraph()] for how the equations are interpreted).
#'
#' The layout follows common pharmacometric conventions: dosing and
#' absorption/transit compartments are above the compartment they feed; the
#' central compartment is in the middle with the compartments it exchanges
#' mass with (peripheral compartments) to its left; unidirectional transfer
#' (e.g. to a metabolite) and eliminations go below; compartments that
#' interact with the model without mass transfer (e.g. effect compartments or
#' pharmacodynamic models) go to the right, with their own inputs above,
#' outputs below and exchange compartments further right.
#'
#' Mass transfer is drawn with solid arrows; interactions without mass
#' transfer are dashed (with a "tee" arrow head for inhibition and a "dot"
#' arrow head when the direction is undetermined with `DiagrammeR`; dotted
#' for inhibition and dot-dashed when undetermined with `ggplot2`).
#'
#' @inheritParams modelGraph
#' @param object model to diagram (see [modelGraph()]) or a
#' `nlmixr2ModelGraph` object.
#' @param engine drawing engine: `"DiagrammeR"` (a Graphviz htmlwidget from
#' the 'DiagrammeR' package), `"ggplot2"` (a `ggplot` object) or `"dot"`
#' (the Graphviz DOT source as a character string, to customize or render
#' elsewhere). The default is `"DiagrammeR"` when that package is
#' installed and `"ggplot2"` otherwise; it may be changed with
#' `options(nlmixr2plot.diagram.engine = ...)`.
#' @param labels logical; when `TRUE` label the arrows with the model terms.
#' @param ... ignored.
#' @return the diagram drawn by the requested `engine`.
#' @export
#' @author Matthew L. Fidler
#' @family model diagrams
#' @examples
#' \donttest{
#' pk.turnover.emax <- function() {
#' ini({
#' tktr <- log(1)
#' tka <- log(1)
#' tcl <- log(0.1)
#' tv <- log(10)
#' poplogit <- 2
#' tec50 <- log(0.5)
#' tkout <- log(0.05)
#' te0 <- log(100)
#' prop.err <- 0.1
#' pkadd.err <- 0.1
#' pdadd.err <- 10
#' })
#' model({
#' ktr <- exp(tktr)
#' ka <- exp(tka)
#' cl <- exp(tcl)
#' v <- exp(tv)
#' emax <- expit(poplogit)
#' ec50 <- exp(tec50)
#' kout <- exp(tkout)
#' e0 <- exp(te0)
#' DCP <- center / v
#' PD <- 1 - emax * DCP / (ec50 + DCP)
#' effect(0) <- e0
#' kin <- e0 * kout
#' d/dt(depot) <- -ktr * depot
#' d/dt(gut) <- ktr * depot - ka * gut
#' d/dt(center) <- ka * gut - cl / v * center
#' d/dt(effect) <- kin * PD - kout * effect
#' cp <- center / v
#' cp ~ prop(prop.err) + add(pkadd.err)
#' effect ~ add(pdadd.err)
#' })
#' }
#' modelDiagram(pk.turnover.emax, engine = "ggplot2")
#' if (requireNamespace("DiagrammeR", quietly = TRUE)) {
#' modelDiagram(pk.turnover.emax, engine = "DiagrammeR")
#' }
#' }
modelDiagram <- function(
object,
dosing = NULL,
data = NULL,
engine = getOption("nlmixr2plot.diagram.engine"),
labels = FALSE,
...
) {
if (inherits(object, "nlmixr2ModelGraph")) {
.graph <- object
} else {
.graph <- modelGraph(object, dosing = dosing, data = data)
}
if (is.null(engine)) {
engine <- if (requireNamespace("DiagrammeR", quietly = TRUE)) {
"DiagrammeR"
} else {
"ggplot2"
}
}
engine <- match.arg(engine, c("DiagrammeR", "ggplot2", "dot"))
if (!(is.logical(labels) && length(labels) == 1L && !is.na(labels))) {
stop("'labels' must be TRUE or FALSE", call. = FALSE)
}
switch(
engine,
DiagrammeR = .mdDiagrammeR(.graph, labels),
ggplot2 = .mdGgplot(.graph, labels),
dot = .mdDot(.graph, labels)
)
}
#' @rdname modelDiagram
#' @param x a `nlmixr2ModelGraph` object, an `rxode2` user interface
#' (`rxUi`) object or a compiled `rxode2` model
#' @details `plot()` of an `rxode2` user interface (`rxUi`) object, like
#' `rxode2::rxode2(modelFunction)`, or of a compiled `rxode2` model draws
#' its model diagram, so `plot(rxode2(model))` is the same as
#' `modelDiagram(model)`. (A fitted `nlmixr2` model keeps its
#' goodness-of-fit `plot()`; use `modelDiagram(fit)` for its diagram.)
#' @export
plot.nlmixr2ModelGraph <- function(x, ..., engine = getOption("nlmixr2plot.diagram.engine"), labels = FALSE) {
modelDiagram(x, engine = engine, labels = labels)
}
#' @rdname modelDiagram
#' @export
plot.rxUi <- function(
x,
...,
dosing = NULL,
data = NULL,
engine = getOption("nlmixr2plot.diagram.engine"),
labels = FALSE
) {
modelDiagram(x, dosing = dosing, data = data, engine = engine, labels = labels)
}
#' @rdname modelDiagram
#' @export
plot.rxode2 <- plot.rxUi
#' @export
print.nlmixr2ModelGraph <- function(x, ...) {
cat("nlmixr2 model graph\n\ncompartments:\n")
.n <- x$nodes[, c("name", "role", "dosing")]
.a <- x$nodes$annotation
if (!is.null(.a) && any(!is.na(.a) & nzchar(.a))) {
.a[is.na(.a)] <- ""
.n$annotation <- gsub("\n", "; ", .a, fixed = TRUE)
}
print(.n, row.names = FALSE)
cat("\nflows:\n")
.e <- x$edges
if (nrow(.e) == 0L) {
cat(" (none)\n")
} else {
.e$from[is.na(.e$from)] <- "(input)"
.e$to[is.na(.e$to)] <- "(output)"
print(.e[, c("from", "to", "type", "sign", "label")], row.names = FALSE)
}
invisible(x)
}
# ---------------------------------------------------------------------------
# Model extraction
# ---------------------------------------------------------------------------
#' Get the model lines, states and data from a supported object
#'
#' @param object model object
#' @return list(states, terms, data)
#' @noRd
.mdModelInfo <- function(object) {
.data <- NULL
if (inherits(object, "nlmixr2FitCore") || inherits(object, "nlmixr2FitData")) {
.data <- tryCatch(object$origData, error = function(e) NULL)
object <- object$ui
}
if (inherits(object, "rxode2")) {
.mv <- rxode2::rxModelVars(object)
# braces let a normalized `}\nelse` parse
.lines <- as.list(str2lang(paste0("{\n", .mv$model["normModel"], "\n}")))[-1]
.order <- .mv$state
} else {
if (is.function(object)) {
object <- rxode2::rxode2(object)
}
if (!inherits(object, "rxUi")) {
stop(
"cannot create a model diagram from an object of class '",
paste(class(object), collapse = "', '"),
"'",
call. = FALSE
)
}
if (.mdIsLinCmt(object)) {
object <- .mdLinToOde(object)
}
.lines <- object$lstExpr
# residual error lines (`cp ~ add(sd)`) are not assignments
.err <- object$predDf$line
if (length(.err) > 0L) {
.lines <- .lines[-.err]
}
.order <- object$mv0$state
}
# substitute definitions into the equations; when that makes an equation
# too large (QSP/PBPK models), redo the whole model with smaller
# substitutions so every equation is written the same way
.parsed <- .mdParseLines(.lines)
for (.level in c("small", "none")) {
if (!isTRUE(.parsed$overflow)) {
break
}
.parsed <- .mdParseLines(.lines, .level)
}
.states <- .parsed$states
# keep rxode2's compartment order (used to map numeric `cmt` values)
.states <- c(intersect(.order, .states), setdiff(.states, .order))
.foldIndex <- .mdFoldIndex(.parsed$defs)
.terms <- do.call(
rbind,
lapply(.states, function(.s) {
.t <- .parsed$ode[[.s]]
if (length(.t) == 0L) {
return(NULL)
}
data.frame(
state = .s,
sign = vapply(.t, function(x) x$sign, numeric(1)),
key = vapply(.t, function(x) .mdTermKey(x$expr), character(1)),
label = vapply(
.t,
function(x) {
# `flux#2` (one name per assignment) is shown as `flux`
gsub("#[0-9]+", "", .mdDeparse(.mdFold(x$expr, .parsed$defs, .foldIndex)), perl = TRUE)
},
character(1)
),
stringsAsFactors = FALSE
)
})
)
if (!is.null(.terms)) {
.terms$states <- unlist(
lapply(.states, function(.s) {
lapply(.parsed$ode[[.s]], function(x) {
.mdExprStates(x$expr, .states, x$snap)
})
}),
recursive = FALSE
)
# direction (1 increasing, -1 decreasing, NA unknown) of each term in
# each compartment it depends on
.terms$dir <- unlist(
lapply(.states, function(.s) {
lapply(.parsed$ode[[.s]], function(x) {
.st <- .mdExprStates(x$expr, .states, x$snap)
.dep <- lapply(.st, .mdDependents, states = .states, closure = x$snap)
stats::setNames(.mdDirections(x$expr, .st, .dep), .st)
})
}),
recursive = FALSE
)
}
# rxode2's full compartment order, including compartments without ODEs
# (e.g. from `cmt()`), maps numeric `cmt` values
list(
states = .states,
terms = .terms,
data = .data,
annotation = .mdAnnotation(.parsed$props, .states),
order = c(.order, setdiff(.states, .order))
)
}
#' Dosing compartments from the dosing records of a dataset
#'
#' @param data dataset (or NULL)
#' @param states compartment names that are diagrammed
#' @param order rxode2's compartment order (numeric `cmt` values index it)
#' @return character vector of dosed compartments (empty when the data has
#' no dose records) or `NULL` when the data has no dosing information
#' @noRd
.mdDosingFromData <- function(data, states, order = states) {
if (!is.data.frame(data) || nrow(data) == 0L) {
return(NULL)
}
.nm <- tolower(names(data))
.col <- function(n) {
.w <- which(.nm == n)
if (length(.w) == 0L) {
return(NULL)
}
data[[.w[1]]]
}
.evid <- .col("evid")
.amt <- .col("amt")
if (is.null(.evid) && is.null(.amt)) {
return(NULL)
}
.dose <- rep(TRUE, nrow(data))
# factors are converted through their labels, not their level codes
.num <- function(v) suppressWarnings(as.numeric(as.character(v)))
if (!is.null(.evid)) {
.evid <- .num(.evid)
# 0 = observation, 2 = other event, 3 = reset
.dose <- .dose & !is.na(.evid) & !(.evid %in% c(0, 2, 3))
}
if (!is.null(.amt)) {
.amt <- .num(.amt)
.dose <- .dose & !is.na(.amt) & .amt != 0
}
if (!any(.dose)) {
return(character(0))
}
.cmt <- .col("cmt")
if (is.null(.cmt)) {
return(intersect(states, order[1]))
}
.cmt <- .cmt[.dose]
if (is.factor(.cmt)) {
.cmt <- as.character(.cmt)
}
# a missing compartment doses the default (first) compartment
.ret <- if (anyNA(.cmt)) order[1] else character(0)
.cmt <- .cmt[!is.na(.cmt)]
if (is.character(.cmt)) {
.num <- suppressWarnings(as.numeric(.cmt))
.chr <- .cmt[is.na(.num)]
.chr[.chr %in% c("(default)", "")] <- order[1]
.ret <- c(.ret, .chr)
.cmt <- .num[!is.na(.num)]
} else {
.cmt <- as.numeric(.cmt)
}
# negative compartment numbers turn compartments off; they are not doses
.cmt <- .cmt[!is.na(.cmt) & .cmt > 0 & .cmt <= length(order)]
.ret <- c(.ret, order[.cmt])
intersect(states, .ret)
}
#' @noRd
.mdIsLinCmt <- function(ui) {
any(vapply(
ui$lstExpr,
function(x) {
"linCmt" %in% all.names(x)
},
logical(1)
))
}
#' @noRd
.mdLinToOde <- function(ui) {
if (!("linToOde" %in% getNamespaceExports("rxode2"))) {
stop("diagramming 'linCmt()' models requires a version of 'rxode2' with 'linToOde()'", call. = FALSE)
}
.fun <- getExportedValue("rxode2", "linToOde")
.fun(ui)
}
#' Parse model lines into ODE terms and variable -> state dependencies
#'
#' Variables that are assigned once (outside of `if` blocks) and depend on a
#' compartment amount are substituted into the differential equations before
#' they are split into terms, so that the direction of an effect expressed
#' through an intermediate variable (e.g. `PD <- 1 - emax*cp/(ec50 + cp)`) is
#' seen.
#'
#' @param lines list of model expressions
#' @return list(ode = named list of term lists, deps = named list of
#' variables to the (possibly empty) set of variables they depend on)
#' @noRd
.mdParseLines <- function(lines, level = c("full", "small", "none")) {
level <- match.arg(level)
.env <- new.env(parent = emptyenv())
.env$ode <- list()
# compartments each variable stands for, as the model is read (a later
# assignment must not change what an earlier use meant)
.env$stateOf <- list()
.env$defs <- list()
.env$count <- list()
.env$inIf <- character(0)
.env$states <- character(0)
.env$props <- list()
.env$defsSmall <- list()
.env$overflow <- FALSE
.env$version <- list()
.env$versioned <- list()
# the definition to substitute for variable `n` (NULL: keep the variable)
.mdDefinition <- function(n, rhs, maxSize) {
if (n %in% .env$states || n %in% .env$inIf || length(all.names(rhs)) > maxSize) {
# values from `if` branches cannot be substituted, and very large
# definitions (QSP/PBPK models) are kept as variables so the
# equations do not grow combinatorially. A reassigned variable that
# is not substituted keeps one name per assignment, so that two
# different values (`flux = k*A` ... `flux = h*B`) are not mistaken
# for one flow
return(.env$versioned[[n]])
}
# a constant (`x = -1`) is substituted so that its value and sign are
# known where it is used
if (length(all.vars(rhs)) == 0L) {
return(rhs)
}
if (!identical(.env$count[[n]], 1)) {
# a reassigned variable is substituted with its current value so
# that a reused name (like `flux`) is not mistaken for one flow
return(rhs)
}
if (any(all.vars(rhs) %in% .env$states)) {
return(rhs)
}
NULL
}
.isAssign <- function(x) {
length(x) == 3L &&
(identical(x[[1]], quote(`<-`)) || identical(x[[1]], quote(`=`)) || identical(x[[1]], quote(`~`)))
}
# first pass: compartment names and how often (and where) variables are
# assigned
.count <- function(x, inIf) {
if (!is.call(x)) {
return(invisible())
}
.f <- x[[1]]
if (identical(.f, quote(`{`))) {
for (.i in seq_along(x)[-1]) {
.count(x[[.i]], inIf)
}
} else if (identical(.f, quote(`if`))) {
.count(x[[3]], TRUE)
if (length(x) == 4L) .count(x[[4]], TRUE)
} else if (.isAssign(x)) {
.state <- .mdDdtState(x[[2]])
if (!is.null(.state)) {
.env$states <- union(.env$states, .state)
} else if (is.name(x[[2]])) {
.n <- as.character(x[[2]])
.env$count[[.n]] <- (if (is.null(.env$count[[.n]])) 0 else .env$count[[.n]]) + 1
if (inIf) .env$inIf <- union(.env$inIf, .n)
}
}
invisible()
}
for (.l in lines) {
.count(.l, FALSE)
}
# add terms to a compartment's equation; a repeated term (`-k*A - k*A`)
# is kept, since each copy moves mass (if/else branches are merged
# before they get here)
.addTerms <- function(state, terms) {
.keep <- Filter(
function(t) {
# a literal zero (e.g. `d/dt(x) <- 0`) is no flow
!(is.numeric(t$expr) && all(t$expr == 0))
},
terms
)
# what the variables mean here, for the compartments of this term
.snap <- .env$stateOf
.keep <- lapply(.keep, function(t) {
if (is.null(t$snap)) {
t$snap <- .snap
}
t
})
.env$ode[[state]] <- c(.env$ode[[state]], .keep)
}
# second pass; `cond` holds the variables of the enclosing `if` conditions
.walk <- function(x, cond) {
if (!is.call(x)) {
return(invisible())
}
.f <- x[[1]]
if (identical(.f, quote(`{`))) {
for (.i in seq_along(x)[-1]) {
.walk(x[[.i]], cond)
}
} else if (identical(.f, quote(`if`))) {
.cond <- union(cond, all.vars(x[[2]]))
# walk each branch separately, then merge: terms in both branches
# apply either way, the others become `ifelse(cond, term, 0)` /
# `ifelse(cond, 0, term)` so that the condition is kept
.base <- .env$ode
.baseState <- .env$stateOf
.env$ode <- list()
.walk(x[[3]], .cond)
.yes <- .env$ode
.yesState <- .env$stateOf
.env$ode <- list()
.env$stateOf <- .baseState
if (length(x) == 4L) {
.walk(x[[4]], .cond)
}
.no <- .env$ode
.noState <- .env$stateOf
# a variable assigned in a branch may hold either branch's value
.merged <- .noState
for (.v in union(names(.yesState), names(.noState))) {
.merged[[.v]] <- union(.yesState[[.v]], .noState[[.v]])
}
.env$stateOf <- .merged
.env$ode <- .base
for (.s in union(names(.yes), names(.no))) {
.addTerms(.s, .mdMergeBranches(x[[2]], .yes[[.s]], .no[[.s]]))
}
} else if (.isAssign(x)) {
.lhs <- x[[2]]
# full substitution, and substitution of small definitions only (used
# when full substitution makes an equation too large)
.rhs <- switch(
level,
full = .mdSubstitute(x[[3]], .env$defs),
small = .mdSubstitute(x[[3]], .env$defsSmall),
none = .mdSubstitute(x[[3]], .env$versioned)
)
.rhsSmall <- .mdSubstitute(x[[3]], .env$defsSmall)
.state <- .mdDdtState(.lhs)
.prop <- .mdDoseProperty(.lhs)
if (!is.null(.prop)) {
# dosing properties (lag, F, rate, dur) are annotations only
.old <- .env$props[[.prop$state]]
.val <- .mdDeparse(x[[3]])
.cur <- .old[.prop$name]
.old[.prop$name] <- if (is.null(.old) || is.na(.cur)) {
.val
} else {
paste(.cur, .val, sep = " / ")
}
.env$props[[.prop$state]] <- .old
} else if (!is.null(.state)) {
# an equation that is too large after substitution makes the whole
# model fall back to a lower substitution level, so that the same
# flow is written the same way in every equation
if (length(all.names(.rhs)) > .mdMaxOdeSize) {
.env$overflow <- TRUE
}
.addTerms(.state, .mdSplitTerms(.rhs, .env$states, .state))
} else if (is.name(.lhs)) {
.n <- as.character(.lhs)
# the compartments this value stands for, resolved now (a
# conditional assignment also depends on its condition)
.dep <- union(all.vars(x[[3]]), cond)
.set <- unique(unlist(c(
intersect(.dep, .env$states),
lapply(setdiff(.dep, .env$states), function(.v) .env$stateOf[[.v]])
)))
if (is.null(.set)) {
.set <- character(0)
}
.env$stateOf[[.n]] <- .set
if (!identical(.env$count[[.n]], 1) && !(.n %in% .env$inIf)) {
# one name per assignment of a reassigned variable; a variable
# assigned in `if` branches keeps one name, since its value is one
# of the branches (and depends on all of them)
.v <- (if (is.null(.env$version[[.n]])) 0L else .env$version[[.n]]) + 1L
.env$version[[.n]] <- .v
.vn <- paste0(.n, "#", .v)
.env$versioned[[.n]] <- as.name(.vn)
.env$stateOf[[.vn]] <- .set
}
# a NULL definition removes the variable from the substitutions
.env$defs[[.n]] <- .mdDefinition(.n, .rhs, .mdMaxDefSize)
.env$defsSmall[[.n]] <- .mdDefinition(.n, .rhsSmall, .mdMaxSmallDefSize)
}
}
invisible()
}
for (.l in lines) {
.walk(.l, character(0))
}
# only single-assignment definitions can be folded back into labels
.defs <- c(.env$defs, .env$defsSmall)
.defs <- .defs[!vapply(.defs, is.null, logical(1))]
.fold <- .defs[vapply(
names(.defs),
function(.n) {
identical(.env$count[[.n]], 1)
},
logical(1)
)]
list(ode = .env$ode, defs = .fold, states = .env$states, props = .env$props, overflow = .env$overflow)
}
#' Replace substituted definitions by their variable names (for labels)
#' @noRd
.mdFold <- function(expr, defs, index = .mdFoldIndex(defs)) {
if (length(defs) == 0L) {
return(expr)
}
.fold <- function(e) {
if (is.call(e)) {
# only deparse sub-expressions the size of some definition
if (length(all.names(e)) %in% index$size) {
.w <- which(index$key == .mdDeparse(e))
if (length(.w) > 0L) return(as.name(names(defs)[.w[length(.w)]]))
}
for (.i in seq_along(e)[-1]) {
.v <- .fold(e[[.i]])
if (!is.null(.v)) e[[.i]] <- .v
}
}
e
}
.fold(expr)
}
#' Deparsed keys and sizes of definitions, computed once per model
#' @noRd
.mdFoldIndex <- function(defs) {
list(key = vapply(defs, .mdDeparse, character(1)), size = vapply(defs, function(d) length(all.names(d)), numeric(1)))
}
#' Substitute variable definitions into an expression
#' @noRd
.mdSubstitute <- function(expr, defs) {
if (length(defs) == 0L) {
return(expr)
}
do.call(substitute, list(expr, defs))
}
#' Dosing property set by an assignment like `alag(depot) <- tlag`
#'
#' @param lhs left hand side of an assignment
#' @return list(state, name) with the display name (`lag`, `F`, `rate` or
#' `dur`), or NULL
#' @noRd
.mdDoseProperty <- function(lhs) {
if (!is.call(lhs) || length(lhs) != 2L || !is.name(lhs[[1]]) || !is.name(lhs[[2]])) {
return(NULL)
}
.n <- c(lag = "lag", alag = "lag", f = "F", F = "F", rate = "rate", dur = "dur")[as.character(lhs[[1]])]
if (is.na(.n)) {
return(NULL)
}
list(state = as.character(lhs[[2]]), name = unname(.n))
}
#' Annotation text for each compartment's dosing properties
#' @param props named list (by compartment) of named character vectors
#' @param states compartment names
#' @return character vector (`""` without properties)
#' @noRd
.mdAnnotation <- function(props, states) {
vapply(
states,
function(.s) {
.p <- props[[.s]]
if (is.null(.p) || length(.p) == 0L) {
return("")
}
.p <- .p[intersect(c("lag", "F", "rate", "dur"), names(.p))]
paste0(names(.p), " = ", .p, collapse = "\n")
},
character(1),
USE.NAMES = FALSE
)
}
#' Return the state name for a `d/dt(state)` expression or NULL
#' @noRd
.mdDdtState <- function(lhs) {
if (
is.call(lhs) &&
identical(lhs[[1]], quote(`/`)) &&
identical(lhs[[2]], quote(d)) &&
is.call(lhs[[3]]) &&
identical(lhs[[3]][[1]], quote(dt)) &&
length(lhs[[3]]) == 2L
) {
return(as.character(lhs[[3]][[2]]))
}
NULL
}
#' States an expression depends on (directly or through variables)
#' @param closure named list of the compartments each variable stands for
#' @noRd
.mdExprStates <- function(expr, states, closure) {
.v <- all.vars(expr)
# a state's own amount is not expanded through assignments
.direct <- .v[.v %in% states]
.via <- unlist(closure[setdiff(.v, states)], use.names = FALSE)
intersect(states, c(.direct, .via))
}
# limits that keep large (QSP/PBPK) models fast: the size (number of names)
# of a definition substituted into the equations, and the number of terms a
# single product may be distributed into
.mdMaxDefSize <- 2000L
.mdMaxTerms <- 64L
# beyond this size (after substitution) an equation only gets definitions up
# to `.mdMaxSmallDefSize` substituted
.mdMaxOdeSize <- 5000L
# a summand is only expanded when its terms stay within this multiple of its
# size
.mdMaxTermGrowth <- 50L
# more neighbors than this in one direction are fanned out on an arc
.mdMaxLine <- 4L
# models with more compartments than this use a bounded placement search
.mdMaxWideSearch <- 100L
# graphs with more edges than this are drawn with straight edges in DOT
.mdMaxSplineEdges <- 200L
.mdMaxSmallDefSize <- 200L
#' Split an equation into terms, bounding the growth of each summand
#'
#' The top-level sum is split first (which never grows); each summand is then
#' expanded with `.mdTerms()` unless that would make it much larger (e.g. a
#' long numerator split over a large denominator), in which case it is kept
#' as one term.
#' @param x expression
#' @param states compartment names
#' @return list of list(sign, expr)
#' @noRd
.mdSplitTerms <- function(x, states = character(0), own = NULL) {
.top <- .mdTopSum(x)
# expand within a size budget; NULL when the budget runs out
.try <- function(expr, own, limit) {
.budget <- new.env(parent = emptyenv())
.budget$left <- limit
tryCatch(.mdTerms(expr, states, own, .budget), mdBudget = function(e) NULL)
}
unlist(
lapply(.top, function(.t) {
.limit <- .mdMaxTermGrowth * length(all.names(.t$expr)) + 200
.e <- .try(.t$expr, NULL, .limit)
if (is.null(.e) && !is.null(own)) {
# coarse expansion (own compartment / other compartments / constants)
.e <- .try(.t$expr, own, .limit)
}
if (is.null(.e)) {
return(list(.t))
}
if (.t$sign < 0) {
.e <- .mdNeg(.e)
}
.e
}),
recursive = FALSE
)
}
#' Total size (number of names) of a list of terms
#' @noRd
.mdSize <- function(terms) {
sum(vapply(terms, function(t) length(all.names(t$expr)), numeric(1)))
}
#' Charge an expansion budget, signalling `mdBudget` when it runs out
#' @noRd
.mdCharge <- function(budget, size) {
if (is.null(budget)) {
return(invisible())
}
budget$left <- budget$left - size
if (budget$left < 0) {
stop(structure(
class = c("mdBudget", "error", "condition"),
list(message = "expansion budget exceeded", call = NULL)
))
}
invisible()
}
#' Split only the top-level sum of an expression into signed summands
#' @noRd
.mdTopSum <- function(x, sign = 1) {
if (is.call(x)) {
.f <- x[[1]]
if (identical(.f, quote(`(`))) {
return(.mdTopSum(x[[2]], sign))
}
if (identical(.f, quote(`+`))) {
if (length(x) == 2L) {
return(.mdTopSum(x[[2]], sign))
}
return(c(.mdTopSum(x[[2]], sign), .mdTopSum(x[[3]], sign)))
}
if (identical(.f, quote(`-`))) {
if (length(x) == 2L) {
return(.mdTopSum(x[[2]], -sign))
}
return(c(.mdTopSum(x[[2]], sign), .mdTopSum(x[[3]], -sign)))
}
}
list(list(sign = sign, expr = x))
}
#' Split an expression into signed additive terms
#'
#' Sums and differences are split, products distribute over sums and a sum
#' in a numerator is split over its denominator.
#' @param x expression
#' @param states compartment names, used to group long sums (see
#' `.mdCollapse()`)
#' @param own when given, the equation's own compartment: products are only
#' expanded into terms with and without it, with other compartments and
#' without any (a coarse expansion that stays small)
#' @return list of list(sign, expr)
#' @noRd
.mdTerms <- function(x, states = character(0), own = NULL, budget = NULL) {
if (is.call(x)) {
.f <- x[[1]]
if (identical(.f, quote(`(`))) {
return(.mdTerms(x[[2]], states, own, budget))
}
if (identical(.f, quote(`+`))) {
if (length(x) == 2L) {
return(.mdTerms(x[[2]], states, own, budget))
}
return(c(.mdTerms(x[[2]], states, own, budget), .mdTerms(x[[3]], states, own, budget)))
}
if (identical(.f, quote(`-`))) {
if (length(x) == 2L) {
return(.mdNeg(.mdTerms(x[[2]], states, own, budget)))
}
return(c(.mdTerms(x[[2]], states, own, budget), .mdNeg(.mdTerms(x[[3]], states, own, budget))))
}
if (identical(.f, quote(`*`)) && length(x) == 3L) {
.a <- .mdTerms(x[[2]], states, own, budget)
.b <- .mdTerms(x[[3]], states, own, budget)
# distributing a product of long sums grows combinatorially; beyond a
# limit first group like terms (same sign and variables) of the longer
# factor, then of the other, and only then keep the product whole
if (!is.null(own)) {
# coarse expansion: only keep apart what the diagram needs (own
# compartment / other compartments / constants)
.a <- .mdCollapse(.a, states, own = own)
.b <- .mdCollapse(.b, states, own = own)
}
for (.coarse in c(FALSE, TRUE)) {
if (length(.a) * length(.b) > .mdMaxTerms) {
.a <- .mdCollapse(.a, states, .coarse)
.b <- .mdCollapse(.b, states, .coarse)
}
}
if (length(.a) * length(.b) > .mdMaxTerms) {
return(list(list(sign = 1, expr = x)))
}
.mdCharge(budget, length(.b) * .mdSize(.a) + length(.a) * .mdSize(.b))
.ret <- list()
for (.i in .a) {
for (.j in .b) {
.ret[[length(.ret) + 1L]] <-
list(sign = .i$sign * .j$sign, expr = .mdMult(.i$expr, .j$expr))
}
}
return(.ret)
}
if (identical(.f, quote(`/`)) && length(x) == 3L) {
.den <- .mdTerms(x[[3]], states, own, budget)
.denSign <- 1
if (length(.den) == 1L) {
# keep a single signed denominator's sign on the term
.denSign <- .den[[1]]$sign
.denExpr <- .den[[1]]$expr
} else {
.denExpr <- x[[3]]
}
.num <- .mdTerms(x[[2]], states, own, budget)
# each numerator term carries a copy of the denominator; when that is
# too much, group like numerator terms first so that a production and
# a loss over the same denominator stay apart
.denSize <- length(all.names(.denExpr))
if (!is.null(budget) && length(.num) * .denSize > budget$left) {
.num <- .mdCollapse(.num, states, own = own)
}
if (!is.null(budget) && length(.num) * .denSize > budget$left) {
.num <- .mdCollapse(.num, states, coarse = TRUE)
}
.mdCharge(budget, length(.num) * .denSize)
return(lapply(.num, function(.t) {
list(sign = .t$sign * .denSign, expr = as.call(list(quote(`/`), .t$expr, .denExpr)))
}))
}
}
if (is.call(x) && identical(x[[1]], quote(ifelse)) && length(x) == 4L) {
# distribute `ifelse(cond, a, b)` over the terms of each branch:
# `ifelse(cond, a1, 0) + ... + ifelse(cond, 0, b1) + ...`; a term in both
# branches applies either way
.isZero <- function(t) is.numeric(t$expr) && all(t$expr == 0)
return(.mdMergeBranches(
x[[2]],
Filter(Negate(.isZero), .mdTerms(x[[3]], states, own, budget)),
Filter(Negate(.isZero), .mdTerms(x[[4]], states, own, budget))
))
}
if (is.numeric(x) && length(x) == 1L && !is.na(x) && x < 0) {
return(list(list(sign = -1, expr = -x)))
}
list(list(sign = 1, expr = x))
}
#' Merge the terms of the two branches of a condition
#'
#' A term in both branches applies either way; the others become
#' `ifelse(cond, term, 0)` or `ifelse(cond, 0, term)`. Repeated terms are
#' paired one-to-one, so `-k*A` against `-k*A - k*A` leaves one conditional
#' `-k*A`.
#' @param cond condition expression
#' @param yes,no lists of terms of each branch
#' @return list of terms
#' @noRd
.mdMergeBranches <- function(cond, yes, no) {
.id <- function(t) paste(t$sign, .mdTermKey(t$expr))
.wrap <- function(t, isYes) {
list(sign = t$sign, expr = as.call(list(quote(ifelse), cond, if (isYes) t$expr else 0, if (isYes) 0 else t$expr)))
}
.noId <- vapply(no, .id, character(1))
.noUsed <- rep(FALSE, length(no))
.ret <- list()
for (.t in yes) {
.w <- which(!.noUsed & .noId == .id(.t))
if (length(.w) > 0L) {
.noUsed[.w[1]] <- TRUE
.ret[[length(.ret) + 1L]] <- .t
} else {
.ret[[length(.ret) + 1L]] <- .wrap(.t, TRUE)
}
}
c(.ret, lapply(no[!.noUsed], .wrap, isYes = FALSE))
}
#' Canonical form of an expression: operands of sums and products sorted
#' @noRd
.mdCanon <- function(x) {
if (!is.call(x)) {
return(x)
}
if (identical(x[[1]], quote(`(`))) {
return(.mdCanon(x[[2]]))
}
# `delay(x, tau)` moves the same mass as `x`, only later: a delayed
# transfer `-ka*depot` / `+ka*delay(depot, tlag)` is still one flow
if (identical(x[[1]], quote(delay)) && length(x) >= 2L) {
return(.mdCanon(x[[2]]))
}
for (.op in list(quote(`+`), quote(`*`))) {
if (identical(x[[1]], .op) && length(x) == 3L) {
.ops <- list()
.flat <- function(e) {
if (is.call(e) && identical(e[[1]], quote(`(`))) {
return(.flat(e[[2]]))
}
if (is.call(e) && identical(e[[1]], .op) && length(e) == 3L) {
.flat(e[[2]])
.flat(e[[3]])
} else {
.ops[[length(.ops) + 1L]] <<- .mdCanon(e)
}
}
.flat(x)
.ops <- .ops[order(vapply(.ops, .mdDeparse, character(1)))]
return(Reduce(function(a, b) as.call(list(.op, a, b)), .ops))
}
}
for (.i in seq_along(x)[-1]) {
.v <- .mdCanon(x[[.i]])
if (!is.null(.v)) x[[.i]] <- .v
}
x
}
#' Group like terms of a long sum into one term
#'
#' Terms are grouped by sign and the compartments they contain (or, when
#' `coarse`, only whether they contain any compartment), which keeps the
#' constant, self-dependent and compartment-driven parts of a long sum apart
#' while bounding the number of terms.
#' @param terms list of terms
#' @param states compartment names (without them, the variables are used)
#' @param coarse group only by sign and whether a compartment appears
#' @param own group by sign, whether `own` appears and whether other
#' compartments appear
#' @noRd
.mdCollapse <- function(terms, states = character(0), coarse = FALSE, own = NULL) {
.key <- vapply(
terms,
function(t) {
.v <- all.vars(t$expr)
if (length(states) > 0L) {
.v <- intersect(.v, states)
}
.v <- if (!is.null(own)) {
paste(own %in% .v, length(setdiff(.v, own)) > 0L)
} else if (coarse) {
as.character(length(.v) > 0L)
} else {
paste(sort(.v), collapse = ",")
}
paste(t$sign, .v)
},
character(1)
)
lapply(split(terms, factor(.key, levels = unique(.key))), function(g) {
list(
sign = g[[1]]$sign,
expr = Reduce(function(a, b) as.call(list(quote(`+`), a, b)), lapply(g, function(t) t$expr))
)
})
}
#' @noRd
.mdNeg <- function(terms) {
lapply(terms, function(.t) {
.t$sign <- -.t$sign
.t
})
}
#' Multiply two expressions, dropping multiplications by one
#' @noRd
.mdMult <- function(a, b) {
if (is.numeric(a) && length(a) == 1L && a == 1) {
return(b)
}
if (is.numeric(b) && length(b) == 1L && b == 1) {
return(a)
}
as.call(list(quote(`*`), a, b))
}
#' Remove all parentheses; deparse re-adds the ones that are needed
#' @noRd
.mdStripParen <- function(x) {
if (is.call(x)) {
if (identical(x[[1]], quote(`(`))) {
return(.mdStripParen(x[[2]]))
}
for (.i in seq_along(x)[-1]) {
.v <- .mdStripParen(x[[.i]])
if (!is.null(.v)) x[[.i]] <- .v
}
}
x
}
#' @noRd
.mdDeparse <- function(x) {
paste(deparse(.mdStripParen(x), width.cutoff = 500L), collapse = " ")
}
#' Direction of the dependence of an expression on a compartment
#'
#' Rates and parameters (including symbolic exponents) are assumed positive.
#' Monotone functions (`exp`, `log`, `sqrt`, `expit`, positive powers) and
#' `delay(x, tau)` keep the direction of their argument, and a quotient whose numerator and
#' denominator both increase is taken as a saturating (Emax/Hill) increase.
#' @param expr expression
#' @param o compartment name
#' @return 1 (increasing), -1 (decreasing), 0 (independent) or NA (unknown)
#' @noRd
.mdDirection <- function(expr, o, states, deps, dependents = .mdDependents(o, states, deps)) {
.mdDirections(expr, o, list(dependents))[[1]]
}
#' Directions of an expression in several compartments at once
#'
#' The expression is walked once; each node's direction is a vector with
#' one entry per compartment in `os` (1 increasing, -1 decreasing, 0
#' independent, NA unknown).
#' @param expr expression
#' @param os compartment names
#' @param dependents list (one per compartment) of the compartment and the
#' variables that depend on it
#' @return numeric vector, one direction per compartment
#' @noRd
.mdDirections <- function(expr, os, dependents) {
.k <- length(os)
if (.k == 0L) {
return(numeric(0))
}
.zero <- rep(0, .k)
# does `e` depend on each compartment?
.dep <- function(e) {
.v <- all.vars(e)
if (length(.v) == 0L) {
return(rep(FALSE, .k))
}
vapply(dependents, function(d) any(.v %in% d), logical(1))
}
# combine the directions of the parts of a sum/product, per compartment
.comb <- function(m) {
if (!is.matrix(m)) {
m <- matrix(m, nrow = .k)
}
apply(m, 1L, function(d) {
d <- d[is.na(d) | d != 0]
if (length(d) == 0L) {
return(0)
}
if (anyNA(d) || length(unique(d)) > 1L) {
return(NA_real_)
}
d[1]
})
}
# the part of a product that depends on compartment `i` (NULL when none)
.depPart <- function(e, i) {
e <- .mdStripParen(e)
if (is.call(e) && identical(e[[1]], quote(`*`)) && length(e) == 3L) {
.a <- .depPart(e[[2]], i)
.b <- .depPart(e[[3]], i)
if (is.null(.a)) {
return(.b)
}
if (is.null(.b)) {
return(.a)
}
return(as.call(list(quote(`*`), .a, .b)))
}
if (.dep(e)[i]) e else NULL
}
# is `num/den` of the form `c*N/(K + N)` in compartment `i`, with K
# positive and independent of it?
.saturating <- function(e, i) {
.n <- .depPart(e[[2]], i)
.den <- .mdStripParen(e[[3]])
if (is.null(.n) || !(is.call(.den) && identical(.den[[1]], quote(`+`)))) {
return(FALSE)
}
.s <- list()
.flat <- function(x) {
x <- .mdStripParen(x)
if (is.call(x) && identical(x[[1]], quote(`+`)) && length(x) == 3L) {
.flat(x[[2]])
.flat(x[[3]])
} else {
.s[[length(.s) + 1L]] <<- x
}
}
.flat(.den)
.isDep <- vapply(.s, function(x) .dep(x)[i], logical(1))
.ds <- .s[.isDep]
.ind <- .s[!.isDep]
# K must be positive (`C/(C - 1)` decreases)
length(.ds) == 1L &&
length(.ind) > 0L &&
isTRUE(all(vapply(.ind, .sgn, numeric(1)) > 0)) &&
identical(.mdDeparse(.mdCanon(.ds[[1]])), .mdDeparse(.mdCanon(.n)))
}
# sign of an expression that does not depend on the compartment: parameters and
# `exp()`/`sqrt()` are positive, constant arithmetic is evaluated, and
# anything else is unknown (NA)
.sgn <- function(e) {
if (is.numeric(e) && length(e) == 1L && !is.na(e)) {
return(sign(e))
}
if (is.name(e)) {
return(1)
}
if (!is.call(e)) {
return(NA_real_)
}
if (length(all.vars(e)) == 0L) {
.v <- tryCatch(eval(e, baseenv()), error = function(err) NA_real_)
if (is.numeric(.v) && length(.v) == 1L && !is.na(.v)) {
return(sign(.v))
}
return(NA_real_)
}
.f <- e[[1]]
if (identical(.f, quote(`(`))) {
return(.sgn(e[[2]]))
}
if (identical(.f, quote(`-`)) && length(e) == 2L) {
return(-.sgn(e[[2]]))
}
if (
(identical(.f, quote(`*`)) || identical(.f, quote(`/`))) &&
length(e) == 3L
) {
return(.sgn(e[[2]]) * .sgn(e[[3]]))
}
if (identical(.f, quote(`+`)) && length(e) == 3L) {
.a <- .sgn(e[[2]])
.b <- .sgn(e[[3]])
if (identical(.a, 1) && identical(.b, 1)) {
return(1)
}
if (identical(.a, -1) && identical(.b, -1)) {
return(-1)
}
return(NA_real_)
}
if (
(identical(.f, quote(exp)) || identical(.f, quote(sqrt))) &&
length(e) == 2L
) {
return(1)
}
# a power of a positive base is positive
if (
(identical(.f, quote(`^`)) || identical(.f, quote(`**`))) &&
length(e) == 3L &&
identical(.sgn(e[[2]]), 1)
) {
return(1)
}
NA_real_
}
# `a` scaled by the sign of an independent factor, where `a` is non-zero
.scale <- function(a, e) {
.s <- .sgn(e)
ifelse(!is.na(a) & a == 0, 0, a * .s)
}
.d <- function(e) {
if (is.name(e)) {
.n <- as.character(e)
.r <- ifelse(os == .n, 1, 0)
# a variable that was not substituted (conditional/reassigned)
.r[.r == 0 & .dep(e)] <- NA_real_
return(.r)
}
if (!is.call(e)) {
return(.zero)
}
.f <- e[[1]]
.fn <- if (is.name(.f)) as.character(.f) else ""
if (.fn == "(") {
return(.d(e[[2]]))
}
if (.fn == "+") {
return(.comb(vapply(as.list(e)[-1], .d, numeric(.k))))
}
if (.fn == "-") {
if (length(e) == 2L) {
return(-.d(e[[2]]))
}
return(.comb(cbind(.d(e[[2]]), -.d(e[[3]]))))
}
if (.fn == "*") {
.a <- .d(e[[2]])
.b <- .d(e[[3]])
# a factor that does not depend on the compartment may still carry a
# sign (R parses `-k*C` as `(-k)*C`)
.a0 <- !is.na(.a) & .a == 0
.b0 <- !is.na(.b) & .b == 0
.r <- .comb(cbind(.a, .b))
.r[.a0 & !.b0] <- .scale(.b, e[[2]])[.a0 & !.b0]
.r[.b0 & !.a0] <- .scale(.a, e[[3]])[.b0 & !.a0]
.r[.a0 & .b0] <- 0
return(.r)
}
if (.fn == "/") {
.n <- .d(e[[2]])
.m <- .d(e[[3]])
.n0 <- !is.na(.n) & .n == 0
.m0 <- !is.na(.m) & .m == 0
.r <- .comb(cbind(.n, -.m))
.r[.m0 & !.n0] <- .scale(.n, e[[3]])[.m0 & !.n0]
.r[.n0 & !.m0] <- .scale(-.m, e[[2]])[.n0 & !.m0]
.r[.n0 & .m0] <- 0
# saturating forms `N/(K + N)` (Emax/Hill: `C^g/(ec50^g + C^g)`)
# increase with C; other quotients may not be monotone
for (.i in which(!is.na(.n) & .n == 1 & !is.na(.m) & .m == 1)) {
if (.saturating(e, .i)) .r[.i] <- 1
}
return(.r)
}
if (.fn %in% c("exp", "log", "sqrt", "expit", "log1p", "log10", "log2") && length(e) == 2L) {
return(.d(e[[2]]))
}
# a delayed value moves with the value itself (the delay time is a
# parameter)
if (.fn == "delay" && length(e) >= 2L) {
.r <- .d(e[[2]])
for (.a in as.list(e)[-(1:2)]) {
.r[.dep(.a)] <- NA_real_
}
return(.r)
}
if (.fn %in% c("^", "**") && length(e) == 3L) {
.b <- .d(e[[2]])
# `(A - 1)^2` is not monotone in A: the base must be non-negative
if (!.mdNonNeg(e[[2]])) {
.b[is.na(.b) | .b != 0] <- NA_real_
}
.p <- .mdStripParen(e[[3]])
.r <- if (is.numeric(.p)) {
sign(.p) * .b
} else if (is.call(.p) && identical(.p[[1]], quote(`-`)) && length(.p) == 2L) {
# `C^(-gamma)`: a negated exponent decreases
-.sgn(.p[[2]]) * .b
} else {
# a symbolic exponent (e.g. a Hill coefficient) is assumed positive
.b
}
.r[.dep(e[[3]])] <- NA_real_
return(.r)
}
ifelse(.dep(e), NA_real_, 0)
}
.d(expr)
}
#' A compartment and the variables that depend on it
#' @noRd
.mdDependents <- function(o, states, closure) {
.v <- names(closure)[vapply(closure, function(s) o %in% s, logical(1))]
c(o, setdiff(.v, states))
}
#' Is an expression non-negative wherever it is defined?
#'
#' Parameters and compartment amounts are assumed non-negative; a difference
#' (or a negated value) may be of either sign.
#' @noRd
.mdNonNeg <- function(e) {
e <- .mdStripParen(e)
if (is.numeric(e)) {
return(all(!is.na(e) & e >= 0))
}
if (is.name(e)) {
return(TRUE)
}
if (!is.call(e)) {
return(FALSE)
}
.f <- e[[1]]
if (identical(.f, quote(`+`)) || identical(.f, quote(`*`)) || identical(.f, quote(`/`))) {
return(all(vapply(as.list(e)[-1], .mdNonNeg, logical(1))))
}
if (
(identical(.f, quote(exp)) || identical(.f, quote(sqrt))) &&
length(e) == 2L
) {
return(TRUE)
}
if (
(identical(.f, quote(`^`)) || identical(.f, quote(`**`))) &&
length(e) == 3L
) {
return(.mdNonNeg(e[[2]]))
}
FALSE
}
#' Interaction sign from a direction (unknown directions are 0)
#' @noRd
.mdSign <- function(d) {
d <- unname(d)
if (length(d) == 0L || is.na(d)) 0 else d
}
#' Numerator and denominator factors of a product/quotient term
#' @return list(num = list of expressions, den = list of expressions)
#' @noRd
.mdTermFactors <- function(x) {
.env <- new.env(parent = emptyenv())
.env$num <- list()
.env$den <- list()
.flat <- function(e, num) {
if (is.call(e) && identical(e[[1]], quote(`(`))) {
return(.flat(e[[2]], num))
}
if (is.call(e) && length(e) == 3L && identical(e[[1]], quote(`*`))) {
.flat(e[[2]], num)
.flat(e[[3]], num)
} else if (is.call(e) && length(e) == 3L && identical(e[[1]], quote(`/`))) {
.flat(e[[2]], num)
.flat(e[[3]], !num)
} else if (num) {
.env$num[[length(.env$num) + 1L]] <- e
} else {
.env$den[[length(.env$den) + 1L]] <- e
}
}
.flat(x, TRUE)
list(num = .env$num, den = .env$den)
}
#' Canonical key of a product/quotient term (factor order ignored)
#' @noRd
.mdTermKey <- function(x) {
.f <- .mdTermFactors(x)
.num <- vapply(.f$num, function(e) .mdDeparse(.mdCanon(e)), character(1))
.den <- vapply(.f$den, function(e) .mdDeparse(.mdCanon(e)), character(1))
paste0(paste(sort(.num), collapse = "*"), "/", paste(sort(.den), collapse = "*"))
}
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
#' Classify ODE terms into graph edges
#' @param terms data frame of terms from `.mdModelInfo()`
#' @param states compartment names
#' @return edge data frame
#' @noRd
.mdClassifyTerms <- function(terms, states) {
.empty <- data.frame(
from = character(0),
to = character(0),
type = character(0),
sign = numeric(0),
label = character(0),
stringsAsFactors = FALSE
)
if (is.null(terms) || nrow(terms) == 0L) {
.empty$bidirectional <- logical(0)
return(.empty)
}
.n <- nrow(terms)
.used <- rep(FALSE, .n)
.matchedFrom <- vector("list", .n)
.rows <- list()
.add <- function(from, to, type, sign, label) {
.rows[[length(.rows) + 1L]] <<-
data.frame(from = from, to = to, type = type, sign = sign, label = label, stringsAsFactors = FALSE)
}
# mass transfer: -term in the source (containing the source amount) and
# +term in another compartment. One +term may receive mass from several
# sources (e.g. binding `kon*C*R` into a complex from both C and R) ...
for (.i in seq_len(.n)) {
if (.used[.i] || terms$sign[.i] > 0) {
next
}
.src <- terms$state[.i]
# a term subtracted from one compartment and added to another is
# conserved flow, whatever drives it (first-order `k*A`, zero-order
# `rate`, or another compartment like an enzyme `Vmax*E`)
.j <- which(
terms$sign > 0 &
terms$state != .src &
terms$key == terms$key[.i] &
!vapply(.matchedFrom, function(m) .src %in% m, logical(1))
)
if (length(.j) == 0L) {
next
}
# ... and one -term may go to several destinations (e.g. dissociation
# `koff*RC` back to both C and R); keep one destination per compartment
.j <- .j[!duplicated(terms$state[.j])]
if (!(.src %in% terms$states[[.i]])) {
# a rate that does not depend on the source (zero-order or driven by
# another compartment) is one flow: pair it with one unused +term
.j <- .j[!.used[.j]][1]
if (is.na(.j)) next
}
.used[c(.i, .j)] <- TRUE
for (.k in .j) {
.matchedFrom[[.k]] <- c(.matchedFrom[[.k]], .src)
.add(.src, terms$state[.k], "transfer", 1, terms$label[.k])
}
}
# compartments that drive a transfer without being its source (e.g. an
# enzyme `E` in `Vmax*E*A`) stimulate/inhibit the destination
for (.j in which(!vapply(.matchedFrom, is.null, logical(1)))) {
.drivers <- setdiff(terms$states[[.j]], c(.matchedFrom[[.j]], terms$state[.j]))
for (.o in .drivers) {
.add(.o, terms$state[.j], "interaction", .mdSign(terms$dir[[.j]][.o]), terms$label[.j])
}
}
for (.i in which(!.used)) {
.s <- terms$state[.i]
.st <- terms$states[[.i]]
.others <- setdiff(.st, .s)
# direction of the effect of each other compartment on d/dt(.s)
.dir <- function(o) {
terms$sign[.i] * .mdSign(terms$dir[[.i]][o])
}
if (terms$sign[.i] < 0) {
if (.s %in% .st || length(.others) == 0L) {
.add(.s, NA_character_, "elimination", -1, terms$label[.i])
}
} else if (length(.others) == 0L) {
.add(NA_character_, .s, "input", 1, terms$label[.i])
}
for (.o in .others) {
.add(.o, .s, "interaction", .dir(.o), terms$label[.i])
}
}
.e <- do.call(rbind, .rows)
if (is.null(.e)) {
.e <- .empty
}
# combine duplicated flows
if (nrow(.e) > 0L) {
.id <- paste(.e$from, .e$to, .e$type, .e$sign, sep = "\r")
.e <- do.call(
rbind,
lapply(unique(.id), function(.k) {
.w <- .e[.id == .k, , drop = FALSE]
# keep every contribution (`k*A + k*A` is twice `k*A`)
.w$label[1] <- paste(.w$label, collapse = " + ")
.w[1, , drop = FALSE]
})
)
}
.tr <- .e$type == "transfer"
.pairs <- paste(.e$from, .e$to, sep = "\r")
.rev <- paste(.e$to, .e$from, sep = "\r")
.e$bidirectional <- .tr & .rev %in% .pairs[.tr]
rownames(.e) <- NULL
.e
}
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
#' Choose the central compartment
#' @noRd
.mdCentral <- function(states, edges) {
.cn <- states[tolower(states) %in% c("central", "center", "centr", "cent")]
if (length(.cn) > 0L) {
return(.cn[1])
}
.tr <- edges[edges$type == "transfer", , drop = FALSE]
.deg <- vapply(states, function(.s) sum(.tr$from == .s | .tr$to == .s), numeric(1))
.elim <- states %in% edges$from[edges$type == "elimination"]
.score <- .deg + 0.5 * .elim
if (all(.deg == 0)) {
return(states[1])
}
states[which.max(.score)]
}
#' Lay out the compartments on a grid
#'
#' @return node data frame with name, role, dosing, x, y
#' @noRd
.mdLayout <- function(states, edges, dosing) {
.central <- .mdCentral(states, edges)
.tr <- edges[edges$type == "transfer", , drop = FALSE]
.bi <- .tr[.tr$bidirectional, , drop = FALSE]
.uni <- .tr[!.tr$bidirectional, , drop = FALSE]
.int <- edges[edges$type == "interaction", , drop = FALSE]
# every arrow drawn between two compartments
.links <- edges[!is.na(edges$from) & !is.na(edges$to), , drop = FALSE]
.x <- stats::setNames(rep(NA_real_, length(states)), states)
.y <- .x
.role <- stats::setNames(rep("other", length(states)), states)
.free <- function(x, y) {
!any(!is.na(.x) & abs(.x - x) < 0.9 & abs(.y - y) < 0.9)
}
# is (x, y) free for `s`, with the arrows between `s` and the placed
# compartments clear of other compartments, and no placed arrow running
# through the new box?
# arrow partners of each compartment and edge end point indices, computed
# once (large QSP/PBPK models have thousands of edges)
.fromI <- match(.links$from, states)
.toI <- match(.links$to, states)
.adj <- lapply(seq_along(states), function(.i) {
unique(c(.fromI[.toI == .i], .toI[.fromI == .i]))
})
names(.adj) <- states
.ok <- function(s, x, y) {
if (!.free(x, y)) {
return(FALSE)
}
.isPlaced <- !is.na(.x)
# arrows between `s` and placed partners must be clear of the other
# placed compartments
.p <- .adj[[s]]
.p <- .p[.isPlaced[.p]]
if (length(.p) > 0L) {
.others <- which(.isPlaced)
.g <- expand.grid(p = .p, o = .others)
.g <- .g[.g$p != .g$o, , drop = FALSE]
if (
nrow(.g) > 0L &&
any(.mdSegRect(.x[.g$p], .y[.g$p], x, y, .x[.g$o], .y[.g$o]))
) {
return(FALSE)
}
}
# no placed arrow may run through the new box
.pe <- .isPlaced[.fromI] & .isPlaced[.toI]
if (
any(.pe) &&
any(.mdSegRect(.x[.fromI[.pe]], .y[.fromI[.pe]], .x[.toI[.pe]], .y[.toI[.pe]], x, y))
) {
return(FALSE)
}
TRUE
}
.place <- function(s, x, y, step) {
# move away (in the `step` direction) until the position is clear,
# trying neighboring columns in each row
# large models search fewer rows before falling back
# a wide search gives cleaner diagrams; very large models use a
# bounded one so the layout stays fast
.wide <- length(states) <= .mdMaxWideSearch
.kmax <- if (.wide) 2L * length(states) + 4L else 12L
for (.k in 0:.kmax) {
for (.dx in if (.wide) c(0, 1, -1, 2, -2, 3, -3, 4, -4) else c(0, 1, -1, 2, -2)) {
if (.ok(s, x + .dx, y + .k * step)) {
.x[s] <<- x + .dx
.y[s] <<- y + .k * step
return(invisible())
}
}
}
# give up on arrow clearance rather than fail
while (!.free(x, y)) {
y <- y + step
}
.x[s] <<- x
.y[s] <<- y
}
# breadth-first placement of the (mass transfer) neighbors of placed nodes
# `side` is where exchange (bidirectional) partners go: left of the PK
# model, right of PD models so they stay off the arrows coming in from the
# left
# place a group of neighbors of `s` in direction `dir` (radians: up is
# pi/2, down -pi/2, left pi, right 0). A few neighbors are placed in the
# usual line; many (a PBPK hub with its tissues) are fanned out on an arc
# whose radius grows with their number, so the arrows radiate out instead
# of grazing the boxes in between
# crossings involving the compartments in `new` (the rest of the diagram
# is the same whichever arrangement is chosen, so they need not be
# counted)
.nCrossings <- function(new) {
.p <- !is.na(.x)
.e <- which(.p[.fromI] & .p[.toI])
.o <- which(.p)
if (length(.e) == 0L || length(.o) == 0L) {
return(0)
}
.f <- .fromI[.e]
.t <- .toI[.e]
.g <- expand.grid(e = seq_along(.e), o = .o)
.keep <- .f[.g$e] != .g$o & .t[.g$e] != .g$o & (.f[.g$e] %in% new | .t[.g$e] %in% new | .g$o %in% new)
.g <- .g[.keep, , drop = FALSE]
if (nrow(.g) == 0L) {
return(0)
}
sum(.mdSegRect(.x[.f[.g$e]], .y[.f[.g$e]], .x[.t[.g$e]], .y[.t[.g$e]], .x[.g$o], .y[.g$o]))
}
# place a group of neighbors of `s` in direction `dir` (radians: up is
# pi/2, down -pi/2, left pi, right 0), either in a line or -- for a hub
# with many neighbors, like a PBPK central compartment -- fanned out on an
# arc whose radius grows with their number. Both are tried and the one
# with fewer crossings is kept.
.placeGroup <- function(nodes, s, dir, step) {
.m <- length(nodes)
if (.m == 0L) {
return(invisible())
}
.line <- function() {
.off <- (seq_len(.m) - 1) - if (abs(cos(dir)) > 0.5) 0 else (.m - 1) / 2
for (.k in seq_len(.m)) {
.dx <- if (abs(cos(dir)) > 0.5) round(cos(dir)) else .off[.k]
.dy <- if (abs(cos(dir)) > 0.5) .off[.k] else round(sin(dir))
.place(nodes[.k], .x[s] + .dx, .y[s] + .dy, step)
}
}
.fan <- function() {
.sector <- pi * 2 / 3
.d <- .sector / (.m - 1)
.r <- max(1.5, 1.3 / .d)
.a <- dir - .sector / 2 + (seq_len(.m) - 1) * .d
for (.k in seq_len(.m)) {
.place(nodes[.k], round((.x[s] + .r * cos(.a[.k])) * 2) / 2, round((.y[s] + .r * sin(.a[.k])) * 2) / 2, step)
}
}
if (.m <= 2L) {
.line()
return(invisible())
}
.new <- match(nodes, states)
.x0 <- .x
.y0 <- .y
.line()
.nLine <- .nCrossings(.new)
.xLine <- .x
.yLine <- .y
.x <<- .x0
.y <<- .y0
.fan()
if (.nCrossings(.new) > .nLine) {
.x <<- .xLine
.y <<- .yLine
}
invisible()
}
.spread <- function(start, side = -1) {
.queue <- start
while (length(.queue) > 0L) {
.s <- .queue[1]
.queue <- .queue[-1]
.isCentral <- .s == .central
# upstream (unidirectional into .s): above
.up <- setdiff(unique(.uni$from[.uni$to == .s]), names(.x)[!is.na(.x)])
.placeGroup(.up, .s, pi / 2, 1)
for (.n in .up) {
if (.role[.n] == "other") .role[.n] <<- "transit"
}
# bidirectional exchange: to the left (PD models: right), fanned
.lr <- unique(c(.bi$to[.bi$from == .s], .bi$from[.bi$to == .s]))
.lr <- setdiff(.lr, names(.x)[!is.na(.x)])
.placeGroup(.lr, .s, if (side < 0) pi else 0, -1)
for (.n in .lr) {
if (.role[.n] == "other") .role[.n] <<- "peripheral"
}
# downstream (unidirectional out of .s): below
.dn <- setdiff(unique(.uni$to[.uni$from == .s]), names(.x)[!is.na(.x)])
.placeGroup(.dn, .s, -pi / 2, -1)
for (.n in .dn) {
if (.role[.n] == "other") {
.role[.n] <<- if (.isCentral) "metabolite" else "other"
}
}
.queue <- c(.queue, .up, .lr, .dn)
}
}
.role[.central] <- "central"
.place(.central, 0, 0, -1)
.spread(.central)
# choose a row in column `nx` for `s` where the straight interaction
# arrows between `s` and the already placed compartments (either
# direction) are clear of the other compartments
.pickRow <- function(s, nx, y0) {
.m <- if (length(states) <= .mdMaxWideSearch) length(states) else 12L
for (.d in c(0, rbind(-seq_len(.m), seq_len(.m)))) {
.cy <- y0 + .d
if (.ok(s, nx, .cy)) return(.cy)
}
y0
}
# compartments interacting without mass transfer: to the right
repeat {
.placed <- names(.x)[!is.na(.x)]
.cand <- .int[.int$from %in% .placed & !(.int$to %in% .placed), , drop = FALSE]
if (nrow(.cand) > 0L) {
.s <- .cand$to[1]
.nx <- max(.x, na.rm = TRUE) + 1
.place(.s, .nx, .pickRow(.s, .nx, .y[.cand$from[1]]), -1)
.role[.s] <- "effect"
.spread(.s, side = 1)
next
}
.left <- states[is.na(.x)]
if (length(.left) == 0L) {
break
}
# compartments acting on the placed ones first, then dosing compartments
.acting <- intersect(.left, .int$from[.int$to %in% .placed])
.s <- c(.acting, intersect(dosing, .left), .left)[1]
.nx <- max(.x, na.rm = TRUE) + 1
.place(.s, .nx, .pickRow(.s, .nx, 0), -1)
.spread(.s, side = if (length(.acting) > 0L) 1 else -1)
}
.role[states %in% dosing & .role != "central"] <- "dosing"
data.frame(
name = states,
role = unname(.role[states]),
dosing = states %in% dosing,
x = unname(.x[states]),
y = unname(.y[states]),
stringsAsFactors = FALSE
)
}
#' Does the segment (x0, y0)-(x1, y1) pass through any of the node boxes?
#'
#' Boxes are centered on (x, y) (grid units) with half width `hw` and half
#' height `hh`; the segment is sampled finely enough for unit-grid layouts.
#' @noRd
.mdSegmentCrosses <- function(x0, y0, x1, y1, x, y, hw = 0.35, hh = 0.25) {
if (length(x) == 0L) {
return(FALSE)
}
any(.mdSegRect(x0, y0, x1, y1, x, y, hw, hh))
}
#' Exact segment / open rectangle intersection (Liang-Barsky), vectorized
#'
#' @param x0,y0,x1,y1 segment end points (recycled)
#' @param cx,cy rectangle centers (recycled)
#' @param hw,hh rectangle half width and half height
#' @return logical vector: does each segment pass through the interior of
#' its rectangle?
#' @noRd
.mdSegRect <- function(x0, y0, x1, y1, cx, cy, hw = 0.35, hh = 0.25) {
.n <- max(length(x0), length(cx))
x0 <- rep_len(x0, .n)
y0 <- rep_len(y0, .n)
x1 <- rep_len(x1, .n)
y1 <- rep_len(y1, .n)
cx <- rep_len(cx, .n)
cy <- rep_len(cy, .n)
.lo <- rep(0, .n)
.hi <- rep(1, .n)
.ok <- rep(TRUE, .n)
.slab <- function(p0, d, c, h) {
.z <- abs(d) < 1e-12
# parallel to the slab: inside only when strictly within it
.ok <<- .ok & (!.z | abs(p0 - c) < h)
.t1 <- ifelse(.z, -Inf, (c - h - p0) / d)
.t2 <- ifelse(.z, Inf, (c + h - p0) / d)
.lo <<- pmax(.lo, pmin(.t1, .t2))
.hi <<- pmin(.hi, pmax(.t1, .t2))
}
.slab(x0, x1 - x0, cx, hw)
.slab(y0, y1 - y0, cy, hh)
.ok & .lo < .hi
}
#' Positions of the invisible input/output end points
#'
#' @return edge data frame with added columns x0, y0, x1, y1 (in grid units)
#' @noRd
.mdEdgeCoords <- function(graph) {
.n <- graph$nodes
.e <- graph$edges
.px <- stats::setNames(.n$x, .n$name)
.py <- stats::setNames(.n$y, .n$name)
.occupied <- function(x, y) any(abs(.n$x - x) < 0.5 & abs(.n$y - y) < 0.6)
.e$x0 <- .px[.e$from]
.e$y0 <- .py[.e$from]
.e$x1 <- .px[.e$to]
.e$y1 <- .py[.e$to]
for (.i in seq_len(nrow(.e))) {
if (.e$type[.i] == "elimination") {
.x <- .e$x0[.i]
.y <- .e$y0[.i]
.e$x1[.i] <- .x
.e$y1[.i] <- .y - 0.7
if (.occupied(.x, .y - 1)) {
.e$x1[.i] <- .x + 0.45
.e$y1[.i] <- .y - 0.6
}
} else if (.e$type[.i] == "input") {
.x <- .e$x1[.i]
.y <- .e$y1[.i]
.e$x0[.i] <- .x
.e$y0[.i] <- .y + 0.7
if (.occupied(.x, .y + 1)) {
.e$x0[.i] <- .x + 0.45
.e$y0[.i] <- .y + 0.6
}
}
}
.e
}
.mdRoleColors <- c(
dosing = "#F2C57C",
central = "#7FB3D5",
peripheral = "#A9CCE3",
transit = "#FAD7A0",
metabolite = "#D2B4DE",
effect = "#A9DFBF",
other = "#E5E7E9"
)
# ---------------------------------------------------------------------------
# Engines
# ---------------------------------------------------------------------------
#' Graphviz DOT source for a model graph
#' @noRd
.mdDot <- function(graph, labels = FALSE) {
# column spacing (inches) wide enough for the longest compartment name
# (14pt Helvetica is about 0.11 inch per character plus margins)
.xs <- max(1.6, 0.11 * max(nchar(graph$nodes$name), 0L) + 0.9)
.ys <- 1.1
# dosing properties shown as an external label next to the compartment
.ann <- graph$nodes$annotation
if (is.null(.ann)) {
.ann <- rep("", nrow(graph$nodes))
}
.ann[is.na(.ann)] <- ""
# quote a DOT string; "\r" (from combined labels) becomes a DOT line break
.q <- function(x) {
paste0("\"", gsub("\r", "\\n", gsub("\"", "\\\\\"", x), fixed = TRUE), "\"")
}
.n <- graph$nodes
.e <- .mdEdgeCoords(graph)
# routing curved edges around the boxes is slow for large graphs, which
# do not gain much from it (the positions are pinned)
.splines <- if (nrow(graph$edges) > .mdMaxSplineEdges) "line" else "true"
.lines <- c(
"digraph model {",
sprintf(
paste0(" graph [layout = neato, splines = %s, ", "outputorder = edgesfirst, forcelabels = true];"),
.splines
),
" node [shape = box, style = \"rounded,filled\", fontname = Helvetica];",
" edge [fontname = Helvetica, fontsize = 10];"
)
for (.i in seq_len(nrow(.n))) {
.lines <- c(
.lines,
sprintf(
" %s [pos = \"%g,%g!\", fillcolor = %s%s%s];",
.q(.n$name[.i]),
.n$x[.i] * .xs,
.n$y[.i] * .ys,
.q(.mdRoleColors[[.n$role[.i]]]),
if (.n$dosing[.i]) ", penwidth = 2" else "",
if (nzchar(.ann[.i])) paste0(", xlabel = ", .q(gsub("\n", "\r", .ann[.i], fixed = TRUE))) else ""
)
)
}
.done <- rep(FALSE, nrow(.e))
for (.i in seq_len(nrow(.e))) {
if (.done[.i]) {
next
}
.t <- .e$type[.i]
.from <- .e$from[.i]
.to <- .e$to[.i]
.attr <- character(0)
.lab <- .e$label[.i]
if (.t %in% c("elimination", "input")) {
.pt <- paste0(".", .t, .i)
.lines <- c(
.lines,
sprintf(
" %s [shape = point, style = invis, width = 0.01, pos = \"%g,%g!\"];",
.q(.pt),
(if (.t == "elimination") .e$x1[.i] else .e$x0[.i]) * .xs,
(if (.t == "elimination") .e$y1[.i] else .e$y0[.i]) * .ys
)
)
if (.t == "elimination") .to <- .pt else .from <- .pt
} else if (.t == "transfer" && .e$bidirectional[.i]) {
.j <- which(.e$type == "transfer" & .e$from == .to & .e$to == .from)
.done[.j] <- TRUE
.attr <- c(.attr, "dir = both")
.lab <- paste(c(.lab, .e$label[.j]), collapse = "\r")
} else if (.t == "interaction") {
.attr <- c(
.attr,
"style = dashed",
"color = gray40",
if (.e$sign[.i] < 0) "arrowhead = tee",
if (.e$sign[.i] == 0) "arrowhead = dot"
)
}
if (labels) {
.attr <- c(.attr, paste0("label = ", .q(.lab)))
}
.lines <- c(
.lines,
sprintf(
" %s -> %s%s;",
.q(.from),
.q(.to),
if (length(.attr) > 0L) paste0(" [", paste(.attr, collapse = ", "), "]") else ""
)
)
.done[.i] <- TRUE
}
paste(c(.lines, "}"), collapse = "\n")
}
#' @noRd
.mdDiagrammeR <- function(graph, labels = FALSE) {
if (!requireNamespace("DiagrammeR", quietly = TRUE)) {
stop(
"the 'DiagrammeR' engine requires the 'DiagrammeR' package; install it or use engine = \"ggplot2\"",
call. = FALSE
)
}
DiagrammeR::grViz(.mdDot(graph, labels))
}
#' Move a segment's end points to the boundaries of the node boxes
#' @noRd
.mdClip <- function(x0, y0, x1, y1, hw, hh, clip0, clip1) {
.dx <- x1 - x0
.dy <- y1 - y0
.t <- function(dx, dy) {
.tx <- if (abs(dx) > 1e-8) hw / abs(dx) else Inf
.ty <- if (abs(dy) > 1e-8) hh / abs(dy) else Inf
min(.tx, .ty, 0.45)
}
.t0 <- if (clip0) .t(.dx, .dy) else 0
.t1 <- if (clip1) .t(.dx, .dy) else 0
c(x0 + .t0 * .dx, y0 + .t0 * .dy, x1 - .t1 * .dx, y1 - .t1 * .dy)
}
#' @noRd
.mdGgplot <- function(graph, labels = FALSE) {
.xs <- 1.6
.hw <- 0.5
.hh <- 0.2
.n <- graph$nodes
.n$x <- .n$x * .xs
.e <- .mdEdgeCoords(graph)
.e$x0 <- .e$x0 * .xs
.e$x1 <- .e$x1 * .xs
.px <- stats::setNames(.n$x, .n$name)
.py <- stats::setNames(.n$y, .n$name)
# arrows between the same two compartments (in either direction)
.pair <- ifelse(
is.na(.e$from) | is.na(.e$to),
paste0(".", seq_len(nrow(.e))),
paste(pmin(.e$from, .e$to), pmax(.e$from, .e$to), sep = "\r")
)
.pairN <- as.integer(stats::ave(seq_along(.pair), .pair, FUN = length))
.pairK <- as.integer(stats::ave(seq_along(.pair), .pair, FUN = seq_along))
.seg <- do.call(
rbind,
lapply(seq_len(nrow(.e)), function(.i) {
.x0 <- .e$x0[.i]
.y0 <- .e$y0[.i]
.x1 <- .e$x1[.i]
.y1 <- .e$y1[.i]
if (.e$type[.i] %in% c("transfer", "interaction") && .e$from[.i] == .e$to[.i]) {
return(NULL)
}
if (.pairN[.i] > 1L) {
# spread several arrows between the same two compartments (both
# directions of an exchange, or a stimulation and an inhibition) to
# either side of the center line
.a <- c(pmin(.e$from[.i], .e$to[.i]), pmax(.e$from[.i], .e$to[.i]))
.dx <- .px[.a[2]] - .px[.a[1]]
.dy <- .py[.a[2]] - .py[.a[1]]
.len <- sqrt(.dx^2 + .dy^2)
.off <- (.pairK[.i] - (.pairN[.i] + 1) / 2) * 0.12
.ox <- -.dy / .len * .off
.oy <- .dx / .len * .off
.x0 <- .x0 + .ox
.x1 <- .x1 + .ox
.y0 <- .y0 + .oy
.y1 <- .y1 + .oy
}
.c <- .mdClip(.x0, .y0, .x1, .y1, .hw, .hh, clip0 = .e$type[.i] != "input", clip1 = .e$type[.i] != "elimination")
data.frame(
x = .c[1],
y = .c[2],
xend = .c[3],
yend = .c[4],
flow = ifelse(
.e$type[.i] == "interaction",
c("inhibition", "modulation", "stimulation")[sign(.e$sign[.i]) + 2],
"mass transfer"
),
label = .e$label[.i],
stringsAsFactors = FALSE
)
})
)
.n$role <- factor(.n$role, levels = names(.mdRoleColors))
if (is.null(.n$annotation)) {
.n$annotation <- ""
}
.n$annotation[is.na(.n$annotation)] <- ""
.ann <- .n[nzchar(.n$annotation), , drop = FALSE]
.ann$x <- .ann$x + .hw * 0.9
.ann$y <- .ann$y + .hh * 1.1
.annLines <- strsplit(.ann$annotation, "\n", fixed = TRUE)
.annExtent <- data.frame(
x = .ann$x + 0.075 * vapply(.annLines, function(l) max(nchar(l)), numeric(1)),
y = .ann$y + 0.13 * lengths(.annLines)
)
.p <- ggplot2::ggplot() +
ggplot2::geom_tile(
data = .n,
ggplot2::aes(x = .data$x, y = .data$y, fill = .data$role),
width = 2 * .hw,
height = 2 * .hh,
color = "gray30",
linewidth = ifelse(.n$dosing, 1, 0.4)
) +
ggplot2::geom_text(data = .n, ggplot2::aes(x = .data$x, y = .data$y, label = .data$name)) +
# dosing properties (lag, F, rate, dur) as an annotation at the upper
# right corner of the compartment
ggplot2::geom_text(
data = .ann,
ggplot2::aes(x = .data$x, y = .data$y, label = .data$annotation),
hjust = 0,
vjust = 0,
size = 2.6,
fontface = "italic",
lineheight = 0.9
) +
# keep the annotations inside the plot (away from the legend)
ggplot2::geom_blank(data = .annExtent, ggplot2::aes(x = .data$x, y = .data$y)) +
ggplot2::scale_fill_manual(values = .mdRoleColors, drop = TRUE, name = "compartment") +
ggplot2::coord_equal(clip = "off") +
ggplot2::theme_void() +
ggplot2::theme(plot.margin = ggplot2::margin(10, 10, 10, 10))
if (!is.null(.seg) && nrow(.seg) > 0L) {
.p <- .p +
ggplot2::geom_segment(
data = .seg,
ggplot2::aes(x = .data$x, y = .data$y, xend = .data$xend, yend = .data$yend, linetype = .data$flow),
arrow = ggplot2::arrow(length = ggplot2::unit(0.08, "inches"), type = "closed")
) +
ggplot2::scale_linetype_manual(
values = c("mass transfer" = "solid", stimulation = "dashed", inhibition = "dotted", modulation = "dotdash"),
name = "flow"
)
if (labels) {
.p <- .p +
ggplot2::geom_label(
data = .seg,
ggplot2::aes(x = (.data$x + .data$xend) / 2, y = (.data$y + .data$yend) / 2, label = .data$label),
size = 2.5
)
}
}
.p
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.