Skip to content

Commit 65d15cc

Browse files
simonbeyer1claude
andcommitted
docs: demos basics, saltation and fitCoPt replace the inst/examples scripts; solveODE() rejects a parameter named twice
The README has a minimal example, switches and sliding among the features, and links the demos. saltation checks the sensitivities across events, switches and sliding against symbolic solutions, forward and reverse, to second order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0590bc4 commit 65d15cc

56 files changed

Lines changed: 2161 additions & 6492 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎NEWS.md‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,32 @@
1616
the native backend, first-order sensitivities on `cvode()`, reverse
1717
gradients on both, and the forward-reverse curvature.
1818

19-
* Switches that cross at the same time take one saltation each, and a slow
20-
crossing takes its saltation.
19+
* Switches that cross at the same time take one saltation each. A root
20+
crossed at a small rate takes its saltation: the crossing counts as
21+
tangential only where the rate is negligible against the terms it is summed
22+
from, not below a fixed 1e-15.
2123

2224
* A switch whose root is zero at `t0`, at a fixed event or after its own
2325
crossing takes the side the solution leaves to, on both backends, and the
2426
sensitivities take its jump once.
2527

26-
* `abs()`, `min()` and `max()` of a state or of the time switch as held
27-
comparisons, and a comparison of switches follows their modes, so the
28-
sensitivities of every order take the kink or jump. `floor()`, `ceiling()`,
28+
* `abs()`, `min()` and `max()` of a state or of the time are switches: the
29+
solver locates the kink as a root, as for a comparison. A comparison of
30+
switches follows their modes. The sensitivities of every order take the kink
31+
or jump. `floor()`, `ceiling()`,
2932
`round()` and `trunc()` of a state or of the time in an ODE right-hand side
3033
are an error.
3134

3235
* At a kink or step the derivative is that of the branch the value takes,
3336
forward and reverse alike, in `cppFUN()` and the symbolic Jacobians.
3437

38+
* `solveODE()`, `solveODEBatch()` and `solveBatch()` reject a parameter
39+
named twice in `parms`.
40+
41+
* The demos `basics`, `saltation` and `fitCoPt` replace the scripts in
42+
`inst/examples/`. `saltation` checks the sensitivities across events,
43+
switches and sliding against symbolic solutions.
44+
3545
# cppDE 0.12.1
3646

3747
* A switch on the time, such as the pulse

