@DifferentialEquation
Many longitudinal processes are naturally described by systems of ordinary differential equations (ODEs). The @DifferentialEquation block specifies the dynamics of such systems, defining how model states evolve over time and optionally declaring derived signals that depend on time and the current state.
Within @Model, this block is optional. When present, the companion @initialDE block is required to supply initial conditions.
Core Syntax
Statements inside @DifferentialEquation take one of two forms:
- State dynamics:
D(state) ~ rhs_expression– defines the time derivative of a state variable. - Derived signals:
signal(t) = expression(orsignal(\xi) = expression) – defines an auxiliary quantity computed within the ODE, useful for intermediate calculations that appear in multiple state equations.
de = @DifferentialEquation begin
s(t) = sin(t)
D(x1) ~ -a * x1 + s(t)
D(x2) ~ a * x1 - b * x2
endThe ordering of D(state) declarations determines the state indexing in the solver's u and du vectors.
Parsing and Validation Rules
The following constraints are enforced at parse time:
- The block must be wrapped in
begin ... end. - Only
D(state) ~ exprandsignal(t) = expr/signal(\xi) = exprforms are permitted. - State names and signal names must be unique across the block.
D(...)must contain exactly one symbolic state name.- Derived signals must always be referenced as function calls (
s(t)ors(\xi)), never as bare symbols (s). - Unresolved symbols or functions in DE expressions raise an error during compilation.
Symbol and Function Resolution
NoLimits resolves symbols appearing in DE expressions from the following sources:
Variable-like symbols are drawn from:
- Fixed effects
- Random effects
- Constant covariates
- PreDE outputs (
@preDifferentialEquation)
Function-like calls are drawn from:
- Dynamic covariates (e.g.,
w(t)) - Helper functions (
@helpers) - Model functions from learned parameter blocks (e.g., neural network, soft tree, or spline functions)
Note that time-varying (non-dynamic) covariates are not available inside the ODE, since their values are defined only at observation times. Use dynamic covariates with an appropriate interpolation method for continuous-time access.
Example: Standalone DE Compilation and Evaluation
The @DifferentialEquation macro can be used outside of @Model for testing or prototyping. The following demonstrates direct compilation and evaluation of a single-state ODE.
using NoLimits
using ComponentArrays
de = @DifferentialEquation begin
s(t) = sin(t)
D(x1) ~ -a * x1^2 + s(t) + w(t) + pre
end
ctx = (
fixed_effects = ComponentArray(a = 0.3),
random_effects = ComponentArray(),
constant_covariates = NamedTuple(),
varying_covariates = (w = t -> 0.2 * t,),
helpers = NamedTuple(),
model_funs = NamedTuple(),
preDE = (pre = 0.5,),
)
pc = get_de_compiler(de)(ctx)
u = [1.0]
du = similar(u)
get_de_f!(de)(du, u, pc, 0.4)Example: Nonlinear Mixed-Effects ODE Model
This example illustrates a complete model in which random effects enter both the ODE dynamics and the observation distribution. The derived signal s(t) encapsulates a nonlinear transformation of a dynamic covariate and a preDE quantity, keeping the state equation concise.
using NoLimits
using Distributions
model = @Model begin
@helpers begin
sat(u) = u / (1 + abs(u))
end
@fixedEffects begin
a = RealNumber(0.4)
b = RealNumber(0.2)
sigma = RealNumber(0.3, scale=:log)
end
@covariates begin
t = Covariate()
w = DynamicCovariate()
end
@randomEffects begin
eta = RandomEffect(TDist(6.0); column=:ID)
end
@preDifferentialEquation begin
pre = exp(a + eta^2)
end
@DifferentialEquation begin
s(t) = sat(w(t) + pre)
D(x1) ~ -(b + tanh(eta)) * x1 + s(t)^2
end
@initialDE begin
x1 = pre
end
@formulas begin
y ~ Normal(log1p(abs(x1(t)) + eta^2), sigma)
end
endExample: Learned Right-Hand Side with Neural Networks and Soft Trees
When the functional form of the ODE right-hand side is unknown or difficult to specify mechanistically, learned components such as neural networks and soft decision trees can be embedded directly into the dynamics. In this example, the transfer and elimination rates of a two-state system are parameterized by neural networks and soft trees, with individual-level random effects on the learned parameters.
using NoLimits
using Distributions
using Lux
using LinearAlgebra
chain_A1 = Chain(Dense(1, 4, tanh), Dense(4, 1))
chain_A2 = Chain(Dense(1, 4, tanh), Dense(4, 1))
depth_st = 2
model = @Model begin
@helpers begin
softplus(u) = u > 20 ? u : log1p(exp(u))
end
@covariates begin
t = Covariate()
d = ConstantCovariate(; constant_on=:ID)
end
@fixedEffects begin
sigma = RealNumber(0.3, scale=:log)
zA1 = NNParameters(chain_A1; function_name=:NNA1, calculate_se=false)
zA2 = NNParameters(chain_A2; function_name=:NNA2, calculate_se=false)
gC1 = SoftTreeParameters(1, depth_st; function_name=:STC1, calculate_se=false)
gC2 = SoftTreeParameters(1, depth_st; function_name=:STC2, calculate_se=false)
end
@randomEffects begin
etaA1 = RandomEffect(MvNormal(zA1, Diagonal(ones(length(zA1)))); column=:ID)
etaA2 = RandomEffect(MvNormal(zA2, Diagonal(ones(length(zA2)))); column=:ID)
etaC1 = RandomEffect(MvNormal(gC1, Diagonal(ones(length(gC1)))); column=:ID)
etaC2 = RandomEffect(MvNormal(gC2, Diagonal(ones(length(gC2)))); column=:ID)
end
@DifferentialEquation begin
a_A(t) = softplus(depot)
x_C(t) = softplus(center)
fA1(t) = softplus(NNA1([t / 24], etaA1)[1])
fA2(t) = softplus(NNA2([a_A(t)], etaA2)[1])
fC1(t) = -softplus(STC1([x_C(t)], etaC1)[1])
fC2(t) = softplus(STC2([t / 24], etaC2)[1])
D(depot) ~ -d * fA1(t) - fA2(t)
D(center) ~ d * fA1(t) + fA2(t) + fC1(t) + d * fC2(t)
end
@initialDE begin
depot = d
center = 0.0
end
@formulas begin
y ~ LogNormal(center(t), sigma)
end
endDE Accessors for States and Signals
After ODE integration, accessor functions provide callable trajectories for each state and derived signal. These are the same accessors that formula evaluation uses internally when outcomes reference x1(t) or s(t).
if @isdefined(sol) && @isdefined(compiled_de_context)
accessors = get_de_accessors_builder(model.de.de)(sol, compiled_de_context)
x1_at_12 = accessors.x1(1.2)
s_at_12 = accessors.s(1.2)
endRelated APIs
The following functions provide lower-level access to the DE subsystem:
get_de_states(de)andget_de_signals(de)– enumerate declared states and signals.get_de_compiler(de),get_de_f!(de),get_de_f(de)– access the compiled ODE function and its context builder.get_de_accessors_builder(de)– returns a function that constructs solution accessors from an ODE solution.build_de_params(de, \theta; ...)– assembles ODE parameters with tunable modes (:theta,:eta,:both).