ODE Models
Maturity: beta — see Feature Maturity for what this means.
For pharmacokinetic models without analytical solutions (e.g., saturable elimination, target-mediated drug disposition), ferx-core provides an ODE solver.
Structural Model Declaration
[structural_model]
ode(obs_cmt=OBSERVABLE_COMPARTMENT, states=[state1, state2, ...])
- obs_cmt: The compartment whose concentration is observed (matched to DV)
- states: List of state variable names (compartments)
ODE Equations
The [odes] block defines the right-hand side of the ODE system:
[odes]
d/dt(state_name) = expression
Expressions can reference: - State variables by name - Individual parameters defined in [individual_parameters] - The reserved builtins TIME/TAFD/TAD (solver time axes) and MACHEPS (machine epsilon, f64::EPSILON) - Arithmetic operators and functions (exp, log, sqrt, etc.) - Conditional logic with the same if (cond) { ... } else { ... } and inline if (cond) expr else expr syntax described in Individual Parameters. For example, you can switch between linear and saturable elimination based on the central amount:
[odes]
d/dt(depot) = -KA * depot
if (central > KM_THRESHOLD) {
d/dt(central) = KA * depot - VMAX * central / (KM + central)
} else {
d/dt(central) = KA * depot - CL_LIN * central
}
Each d/dt(state) reachable from any branch counts as defined; states that aren’t assigned in the firing branch this step receive a derivative of 0.
Every name in an ODE expression must resolve to a declared state, an individual parameter, an intermediate variable assigned earlier in the block, or one of the reserved builtins TIME/TAFD/TAD/MACHEPS. A name that matches none of these — a typo, an omitted parameter, or a covariate — is rejected at parse time rather than silently read as 0.0, the same structurally-broken-fit guard the analytical pk(...) mappings apply. Covariates cannot be referenced directly in an ODE RHS: pre-compute the covariate-dependent term in [individual_parameters] and reference that variable here instead.
Initial Compartment Amounts
By default every compartment starts at zero, and drug enters only through dose records. To start a compartment at a non-zero amount — e.g. a pre-dose baseline for an indirect-response / turnover model — declare an initial condition in the [odes] block:
[odes]
init(state_name) = expression
d/dt(state_name) = expression
- The right-hand side is evaluated once per subject at the start of the record and may reference individual parameters (and therefore folds in
theta,eta, and covariates through the[individual_parameters]layer). State names referenced in aninitexpression are treated as0(no drug is present yet). - A name in an
initexpression that is not a declared state or individual parameter is rejected at parse time (it would otherwise be read as0.0). - Compartments without an
init(...)line start at zero, as before. - This is the analogue of NONMEM’s
A_0(n).
What an init(...) expression may reference
The scope is closed. It is not identical on the two init surfaces — the [odes] block injects solver built-ins that do not exist outside it, so [odes] init(...) and the analytical [initial_conditions] block differ on four names:
| Name | [odes] init(...) |
[initial_conditions] init(...) |
|---|---|---|
| declared states | yes — bound to 0 (no drug is present yet) |
n/a |
| individual parameters | yes (folds in theta / eta / covariates) |
yes, plus theta / eta directly |
| covariates by name | no — reach them through [individual_parameters] |
yes — a required, case-sensitive data column |
MIXNUM |
yes (any casing) | yes (any casing) |
MACHEPS |
yes (any casing) — machine epsilon | no — an ordinary covariate here |
TIME / time |
rejected | rejected |
T, TAFD, TAD |
rejected | ordinary covariates |
KAPPA_* (IOV) |
n/a | rejected (see that page) |
MIXNUM resolves to the subject’s mixture class on both surfaces, so a class-switched baseline works. MACHEPS / TAFD / TAD are solver-injected built-ins only inside [odes]; anywhere else — [scaling] and [initial_conditions] alike — the name is an ordinary covariate that must be a data column. That is deliberate, so a dataset that really carries a TAD column can use it.
TIME is the one name rejected on both surfaces. An initial condition is evaluated at the time origin, so a clock there reads exactly 0 and the expression can only ever produce its t = 0 value. Before issue #994 a bare TIME was accepted on both — init(central) = TIME * SOMETHING initialised the compartment to zero, validated clean, and fitted — while Time was reported as an undefined name and T / TAFD / TAD were rejected, a split that followed how each name happened to be represented in the parser rather than any scope decision. Put the time dependence in d/dt(...), where TIME resolves to the integrator’s current time, or in a [scaling] Form C readout on the analytical path, where it resolves per observation.
The [odes] built-in name sets are exposed by the engine (ODE_INIT_SCOPE_BUILTINS / ODE_INIT_REJECTED_BUILTINS) so a code generator can source the rule from the same binary it will parse with, instead of keeping its own copy — the same arrangement as known_block_names() for block names. They are named for the [odes] surface because that is what they describe: mirroring them on an analytical model would reject a legal TAD covariate and accept a MACHEPS that is really a missing data column.
Time-varying covariates. Because the initial condition is a pre-record baseline, it is evaluated a single time using the covariate values from the subject’s first record. If a covariate that feeds the init expression changes later in the record, the initial amount is not re-evaluated — the later covariate values affect d/dt(...) going forward (the system evolves from the baseline), but the t=0 starting point is fixed by the first record’s covariates. For most models this is exactly what you want, since the baseline represents the pre-dose steady state. If you need the starting amount to track a covariate value observed mid-record, model it as a state driven by d/dt rather than as an init.
A turnover model whose response variable sits at its baseline KIN/KOUT before any perturbation:
[odes]
init(response) = KIN / KOUT
d/dt(response) = KIN - KOUT * response
Interaction with system resets (EVID=3/4): a reset re-applies the init expression to initialized compartments (returning them to baseline) and zeros all other compartments — so a reset behaves like the start of a fresh episode. See Data Format for reset rows.
Note one deliberate asymmetry with the start-of-record seeding described above: the re-applied baseline at a reset is evaluated with the covariate values in effect at the reset time, not the first record’s. With time-varying covariates this means the post-reset baseline reflects the most recent covariate values — appropriate for a “fresh episode” that starts under current conditions — whereas the very first baseline uses the first record’s covariates. For time-constant covariates the two are identical.
Example: Michaelis-Menten Elimination
A one-compartment oral model with saturable (Michaelis-Menten) elimination:
[parameters]
theta TVVMAX(10.0, 0.1, 1000.0)
theta TVKM(2.0, 0.01, 100.0)
theta TVV(10.0, 0.1, 500.0)
theta TVKA(1.0, 0.01, 50.0)
omega ETA_VMAX ~ 0.09
omega ETA_V ~ 0.04
sigma PROP_ERR ~ 0.1
[individual_parameters]
VMAX = TVVMAX * exp(ETA_VMAX)
KM = TVKM
V = TVV * exp(ETA_V)
KA = TVKA
[structural_model]
ode(obs_cmt=central, states=[depot, central])
[odes]
d/dt(depot) = -KA * depot
d/dt(central) = KA * depot / V - VMAX * central / (KM + central)
[error_model]
DV ~ proportional(PROP_ERR)
Solver Details
ferx-core integrates with an adaptive Dormand-Prince RK45 solver by default:
| Setting | Value |
|---|---|
| Method | Explicit Runge-Kutta 4(5) |
| Absolute tolerance | 1e-6 |
| Relative tolerance | 1e-4 |
| Max steps | 10,000 |
| Initial step size | 0.1 |
| Minimum step size | 1e-12 |
The solver automatically adapts step sizes based on local error estimates.
Stiff systems
An explicit method like RK45 is stability-limited on a stiff system: it keeps shrinking the step to stay stable, not to meet your tolerance. The symptom is a fit that crawls, or an integration that silently exhausts ode_max_steps and freeze-pads the remainder of the segment. Stiffness in PK/PD models typically comes from a wide separation of time scales:
- fast reversible binding on top of slow elimination (TMDD and quasi-equilibrium models);
- Michaelis-Menten elimination with
KMfar below the observed concentrations; - long transit chains with a large
KTR; - QSP / systems-pharmacology cascades mixing minute- and week-scale states.
Set ode_method in [fit_options] to pick a different stepper:
ode_method |
Order | Stages | Stiff? | Use for |
|---|---|---|---|---|
rk45 (default) |
5(4) | 7 | no | the general-purpose choice; fastest at default tolerances |
vern7 (alias verner7) |
7(6) | 10 | no | accuracy-limited fits at tight tolerances (ode_reltol ≤ 1e-8) |
rosenbrock23 (aliases ros23, ode23s) |
2(3) | 3 | yes | crude tolerances, rough right-hand sides |
rodas4 |
4(3) | 6 | yes | the stiff workhorse at typical tolerances (1e-6 – 1e-9) |
rodas5p |
5(4) | 8 | yes | stiff systems at tight tolerances (ode_reltol ≤ 1e-9) |
Note the two different axes. The Rosenbrock methods buy stability; vern7 buys order. Which one helps depends on what is capping your step size, and picking the wrong one costs time without buying accuracy — see below.
[fit_options]
ode_method = rodas5p
ode_reltol = 1e-9
ode_abstol = 1e-11
A stiff step is more expensive than an explicit one — it builds a finite-difference Jacobian (n + 1 extra right-hand-side evaluations) and factorizes an n × n matrix once per step — so on a non-stiff one- to three-compartment model RK45 remains faster. Switch only when stiffness, not accuracy, is what is capping the step size.
Which regime am I in?
This matters, because a stiff solver does not speed up a fit that is merely accurate- limited. Run your model and look at the solver’s step counts:
Stability-limited (stiff). Steps are tiny regardless of
ode_reltol, and the integration hits its minimum step or exhaustsode_max_steps. A stiff method is the fix, and the win is large.A non-zero min-step count is the one number worth checking after switching, too. If a Rosenbrock method cannot form a step at all at the minimum step size — a singular
W = I/(γh) − J, which a badly scaled or discontinuous right-hand side can produce — the integration stops there and pads the remaining output times with the last state it reached. Those predictions are finite and look perfectly ordinary; the min-step count is the only place that failure is reported.Accuracy-limited. Almost every step is accepted, nothing is min-step clamped, and tightening
ode_reltolis what drives the step count up. A stiff method will not help; what helps is a higher-order method, or a looser tolerance if the model can afford it.
The Savic transit anchor (tests/transit_nonmem_anchor.rs) at NONMEM-equivalent accuracy (ode_reltol = ode_abstol = 1e-9) is a measured example of the second regime — 97 % of steps accepted, zero min-step clamps — and the methods behave exactly as that diagnosis predicts:
ode_method |
accepted steps | rejected | min-step clamped | wall-clock |
|---|---|---|---|---|
rk45 |
3 940 | 120 | 0 | 3.9 ms |
vern7 |
1 420 | 140 | 0 | 1.7 ms |
rodas5p |
4 060 | 180 | 0 | 5.8 ms |
rodas4 |
16 200 | 120 | 0 | 17.6 ms |
rosenbrock23 |
85 180 | 120 | 0 | 62.4 ms |
vern7 takes 2.8× fewer steps and is ~2.3× faster: higher order is what an accuracy-limited fit rewards. rodas5p takes the same number of steps as rk45 and is ~1.5× slower, because each step now buys a Jacobian and a factorization the fit does not need — and the lower-order stiff methods are worse still (4.5× and 16×), ordered exactly as their convergence orders predict.
The crossover is real, so do not switch blindly: on the same model at default tolerances vern7 is ~1.4× slower than rk45 (740 → 520 steps, but 10 stages per step instead of 6), because there is no accuracy pressure for its extra order to relieve. Tight tolerance → vern7; stability-limited → a Rosenbrock method; otherwise stay on rk45.
Read the step counts, not the milliseconds. Step counts are a deterministic property of the method and the model — the ones above reproduce exactly, run to run and machine to machine. The timings are medians of five runs of an optimized build on one machine and move by tens of percent between runs; they are here to show the ordering, not as a benchmark to reproduce.
vern7
Features that read state between solver steps — a joint PK-TTE hazard at event times, a CTMM occupancy grid, adaptive-dosing monitors — use each method’s continuous extension. vern7 interpolates with a cubic Hermite (its own continuous extension needs three extra stages), so those readouts are 3rd-order accurate even though its steps are 7th-order accurate. For a model that leans on in-step readouts at very tight tolerance, rk45 or rodas5p interpolate closer to their own step accuracy.
These methods are linearly implicit: one Jacobian and one matrix factorization per step, with no Newton iteration. That is also what lets ferx’s analytic sensitivities (∂ŷ/∂θ, ∂ŷ/∂η) run through the identical stepper, so switching methods does not move you off the analytic-gradient path.
The stiff methods are full peers of rk45: every feature works with every method. Each method carries its own continuous extension (the polynomial that reconstructs the state between two solver steps), which is what the rest of the engine reads state through — the cumulative hazard at event times in a joint PK-TTE fit, a CTMM occupancy grid, [output] state columns, adaptive-dosing monitors, and time-to-event simulation, which locates the hazard crossing inside a step. Analytic sensitivities run through the same steppers too. So non-Gaussian endpoints, feedback dosing and TTE simulation all work under rodas5p exactly as they do under rk45, and asking for an interpolated readout never changes the trajectory it is read from.
Dose Handling
- Bolus doses: Applied as instantaneous state changes at dose times. The dose amount, scaled by bioavailability (
F · AMT), is added to the target compartment (the state atCMT − 1, sinceCMTis 1-based — see indexing below) - Infusion doses (
RATE > 0): Treated as a continuous zero-order input. ARATE>0(orRATE=-1) infusion is rate-defined, so bioavailability holds the rate and scales the duration (#419): the integrator’s timeline is broken at theF-scaled endtime + F·AMT/RATE, and the unscaledRATEis added to the target compartment’s derivative for every fully-spanned segment. (ARATE=-2modeled-duration infusion is instead duration-defined — the window istime + D{cmt}and the rate is scaled toF·AMT/D{cmt}.) Overlapping infusions on the same compartment sum their rates - Compartment indexing: Compartments are 1-indexed in the data file (
CMT=1corresponds to the first state in thestateslist) - Multiple doses: The ODE is integrated in segments between dose events, with state discontinuities at each bolus
- Built-in absorption input rates: A dose can instead be delivered as a dose-driven appearance rate
R_in(tad)(e.g. transit-compartment absorption) added into the depot over time — see Built-in Absorption Models
Bioavailability
If your [individual_parameters] block declares an F parameter, the ODE engine applies it when the dose enters the compartment — a bolus loads the dosing compartment with F · AMT, and an infusion delivers a total of F · AMT (a rate-defined infusion holds its rate and scales the duration to F·AMT/RATE; a duration-defined RATE=-2 infusion holds its duration and scales the rate to F·AMT/D{cmt}; #419) — exactly like NONMEM’s F1 and like ferx’s analytical PK functions. Write the depot’s elimination as the plain KA · depot and do not multiply by F anywhere in the right-hand side, or bioavailability is applied twice. F defaults to 1.0 when not declared, so IV and non-bioavailability models are unaffected.
[individual_parameters]
CL = TVCL * exp(ETA_CL)
V = TVV
KA = TVKA
F = inv_logit(logit(THETA_F) + ETA_F) # F is applied at dose entry
[odes]
d/dt(depot) = -KA * depot
d/dt(central) = KA * depot / V - CL/V * central # no F here
⚠️ Migration note. Earlier versions of ferx added the full dose to the compartment and required
Fto be folded into the absorption flux (e.g.d/dt(central) = F * KA * depot / V - …). ThatFmust now be removed from the right-hand side — otherwise it is applied both at dose entry and in the flux, giving an effective bioavailability ofF². Since #993 such a model is rejected rather than quietly computingF², so a model carried over from that era fails loudly with the fix named.ferx is stricter than NONMEM here — measured, not assumed. Two
ADVAN13control streams differing in exactly one$DESline,F1 = 0.5fixed, run on NONMEM 7.6.0: withDADT(2) = F1*KA*A(1) − K*A(2)every prediction is lower than theDADT(2) = KA*A(1) − K*A(2)form by exactlyF1(B/A = 0.500000across 0.5–24 h), i.e. an effective bioavailability ofF²— and NONMEM reports nothing, completing the run with an objective function value. That divergence is the entire point of #993, but it means a mechanically translated control stream can newly fail to parse even though it ran in NONMEM. Anyone converting control streams (includingferxtranslate) should drop theFfrom the translated RHS rather than transcribe$DESliterally. Control streams, outputs and the ferx-vs-NONMEM comparison are innonmem_anchor/andtests/dose_attr_double_use_nonmem_anchor.rs.
The name F (any case) is what flags a parameter as bioavailability and routes it to the dosing compartment. If you need a fraction-like quantity inside the RHS that is not bioavailability, give it a different name.
Reading a dose attribute is an error, not a warning
F is consumed by the engine at the dose event, so a correct model never reads it back on the prediction path. Declaring F and referencing it in the [odes] RHS or [scaling] is rejected at parse time with E_DOSE_ATTR_DOUBLE_USE (#993) — an init(...) seed is exempt, see below:
[odes]: `F` is this model's bioavailability — the engine already scales each dose
amount by it — but it is also read in the [odes] RHS, so the value is applied
twice: once at the dose, once where you read it (#993). If `F` is meant to be
bioavailability, remove it from the [odes] RHS; if it is meant to be an ordinary
parameter, rename it — `F` is a reserved dose-attribute name.
The rule follows the expressions compiled against your individual parameters, not the block headings: every read in the [odes] RHS counts, and so does the [adaptive_dosing] observe signal. That last one matters most — observe is what the when rules compare against, so a double-applied F there biases the titration decision itself and every dose the controller goes on to emit, not just a reported number.
init(state) = … seed is not a double use
An initial condition is not an absorbed dose. The engine seeds the state with the raw expression value and applies dose attributes only at dose events, so
[odes]
init(central) = F * 100.0
d/dt(central) = -(CL/V) * central
applies F once, to a quantity nothing else scales — the bioavailable residue of a pre-study 100 mg dose. This is accepted (#1046); it was rejected before, which made a correct model unwritable and advised the one repair that is always wrong for it — renaming the parameter whose meaning is bioavailability.
The RHS is genuinely different: d/dt(central) = … F … folds F into the flux, so every gram that ever entered the compartment is multiplied by it a second time. That stays rejected.
NONMEM agrees. A_0(1) = F1*100 with F1 = 0.5 seeds 50, not 25, and A_0(1) = ALAG1*100 is deposited unshifted — each run byte-identical to the twin seeding from an ordinary parameter of the same value (nonmem_anchor/odes_init_dose_attr_{f,lag}_{A,B}.ctl). The analytical [initial_conditions] block carries the same carve-out.
The same rule covers LAGTIME/ALAG and the compartment-indexed F{n} / ALAG{n} / LAGTIME{n} below. These apply to every dose, so the collision is decidable from the model alone. D{n} / R{n} are different: the engine only consults them for a dose that codes RATE=-2 / RATE=-1, so an R1 that is really a rate constant is a perfectly good model on ordinary data. That pair is therefore checked against the dataset instead — see Modeled infusion duration and Modeled infusion rate — and reports the same code only when a coded-RATE dose actually lands on the parameter.
Reads that are not on the prediction path are fine: [derived] and [output] are post-solve reporting, so tabulating your own F (or an exposure computed from it) is correct and stays silent.
Analytical (pk ...) models are covered by the same rule (#1004), keyed off the pk(..., f=F) / lagtime=… mapping instead of the name — their prediction-path surfaces are [scaling] and [adaptive_dosing] observe (an [initial_conditions] amount is not a dose, so the engine seeds it with F = 1 and no lag, and reading the attribute there applies it once). Because the mapping is the binding, the remediation is to drop the f=/lagtime= argument rather than rename the parameter; a parameter merely named F that nothing maps is an ordinary parameter on that engine. See Individual Parameters.
See examples/bioavailability_ode.ferx for a complete worked model.
Compartment-indexed bioavailability and lag (Fn / ALAGn)
When a model is dosed into more than one compartment, bioavailability and absorption lag can differ by route. Mirroring NONMEM’s F1/F2 and ALAG1/ALAG2, name an individual parameter F{n} or ALAG{n} (equivalently LAGTIME{n}), where n is the 1-based dose compartment:
[individual_parameters]
CL = TVCL * exp(ETA_CL)
V = TVV
F1 = inv_logit(THETA_F1) # bioavailability for doses into compartment 1
F2 = inv_logit(THETA_F2) # ... and into compartment 2
ALAG2 = TVLAG2 # absorption lag for compartment-2 doses only
- A dose into compartment
nusesF{n}/ALAG{n}if declared. - A bare
F/lagtime(no index) remains the all-compartment default, so existing single-route models are unchanged. An indexed value overrides the bare default for its compartment only; compartments without an indexed entry fall back to the bare value (or toF = 1,lag = 0). - The index must refer to a compartment the model actually has —
F3on a two-state model is a parse error, not a silently-ignored parameter. - Each declared
Fn/ALAGnoccupies one of the seven spare slots in the fixed 16-slot PK parameter layout (shared with other ODE structural parameters). Declaring the full set for many compartments can exhaust them; if so,ode_param_slotsreports a clear “too many individual parameters” error rather than failing silently.
⚠️
F{n}/ALAG{n}/LAGTIME{n}are reserved names (just like the bareF/lagtimeabove, and exactly as in NONMEM). On an ODE model, declaring an individual parameter with one of these names binds it as compartmentn’s bioavailability / lag and applies it to every dose into compartmentn— even if you also reference the parameter in the[odes]RHS. So don’t reuseF2,ALAG2, … for an unrelated fraction or rate term; give such a quantity a different (un-indexed-looking) name.
This is an ODE-engine feature: the analytical PK functions have a single fixed dose route, so they take only the bare f=/lagtime= mapping. You may still name an analytical parameter F{n}/ALAG{n} and bind it through that mapping (pk(..., f=F1) routes F1 into the single F slot and applies it exactly like f=F), but an F{n}/ALAG{n}/LAGTIME{n} parameter left unmapped on an analytical model is a parse error rather than a silently-ignored no-op (#725). (The EKF/[diffusion] path applies per-compartment F but, as elsewhere, does not apply absorption lag.)
Per-compartment observation scaling (NONMEM’s
Sn, e.g.S2 = V) is a separate, readout-side concept — it divides a compartment’s amount to give the observed concentration. It is configured in the[scaling]block (obs_scale[CMT=n] = …ory[CMT=n] = …), not via a reservedSnindividual parameter.
Modeled infusion duration (Dn, RATE=-2)
NONMEM’s RATE = -2 makes a zero-order infusion’s duration a model parameter rather than a data value. Mirror it by naming an individual parameter D{n} for the dose compartment n, and coding RATE = -2 on the dose row (AMT is still the amount). ferx then infuses AMT over the modeled duration D{n} — i.e. at rate AMT / D{n} — resolved per iteration and occasion from the parameter, so the duration can carry covariate effects and between-occasion variability:
[parameters]
theta TVD1(2.0, 0.1, 24.0)
[individual_parameters]
CL = TVCL * exp(ETA_CL)
V = TVV
D1 = TVD1 * exp(ETA_D1) # modeled duration for infusions into compartment 1
# dataset: a RATE=-2 dose of 100 units into compartment 1
ID,TIME,DV,EVID,AMT,CMT,RATE,MDV
1,0,.,1,100,1,-2,1
- A
RATE=-2dose into compartmentnrequires aD{n}parameter; without one it is a loud error at the model+data join (ferx check/fit), never a silent bolus. D{n}composes with the dose attributes above: bioavailabilityF{n}scales the delivered amount once (F·AMToverD{n}, matching NONMEM’sF·RATE), and absorption lagALAG{n}shifts the infusion window’s start whileD{n}sets its length.- A transient
D{n} ≤ 0during estimation is clamped to a tiny positive floor (soAMT / D{n}stays finite); the converged optimum is interior, so reported estimates are unaffected — the same guard the built-in absorption models use.
⚠️ Like
F{n}/ALAG{n},D{n}is a reserved name when aRATE=-2dose targets compartmentn(as in NONMEM). It then denotes that compartment’s infusion duration even if you also reference it in the[odes]RHS — so don’t reuseD1,D2, … for an unrelated decay constant or rate term.
RATE=-2 works on both engines. On an analytical model (pk(...)) declare the D{n} individual parameter and the closed-form infusion uses rate = AMT / D{n}. A RATE=-2 dose still requires a matching D{n} parameter, or it is a loud error (never a silent bolus). The compartment index follows the analytical model’s compartment numbering (e.g. D1 for the central compartment of a two_cpt_iv model, D2 for its peripheral compartment).
The modeled duration just sets the rate of an otherwise ordinary infusion, so the target compartment must be one the analytical engine can infuse into — exactly the same set as for an explicit positive RATE: the central compartment for every model, the peripheral compartment(s) for the 2-/3-cpt IV models, and — since #400 — the oral depot (compartment 1) of one_cpt_oral / two_cpt_oral / three_cpt_oral. A D1 into the oral depot is a zero-order absorption model: drug is released into the depot at a constant rate over the modeled duration, then absorbed first-order into central via KA. This stays on the closed-form engine — no ode(...) block needed. (Per-compartment amounts in sdtab/[derived] are not available for those subjects — the predictions are exact; use an ode(...) model if you need the compartment amounts.) Since #375 the closed forms also infuse an oral peripheral compartment, so D3 on two_cpt_oral (and D3/D4 on three_cpt_oral) is accepted. A D{cmt} naming a compartment the model does not have — or any D{cmt} on a transit / inverse-Gaussian absorption model — is still rejected at parse time; use an ode(...) model for those.
One subtlety: when a subject has any modeled-
RATEdose (RATE=-2or-1) on an analytical model, that subject’s inner-loop gradient falls back to finite differences, because the analytic sensitivity kernels cannot carry the modeled duration/rate’s∂/∂η. Results are unchanged; only the gradient route differs.
Modeled infusion rate (Rn, RATE=-1)
NONMEM’s RATE = -1 is the mirror of -2: it makes the infusion rate a model parameter rather than a data value. Name an individual parameter R{n} for the dose compartment n and code RATE = -1 on the dose row; ferx then infuses AMT at the modeled rate R{n} — i.e. over duration AMT / R{n} — resolved per iteration and occasion, so the rate can carry covariate effects and between-occasion variability:
[individual_parameters]
R1 = TVR1 * exp(ETA_R1) # modeled rate for infusions into compartment 1
# dataset: a RATE=-1 dose of 100 units into compartment 1
ID,TIME,DV,EVID,AMT,CMT,RATE,MDV
1,0,.,1,100,1,-1,1
Everything said about D{n} applies symmetrically: a RATE=-1 dose requires a matching R{n} (else a loud E_MODELED_RATE_NO_PARAM error, never a silent bolus); R{n} is a reserved name when a RATE=-1 dose targets compartment n; it works on both engines over the same infusable compartments; a transient R{n} ≤ 0 is clamped to a tiny positive floor (and warned via W_MODELED_RATE_NONPOSITIVE if non-positive at the initial estimate); and a modeled-rate dose routes its analytical gradient to finite differences. Internally, RATE=-1 R{n}=r resolves to exactly the explicit RATE = r infusion.
⚠️ Bioavailability
F ≠ 1. ferx appliesFby scaling the infusion rate (over the durationAMT/R{n}), so aRATE=-1dose behaves identically to its explicitRATE = R{n}twin — exact atF = 1(the usual case, and the NONMEM-anchored one). NONMEM instead keeps the rate atR{n}and scales the duration toF·AMT/R{n}for rate-defined infusions; total exposure (F·AMT) agrees but the infusion shape differs whenF ≠ 1. Aligning rate-defined infusions (RATE>0andRATE=-1) with NONMEM’s duration-scaling underF ≠ 1is a tracked follow-up.
Stochastic ODE Models (SDE)
To model within-subject system noise that accumulates between observations, add a [diffusion] block to your ODE model. See Stochastic Differential Equations for a full description, worked example, and comparison with sigma and omega.
Limitations
- The observable compartment contains the amount (not concentration). Divide by volume in the ODE equations if needed
- SDE (
[diffusion]) is not compatible with SAEM or the analytic gradient path (uses FD)
Steady-state (SS=1) is supported for ODE models via numerical pulse-expansion equilibration — see Steady-State Doses for the mechanism and how it differs from the analytical closed forms.