# 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):

```python
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:

```python
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

```python
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 |
|-----------|---------|-------------|
| `n_compartments` | 10 | Number of ODE compartments |
| `rtol` | 1e-6 | Relative ODE tolerance |
| `atol` | 1e-8 | Absolute ODE tolerance |
| `method` | `"RK45"` | scipy `solve_ivp` integration method |

### 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 `rtol` and `atol` are tight
  enough.
- JAX/diffrax adjoint sensitivity (ADVAN13) 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

```bash
pip install python-libsbml
```

### Loading an SBML file

```python
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 |
|-----------|-------------|
| `species_names` | Ordered list of species IDs (index n → A[n]) |
| `parameter_names` | Parameter IDs in THETA order |
| `default_pk_params` | Initial parameter values from SBML |
| `initial_amounts` | Initial species amounts |
| `n_compartments` | Number of ODE compartments |
| `des_callable` | DES function compatible with ADVAN6 |

### Simulation

```python
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:

```python
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:

```python
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:

```python
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 |
|---------|-------------|---------|
| `multiprocessing` | Single machine, always available | — |
| `dask` | HPC clusters, large-scale work | `pip install dask[distributed]` |
| `ray` | Cloud, mixed CPU/GPU | `pip install ray` |

### Usage

```python
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)

```python
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

```python
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

```python
backend = get_backend(n_jobs=8, backend="ray")
# Optionally connect to an existing Ray cluster:
backend = get_backend(backend="ray", address="auto")
```

### Bootstrap example

```python
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

```python
# 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.
