User Features
What makes camdl pleasant to write models in.
Write the math, not the code
camdl reads like the math it represents. A transition is “from → to at rate.” An index is a mathematical subscript. A table is a lookup array.
infection[a in age] : S[a] --> I[a]
@ beta * S[a] * sum(b in age, C[a, b] * I[b] / N[b])No hidden multiplication by population counts. No implicit scope rules. The rate is the total propensity — what you’d write on paper.
Physical units
Unit literals prevent the most common class of modeling errors: rate/duration confusion. The compiler tracks dimensions and converts at compile time.
time_unit = 'days
parameters {
gamma : rate # 1/time
mu : rate
}
tables {
age_dur : age 'years = [5, 60] # durations in years
mu_age : age 'per_day = [0.00007, 0.00004] # rates in per-day
}
simulate {
from = 0 'days
to = 5 'years # automatically converted: 5 × 365.25 = 1826.25 days
}Supported: 'days, 'weeks, 'months, 'years, 'per_day, 'per_week, 'per_month, 'per_year. Mixed-unit arithmetic works: 0.1 'per_day * 5 'days = 0.5 (dimensionless). Adding a rate to a duration is a compile error.
Calendar-based forcing with range syntax
Specify school terms, work weeks, or campaign windows as day ranges instead of raw arrays. The compiler generates the values.
forcing {
# UK school calendar (He et al. 2010)
school : periodic {
period = 365.25 'days
step = 1 'days
on = [7:100, 115:199, 252:300, 308:356]
}
}Four ranges, one line. The compiler produces 365 bins with exactly 277 school days (fraction = 0.7589). If you use step = 7 'days with day-granularity ranges, the compiler warns that endpoints don’t align to the step size (W301).
Use school(t) in rate expressions — the (t) makes the time dependency explicit. Bare school also works.
Stochastic process control
Rate wrappers control how event counts are drawn per transition. The default is Poisson (demographic stochasticity). Two alternatives for specific modeling needs:
transitions {
# Standard: count ~ Poisson(rate × dt)
recovery : I --> R @ gamma * I
# Extra-demographic noise: count ~ NegBinomial (He et al. 2010)
# Gamma noise on the rate — variance scales quadratically with mean
infection : S --> E @ overdispersed(beta * S * I / N, sigma_se)
# Deterministic rounding: count = nearbyint(rate × dt)
# For demographic flows where Poisson noise is unphysical
birth : --> S @ deterministic((1.0 - cohort) * daily_births)
}Models with overdispersed() transitions produce a hard error on --backend gillespie — the capabilities system catches incompatible backend choices before simulation starts.
Math functions and time
t is the current simulation time, available anywhere in expressions. Standard math functions work as expected:
let day_of_year = mod(t, 365.25)
let pop_decay = N0 * exp(-mu * t)
let threshold = if I > floor(sqrt(N)) then 1.0 else 0.0Available: exp, log, sqrt, abs, floor, ceil, mod.
Named indexing
When a compartment has multiple dimensions, use named indices to avoid positional ambiguity:
dimensions {
age = [child, adult]
patch = [north, south, east]
}
# Positional: first index = age, second = patch
S[child, north]
# Named: order doesn't matter, intent is clear
S[patch = north, age = child]
# Omit a dimension to sum over it
S[age = child] # = S[child, north] + S[child, south] + S[child, east]Data-driven dimensions
Dimension levels can come from data files. No manual enumeration of 774 district names:
dimensions {
patch = read("data/population.tsv", column = "district")
}
tables {
pop : patch = read("data/population.tsv")
adj : patch × patch = read("data/adjacency.tsv", default = 0.0)
}The compiler validates every table entry against the known dimension levels. Typos produce an error with a Levenshtein suggestion.
Iteration primitives
Three composable patterns cover all structured transitions:
# For each value in a dimension
infection[a in age] : S[a] --> I[a] @ beta * S[a] * I[a] / N[a]
# For consecutive pairs (aging, Erlang sub-stages)
aging[(a, a_next) in consecutive(age)] : S[a] --> S[a_next]
@ (1 / age_dur[a]) * S[a]
# For every integer compartment (death, migration)
death[c in compartments, a in age] : c[a] --> @ mu * c[a]Combine with where guards for compile-time filtering:
migration[c in compartments, src in patch, dst in patch]
: c[src] --> c[dst]
@ theta * pop[dst] / (distance[src, dst] ^ 2) * c[src]
where src != dstThe compiler expands the Cartesian product and filters at compile time. 774² = 599,076 candidate transitions, minus 774 self-loops, in one declaration.
Scenarios as counterfactual patches
Interventions are off by default. Scenarios select which fire:
scenarios {
baseline {
label = "no SIA"
}
with_sia {
enable = [sia]
set = { vacc_eff = 0.95 }
}
high_transmission {
scale = { beta = 1.5 }
}
combined {
compose = [with_sia, high_transmission]
}
}CRN coupling: same seed with different scenarios produces correlated trajectories. Pre-intervention trajectories are byte-identical.
Inspect without simulating
camdl eval evaluates time-dependent expressions at a grid without running a simulation. Useful for verifying forcing curves, covariates, and parameter formulas:
camdl eval model.camdl --params p.toml --expr "school,seas" --from 0 --to 365 --every 1Output is TSV — pipe to a file, load in polars/R, plot. If an expression references compartment state, the error message directs you to --trace instead.
Particle filter diagnostics
camdl pfilter --trace shows one-step-ahead predictions alongside the data, not just a log-likelihood number:
time ll_increment ESS pred_mean pred_q05 pred_q50 pred_q95 observed
7 -7.84 17.4 42.3 5 31 112 82
14 -5.37 217.7 51.2 12 45 98 98
See exactly where the model predicts well (data inside the 90% interval) and where it fails. Supports both NegBinomial and discretized Normal observation models (--obs-model discretized_normal).
Compiler diagnostics
The compiler catches errors at compile time with domain-specific messages:
error[E100]: parameter name 't' is reserved for simulation time
= hint: choose a different name
error[E203]: C_age is declared as age × age, but index 2 ('j') is bound
to 'sex'. Did you mean 'j in age'?
warning[W301]: periodic range 7:100 is not aligned to step size 7
= hint: use step = 1 for exact boundaries
Dimension mismatches, missing indices, wrong function arities, reserved name collisions, and unit errors are all caught before simulation starts.
Content-addressable output
Every simulation run is stored in a directory determined by its inputs:
runs/{sim_hash}/{scenario}-{scen_hash}/seed_{N}/
Same inputs → same hash → cached. Different inputs → different directory. Add more seeds without re-running existing ones. Change one scenario without invalidating others.
Multiple simulation backends
One model, four backends. Choose the right tradeoff:
| Backend | When to use |
|---|---|
gillespie |
Small populations, extinction matters |
tau_leap |
Large populations, fast approximate |
chain_binomial |
Euler-multinomial (matches pomp’s reulermultinom) |
ode |
Deterministic parameter sweeps |
camdl simulate model.camdl --params p.toml --backend chain_binomial --dt 0.5 --seed 42The chain-binomial uses true multinomial competing-risk draws with deferred state updates — the exact Euler-multinomial algorithm used in the pomp ecosystem.
Why camdl: a side-by-side comparison
The He et al. (2010) London measles model — the same model, in pomp and camdl.
School-term forcing
pomp — 20 lines of C inside a string:
// Inside Csnippet("...")
seas = 1.0 - amplitude;
if ((t-floor(t)) >= 7.0/365.0 && (t-floor(t)) <= 100.0/365.0)
seas = 1.0 + amplitude * 0.2411/0.7589;
else if ((t-floor(t)) >= 115.0/365.0 && (t-floor(t)) <= 199.0/365.0)
seas = 1.0 + amplitude * 0.2411/0.7589;
else if ((t-floor(t)) >= 252.0/365.0 && (t-floor(t)) <= 300.0/365.0)
seas = 1.0 + amplitude * 0.2411/0.7589;
else if ((t-floor(t)) >= 308.0/365.0 && (t-floor(t)) <= 356.0/365.0)
seas = 1.0 + amplitude * 0.2411/0.7589;camdl — 4 ranges:
forcing {
school : periodic {
period = 365.25 'days
step = 1 'days
on = [7:100, 115:199, 252:300, 308:356]
}
}
let seas = 1.0 - amplitude + amplitude * (1.0 + 0.2411 / 0.7589) * school(t)Transmission with overdispersion
pomp — manual Gamma draw and rate arithmetic:
dw = rgammawn(sigmaSE, dt);
beta = R0 * (gamma+mu) * seas;
foi = beta * pow(I+iota, alpha) / pop * dw/dt;
rate[0] = foi;
rate[1] = mu;
reulermultinom(2, S, &rate[0], dt, &trans[0]);
S += nearbyint(pop*br*dt) - trans[0] - trans[1];camdl — the transition reads as math:
infection : S --> E @ overdispersed(beta * seas * S * ((I + iota) ^ alpha) / pop(t), sigma_se)The overdispersed() wrapper handles the Gamma-Poisson compound internally. The compiler expands the stoichiometry. The runtime handles competing risks. No manual index arithmetic.
Observation model
pomp — 8 lines of C:
double m = rho*C;
double v = m*(1.0-rho+psi*psi*m);
double tol = 1e-18;
if (cases > 0.0)
lik = pnorm(cases+0.5,m,sqrt(v),1,0) - pnorm(cases-0.5,m,sqrt(v),1,0) + tol;
else
lik = pnorm(0.5,m,sqrt(v),1,0) + tol;
if (give_log) lik = log(lik);camdl — one block:
observations {
weekly_cases : {
projected = incidence(recovery)
every = 7 'days
likelihood = neg_binomial(mean = rho * projected, r = k)
}
}Parameter transforms
pomp — separate declaration, manual enumeration:
partrans = parameter_trans(
log = c("R0","sigma","gamma","alpha","iota","sigmaSE","psi"),
logit = c("rho","cohort","amplitude"),
barycentric = c("S_0","E_0","I_0","R_0")
)camdl — derived from parameter types:
parameters {
R0 : positive # → log transform
sigma : rate # → log transform
rho : probability # → logit transform
amplitude : probability # → logit transform
}No separate declaration. The type system implies the transform. If you declare a parameter as probability, the inference engine knows it lives on [0,1] and uses logit. You can’t accidentally forget to list a parameter in the transform declaration.
The model as a whole
pomp stitches together C code strings, R function calls, covariate tables, parameter name vectors, and state variable lists. The model structure (compartments, transitions, stoichiometry) is implicit in the C snippets — you have to read the code to know that trans[0] is infection and rate[2] is sigma.
camdl is one file where every piece has a name: compartments are declared, transitions read as “from → to at rate,” tables have typed dimensions, and the compiler validates everything at compile time. A dimension mismatch, a missing index, or a unit confusion produces a clear error before simulation starts — not a segfault in dynamically compiled C code at runtime.