diff --git a/NEWS.md b/NEWS.md index 6dfe0d104..45168fdbf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,28 @@ # rxode2 5.1.7 +## Bug fixes + +### Delay differential equations + +- `rxOptExpr()` no longer fails on a `past(state, tau)` whose delay duration is + an expression rather than a name or a number (`past(G, exp(lT))`, + `past(G, tau*2)`), which raised `unsupported lhs in optimize expression` and + printed the duration into the middle of the progress bar. This made + `optExpression=TRUE` unusable for such a delay differential equation; it now + optimizes, and the duration follows the same common subexpression its + `delay()` terms do, so the history stays matched to them. + +- A generated delay differential equation model (`rxode2(..., calcJac=TRUE)`, + `calcSens=`, or an nlmixr2 estimation model) now resolves the `past()` delay + duration the same way it resolves the history itself. A duration written as + an intermediate (`T <- exp(lT)`) was emitted verbatim while every `delay()` + had its duration inlined, so the generated model named a duration no `delay()` + used any more and `rxSolve()` rejected it with `duration 'T' does not match + any delay(...)`. This also covers a duration or a history written with + `THETA[n]`/`ETA[n]`, as every mu-referenced model is: they were left + unresolved, and an unresolved history additionally emitted no per-parameter + sensitivity pre-history at all. + # rxode2 5.1.6 ## New features diff --git a/R/dde.R b/R/dde.R index d000f6797..28d1559ec 100644 --- a/R/dde.R +++ b/R/dde.R @@ -67,6 +67,95 @@ .df } +#' Evaluate stored rxode2 text in a symengine env +#' +#' The `past()` pieces are stored as plain rxode2 source text, which is not +#' symengine syntax: `THETA[1]`/`ETA[1]` are subscripts there and symbols +#' (`THETA_1_`) in the env, so evaluating the text directly fails on every +#' mu-referenced model. Translate it the way the model body itself was +#' translated, then evaluate inside `with()` (the safe idiom -- symengine masks +#' `get`/`[[`). +#' +#' @param model symengine environment. +#' @param txt rxode2 source text. +#' @return the `Basic` it evaluates to, or `NULL` when it does not resolve. +#' @noRd +.rxSeEvalTxt <- function(model, txt) { + .e <- tryCatch(parse(text = txt)[[1L]], error = function(e) NULL) + if (is.null(.e)) return(NULL) + .se <- tryCatch(.rxToSE(.e, envir = model), error = function(e) NULL) + if (is.null(.se)) return(NULL) + .b <- tryCatch(eval(parse(text = paste0("with(model,", .se, ")"))), + error = function(e) NULL) + if (inherits(.b, "Basic")) .b else NULL +} + +#' Resolve a stored past() delay duration through a symengine env +#' +#' The duration is rendered by evaluating a surrogate `delay(state, tau)` in the +#' env and taking its second argument back off, so it comes out as the same +#' expression the duration `.rxDelaySensAugment()` reads off the augmented +#' `d/dt()` is (both are `deparse1()` of the same `rxFromSE()` round trip; the +#' spacing `deparse1()` puts around an operator is normalized away when a history +#' is matched to its `delay()`). Evaluating the duration on +#' its own is the fallback; a constant-folded intermediate is stored as a plain R +#' numeric rather than a `Basic`, which is exactly the case the surrogate gets +#' right and a bare `eval()` does not. An unresolvable duration (env binding +#' missing, eval error) keeps its stored text. +#' +#' @param model symengine environment. +#' @param state state the history belongs to. +#' @param tauTxt stored duration text (`rx__pastTau_STATE__`). +#' @return duration text to emit. +#' @noRd +.rxTauFromEnv <- function(model, state, tauTxt) { + if (is.null(tauTxt)) return(tauTxt) + .b <- .rxSeEvalTxt(model, paste0("delay(", state, ",", tauTxt, ")")) + if (!is.null(.b)) { + .c <- tryCatch(parse(text = rxFromSE(.b))[[1L]], error = function(e) NULL) + if (is.call(.c) && length(.c) == 3L) return(deparse1(.c[[3L]])) + } + .t <- .rxSeEvalTxt(model, tauTxt) + if (!is.null(.t)) return(rxFromSE(.t)) + tauTxt +} + +#' Read a state's stored past() history out of a symengine env, resolved +#' +#' Both the history RHS and the duration are stored as plain rxode2 text +#' (`rx__pastRhs_STATE__` / `rx__pastTau_STATE__`, see `R/symengine.R`). The RHS +#' has always been resolved back through the env; the duration was not, so a +#' duration written as an intermediate (`T`) was emitted verbatim even though that +#' intermediate had been dead-code eliminated from the generated model, while the +#' matching `delay()` had its duration inlined by symengine. Resolving both here +#' is what keeps the emitted `past()` line matched to its `delay()` terms, and +#' keeps the three emitters from drifting apart. Both go through `.rxSeEvalTxt()`, +#' so a `THETA[n]`/`ETA[n]` subscript resolves as well -- without it a +#' mu-referenced history resolved to nothing and its per-parameter sensitivity +#' pre-history lines were silently dropped. +#' +#' @param model symengine environment. +#' @param state state the history belongs to. +#' @return `NULL` when the state has no `past()`, otherwise a list with the +#' resolved duration `tau`, the resolved history text `rhs`, and the history +#' `Basic` `rhsB` (`NULL` when it did not resolve) for differentiation. +#' @noRd +.rxPastFromEnv <- function(model, state) { + .rhsTxt <- base::mget(paste0("rx__pastRhs_", state, "__"), envir = model, + ifnotfound = list(NULL))[[1L]] + if (is.null(.rhsTxt)) return(NULL) + .tauTxt <- base::mget(paste0("rx__pastTau_", state, "__"), envir = model, + ifnotfound = list(NULL))[[1L]] + ## resolve the duration first: rxFromSE() of a Basic holding lag0()/llik*() + ## poisons the next `[[`/get read from the env, and the history RHS is the one + ## that may carry such a call + .tau <- .rxTauFromEnv(model, state, .tauTxt) + .rhsB <- .rxSeEvalTxt(model, .rhsTxt) + list(tau = .tau, + rhs = if (is.null(.rhsB)) .rhsTxt else rxFromSE(.rhsB), + rhsB = .rhsB) +} + #' Base past(state, tau) <- expr history lines from a symengine env #' #' Rebuilds the base `past(state,tau)=expr` line(s) from the stored @@ -83,16 +172,10 @@ .states <- tryCatch(rxode2::rxStateOde(model), error = function(e) character(0)) .lines <- character(0) for (.si in .states) { - .rhsTxt <- base::mget(paste0("rx__pastRhs_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] - if (is.null(.rhsTxt)) next - .tauTxt <- base::mget(paste0("rx__pastTau_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] ## resolve through the env so the injected line references root parameters - .rhsB <- tryCatch(eval(parse(text = .rhsTxt), envir = model), - error = function(e) NULL) - .rhsOut <- if (!is.null(.rhsB) && inherits(.rhsB, "Basic")) rxFromSE(.rhsB) else .rhsTxt - .lines <- c(.lines, sprintf("past(%s,%s)=%s", .si, .tauTxt, .rhsOut)) + .p <- .rxPastFromEnv(model, .si) + if (is.null(.p)) next + .lines <- c(.lines, sprintf("past(%s,%s)=%s", .si, .p$tau, .p$rhs)) } if (length(.lines)) .lines else NULL } @@ -380,21 +463,16 @@ .baseLines <- character(0) # base state history (also needed by gradient-free SAEM) .pastLines <- character(0) # base + per-sensitivity-compartment histories for (.si in .states) { - .rhsTxt <- base::mget(paste0("rx__pastRhs_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] - if (is.null(.rhsTxt)) next - .tauTxt <- base::mget(paste0("rx__pastTau_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] ## resolve through the env so the line references root parameters ## (past()-only intermediates are dead-code eliminated from the model) - .rhsB <- tryCatch(eval(parse(text = .rhsTxt), envir = model), - error = function(e) NULL) - .rhsOut <- if (!is.null(.rhsB) && inherits(.rhsB, "Basic")) rxFromSE(.rhsB) else .rhsTxt - .base <- sprintf("past(%s,%s)=%s", .si, .tauTxt, .rhsOut) + .pe <- .rxPastFromEnv(model, .si) + if (is.null(.pe)) next + .rhsB <- .pe$rhsB + .base <- sprintf("past(%s,%s)=%s", .si, .pe$tau, .pe$rhs) .baseLines <- c(.baseLines, .base) .pastLines <- c(.pastLines, .base) ## sens-compartment pre-history: d(history)/d(param) - if (is.null(.rhsB) || !inherits(.rhsB, "Basic")) next + if (is.null(.rhsB)) next for (.p in params) { .dp <- tryCatch(symengine::D(.rhsB, symengine::S(.p)), error = function(e) NULL) if (is.null(.dp)) next @@ -402,7 +480,7 @@ if (identical(.dpTxt, "0")) next .pastLines <- c(.pastLines, sprintf("past(rx__sens_%s_BY_%s__,%s)=%s", - .si, .p, .tauTxt, .dpTxt)) + .si, .p, .pe$tau, .dpTxt)) } } ## append; unique dedups the base past() line shared by the 1st/2nd-order augments @@ -845,14 +923,10 @@ ## 2nd-order pre-history: past(rx__sens_s_BY_p_BY_q__, tau) = d^2 expr/dp dq .pastLines2 <- character(0) for (.si in .states) { - .rhsTxt <- base::mget(paste0("rx__pastRhs_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] - if (is.null(.rhsTxt)) next - .tauTxt <- base::mget(paste0("rx__pastTau_", .si, "__"), envir = model, - ifnotfound = list(NULL))[[1L]] - .rhsB <- tryCatch(eval(parse(text = .rhsTxt), envir = model), - error = function(e) NULL) - if (is.null(.rhsB) || !inherits(.rhsB, "Basic")) next + .pe <- .rxPastFromEnv(model, .si) + if (is.null(.pe)) next + .rhsB <- .pe$rhsB + if (is.null(.rhsB)) next .cmts <- regmatches(sensVec, regexpr(paste0("rx__sens_", .si, "_BY_[^,)]+_BY_[^,)]+__"), sensVec)) @@ -867,7 +941,7 @@ .d2Txt <- rxFromSE(.d2) if (identical(.d2Txt, "0")) next .pastLines2 <- c(.pastLines2, - sprintf("past(%s,%s)=%s", .cmt, .tauTxt, .d2Txt)) + sprintf("past(%s,%s)=%s", .cmt, .pe$tau, .d2Txt)) } } if (length(.pastLines2)) { diff --git a/R/rxOptExpr.R b/R/rxOptExpr.R index 8ce694f91..66db1ba16 100644 --- a/R/rxOptExpr.R +++ b/R/rxOptExpr.R @@ -167,7 +167,15 @@ } else if (identical(x[[1]], quote(`dur`))) { return(paste0("dur(", ..rxOptLhs(x[[2]]), ")")) } else if (identical(x[[1]], quote(`past`))) { - return(paste0("past(", ..rxOptLhs(x[[2]]), ",", ..rxOptLhs(x[[3]]), ")")) + ## The delay duration is an ordinary expression, not a left-hand side, so it + ## is rendered by the right-hand-side optimizer rather than by this one -- + ## which only knows names and the dosing-property heads and would reject any + ## `past(G, exp(lT))`. Going through .rxOptExpr() also makes the duration + ## pick up the SAME rx_expr_ temporary the matching delay(state, tau) calls + ## do, which .rxValidatePast() requires (it matches the two by text). + .tau <- .rxOptExpr(x[[3]]) + if (!is.character(.tau) || length(.tau) != 1L) .tau <- deparse1(x[[3]]) + paste0("past(", ..rxOptLhs(x[[2]]), ",", .tau, ")") } else if (identical(x[[1]], quote(`dy`))) { return(paste0("dy(", ..rxOptLhs(x[[2]]), ")")) } else if (identical(x[[1]], quote(`df`))) { @@ -175,8 +183,7 @@ } else if (identical(x[[2]], 0)) { return(paste0(as.character(x[[1]]), "(0)")) } else { - print(x) - stop("unsupported lhs in optimize expression") + stop("unsupported lhs in optimize expression: ", deparse1(x), call. = FALSE) } } } @@ -544,6 +551,197 @@ txt } +# Index of the ")" matching the "(" at `open`, or NA when the line does not close it. +.rxMatchParen <- function(line, open) { + .ch <- strsplit(substr(line, open, nchar(line)), "", fixed = TRUE)[[1]] + .rel <- which(cumsum((.ch == "(") - (.ch == ")")) == 0L)[1L] + if (is.na(.rel)) NA_integer_ else open + .rel - 1L +} + +# Split "state, tau" at its top-level comma. NULL when there is no such comma or the +# first argument is not a plain name (`delay(f(x), tau)` is not a state). A state may +# lead with "." (`d/dt(.y.1)` parses), so a name is not required to start with a letter. +# `code` is the code-only form of `inner` (see .rxCodeOnly()); the comma is found in it, +# so a string holding one does not split the arguments, and the text is taken from +# `inner`, which still has the string. The two agree column for column. +.rxSplitStateTau <- function(inner, code = inner) { + .ch <- strsplit(code, "", fixed = TRUE)[[1]] + .depth <- cumsum((.ch == "(") - (.ch == ")")) + .at <- which(.ch == "," & .depth == 0L)[1L] + if (is.na(.at)) return(NULL) + .st <- trimws(substr(inner, 1L, .at - 1L)) + if (!grepl("^[a-zA-Z.][a-zA-Z0-9_.]*$", .st)) return(NULL) + list(state = .st, tau = trimws(substr(inner, .at + 1L, nchar(inner)))) +} + +# Comparable form of a duration: whatever spacing the text carries, two durations that +# parse to the same expression compare equal. Text that does not parse compares as itself. +.rxTauKey <- function(tau) { + .e <- tryCatch(parse(text = tau)[[1L]], error = function(e) NULL) + if (is.null(.e)) trimws(tau) else deparse1(.e) +} + +# The code part of a line: the comment dropped, and the inside of every string literal +# blanked out. Neither is code, so a delay() written in one is not a delay() call, and the +# scan below must neither read one nor be stopped by an unbalanced parenthesis in one. +# Blanking rather than removing keeps every column where it was, so what the scan does read +# it reads from the right place. +.rxCodeOnly <- function(line) { + if (!grepl("[#\"']", line)) return(line) + .ch <- strsplit(line, "", fixed = TRUE)[[1]] + .q <- "" + .i <- 1L + while (.i <= length(.ch)) { + .c <- .ch[.i] + if (nzchar(.q)) { + .ch[.i] <- " " + if (.c == "\\") { # escaped: whatever follows is not a delimiter + .i <- .i + 1L + if (.i <= length(.ch)) .ch[.i] <- " " + } else if (.c == .q) { + .ch[.i] <- .c # keep the quote that closes it + .q <- "" + } + } else if (.c == "\"" || .c == "'") { + .q <- .c + } else if (.c == "#") { + return(paste(.ch[seq_len(.i - 1L)], collapse = "")) + } + .i <- .i + 1L + } + paste(.ch, collapse = "") +} + +# Durations of every delay(state, tau) in `txt`, per state, in order of first appearance. +# Found by matching parentheses rather than by parsing, the way .rxDisguiseDelayChunks() +# already scans for the same calls: `txt` is reassembled optimizer output, which is rxode2 +# syntax and not guaranteed to be valid R, and a scan that quietly stopped working on some +# model shape would silently leave a past() line unmatched. +# +# A call the scan cannot read -- one split over lines, say -- sets the "incomplete" +# attribute: the result is then a subset of the model's delay() calls, so neither the +# order of what it did read nor the absence of a duration from it says anything. +# +# A call is LOCATED on the code-only form of the line and READ off the line itself: a +# duration may legitimately hold a string (`delay(G, 1+(OCC=="first"))`), and the masking +# that keeps the scan out of a string exists only to say what is code, not to alter it. +# The two agree column for column, which is why .rxCodeOnly() blanks rather than removes. +.rxDelayDurs <- function(txt) { + .re <- "(?0) {", "v1=1", "}", "else {", "v1=2", "}", + "rx_expr_0~exp(lT)", "d/dt(G)=-delay(G, rx_expr_0)", + "past(G,exp(lT))=2"), collapse = "\n") + expect_identical(.pastOf(.r(.ie, "d/dt(G)=-delay(G, exp(lT))\npast(G,exp(lT))=2")), + "past(G,rx_expr_0)=2") + # two distinct durations on one state: with the pre-optimization text each past() + # line follows the delay() it came from (order of first appearance is preserved) ... + .two0 <- paste(c("d/dt(G)=-delay(G, exp(lT))-delay(G, 2*lT)", + "past(G,exp(lT))=2", "past(G,2*lT)=3"), collapse = "\n") + .two1 <- paste(c("rx_expr_0~exp(lT)", "rx_expr_1~2*lT", + "d/dt(G)=-delay(G, rx_expr_0)-delay(G, rx_expr_1)", + "past(G,exp(lT))=2", "past(G,2*lT)=3"), collapse = "\n") + expect_identical(.pastOf(.r(.two1, .two0)), + c("past(G,rx_expr_0)=2", "past(G,rx_expr_1)=3")) + # ... and when it does not line up with them, neither line is guessed at + expect_identical(.r(.two1, "d/dt(G)=-delay(G, exp(lT))\npast(G,exp(lT))=2\npast(G,2*lT)=3"), + .two1) + # when it does rewrite, only the duration changes -- spacing is kept + .sp <- " past(G,exp(lT)) = 2\nd/dt(G)=-delay(G,rx_expr_0)\nrx_expr_0~exp(lT)" + expect_identical(strsplit(.r(.sp, " past(G,exp(lT)) = 2\nd/dt(G)=-delay(G,exp(lT))"), + "\n")[[1]][1L], + " past(G,rx_expr_0) = 2") + # a duration that matched no delay() BEFORE optimizing is the model's own error and + # is left for the validator: re-pointing it would make a rejected duration accepted + .bad1 <- "rx_expr_0~exp(lT)\nd/dt(G)=-delay(G,rx_expr_0)\npast(G,typo)=2\n" + .bad0 <- "d/dt(G)=-delay(G,exp(lT))\npast(G,typo)=2\n" + expect_identical(.r(.bad1, .bad0), .bad1) + # optimizing can DROP a duration (0*delay(G,10) folds to 0), and the one left is not + # the one the history meant: re-pointing there would hand the surviving delay() term + # another one's history and the model would solve, wrongly + .dc0 <- "d/dt(G)=0*delay(G,10)+delay(G,20)\npast(G,10)=5\n" + .dc1 <- "d/dt(G)=0+delay(G, 20)\npast(G,10)=5\n" + expect_identical(.r(.dc1, .dc0), .dc1) + # two histories on one state whose delay() durations optimized down to one: rewriting + # both would leave one silently shadowing the other, so neither is guessed at + .mrg0 <- paste(c("d/dt(G)=-delay(G,1+1)-delay(G,2)", + "past(G,1+1)=2", "past(G,2)=3"), collapse = "\n") + .mrg1 <- paste(c("rx_expr_0~2", "d/dt(G)=-delay(G,rx_expr_0)-delay(G,rx_expr_0)", + "past(G,1+1)=2", "past(G,2)=3"), collapse = "\n") + expect_identical(.r(.mrg1, .mrg0), .mrg1) + # a state may lead with a "." -- d/dt(.y.1) parses, so the scan must take it + .dot <- "past(.y.1,exp(lT))=2\nd/dt(.y.1)=-delay(.y.1,rx_expr_0)\nrx_expr_0~exp(lT)\n" + expect_identical(.pastOf(.r(.dot, "past(.y.1,exp(lT))=2\nd/dt(.y.1)=-delay(.y.1,exp(lT))\n")), + "past(.y.1,rx_expr_0)=2") + # the same duration twice is one duration, so the past() line still follows it + .dup0 <- "d/dt(G)=-delay(G, exp(lT))-delay(G, exp(lT))\npast(G,exp(lT))=2" + .dup1 <- paste(c("rx_expr_0~exp(lT)", "d/dt(G)=-delay(G, rx_expr_0)-delay(G, rx_expr_0)", + "past(G,exp(lT))=2"), collapse = "\n") + expect_identical(.pastOf(.r(.dup1, .dup0)), "past(G,rx_expr_0)=2") + # the scan reads a line at a time, so a delay() split over lines is not read at all. + # Either text holding one is only a subset of the model's delay() calls and nothing can + # be concluded from it -- not that a duration was already wrong (it may be the call + # that was dropped), and not which optimized duration a past() line meant. Neither is + # realigned, which is what happened before this pass existed + .ml0 <- "lT=2.5\nd/dt(G)=-delay(G,\n exp(lT))\npast(G, exp(lT))=10\n" + .ml1 <- "rx_expr_0~exp(lT)\nd/dt(G)=-delay(G, rx_expr_0)\npast(G, exp(lT))=10\n" + expect_identical(.r(.ml1, .ml0), .ml1) + expect_identical(.r("d/dt(G)=-delay(G,\n rx_expr_0)\npast(G,exp(lT))=2\n", .ml0), + "d/dt(G)=-delay(G,\n rx_expr_0)\npast(G,exp(lT))=2\n") + # in particular a duration is never matched by position against a scan that dropped a + # call: here the two readable durations of `orig` line up with the two of the optimized + # text only by coincidence, and past(G,exp(lT)*1) would take tau2's temporary + .co0 <- paste(c("d/dt(G)=-delay(G, exp(lT))-delay(G,", "tau2)-delay(G, exp(lT)*1)", + "past(G, exp(lT))=1", "past(G, tau2)=2", + "past(G, exp(lT)*1)=3"), collapse = "\n") + .co1 <- paste(c("rx_expr_1~exp(lT)", "rx_expr_2~tau2", + "d/dt(G)=-delay(G, rx_expr_1)-delay(G, rx_expr_2)-delay(G, rx_expr_1)", + "past(G, exp(lT))=1", "past(G, tau2)=2", + "past(G, exp(lT)*1)=3"), collapse = "\n") + expect_identical(.r(.co1, .co0), .co1) + # a delay() in a comment is not a delay(): counting one would leave the state with a + # duration no delay() has, and the past() line refused a rewrite it needed + .cm0 <- paste(c("d/dt(G)=-delay(G,exp(lT)) # delay(G,junk)", "past(G,exp(lT))=2"), + collapse = "\n") + .cm1 <- paste(c("rx_expr_0~exp(lT)", "d/dt(G)=-delay(G,rx_expr_0) # delay(G,junk)", + "past(G,exp(lT))=2"), collapse = "\n") + expect_identical(.pastOf(.r(.cm1, .cm0)), "past(G,rx_expr_0)=2") + # ... and an unbalanced parenthesis in one does not stop the scan either + .cm2 <- "rx_expr_0~exp(lT)\nd/dt(G)=-delay(G,rx_expr_0) # (see above\npast(G,exp(lT))=2" + expect_identical(.pastOf(.r(.cm2, "d/dt(G)=-delay(G,exp(lT)) # (see above\npast(G,exp(lT))=2")), + "past(G,rx_expr_0)=2") + # a delay() inside a string is not one either -- it must neither be read nor stop the + # scan, which would take the whole model with it + .st0 <- "s=\"delay(G,\"\nd/dt(G)=-delay(G,exp(lT))\npast(G,exp(lT))=2" + .st1 <- "s=\"delay(G,\"\nrx_expr_0~exp(lT)\nd/dt(G)=-delay(G,rx_expr_0)\npast(G,exp(lT))=2" + expect_identical(.pastOf(.r(.st1, .st0)), "past(G,rx_expr_0)=2") + # ... but a duration may legitimately hold one, and it is read back whole: masking + # says what is code, it does not change what the duration is + .sd0 <- paste(c("OCC=\"first\"", "d/dt(I)=delay(G, 1+(OCC==\"first\"))", + "past(G, 1+(OCC==\"first\"))=2"), collapse = "\n") + .sd1 <- paste(c("OCC=\"first\"", "rx_expr_0~1+(OCC==\"first\")", + "d/dt(I)=delay(G, rx_expr_0)", "past(G, 1+(OCC==\"first\"))=2"), + collapse = "\n") + expect_identical(.pastOf(.r(.sd1, .sd0)), "past(G,rx_expr_0)=2") + # a comma or a parenthesis inside a string does not split the call either + expect_identical(.rxDelayDurs("d/dt(I)=delay(G, f(\"a,b)\")+1)\n")[["G"]], + "f(\"a,b)\")+1") + # code only: the comment goes, a string keeps its quotes and its width but not its + # contents, and a "#" inside a string is not a comment + expect_identical(.rxCodeOnly("printf(\"a#b\") # c"), "printf(\" \") ") + expect_identical(.rxCodeOnly("s=\"delay(G,\""), "s=\" \"") + expect_identical(.rxCodeOnly("d/dt(G)=-delay(G,rx_expr_0)"), + "d/dt(G)=-delay(G,rx_expr_0)") + }) + + test_that("the chunked path rejects a wrong duration the way the whole model does", { + .pad <- paste(sprintf("v%d=%d", 1:60, 1:60), collapse = "\n") + .mod <- function(.tau, .dt = "delay(G,exp(lT))") { + paste0("lT=0.2\nb=0.5\nG(0)=1\n", .pad, + "\nd/dt(G)=-exp(lT)*", .dt, "\npast(G,", .tau, ")=exp(b*t)\n") + } + # ... end to end: a dropped delay() leaves its history dangling, and it is reported + .dc <- suppressMessages(rxOptExpr("G(0)=1\nd/dt(G)=0*delay(G,10)+delay(G,20)\npast(G,10)=5\n", + "m", chunkLines = 1L)) + expect_error(.rxValidatePast(rxModelVars(.dc)), "'10' does not match") + # a comment naming a delay() the model does not have must not stop the realign + .cm <- suppressMessages(rxOptExpr(.mod("exp(lT)", "delay(G,exp(lT)) # delay(G,junk)"), + "m", chunkLines = 15L)) + expect_error(.rxValidatePast(rxModelVars(.cm)), NA) + expect_true(grepl("past\\(G,rx_expr_", .cm)) + # ... including when the model's own delay() is split over lines, which the realign + # scan cannot read: an unreadable model must not be guessed into a valid one + expect_error(.rxValidatePast(rxModelVars(suppressMessages( + rxOptExpr(.mod("typo", "delay(G,\n exp(lT))"), "m", chunkLines = 15L)))), + "'typo' does not match") + for (.chunk in c(0L, 10L)) { + .o <- suppressMessages(rxOptExpr(.mod("exp(lT)"), "m", chunkLines = .chunk)) + expect_error(.rxValidatePast(rxModelVars(.o)), NA) + expect_true(grepl("past\\(G,rx_expr_", .o)) + # a genuinely wrong duration still names itself in the error, on either path + .b <- suppressMessages(rxOptExpr(.mod("typo"), "m", chunkLines = .chunk)) + expect_error(.rxValidatePast(rxModelVars(.b)), "'typo' does not match") + } + }) + + test_that("an unsupported lhs names itself and does not print (#1192)", { + # the branch is only reachable directly -- an unsupported lhs is already a parse + # error in rxModelVars() -- but the stray print() it used to do landed in the + # middle of the progress bar + expect_error(..rxOptLhs(quote(foo(bar, baz))), + "foo(bar, baz)", fixed = TRUE) + expect_output(try(..rxOptLhs(quote(foo(bar, baz))), silent = TRUE), NA) + }) })