
Solve-time hooks for package developers
Source:vignettes/articles/rxode2-solve-hooks.Rmd
rxode2-solve-hooks.RmdOverview
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 # clearNames 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:
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 solvesrxSolve() 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()insrc/init.c. - A downstream package installs it into its own globals with
iniRxodePtrs()/iniRxodePtrs0()from the headerinst/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.