‎R/solveODE.R‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,8 @@
393393
## --- parms ---
394394
if (!is.numeric(parms) || is.null(names(parms)))
395395
stop("'parms' must be a named numeric vector")
396+
dup <- unique(names(parms)[duplicated(names(parms))])
397+
if (length(dup)) stop("'parms' names more than once: ", paste(dup, collapse = ", "))
396398
required_nms <- c(variables, parameters)
397399
miss <- setdiff(required_nms, names(parms))
398400
if (length(miss)) stop("'parms' missing: ", paste(miss, collapse = ", "))
@@ -1011,6 +1013,10 @@ solveBatch <- function(handle, parms = NULL, tangent = NULL, hessian = NULL,
10111013
if (!is.null(parms)) {
10121014
pk <- parms[[k]]
10131015
if (is.null(names(pk))) stop("'parms' elements must be named", call. = FALSE)
1016+
dup <- unique(names(pk)[duplicated(names(pk))])
1017+
if (length(dup))
1018+
stop("condition ", k, ": 'parms' names more than once ",
1019+
paste(dup, collapse = ", "), call. = FALSE)
10141020
miss <- setdiff(handle$required, names(pk))
10151021
if (length(miss))
10161022
stop("condition ", k, ": 'parms' missing ", paste(miss, collapse = ", "),

‎README.Rmd‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ order.
4646
parameter through the solve; reverse mode returns the gradient of a functional
4747
in one backward sweep, at a cost independent of the parameter count
4848
- **Second order**: exact Hessians, forward over forward or forward over reverse
49-
- **Events**: time-based and root-triggered, with the saltation correction of
50-
first- and second-order sensitivities across a jump
49+
- **Events and switches**: time- and root-triggered events, and `piecewise()`
50+
or comparisons on states and time located as roots; a switch whose branches
51+
both push onto its surface slides along it (Filippov). First- and
52+
second-order sensitivities take every jump by the saltation correction
5153
- **Code generation on an expression graph**: Jacobians and derivative
5254
contractions by automatic differentiation, emitted as straight-line C++, with
5355
automatic sparsity detection and KLU factorisation for large systems
@@ -114,11 +116,37 @@ writeLines(readLines(system.file("cvodeConfig.dcf", package = "cppDE")))
114116

115117
## Example
116118

119+
A two-state model, compiled once and solved with the sensitivities of every
120+
state in the initial values and parameters:
121+
122+
```{r example}
123+
library(cppDE)
124+
125+
model <- cppODE(c(A = "-k1*A", B = "k1*A - k2*B"))
126+
out <- solveODE(model, times = 0:10, parms = c(A = 1, B = 0, k1 = 0.5, k2 = 0.2))
127+
128+
out$variable[11, ] # states at t = 10
129+
out$tangent[11, , ] # [state, parameter] at t = 10
130+
```
131+
132+
`derivMode = "reverse"` builds the same model for gradients by the adjoint,
133+
`cvode()` for SUNDIALS CVODES; `?expressions` lists what an equation can contain.
134+
135+
## Demos
136+
137+
- [Basics](examples/basics.md): sensitivities of first and second order,
138+
reverse-mode gradients, events, switches and sliding, forcings, steady
139+
states, `cvode()` and `cppFUN()` on small models.
140+
- [Saltation](examples/saltation.md): sensitivities across events, switches
141+
and sliding, forward and reverse, against symbolic solutions to second order.
117142
- [Fitting measured magnetisation dynamics](examples/fitCoPt.md): a
118143
Landau-Lifshitz-Gilbert model fitted to pump-probe measurements by
119144
Gauss-Newton on forward sensitivities, with a moving-average noise model and
120145
a multistart.
121146

147+
`demo("saltation", package = "cppDE")` runs one, `demo(package = "cppDE")`
148+
lists them.
149+
122150
## License
123151

124152
cppDE is distributed under the **MIT License**; see `LICENSE` / `LICENSE.md`

‎README.md‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ first and second order.
3131
of the parameter count
3232
- **Second order**: exact Hessians, forward over forward or forward over
3333
reverse
34-
- **Events**: time-based and root-triggered, with the saltation
35-
correction of first- and second-order sensitivities across a jump
34+
- **Events and switches**: time- and root-triggered events, and
35+
`piecewise()` or comparisons on states and time located as roots; a
36+
switch whose branches both push onto its surface slides along it
37+
(Filippov). First- and second-order sensitivities take every jump by
38+
the saltation correction
3639
- **Code generation on an expression graph**: Jacobians and derivative
3740
contractions by automatic differentiation, emitted as straight-line
3841
C++, with automatic sparsity detection and KLU factorisation for large
@@ -61,10 +64,10 @@ compiler** and **Python with SymPy** are required. Windows users need
6164
The package installs and runs without any system library. Three features
6265
are gated on one, detected by `./configure` at install time:
6366

64-
| Feature | Needs | Without it |
65-
|:---------------------------|:------------------------|:-----------------------------------------|
66-
| `cvode()` backend | SUNDIALS (\>= 6.0) | the native solvers are unaffected |
67-
| sparse Jacobian path | SuiteSparse / KLU | `sparse = TRUE` says how to enable it |
67+
| Feature | Needs | Without it |
68+
|:---|:---|:---|
69+
| `cvode()` backend | SUNDIALS (\>= 6.0) | the native solvers are unaffected |
70+
| sparse Jacobian path | SuiteSparse / KLU | `sparse = TRUE` says how to enable it |
6871
| parallel `solveODEBatch()` | a toolchain with OpenMP | correct results, conditions run serially |
6972

7073
The three are independent; `cppODE()` uses KLU without SUNDIALS. OpenMP
@@ -73,12 +76,12 @@ needs `libomp` (`brew install libomp`).
7376

7477
Either install the libraries as system packages,
7578

76-
| Platform | SUNDIALS | SuiteSparse / KLU |
77-
|:-----------------|:-----------------------------------|:--------------------------------------|
78-
| Debian/Ubuntu | `sudo apt install libsundials-dev` | `sudo apt install libsuitesparse-dev` |
79-
| Fedora | `sudo dnf install sundials-devel` | `sudo dnf install suitesparse-devel` |
80-
| Arch | `sudo pacman -S sundials` | `sudo pacman -S suitesparse` |
81-
| macOS (Homebrew) | `brew install sundials` | `brew install suite-sparse` |
79+
| Platform | SUNDIALS | SuiteSparse / KLU |
80+
|:---|:---|:---|
81+
| Debian/Ubuntu | `sudo apt install libsundials-dev` | `sudo apt install libsuitesparse-dev` |
82+
| Fedora | `sudo dnf install sundials-devel` | `sudo dnf install suitesparse-devel` |
83+
| Arch | `sudo pacman -S sundials` | `sudo pacman -S suitesparse` |
84+
| macOS (Homebrew) | `brew install sundials` | `brew install suite-sparse` |
8285

8386
or let cppDE build pinned releases into a per-user cache, which needs no
8487
administrator rights:
@@ -100,11 +103,45 @@ writeLines(readLines(system.file("cvodeConfig.dcf", package = "cppDE")))
100103

101104
## Example
102105

106+
A two-state model, compiled once and solved with the sensitivities of
107+
every state in the initial values and parameters:
108+
109+
``` r
110+
library(cppDE)
111+
112+
model <- cppODE(c(A = "-k1*A", B = "k1*A - k2*B"))
113+
out <- solveODE(model, times = 0:10, parms = c(A = 1, B = 0, k1 = 0.5, k2 = 0.2))
114+
115+
out$variable[11, ] # states at t = 10
116+
#> A B
117+
#> 0.006738205 0.214328465
118+
out$tangent[11, , ] # [state, parameter] at t = 10
119+
#> sens
120+
#> variable A B k1 k2
121+
#> A 0.006738205 0.0000000 -0.06737844 0.00000
122+
#> B 0.214328465 0.1353353 -0.17347388 -1.54116
123+
```
124+
125+
`derivMode = "reverse"` builds the same model for gradients by the
126+
adjoint, `cvode()` for SUNDIALS CVODES; `?expressions` lists what an
127+
equation can contain.
128+
129+
## Demos
130+
131+
- [Basics](examples/basics.md): sensitivities of first and second order,
132+
reverse-mode gradients, events, switches and sliding, forcings, steady
133+
states, `cvode()` and `cppFUN()` on small models.
134+
- [Saltation](examples/saltation.md): sensitivities across events,
135+
switches and sliding, forward and reverse, against symbolic solutions
136+
to second order.
103137
- [Fitting measured magnetisation dynamics](examples/fitCoPt.md): a
104138
Landau-Lifshitz-Gilbert model fitted to pump-probe measurements by
105139
Gauss-Newton on forward sensitivities, with a moving-average noise
106140
model and a multistart.
107141

142+
`demo("saltation", package = "cppDE")` runs one,
143+
`demo(package = "cppDE")` lists them.
144+
108145
## License
109146

110147
cppDE is distributed under the **MIT License**; see `LICENSE` /

‎demo/00Index‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
basics Sensitivities, reverse mode, events, switches, forcings, steady states, cvode() and cppFUN() on small models
2+
saltation Sensitivities across events, switches and sliding, checked against symbolic solutions
3+
fitCoPt Landau-Lifshitz-Gilbert model fitted to measured magnetisation dynamics

‎demo/basics.R‎

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# The main functions of cppDE on small models: sensitivities of first and second
2+
# order, reverse-mode gradients, events, switches, forcings, steady states, the
3+
# cvode() backend and cppFUN().
4+
5+
library(cppDE)
6+
library(ggplot2)
7+
8+
theme_set(theme_bw(base_size = 11))
9+
10+
# A trajectory and its sensitivities as one long data frame, one panel per
11+
# state and per derivative of a state.
12+
longFormat <- function(out, states = colnames(out$variable), sens = TRUE) {
13+
nt <- length(out$time)
14+
value <- data.frame(time = out$time, panel = rep(states, each = nt),
15+
value = as.vector(out$variable[, states]))
16+
if (!sens) return(value)
17+
tangent <- out$tangent[, states, , drop = FALSE]
18+
grid <- expand.grid(time = out$time, state = states,
19+
par = dimnames(tangent)[[3]], stringsAsFactors = FALSE)
20+
rbind(value, data.frame(time = grid$time,
21+
panel = sprintf("d%s/d%s", grid$state, grid$par),
22+
value = as.vector(tangent)))
23+
}
24+
25+
26+
# Values and sensitivities ------------------------------------------------------
27+
# A decay chain A -> B. cppODE() generates and compiles the solver; solveODE()
28+
# returns the states and, by default, their derivatives in every initial value
29+
# and parameter.
30+
eqns <- c(A = "-k1*A", B = "k1*A - k2*B")
31+
parms <- c(A = 1, B = 0, k1 = 0.5, k2 = 0.2)
32+
times <- seq(0, 10, by = 0.1)
33+
34+
chain <- cppODE(eqns, modelname = "chain")
35+
out <- solveODE(chain, times, parms)
36+
37+
dim(out$tangent) # [time, state, parameter]
38+
out$tangent[101, "B", ] # dB(10) / d(A, B, k1, k2)
39+
40+
ggplot(longFormat(out), aes(time, value)) +
41+
geom_line() +
42+
facet_wrap(~panel, scales = "free_y", ncol = 5)
43+
44+
45+
# Second order -----------------------------------------------------------------
46+
# deriv2 = TRUE adds the Hessian of every state in the initial values and
47+
# parameters.
48+
chain2 <- cppODE(eqns, deriv2 = TRUE, modelname = "chain2")
49+
out2 <- solveODE(chain2, times, parms)
50+
out2$hessian[101, "B", , ] # Hessian of B(10)
51+
52+
53+
# Reverse mode -----------------------------------------------------------------
54+
# derivMode = "reverse" returns the gradient of a weighted sum of the states,
55+
# sum(cotangent * x), in one backward sweep. The cotangent below weights B at
56+
# t = 10 only, so the gradient is the row dB(10) / d(A, B, k1, k2) from above.
57+
chainR <- cppODE(eqns, derivMode = "reverse", modelname = "chainR")
58+
w <- matrix(0, length(times), 2, dimnames = list(NULL, c("A", "B")))
59+
w[101, "B"] <- 1
60+
solveODE(chainR, times, parms, cotangent = w)$cotangent
61+
62+
# The gradient of a least-squares objective: the cotangent holds the residuals.
63+
data <- data.frame(time = times, B = 0.4 * exp(-0.3 * times) * (1 - exp(-times)))
64+
res <- out$variable[, "B"] - data$B
65+
w[] <- 0
66+
w[, "B"] <- 2 * res
67+
solveODE(chainR, times, parms, cotangent = w)$cotangent
68+
2 * colSums(res * out$tangent[, "B", ]) # the same from forward mode
69+
70+
71+
# Events -----------------------------------------------------------------------
72+
# Events are a data frame: which state, when or on which root, which value, and
73+
# how it is applied (replace, add, multiply). The sensitivities take each jump,
74+
# also where a parameter moves the event time.
75+
events <- data.frame(var = c("A", "A"), time = c("t_e", NA), value = c("dose", "dose"),
76+
method = c("add", "add"), root = c(NA, "A - Amin"))
77+
dosed <- cppODE(eqns, events = events, modelname = "dosed")
78+
outE <- solveODE(dosed, times, c(parms, t_e = 2, dose = 1, Amin = 0.3), maxroot = 3)
79+
80+
ggplot(longFormat(outE, "A"), aes(time, value)) +
81+
geom_line() +
82+
facet_wrap(~panel, scales = "free_y", ncol = 4)
83+
84+
85+
# Switches ---------------------------------------------------------------------
86+
# Comparisons and piecewise() switch the right-hand side. The solver locates the
87+
# switch as a root, so a short pulse is not stepped over. Below, a pulse of
88+
# inflow into A between t1 and t2.
89+
pulse <- cppODE(c(A = "u*(time >= t1 && time < t2) - k1*A", B = "k1*A - k2*B"),
90+
modelname = "pulse")
91+
outP <- solveODE(pulse, times, c(A = 0, B = 0, k1 = 0.5, k2 = 0.2, u = 1, t1 = 1,
92+
t2 = 1.5))
93+
94+
ggplot(longFormat(outP, "B"), aes(time, value)) +
95+
geom_line() +
96+
facet_wrap(~panel, scales = "free_y", ncol = 4)
97+
98+
# A switch on a state. Above xc, x decays; below, an inflow p > k*xc pushes it
99+
# back up. Both branches move onto x = xc, so x slides along it, and from then
100+
# on its only sensitivity is the one in xc.
101+
slide <- cppODE(c(x = "-k*x + p*(x < xc)"), modelname = "slide")
102+
outS <- solveODE(slide, times, c(x = 2, k = 1, p = 3, xc = 1))
103+
104+
ggplot(longFormat(outS), aes(time, value)) +
105+
geom_line() +
106+
facet_wrap(~panel, scales = "free_y", ncol = 5)
107+
108+
109+
# Forcings ---------------------------------------------------------------------
110+
# A forcing is an input given as data when solving. Between its points it is
111+
# interpolated monotonically.
112+
forced <- cppODE(c(A = "u - k1*A", B = "k1*A - k2*B"), forcings = "u",
113+
modelname = "forced")
114+
u <- list(u = data.frame(time = seq(0, 10, by = 0.5),
115+
value = 1 + sin(seq(0, 10, by = 0.5))))
116+
outF <- solveODE(forced, times, parms, forcings = u)
117+
118+
ggplot(longFormat(outF, sens = FALSE), aes(time, value)) +
119+
geom_line() +
120+
facet_wrap(~panel, scales = "free_y")
121+
122+
123+
# Steady states ----------------------------------------------------------------
124+
# rootfunc = "equilibrate" integrates until every derivative is negligible and
125+
# returns the steady state with its sensitivities.
126+
steady <- cppODE(c(A = "k0 - k1*A", B = "k1*A - k2*B"), rootfunc = "equilibrate",
127+
modelname = "steady")
128+
outQ <- solveODE(steady, c(0, 1e4), c(parms, k0 = 1), abstol = 1e-10,
129+
reltol = 1e-10, roottol = 1e-10)
130+
tail(outQ$time, 1)
131+
tail(outQ$variable, 1) # k0/k1 and k0/k2
132+
outQ$tangent[nrow(outQ$variable), , ]
133+
134+
# ptc() solves for the steady state directly, by pseudo-transient continuation,
135+
# with the conservation laws of the system as constraints. The right-hand side
136+
# is a cppFUN() object.
137+
enzyme <- c(E = "-kon*E*S + (koff + kcat)*ES",
138+
S = "-kon*E*S + koff*ES + kback*P",
139+
ES = " kon*E*S - (koff + kcat)*ES",
140+
P = " kcat*ES - kback*P")
141+
enz <- cppFUN(enzyme, variables = names(enzyme),
142+
parameters = c("kon", "koff", "kcat", "kback"), modelname = "enzyme",
143+
compile = TRUE)
144+
C <- rbind(Etot = c(E = 1, S = 0, ES = 1, P = 0),
145+
Stot = c(E = 0, S = 1, ES = 1, P = 1))
146+
ss <- ptc(enz, x = c(E = 1, S = 1, ES = 1, P = 1),
147+
parms = c(kon = 1, koff = 0.5, kcat = 0.3, kback = 0.1),
148+
C = C, total = c(1, 10))
149+
ss$x
150+
151+
152+
# The cvode() backend -----------------------------------------------------------
153+
# The same equations on SUNDIALS CVODES, if cppDE was installed with it.
154+
if (codegenAvailable("cvode")) {
155+
chainC <- cvode(eqns, deriv = TRUE, modelname = "chainC")
156+
outC <- solveODE(chainC, times, parms, abstol = 1e-10, reltol = 1e-10)
157+
outN <- solveODE(chain, times, parms, abstol = 1e-10, reltol = 1e-10)
158+
max(abs(outC$tangent - outN$tangent))
159+
}
160+
161+
162+
# Algebraic functions ----------------------------------------------------------
163+
# cppFUN() compiles functions of variables and parameters with their Jacobian,
164+
# here an observable of the decay chain. The Jacobian is in the variables and
165+
# parameters together.
166+
g <- cppFUN(c(y = "s*(A + B)", z = "log(B)"), variables = c("A", "B"),
167+
parameters = "s", modelname = "observable", compile = TRUE)
168+
g$func(A = 0.2, B = 0.5, s = 2)
169+
g$jac(A = 0.2, B = 0.5, s = 2)[1, , ]

0 commit comments

Comments
 (0)