nlmixr2est events for loggers

knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
has_bus <- exists("rxEventListen", asNamespace("rxode2"))
knitr::opts_chunk$set(eval = has_bus)
rxode2::rxSetProgressBar(0)
cat("> **Note:** the installed `rxode2` has no event bus, so the code in",
    "this vignette is shown but not run.  With such an `rxode2`, nlmixr2est",
    "emits nothing and behaves exactly as before.\n")

nlmixr2est tells the rxode2 event bus when a user-facing piece of work finishes: a fit, a simulation, or an update to an existing fit. This vignette is for people who want to listen to those events, for example to keep a log of every fit in a project. The main consumer is the 'nlmixr2log' package (in development); once available, use it for a full log instead of writing your own listener.

The event bus in brief

The bus lives in rxode2 (see ?rxode2::rxEventListen for the full API). A listener is a function function(event, ...): it receives the event name and the payload fields as named arguments. It is registered with rxode2::rxEventListen(id, fun) and removed with rxode2::rxEventUnlisten(id).

Events are delivered only from the outermost operation. Every emitting function enters a shared operation scope, and an event is delivered only when no scope is active. So the many rxSolve() calls, refits and covariance steps done inside nlmixr2() are silent, and a bootstrap or a covariate search that runs many fits reports only its own result (from the package that runs it). Worker processes started inside a scope stay silent too.

A listener that errors gives a warning; it never stops the fit or the other listeners. Events triggered while a listener runs are dropped.

nlmixr2est checks whether the installed rxode2 has the bus; without it every hook is a no-op.

Events emitted by nlmixr2est

| Function | Event | Notes | |---|---|---| | nlmixr2() that returns a fit | fitComplete | any est, including nlmixr2(fit, est = ...) | | nlmixr2() with est = "rxSolve", "simulate" or "predict" | solveComplete | also reached by simulate(fit) (kind = "rxSolve") and predict(fit) | | vpcSim() | solveComplete | kind = "vpcSim"; the simulations inside are silent | | augPred() on a fit | solveComplete | kind = "augPred" | | addCwres() | fitUpdate | what = "cwres" | | addNpde() | fitUpdate | what = "npde" | | addTable() | fitUpdate | what = "table" | | setOfv() | fitUpdate | what = "ofv" | | reading a deferred objective (fit$objf, fit$AIC, ...) | fitUpdate | what = "ofv"; only the first read, when it computes the value |

predict(fit, level = "individual") calls rxode2::rxSolve() directly, so its solveComplete comes from rxode2 (with kind = "rxSolve" and the fit as object).

fitComplete

| Field | Content | |---|---| | fit | the new fit (an nlmixr2FitCore object, with its final timing) | | object | what was passed to nlmixr2(): a model function, a rxUi, or a previous fit | | call | the nlmixr2() call, normalized (see below) | | objName | the variable name of object when it was a plain name (e.g. "one.cmt"), otherwise NULL; the pipe placeholders . and .x give NULL | | source | always "fit" for nlmixr2est |

solveComplete

| Field | Content | |---|---| | result | the simulation or prediction (an rxSolve object for nlmixr2(); the returned value for vpcSim() and augPred()) | | object | the fit (or model) it was made from, so the result can be linked to its fit | | call | the call, normalized | | kind | the est used by nlmixr2() ("rxSolve", "simulate" or "predict"), or "vpcSim" or "augPred" |

fitUpdate

| Field | Content | |---|---| | fit | the updated fit | | original | the fit that was passed in | | name | the variable name of the user's fit when the update is in place, otherwise NULL | | what | "cwres", "npde", "table" or "ofv" | | inPlace | TRUE when the user's object now holds the result |

inPlace follows what actually happened to the user's object:

The call field

rxode2 normalizes every call field before delivery so a recorded call never embeds a large object: the function name is restored (e.g. nlmixr2 or vpcSim), and arguments that are values rather than code (for example a data frame inlined by do.call()) become `<value>` (or a single `<...>` marker when there are many).

What is not emitted

Listening

A small recording listener keeps the event names and a few payload fields:

library(nlmixr2est)

events <- list()
rxode2::rxEventListen("demo", function(event, ...) {
  p <- list(...)
  fields <- p[c("kind", "what", "objName", "name", "inPlace")]
  fields <- fields[!vapply(fields, is.null, logical(1))]
  events[[length(events) + 1L]] <<- paste0(
    event, "(",
    paste(names(fields), vapply(fields, deparse, character(1)),
          sep = " = ", collapse = ", "),
    ")"
  )
})
show <- function() {
  if (length(events) == 0L) cat("(no events)\n") else writeLines(unlist(events))
  events <<- list()
}

A small model fit with posthoc:

one.cmt <- function() {
  ini({
    tka <- log(1.57)
    tcl <- log(2.72)
    tv <- log(31.5)
    eta.ka ~ 0.6
    eta.cl ~ 0.3
    eta.v ~ 0.1
    add.sd <- 0.7
  })
  model({
    ka <- exp(tka + eta.ka)
    cl <- exp(tcl + eta.cl)
    v <- exp(tv + eta.v)
    linCmt() ~ add(add.sd)
  })
}

fit <- suppressMessages(
  nlmixr2(one.cmt, nlmixr2data::theo_sd, est = "posthoc")
)
show()

Only one fitComplete is delivered, although the fit solved the model many times. Simulations and predictions from the fit each give one solveComplete:

s <- suppressMessages(simulate(fit))
p <- suppressMessages(predict(fit, nlmixr2data::theo_sd))
v <- suppressMessages(vpcSim(fit, n = 3))
show()

Updates report whether the user's object now holds the result. addTable() works on a copy by default, while updateObject = TRUE changes the fit's shared environment. The second call below recomputes the table the fit already has, so its result is identical to its input and nothing is emitted; the third adds NPDE in place:

f2 <- suppressMessages(addTable(fit))
fit <- suppressMessages(addTable(fit, updateObject = TRUE))
fit <- suppressMessages(addTable(fit, updateObject = TRUE,
                                 table = tableControl(npde = TRUE)))
show()

A saem fit defers its objective function. The first read computes it inside the fit and emits a fitUpdate; later reads do not:

fit2 <- suppressMessages(nlmixr2(one.cmt, nlmixr2data::theo_sd, est = "saem",
                                 control = saemControl(print = 0)))
show()
o1 <- suppressMessages(fit2$objf)
o2 <- fit2$objf
show()

addCwres() rebinds the variable it was given. Calling it again has nothing to do, so the second call emits nothing:

fit2 <- suppressMessages(addCwres(fit2))
show()
fit2 <- suppressMessages(addCwres(fit2))
show()

Remove the listener when you are done:

rxode2::rxEventUnlisten("demo")

Full logging

'nlmixr2log' (in development) listens to these events (and to those of the other nlmixr2 packages) and keeps a project log of fits, simulations, updates and saved results, so you rarely need to write a listener yourself.

For developers: nlmixrUpdateObject()

nlmixrUpdateObject(fit, objName, envir, origFitEnv) rebinds the variable objName in envir to the updated fit when that variable still holds the original fit. It now returns TRUE (invisibly) when it rebound the variable and FALSE otherwise, for instance when objName is not a single name (as for addCwres(fits[[1]]), where it used to error) or the variable is not bound to the original fit. A package that writes its own updating function can use this value to set inPlace on the fitUpdate event it emits.



Try the nlmixr2est package in your browser

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

nlmixr2est documentation built on Oct. 14, 2026, 1:07 a.m.