Writing a compartmental model¶
This SEIR example introduces the syntax through one deterministic compartmental model. The same specification layer also describes spatial/trait fields, nonlocal kernel interactions and stochastic channels. See the model-class map and operator contracts for the corresponding interfaces and boundaries.
Install the core compiler and a YAML reader:
python -m pip install op-system PyYAML
The compiler accepts a Python mapping. YAML/JSON loading is a caller concern;
compile_spec(spec) validates, normalizes, and compiles it. normalize_rhs(spec)
returns a normalized model without compilation, and validate_spec(spec)
returns a structural report. There is no public load_spec file loader.
Optional parameter declarations attach support, units, descriptions and fixed/free status to inferred RHS parameters for downstream consumers, without changing numerical evaluation.
A complete age-structured SEIR model¶
Save the following as examples/age-seir.yaml (create the examples directory
first), or use the checked example.
The groups are categorical population bins, so mixing uses an unweighted sum.
Rates are per unit time. This synthetic example demonstrates authoring and
balance checks; choose and independently qualify your scientific equations.
# Shared rate beta; susceptibility varies by target age group.
kind: transitions
axes:
- name: age
type: categorical
coords: [young, old]
state: ["S[age]", "E[age]", "I[age]", "R[age]"]
aliases:
"N[age]": "S[age] + E[age] + I[age] + R[age]"
"foi[age]": "beta * susceptibility[age] * apply_along(C[age:s,age] * I[age:s] / N[age:s], age=s, kernel=sum)"
transitions:
- name: infection
from: "S[age]"
to: "E[age]"
rate: "foi[age]"
- name: progression
from: "E[age]"
to: "I[age]"
rate: "kappa"
- name: recovery
from: "I[age]"
to: "R[age]"
rate: "gamma"
# Optional provider seed metadata; runtime values are supplied separately.
initial_state:
"S[age]": {shaped: S0, axes: [age]}
"E[age]": {shaped: E0, axes: [age]}
"I[age]": {shaped: I0, axes: [age]}
"R[age]": {shaped: R0, axes: [age]}
state order fixes the flat layout: all young/old S cells, then E, I, and R.
Each bracketed state carries only its listed axes. Bare states are scalar;
there is no automatic inheritance of every declared axis. Quote selectors in
YAML flow lists, because their brackets and commas are part of the string.
An alias is an expression, not another compartment. N[age] is the current
population in that age bin. C[source_age, target_age] has source rows and target
columns: C[age:s,age] binds the first index to source s, keeps the second at
the current target age, and sums the source prevalence I/N. Use an asymmetric
matrix while checking orientation; a symmetric matrix can hide a transpose.
A transition with from uses a per-capita hazard: the infection flow is
foi[age] * S[age], not just foi[age]. Do not multiply by the source a second
time in its rate. from omitted/null creates an absolute source inflow.
to must name a declared state: omitted/null sink destinations are currently
unsupported and fail validation. Use an expression equation for an open loss,
or track the loss in an explicit compartment. The latter retains the lost
amount in the declared state total and exports a closed transfer reaction.
A closed transition subtracts and adds the same flow, preserving population.
These standalone alternatives both give x' = -k*x; the explicit loss
compartment additionally gives loss' = k*x:
import numpy as np
from op_system import compile_spec
open_loss = compile_spec({
"kind": "expr",
"state": ["x"],
"equations": {"x": "-k*x"},
})
tracked_loss = compile_spec({
"kind": "transitions",
"state": ["x", "loss"],
"transitions": [{"name": "loss", "from": "x", "to": "loss", "rate": "k"}],
})
open_derivative = open_loss.eval_fn(0.0, np.asarray([3.0]), k=2.0) # [-6]
tracked_derivative = tracked_loss.eval_fn(0.0, np.asarray([3.0, 0.0]), k=2.0) # [-6, 6]
Expression equations do not currently export executable jump reactions; choose the representation required by the consuming deterministic/stochastic engine.
initial_state records provider seed parameter names and constants. It does not
allocate or initialize core arrays. A consuming framework resolves S0, E0, I0,
and R0; core callers supply the state themselves as below. The shaped form
states the parameter's axis order explicitly. A scalar mapping such as S: S0 or
a per-cell template such as "S[age]": "S0[age]" is also supported.
A state that starts at a known constant (typically 0 for a cumulative counter
fed by a source-only transition) needs no seed parameter: give the key a numeric
literal, for example "C[age]": 0.0 or D: 0. The literal is broadcast to every
cell the key selects (a pinned coordinate such as "C[age=young]": 0.0 selects
one), is stored as a float in compiled.meta["initial_state"], and requests no
provider parameter. It must be a finite real number; booleans, nan, and inf
are rejected. There is no {fixed: ...}/{value: ...} mapping spelling, and a
quoted "0.0" is still a parameter name. States omitted from initial_state
are left to the consuming framework (the op_engine provider uses zero, but a
literal records the intent explicitly).
Evaluate the example¶
Run from the directory containing examples/age-seir.yaml. The scalar parameters
beta, kappa, and gamma are shared, susceptibility varies over the target
age axis, and C is shared as a two-axis matrix. Arrays use float64 throughout.
compiled.param_names lists inputs; validate_spec(spec).parameters reports
scalar and shaped parameter axes;
compiled.template_shapes describes the structured layout.
from pathlib import Path
import numpy as np
import yaml
from op_system import compile_spec
spec = yaml.safe_load(Path("examples/age-seir.yaml").read_text())
compiled = compile_spec(spec)
state = {
"S": np.asarray([900.0, 600.0]),
"E": np.asarray([20.0, 10.0]),
"I": np.asarray([30.0, 40.0]),
"R": np.asarray([50.0, 50.0]),
}
params = {
"beta": 0.3,
"kappa": 0.2,
"gamma": 0.1,
"susceptibility": np.asarray([1.0, 0.7]),
"C": np.asarray([[4.0, 1.0], [2.0, 3.0]]),
}
flat = np.concatenate([state[name] for name in ("S", "E", "I", "R")])
flat_rhs = compiled.eval_fn(0.0, flat, **params)
assert compiled.pytree_eval_fn is not None
rhs = compiled.pytree_eval_fn(0.0, state, **params)
np.testing.assert_allclose(
flat_rhs, np.concatenate([rhs[name] for name in ("S", "E", "I", "R")])
)
np.testing.assert_allclose(sum(rhs.values()), np.zeros(2), atol=1e-12)
print(np.round(np.stack(list(rhs.values())), 3))
Expected rows are S, E, I, R, with columns young, old:
[[-63.257 -25.38 ]
[ 59.257 23.38 ]
[ 1. -2. ]
[ 3. 4. ]]
This evaluates a derivative. Use a numerical engine to integrate it in time.
For scientific checks beyond structural validation, use
qualify_rhs with independently written equations
and fixtures. The guide's CI test executes these exact YAML/Python fences and
checks cell flows and per-age conservation against an independent loop oracle.
Equations and expression syntax¶
Use kind: expr with one equation for each declared state to write derivatives
directly. The following equations are equivalent to the transitions above when
the same axes, aliases, and state are retained and transitions is removed:
kind: expr
equations:
"S[age]": "-foi[age] * S[age]"
"E[age]": "foi[age] * S[age] - kappa * E[age]"
"I[age]": "kappa * E[age] - gamma * I[age]"
"R[age]": "gamma * I[age]"
Expressions support allowlisted arithmetic, named state/parameter references,
aliases, and documented helper calls. They are not arbitrary Python programs:
imports, attribute traversal, and arbitrary function calls are rejected.
Reference a templated state as I[age]; a bare I is invalid unless a scalar
state I is declared. Expression bindings use age:s inside brackets and
age=s in reducer arguments. Endpoint pinning uses "S[age=young]" in transition
selectors. See expression selectors,
global totals, and aging chains.
Axes and mixing choices¶
| Axis type | Coordinates and reduction |
|---|---|
| Categorical (default) | Unique nonempty labels; sum_over / apply_along(..., kernel=sum) use unweighted sums |
| Ordinal | Unique labels in declared order; reductions use sums while directional operators use the order |
| Continuous | Strictly increasing numeric coordinates; integrate_over / default apply_along use trapezoidal weights, requiring at least two points except the special constant time-table axis |
Nonuniform continuous grids carry quadrature weights; interpreting population
counts as density values changes units. sum_state() totals declared cells
without quadrature. For example, sum_over(I[age:a], age=a) sums discrete age
bins, while integrate_over(u[x:i], x=i) integrates a field over continuous x.
Time is a separate forcing axis; see time-indexed parameters.
Add spatial/risk axes explicitly to states and use local parameters such as
beta[loc]. An independent loc can be declared in factorize_axes: [loc] only
when no RHS, alias, or operator couples locations. The compiler rejects invalid
factorization; see independent batches. Full and block
layouts are separate choices from the runtime array namespace.
Core callers supply matrix C as a parameter. Provider users can alternatively
declare kernels with an explicit value matrix or a supported analytic form
and declared numeric axes; the provider constructs and injects those values.
Core compilation preserves these declarations as metadata and does not itself
materialize analytic kernels. See the
provider README
and coverage audit for generation contracts.
Operators and splitting¶
An operators block describes processes for a consuming numerical engine,
separately from equations/transitions. For example, an ordinal immune-state axis
can declare advection with a rate parameter and direction, while a continuous
trait can declare conservative jumps. Compilation exposes typed descriptors in
compiled.operators; eval_fn alone does not advance or apply these metadata
operators. The engine must resolve coefficients, discretization, boundaries,
and splitting stages. Follow the operator guide for supported
schemas and conservative reference functions; do not count a process twice by
also including it in the RHS.
Cross-axis constraints: current boundary¶
constraints is currently a reserved metadata block. Public normalization
preserves it verbatim; the private constraint parser's validation does not
provide public masking. State expansion and reductions still cover the full
Cartesian product. Filtering and constraint-aware reductions are not implemented.
The planned schema uses at least two axes and either allow or exclude:
# Planned masking metadata; this does not remove current compartments.
constraints:
- axes: [age, vax]
allow:
- {age: young, vax: [none]}
- {age: old}
An allow rule would retain its matching combinations; an omitted axis in a
rule would match all its coordinates. An exclude rule would remove matches.
Until filtering is implemented, represent asymmetric populations explicitly,
for example state: ["S_young", "S_old[vax]"], with equations/transitions for
those states and independently checked totals. Do not rely on this reserved
block to prevent invalid flows or omit state cells.