Advanced PK Features
This page documents OpenPKPD’s advanced pharmacokinetic modelling features: delay differential equations (DDE), SBML model import, inter-occasion variability (IOV), and parallel execution.
Delay Differential Equations (DDE)
Motivation
Standard ODEs assume the rate of change at time t depends only on the current state A(t). Some PK/PD processes involve explicit delay:
Transit absorption: drug must traverse n transit compartments before reaching the central compartment. The transit compartment ODE chain is equivalent to a delay.
Target-mediated drug disposition (TMDD): receptor-occupancy feedback.
Tumour cell-cycle models: mitosis occurs after a fixed maturation delay τ.
OpenPKPD supports these via DDESubroutine (registered as ADVAN16):
from openpkpd.pk.ode.dde import DDESubroutine
# or equivalently:
from openpkpd.pk import get_advan
solver = get_advan(16)
How it works
The history function _AHISTORY is injected into pk_params before each call
to your $DES callable. Query it at any past time:
def my_des(t, A, pk_params, theta, eta):
hist = pk_params["_AHISTORY"] # callable: t_past -> list[float]
tau = pk_params["TAU"] # delay parameter from $PK
A_lag = hist(max(t - tau, 0.0)) # A at time t - tau (zeros before dose)
CL, V = pk_params["CL"], pk_params["V"]
return [-(CL / V) * A_lag[0]]
The delay value τ is read from pk_params under the key "TAU" or
"DELAY". If neither is present, the solver degenerates to a plain ODE.
Dose events and history
Dose events (bolus and infusion) are handled identically to ADVAN6.
The history returns 0 for any query before the first dose, consistent with
the assumption that all compartments start empty.
Initial non-zero amounts can be set via pk_params["A0_1"], "A0_2", etc.
Full example
import numpy as np
from openpkpd.data.event_processor import DoseEvent
from openpkpd.pk.ode.dde import DDESubroutine
def elimination_delay_des(t, A, pk_params, theta, eta):
"""Elimination rate driven by A(t - TAU) instead of A(t)."""
hist = pk_params.get("_AHISTORY")
tau = pk_params.get("TAU", 0.0)
ke = pk_params["CL"] / pk_params["V"]
if hist and tau > 0:
A_lag = hist(max(t - tau, 0.0))
return [-ke * A_lag[0]]
return [-ke * A[0]]
dose_events = [DoseEvent(time=0.0, amount=100.0, rate=0.0, duration=0.0, compartment=1)]
obs_times = np.linspace(0.5, 12.0, 48)
solver = DDESubroutine(n_compartments=1, rtol=1e-6, atol=1e-8)
sol = solver.solve(
pk_params={"CL": 2.0, "V": 10.0, "TAU": 0.5},
dose_events=dose_events,
obs_times=obs_times,
des_callable=elimination_delay_des,
)
# sol.ipred — concentration at each obs time (A/V)
# sol.amounts — shape (n_obs, n_compartments)
Parameters
Parameter |
Default |
Description |
|---|---|---|
|
10 |
Number of ODE compartments |
|
1e-6 |
Relative ODE tolerance |
|
1e-8 |
Absolute ODE tolerance |
|
|
scipy |
Limitations
The current implementation uses dense-output piecewise RK45 with a linear interpolated history buffer. For systems requiring very long history queries relative to the observation window, ensure
rtolandatolare tight enough.ADVAN13 forward sensitivity is separate from DDE support.
SBML Model Import
Motivation
Systems Biology Markup Language (SBML) is the standard format for QSP
(quantitative systems pharmacology) and mechanistic PK/PD models. OpenPKPD
can import SBML files and automatically generate a des_callable compatible
with the ADVAN6/DDESubroutine integration engine.
Requirements
pip install python-libsbml
Loading an SBML file
from openpkpd.io import load_sbml
model = load_sbml("tumor_growth.xml")
print(model.species_names) # ['A_tumor', 'A_drug']
print(model.parameter_names) # ['kgrow', 'kdrug', 'V']
print(model.default_pk_params) # {'kgrow': 0.1, 'kdrug': 0.5, 'V': 1.0}
print(model.n_compartments) # 2
The SBMLModel object provides:
Attribute |
Description |
|---|---|
|
Ordered list of species IDs (index n → A[n]) |
|
Parameter IDs in THETA order |
|
Initial parameter values from SBML |
|
Initial species amounts |
|
Number of ODE compartments |
|
DES function compatible with ADVAN6 |
Simulation
from openpkpd.pk.ode.advan6 import ADVAN6
from openpkpd.data.event_processor import DoseEvent
import numpy as np
dose_events = [DoseEvent(time=0.0, amount=100.0, rate=0.0, duration=0.0, compartment=1)]
obs_times = np.linspace(0.0, 24.0, 100)
advan = ADVAN6(n_compartments=model.n_compartments)
sol = advan.solve(
pk_params=model.default_pk_params,
dose_events=dose_events,
obs_times=obs_times,
des_callable=model.des_callable,
)
Estimation
Convert SBML parameters to ThetaSpec for use with any estimation method:
from openpkpd.model.parameters import ParameterSet
theta_specs = model.to_theta_specs()
params = ParameterSet.from_specs(theta_specs, [], [])
# Map estimated THETA back to pk_params dict:
pk_params = model.pk_callable_from_theta(result.theta_final.tolist())
Supported SBML features
Species (→ compartment amounts A[n])
Parameters (→ ThetaSpec initial estimates)
Compartment volumes (used for concentration/amount conversion)
Reactions with MathML kinetic laws (evaluated via
libsbml.formulaToL3String)
Not yet supported: SBML events, rules, constraints (silently warned and skipped).
Building models programmatically
Without an SBML file, you can construct an SBMLModel directly using the
internal _build_des_callable helper:
from openpkpd.io.sbml import SBMLModel, _build_des_callable
species_names = ["A_central", "A_peripheral"]
parameter_names = ["CL", "V1", "Q", "V2"]
species_index = {"A_central": 0, "A_peripheral": 1}
dadt_exprs = {
0: ["-(pk_params['CL']/pk_params['V1'])*A[0]",
"-(pk_params['Q']/pk_params['V1'])*A[0]",
"+(pk_params['Q']/pk_params['V2'])*A[1]"],
1: ["+(pk_params['Q']/pk_params['V1'])*A[0]",
"-(pk_params['Q']/pk_params['V2'])*A[1]"],
}
des_callable = _build_des_callable(
dadt_exprs, species_names, species_index, parameter_names, 2, []
)
model = SBMLModel(
species_names=species_names,
parameter_names=parameter_names,
default_pk_params={"CL": 3.0, "V1": 10.0, "Q": 1.5, "V2": 30.0},
initial_amounts={"A_central": 0.0, "A_peripheral": 0.0},
n_compartments=2,
des_callable=des_callable,
)
Inter-Occasion Variability (IOV)
Background
IOV models separate random effects into:
IIV (inter-individual variability) — differences between subjects
IOV (inter-occasion variability) — differences within the same subject across study occasions (e.g., Period 1 vs Period 2 of a cross-over study)
Data requirements
The dataset must contain an OCC column (occasion indicator, integer ≥ 1).
OpenPKPD’s EventProcessor reads this column automatically and stores it
per subject as occasion_indices.
Model specification
In your $PK block, use conditional logic on the OCC covariate:
$PK
ETAKA = ETA(1)
IF (OCC.EQ.2) ETAKA = ETA(4) ; Occasion-specific ETA
KA = THETA(1) * EXP(ETAKA)
CL = THETA(2) * EXP(ETA(2))
V = THETA(3) * EXP(ETA(3))
Define the occasion OMEGA block with same=True to link occasions:
omega_specs = [
OmegaSpec(init=0.3), # IIV on KA
OmegaSpec(init=0.2), # IIV on CL
OmegaSpec(init=0.15), # IIV on V
OmegaSpec(init=0.3, same=True), # IOV on KA (same variance as occasion 1)
]
The compiled $PK callable receives covariates={"OCC": float} for each
occasion and the results are stitched together across occasions during
IndividualModel.evaluate().
Parallel Execution
Overview
OpenPKPD’s parallel module provides a unified map() interface across three
backends:
Backend |
When to use |
Install |
|---|---|---|
|
Single machine, always available |
— |
|
HPC clusters, large-scale work |
|
|
Cloud, mixed CPU/GPU |
|
Usage
from openpkpd.parallel import get_backend
# Auto-select best available backend
backend = get_backend(n_jobs=8)
# Apply a function to each element of a list in parallel
results = backend.map(my_function, list_of_arguments)
# Context manager (cleanly shuts down Dask/Ray on exit)
with get_backend(n_jobs=4) as backend:
results = backend.map(fit_bootstrap, replicate_datasets)
Backends in detail
Multiprocessing (always available)
from openpkpd.parallel import _MultiprocessingBackend
b = _MultiprocessingBackend(n_jobs=4) # or n_jobs=-1 for all CPUs
results = b.map(fit_function, args_list)
Uses concurrent.futures.ProcessPoolExecutor. n_jobs=1 executes inline
(no subprocess overhead — useful for debugging).
Dask
backend = get_backend(n_jobs=8, backend="dask")
# Optionally connect to an existing Dask scheduler:
backend = get_backend(backend="dask", scheduler_address="tcp://scheduler:8786")
Ray
backend = get_backend(n_jobs=8, backend="ray")
# Optionally connect to an existing Ray cluster:
backend = get_backend(backend="ray", address="auto")
Bootstrap example
import numpy as np
from openpkpd.parallel import get_backend
def fit_replicate(replicate_df):
from openpkpd import ModelBuilder
from openpkpd.data.dataset import NONMEMDataset
ds = NONMEMDataset.from_dataframe(replicate_df)
res = (ModelBuilder().dataset(ds).subroutines(advan=2)
.pk("CL=THETA(1); V=THETA(2)").error("Y=F*(1+EPS(1))")
.theta([(0.1,3,10),(1,30,200)]).omega([0.1,0.1]).sigma(0.05)
.estimation("FO").build().fit())
return res.theta_final.tolist()
n_jobs = 4
replicates = [...] # list of DataFrames
backend = get_backend(n_jobs=n_jobs, backend="multiprocessing")
with backend:
boot_thetas = backend.map(fit_replicate, replicates)
# Percentile CI
boot_arr = np.array(boot_thetas)
ci_lo = np.percentile(boot_arr, 2.5, axis=0)
ci_hi = np.percentile(boot_arr, 97.5, axis=0)
Timeout support
# Abort workers that take longer than 60 seconds
results = backend.map(fit_function, args_list, timeout=60.0)
Timeout is supported by the multiprocessing backend. For Dask, use
client.gather(futures, timeout=60) directly.