Evaluates a linear compartment model in closed form, as an
alternative to frm_ode() for the systems where one exists. The
dosing grammar, the grouping and the place in a bf(nl = TRUE)
formula are frm_ode()'s, so a linear model written for the solver
becomes a frm_lincmt() call with the same events table. There is
no integrator: the states are a superposition of the impulse
responses of the doses that precede each observation, and a
steady-state record is the limit of that superposition rather than a
run-in.
Usage
frm_lincmt(
parms,
times,
group = NULL,
ncmt = 1L,
depot = FALSE,
output = c("conc", "central", "depot"),
t0 = 0,
init = NULL,
events = NULL,
event_scale = 1,
n_ss = Inf,
tv = NULL,
tv_break = NULL
)Arguments
- parms
A named list of parameters. See "Parameters". Inside a
bf(nl = TRUE)body a bare NAME is a request for a column of the data. The arguments that are not columns (ncmt,depot,output,n_ss) therefore have to be written as literals there rather than held in a variable.- times
Observation times, one per row of the data.
- group
Grouping vector naming the unit that owns one system, one value per row.
NULLtreats the whole data as one group.- ncmt
Number of compartments in the disposition model: 1, 2 or 3. The depot, if any, is not counted.
- depot
Whether a depot compartment feeds the central one by first-order absorption at rate
ka.- output
What to return:
"conc"(the default) is the central amount divided byV,"central"and"depot"are amounts. One column only;frm_ode()'s per-rowoutputis refused by name.- t0
Initial time, a scalar or one value per row (constant within group). Every observation time must be at or after it.
- init
Amounts present at
t0, as a named list with elementsdepotandcentral, each one value per observation (constant within group) or a single value.NULLstarts empty.- events
Optional dosing table, exactly
frm_ode()'s. See "Dosing".- event_scale
A multiplier on every
events$value, one value per observation (constant within group) or a single shared value.- n_ss
How many dosing cycles a steady-state record carries.
Inf, the default, is the exact geometric limit and costs one term. A whole number writes out that many cycles, which is whatfrm_ode()simulates.- tv, tv_break
Refused, and present so that the refusal names them. See "What is refused, and why".
Value
A numeric vector of length nrow(data). On the automatic
differentiation tape it carries the advector class.
The model
One central compartment, up to two peripheral compartments exchanging with it, and an optional depot feeding it by first-order absorption:
$$ \frac{dA_d}{dt} = -k_a A_d, \qquad \frac{dA_1}{dt} = k_a A_d - (k_e + k_{12} + k_{13}) A_1 + k_{21} A_2 + k_{31} A_3 $$ $$ \frac{dA_j}{dt} = k_{1j} A_1 - k_{j1} A_j $$
The compartments are named "depot", "central", "peripheral1"
and "peripheral2", in that order, and events$state may use those
names or the positions they stand at. States are AMOUNTS; the default
output = "conc" divides the central amount by V.
Parameters
parms is a NAMED list, because which parameters a model needs
depends on ncmt and depot. Two spellings are read, and mixing
them is refused:
rate constants:
ke,V, andka,k12,k21,k13,k31as the shape requires;clearances:
CL,V, andka,Q2,V2,Q3,V3, with \(k_e = CL/V\), \(k_{12} = Q_2/V\), \(k_{21} = Q_2/V_2\).
Each element is one value per observation, constant within a group,
or a single value shared by every group, exactly as frm_ode()'s
parms is. They are ordinary nonlinear parameters, so they take
fixed effects, random effects and covariates.
Dosing
events is frm_ode()'s table and goes through the same validation,
so time, value, state, method, duration, ii, addl, ss
and group mean what ?frm_ode says they mean, including that an
observation at a dose time reads the trough. event_scale is the
same estimated multiplier on every dose, which is how a
bioavailability is written.
A steady-state row is exact here. frm_ode() approaches a steady
state by simulating n_ss cycles; frm_lincmt() sums the geometric
series, so the default n_ss = Inf is the limit itself and costs one
term. A finite n_ss writes out that many cycles as ordinary doses,
which is what reproduces a frm_ode() fit exactly and is otherwise
not worth paying for. The limit is what it says: a drug that is not
eliminated over its dosing interval accumulates without bound, and
the series says so, where a run-in of twenty cycles reports twenty
cycles worth. Nothing overflows, so an optimizer that wanders into
that region gets a large finite objective and backs out of it.
What is refused, and why
Superposition adds impulse responses. It never forms the state vector, so anything that reads or sets that vector is refused by name rather than approximated:
method = "replace"andmethod = "multiply". A"reset"to zero IS supported, because "forget every dose before this time" needs no state; a reset to a non-zero level is refused.a dose into a peripheral compartment, and
outputnaming one.an infusion into the depot, which is zero-order absorption.
tvandtv_break. A rate constant that changes with time makes the system time-varying, and superposition over the whole history is then wrong rather than approximate.an infusion that is still running at a
ssor"reset"time.
frm_ode() fits every one of them.
Accuracy
The textbook closed form divides by differences of rate constants and
loses all its precision where two of them meet: \(k_a = k_e\) is
the ordinary flip-flop case of an oral model, and it is also the
starting value of any fit that starts two log rates at the same
number. The ABSORPTION cancellation is removed completely: every
response is built from (exp(-p) - exp(-q)) / (q - p) evaluated as
exp(-max(p, q)) (1 - exp(-|p - q|)) / |p - q|, which is exact at
p == q and never overflows, where the textbook difference of
exponentials returns NaN there and has lost most of its digits by
a separation of 1e-12. The disposition coefficients of a mammillary
model are non-negative and sum to one, so the sum of exponentials is
a positive combination.
One cancellation is NOT removed, and it is worth knowing which. The
smaller of the two disposition coefficients of a two-compartment
model is spelled 1 - c1, and when the peripheral compartment is
nearly decoupled that subtraction loses about as many digits as
k12 k21 / g^2 has decades, much as the textbook spelling does. It
is bounded where it matters and unbounded where it does not, and the
two statements are the same statement: the ABSOLUTE error is held at
a few parts in 1e10 of the trajectory's own maximum, so the
POINTWISE relative error at a point 10^-d below Cmax is at most
about that times 10^d. An observation within four decades of
Cmax therefore carries at most about 1e-11 and the 1e-8 the
feature is held to is crossed only beyond seven decades below
Cmax, which is far below any assay's limit of quantification.
Measured: over 118 schedules the disagreement with frm_ode() run
at atol = rtol = 1e-12 is at most 2.6e-12 of the trajectory's own
scale, and tightening the solver drives it down rather than leaving
it, so it is the solver's tolerance rather than the closed form's
error. Against a 240-bit reference that never forms an eigenvalue,
the worst relative error through every coalescence down to exact
equality is 7.2e-15, and over a seven-decade box of rate constants
the worst error relative to the trajectory's maximum found by random
search is 3.6e-10. The repository's dev/lincmt-findings.md has the
tables.
Sampling
frmtmb.sample::frm_sample() refuses a model containing
frm_ode(), because RTMBode calls deSolve unguarded and
the chain aborts at warmup iteration 1. frm_lincmt() calls
neither, carries no refusal in frmtmb::frm_compat(), and samples.
Cost
One term per (observation, dose) pair, plus one term per group for a
steady-state record, all evaluated in one vectorized pass over the
whole data. There is no per-group loop on the tape and no solver, so
the cost is linear in the number of doses each observation follows.
A schedule with hundreds of addl doses before each observation is
the case where frm_ode()'s per-segment cost can win.
The accurate domain
Up to a six-decade spread of rate constants, nothing degrades.
Over 60 random three-compartment draws per cell, the worst
disagreement with frm_ode() at atol = rtol = 1e-12 is 9.9e-11 at
a two-decade spread, 1.3e-09 at four and 1.4e-07 at six, and in
every one of those cells it is BELOW what the solver's own tolerance
costs on the same draws (1.3e-07, 1.6e-06, 1.9e-05). The direction
an optimizer actually travels is cleaner still: driving the four
peripheral rate constants from one to twelve decades below the
absorption rate, which is what a fit does to a compartment the data
do not support, leaves the worst error at 6.9e-11 and does not
degrade at all.
Beyond an eight-decade spread that includes the FAST rates, both
the value and the gradient go wrong. There the trajectory can be
wrong by up to 1.3e-04 of its own maximum against a 300-bit
reference, with nothing warning, and the gradient can be NaN. That
region needs a terminal half-life of seconds beside an absorption
rate of hundreds per hour, which is not a drug; and frm_ode() is
not a usable reference there either, because DLSODA reaches its step
limit and disagrees with itself by 1e+05 and more.
Boundary
A three-compartment model returns NaN in the GRADIENT, though not
in the value, when the cubic's two smaller roots have lost their
precision against each other. lincmt_disp() solves the cubic
through acos(), and a double root puts its argument at exactly
one, where acos() has an infinite derivative.
The derivative itself is finite there. What frm_lincmt()
returns is a symmetric function of the three roots, hence a function
of the characteristic polynomial's coefficients, which are
polynomials in the rate constants, so the residue formula's poles at
a double root are removable and the trajectory is analytic in the
rate constants AT the tangency. Measured at an exact double root
(k13 at 1e-300, k31 on the slow root of the reduced quadratic):
d/d(log ke) is -9.2779361, stable to eight significant digits at
step sizes 1e-4 through 1e-7 and equal to what the tape itself
returns one part in 1e8 away. The NaN is the implementation
routing through the eigenvalues, not a derivative that does not
exist.
How often it happens. Not a knife edge. Over 4000 random draws per box: 0 on a pharmacokinetic box of 1e-2 to 10 per hour, 0 on 1e-4 to 20, 38 on 1e-8 to 50, and 749 on 1e-20 to 50. By spread, 3000 draws per cell: 0 at six decades or fewer, 0.07 percent at eight decades and 1.33 percent at ten, which is one draw in 75.
And the NaN is the loud end of a soft region rather than a
boundary guarding a right answer. On ten-decade draws where the
gradient comes back FINITE, so nothing fires at all, the value is
wrong by up to 1.3e-04 of the trajectory's own maximum. A silent
value error outranks a loud NaN, and it is the reason this is
stated as a domain above rather than patched.
Why it is not clamped. Holding the acos() argument below
1 - 1e-14 makes the gradient finite and right at a genuine double
root and costs nothing there (value error 5.177e-13 against the
shipped 5.181e-13, and no change at all over 400 ordinary draws).
But on 40 draws from the wide region it changes the VALUE by a
factor of 2.8e+02 to 2.5e+06. It is the right fix for a real
collision and destructive where the roots were already garbage, and
telling the two apart needs a test on whether the cubic's
coefficients still carry relative precision, which is a comparison
on an automatic-differentiation value and is the constraint this
whole function is written around. So it is left, and said.
What a user sees. An optimizer cannot walk INTO the region:
fitting three compartments to two-compartment data from six starting
points, including log(k13) at -20, -30, -35 and -700, every fit
returned normally and nlminb left log(k13) exactly where it
began, because the gradient in that direction is zero once k13
stops mattering. What a user can do is START inside it, and then the
fit stops with nlminb's NA/NaN gradient evaluation, which is
loud and names neither this function nor the cause.
A three-compartment model whose five disposition rate constants have
all underflowed to exactly zero returns NaN, in the value and in
the gradient, because the cubic is then a triple root at zero and
its trigonometric solution reads 0 / 0. It fails loudly rather
than quietly, and reaching it needs five log rates below about -745
at once. It is not offset, because offsetting it by this file's own
1e-150 returns a finite answer at eight-ninths of the true height, a
silent factor of 1.125.
See also
frm_ode() for a system with no closed form, and for every
feature named in "What is refused, and why".
Examples
# One-compartment oral pharmacokinetics, 100 into the depot every 12
# hours, and the same schedule already at steady state.
doses <- data.frame(time = 0, state = "depot", value = 100,
ii = 12, addl = 3L)
frm_lincmt(parms = list(ka = 1, ke = 0.2, V = 10),
times = c(6, 18, 30, 42), ncmt = 1, depot = TRUE,
events = doses)
#> [1] 3.733943 4.075490 4.106474 4.109285
frm_lincmt(parms = list(ka = 1, ke = 0.2, V = 10),
times = c(6, 18, 30, 42), ncmt = 1, depot = TRUE,
events = data.frame(time = 0, state = "depot", value = 100,
ii = 12, ss = TRUE))
#> [1] 4.109565265 0.375622018 0.034075678 0.003091276
# The flip-flop case, where the textbook form returns NaN
frm_lincmt(parms = list(ka = 0.2, ke = 0.2, V = 10), times = 1:4,
ncmt = 1, depot = TRUE, init = list(depot = 100))
#> [1] 1.637462 2.681280 3.292870 3.594632
# In a formula, with between-subject variability on both rates
set.seed(2026)
tt <- c(0.25, 0.5, 1, 2, 4, 6, 8, 12)
dd <- data.frame(id = factor(rep(1:6, each = length(tt))),
time = rep(tt, 6), dose = 100)
ka <- exp(rnorm(6, 0, 0.3))[as.integer(dd$id)]
ke <- exp(rnorm(6, log(0.2), 0.25))[as.integer(dd$id)]
dd$conc <- 100 * ka / (10 * (ka - ke)) *
(exp(-ke * dd$time) - exp(-ka * dd$time)) + rnorm(nrow(dd), 0, 0.3)
# \donttest{
fit <- frm(
bf(conc ~ frm_lincmt(parms = list(ka = exp(lka), ke = exp(lke),
V = exp(lV)),
times = time, group = id, ncmt = 1,
depot = TRUE, init = list(depot = dose)),
lka ~ 1 + (1 | id), lke ~ 1 + (1 | id), lV ~ 1, nl = TRUE) +
gaussian(),
data = dd, start = list(beta = c(0, log(0.25), log(8))))
fixef(fit)
#> Estimate Est.Error Q2.5 Q97.5
#> lka_Intercept -0.2014589 0.13520979 -0.4664652 0.06354744
#> lke_Intercept -1.7437250 0.06551336 -1.8721288 -1.61532116
#> lV_Intercept 2.2984394 0.03356308 2.2326570 2.36422183
# }