Skip to contents

Overview

Most rxode2 models are fully specified by their parameters, data and initial conditions. Some downstream tools, though, need to change what a solve sees from outside the model text – for example to inject a block of externally-owned parameters (neural-network weights, a lookup table) on every solve, or to add a forcing term to a state derivative. rxode2 exposes a small set of hooks for this. They fall into two groups:

  • R-level: rxForcedPars() – override parameter values on a model, carried with the model itself.
  • C-level: the par-loader and dydt forcing hooks, plus the function-pointer table that lets a downstream package call rxode2’s C entry points. These are for package authors writing compiled code.

This article is a companion to Providing Custom C/C++ Functions to rxode2: that one adds new functions callable inside a model; this one changes the parameters and derivatives a solve uses.

rxForcedPars(): forced parameters on a model

rxForcedPars() sets a named vector of parameter values that override params/data and the model initial estimates on every solve of a ui. The values are stored on the ui (hidden from the printed model, registered as a sticky item), so they travel with it – through model piping and into any nlmixr2 fit built from it. That keeps a model self-contained: a fit that carries, say, trained weights re-solves, predicts and simulates with those weights and no external state.

ui <- function() {
  model({
    d/dt(center) <- -(cl/v) * center
    cp <- center/v
  })
}
ui <- rxode2(ui)
rxForcedPars(ui) <- c(cl = 1.2, v = 8)   # force cl, v on every solve
rxForcedPars(ui)                          # getter
rxForcedPars(ui) <- NULL                  # clear

Names that are not model parameters are ignored at solve time. Forced parameters are written into every subject/simulation column at solve setup – the same injection point the par-loader hook (below) uses, so a registered loader can still override them if needed.

The par-loader hook (C)

For a parameter block that is not a fixed vector but is computed – owned by a plugin and refreshed each solve – register a par-loader. It runs once per solve, single-threaded, right after rxode2 lays out the global parameter matrix (gpars) and before the parallel integration, so the injected values are in place for the (thread-safe) solve.

/* signature (inst/include/rxode2.h) */
typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols);
void rxRegisterParLoader(t_rxParLoader cb);
void rxRemoveParLoader(t_rxParLoader cb);

gpars is the flat ncols x npars parameter matrix (column c’s par_ptr is &gpars[c*npars]). A loader writes its block into every column:

static void myLoader(rx_solve *rx, double *gpars, int npars, int ncols) {
  for (int c = 0; c < ncols; ++c) {
    double *par_ptr = gpars + (size_t)c * npars;
    for (int k = 0; k < BLOCK_N; ++k) par_ptr[base + k] = myBlock[k];  /* base = block start */
  }
}

Register it in .onLoad() (via a .Call wrapper) and rxRemoveParLoader() in .onUnload(). The block must occupy real par_ptr slots: declare its parameters with param(...) (or as covariates) so rxode2 reserves contiguous positions, resolve base from the model’s solve parameter order once, and cache it. rxInjectedPars() reports what a loader changed on the last solve.

Named loaders: dispatch to the right model only

A loader registered with rxRegisterParLoader() runs on every solve, so it must guard itself, and it can still corrupt an unrelated model if its guards are loose. Prefer a named loader instead:

rxRegisterParLoaderNamed("myPkg:myLoader", myLoader);   /* runs only when flagged */

A named loader runs only while a model flags its name. Flag the injector on the model that needs it with rxParLoader() (a sticky ui item, like rxForcedPars()):

rxParLoader(ui) <- "myPkg:myLoader"   # only this loader fires for ui's solves

rxSolve() sets that flag active for the model’s solve and clears it afterward, so the loader never touches any other model. For solves that bypass the ui path (estimation internals, plain rxode2() models), a package can set the active flag directly around its own solve batch and clear it after. This is how nlmixr2nn keeps its neural-network weight loader from leaking into unrelated fits.

The dydt forcing hook (C)

To add a term to a state derivative that the model text cannot express (e.g. a plugin’s contribution computed in C), register a dydt-force callback. The generated model calls it at the end of dydt, so the added forcing is integrated like any other RHS term:

/* signature (inst/include/rxode2.h) */
typedef void (*t_rxDydtForce)(int *neq, double t, double *y, double *dydt);
void rxRegisterDydtForce(t_rxDydtForce cb);
void rxRemoveDydtForce(t_rxDydtForce cb);
static void myForce(int *neq, double t, double *y, double *dydt) {
  dydt[stateIdx] += myForcingTerm(t, y);   /* on top of the model's own RHS */
}

A model with no registered forcing is unaffected (the call is a null-pointer no-op), so this is safe to leave installed.

rxRegisterUiPrep(): rehydrating state before a ui solve

The C hooks above rely on state a package registers in the running session. That state is lost when a model or fit is saved to disk and reloaded in a fresh session – the serializable ui survives, but the C-side registry behind it does not. rxRegisterUiPrep(name, fn) closes that gap: fn(ui) is called with the ui at the very start of every ui solve (before parameter loaders run), so a package can rebuild transient C state from serializable ui slots.

# in a downstream package's .onLoad():
rxode2::rxRegisterUiPrep("myPkg:rehydrate", function(ui) {
  meta <- ui$myMeta            # a slot the package stamped on the ui at fit time
  if (is.null(meta)) return(invisible())   # no-op for models it does not own
  # ... resolve indices BY NAME from the current parameter layout and re-register
  #     the C-side state so the reloaded ui solves correctly ...
})

Guidelines for a prep hook:

  • Be cheap and a no-op for unrelated models – it runs on every ui solve.
  • Resolve positions by name, not by a stored index. A ui saved by one build and reloaded by another may have a different parameter column layout; matching a saved name against the current layout is robust, a cached integer is not.
  • Errors are downgraded to a warning so a buggy hook cannot break other solves.

This is how nlmixr2nn makes a saved neural-network fit reload: the trained weights ride on the ui in rxForcedPars() and the network shapes in a sticky nnMeta slot; the prep hook re-registers the shapes (base resolved by name) so the persisted weight values land in a network that can stride them. Pair a rxRegisterUiPrep() with rxRemoveUiPrep(name) in .onUnload().

The function-pointer table

The hooks above are C callables inside rxode2. rxode2 shares its C entry points through a positional function-pointer table rather than by name:

  • rxode2::.rxode2ptrs() returns the table (an external-pointer list) built by _rxode2_rxode2Ptr() in src/init.c.
  • A downstream package installs it into its own globals with iniRxodePtrs() / iniRxodePtrs0() from the header inst/include/rxode2ptr.h, calling .Call("_myPkg_iniRxodePtrs", rxode2::.rxode2ptrs()) in .onLoad().

Each table slot is a fixed index; new entries are appended (so the layout stays backward compatible). rxRegisterParLoader / rxRegisterDydtForce are exposed this way, so a plugin can register its hooks from its own compiled code. See the rxode2 source (src/init.c, inst/include/rxode2ptr.h) for the current table, and the nlmixr2nn package for a worked consumer that uses the par-loader to inject neural-network weights.

See also

  • Providing Custom C/C++ Functions to rxode2 – adding model-callable functions.
  • nlmixr2est: “Extending nlmixr2est” – creating estimation methods, intercepting estimations, and contributing to the objective, which build on these solve hooks.