Skip to content

Core Concepts

Back to Home

A Commol model is a set of compartments that each hold an amount, and transitions that move amounts between them along the axis, the independent variable of the model (time is the most common one). The amounts of every compartment together are the state of the model, and their sum is the total. An amount can be a count, a mass or a concentration. This page explains each building block once, with a runnable example. For every option of every method, see Building Models.

The concepts at a glance

Concept What it is Builder method
Compartment (bin) A state that holds an amount add_bin
Stratification A dimension that splits every compartment into categories add_stratification
Parameter A named constant or formula used in rates add_parameter
Transition A flow between compartments, with a rate add_transition
Accumulator A counter of what a transition moved; not part of the state add_accumulator
Process The bins, stratifications, transitions and accumulators: what changes and how, written once the add_* methods
Structure How the process is laid out; Aggregate holds amounts per compartment and says how much starts where Aggregate(...)
Model The validated definition: a header, a process, a structure and an engine build(structure, engine)
Simulation Runs a model for a number of steps Simulation(model).run

Compartments

A compartment (a bin) holds an amount, in the unit of the bin. A model can have any number of them, with any ids.

from commol import Aggregate, ModelBuilder, Simulation

builder = (
    ModelBuilder(name="Concepts")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_bin(id="C", name="State C")
)

Parameters

A parameter is a named value that rates can use. Its value is a number, a formula (a string that can use other parameters and the special variables), or None when calibration will find it.

builder = (
    builder
    .add_parameter(id="k1", value=0.3, description="A to B rate")
    .add_parameter(id="k2", value=0.1, description="B to C rate")
)

Transitions

A transition moves an amount from its source compartments to its target compartments along the axis. How much it moves depends on its rate:

The rate… Kind Flow Example
names a compartment or a bin total Absolute the rate itself "k1 * A * B / total"
names no compartment Proportional rate × source "k2" moves k2 × B
builder = (
    builder
    .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1 * A * B / total")
    .add_transition(id="t_bc", source=["B"], target=["C"], rate="k2")
)

The engine decides the kind when the model is built, and model.describe() shows the result: the proportional rate above shows as k2 * B. Transitions gives the exact rule, with its edge cases.

Accumulators

An accumulator counts what a transition has moved since step 0. It is an output of the simulation, not part of the state: nothing is ever taken from it, and it is not part of the total.

builder = builder.add_accumulator(id="cum_ab", name="Total moved from A to B")
builder = builder.add_transition(
    id="t_ab_counted",
    source=["A"],
    target=["B"],
    rate="0.01 * A",
    accumulators=["cum_ab"],
)

With stratifications, an accumulator expands like a bin (cum_ab_g1, cum_ab_g2, …). Resetting a simulation sets it back to 0.

Initial conditions: the structure

The calls above write the process: what changes and how. The structure lays the process out. Aggregate holds an amount per compartment, and gives the amount that each bin starts with, in the unit of the bin. An amount is a real number, finite and not negative:

structure = Aggregate(bin_amounts={"A": 900, "B": 100, "C": 0})

Every bin needs an amount. An amount may be None when calibration will find it (Model Calibration). The structure lists every argument of Aggregate and when each rule is checked.

Build and run

build joins the process with the structure and an engine, validates the definition and returns a Model. Simulation runs it in the Rust engine:

model = builder.build(structure, engine="DifferenceEquations")

results = Simulation(model).run(num_steps=50)

print(sorted(results))   # ['A', 'B', 'C', 'cum_ab']
print(len(results["A"]))  # 51: step 0 to step 50

A Model has a header (its name, parameters and step unit), the process (bins, stratifications, transitions and accumulators), the structure that lays it out, and the engine that runs it. The process is written once; the structure is what changes between kinds of model, so ModelBuilder builds every kind its process fits. Here model.structure is an Aggregate:

print(model.engine.type)                    # DifferenceEquations
print(model.structure.kind)                 # aggregate
print([b.id for b in model.process.bins])   # ['A', 'B', 'C']
print(model.structure.bin_amounts)          # {'A': 900.0, 'B': 100.0, 'C': 0.0}

Four engines run an aggregate model, with the same process and structure:

Engine Time Crate
DifferenceEquations Advances in whole steps; each step applies every flow once. commol-difference
DifferentialEquations Continuous: the engine integrates dX/dt = Σ flows with an ODE solver. commol-differential
StochasticEvents Continuous, at random: whole units move one event at a time. commol-stochastic
StochasticSteps Whole steps, at random: each step draws how many units each flow moves. commol-stochastic

See Differential equations and Stochastic Simulation.

What a rate means

A flow is per unit of the axis in every engine, and the unit check is the same for every engine. What its value means is the engine's (rate_meaning of the engine's list, EngineType in crates/commol-core/src/types/engine.rs):

Engine rate_meaning A flow of value f Equation
DifferenceEquations per_step Moves f in one step: the flow of an absolute rate, or f × source for a proportional rate X(n+1) = X(n) + Σ flows
DifferentialEquations continuous Is the instantaneous rate of change: over a short span Δ it moves about f × Δ dX/dt = Σ flows
StochasticEvents continuous Is the propensity of its events: the expected number of units it moves per unit of the axis dX/dt = Σ flows, in the mean
StochasticSteps per_step Is the expected number of units it moves in one step; a proportional rate is the probability that one unit moves X(n+1) = X(n) + Σ flows, in the mean

So one rate gives two different dynamics: a proportional rate k keeps (1 - k)^n of its source after n steps in a difference model, and exp(-k n) in a differential model. To find the rate that gives a mean span or a probability over a period in the engine of a model, use AxisUnits: it asks the engine.

Stratifications

A stratification splits every compartment into one compartment per category. The expanded name is {bin}_{category}, with one suffix per stratification, in the order the stratifications are declared:

Bins: A, B           Stratification group = [g1, g2]

A -> A_g1, A_g2
B -> B_g1, B_g2      4 compartments (2 bins x 2 categories)

With several stratifications, the expansion is the product of all their categories:

Stratifications group = [g1, g2] and type = [t1, t2]

A -> A_g1_t1, A_g1_t2, A_g2_t1, A_g2_t2      8 compartments for 2 bins

The initial amount of each compartment is the amount of its bin times the product of the fractions of its categories:

stratified = (
    ModelBuilder(name="Stratified")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2"])
    .add_parameter(id="k1", value=0.1)
    .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1")
    .build(
        Aggregate(
            bin_amounts={"A": 9000, "B": 1000},
            stratification_fractions={"group": {"g1": 0.6, "g2": 0.4}},
        ),
        engine="DifferenceEquations",
    )
)

for compartment in stratified.structure.initial_state(stratified.process):
    print(compartment.name, compartment.amount)
Compartment Calculation Initial amount
A_g1 9000 × 0.6 5400
A_g2 9000 × 0.4 3600
B_g1 1000 × 0.6 600
B_g2 1000 × 0.4 400

model.structure.initial_state(model.process) returns this table as the engine builds it: each compartment with its bin, its categories and its amount.

Use a stratification when subgroups have different rates, when you need output per subgroup, or when the data you fit is broken down by category.

Conditional stratifications

A stratification with conditions only splits the compartments whose earlier categories satisfy all of them. A condition may only name a stratification declared before it.

Stratifications group = [g1, g2] and subtype = [s1, s2] (only where group = g2)

A -> A_g1, A_g2_s1, A_g2_s2      3 compartments per bin instead of 4
conditional = (
    ModelBuilder(name="Conditional")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2"])
    .add_stratification(
        id="subtype",
        categories=["s1", "s2"],
        conditions=[{"stratification": "group", "category": "g2"}],
    )
    .add_parameter(id="k1", value=0.1)
    .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1")
    .build(
        Aggregate(
            bin_amounts={"A": 9000, "B": 1000},
            stratification_fractions={
                "group": {"g1": 0.6, "g2": 0.4},
                "subtype": {"s1": 0.5, "s2": 0.5},
            },
        ),
        engine="DifferenceEquations",
    )
)

print([c.name for c in conditional.structure.initial_state(conditional.process)])
# ['A_g1', 'A_g2_s1', 'A_g2_s2', 'B_g1', 'B_g2_s1', 'B_g2_s2']

The fractions of a conditional stratification are fractions of the subgroup it splits, not of the whole bin, so they multiply along the chain. subgroup_total sums the initial state over the compartments of a subgroup, and get_conditioning_categories names the subgroup:

aggregate, process = conditional.structure, conditional.process
print(aggregate.subgroup_total(process))                 # 10000
print(aggregate.subgroup_total(process, ["g2"]))         # 10000 x 0.4 = 4000
print(aggregate.subgroup_total(process, ["g2", "s1"]))   # 4000 x 0.5 = 2000
print(aggregate.get_conditioning_categories(process, "subtype"))  # ('g2',)

See Conditional Stratifications for the full API.

Per-compartment overrides

When the product of fractions cannot express the starting state, for example a state that is empty in only one category, compartment_amounts overrides single expanded compartments. Each amount is in the unit of the bin of its compartment and replaces the product for that compartment only:

overridden = (
    ModelBuilder(name="Override")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2"])
    .add_parameter(id="k1", value=0.1)
    .add_transition(id="t_ab", source=["A"], target=["B"], rate="k1")
    .build(
        Aggregate(
            bin_amounts={"A": 9000, "B": 1000},
            stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}},
            compartment_amounts={"B_g1": 0},
        ),
        engine="DifferenceEquations",
    )
)

print({c.name: c.amount for c in overridden.structure.initial_state(overridden.process)})
# B_g1 starts empty; the other compartments keep their product
Rule Checked when
Each amount is finite and not negative Aggregate(...)
Each name is an expanded compartment The model is built

An override may hold more than the product gave its compartment: the bins keep no sum. model.structure.update_compartment_amounts(model.process, {"B_g1": 50}) changes or adds overrides on a built model; the engine checks them against the expansion of the process before they are stored.

Updating the initial state after building

overridden.structure.update_bin_amounts({"A": 8500, "B": 1500})
overridden.structure.update_stratification_fractions({"g1": 0.7})  # g2 becomes 0.3
Method Rule
update_bin_amounts Each amount is finite and not negative, or None for calibration. The other bins keep their amounts.
update_stratification_fractions Within each stratification you may omit one category; it receives what remains of 1, or 0 when the given fractions pass 1. Omitting two raises. Each fraction is from 0 to 1 and the fractions sum to 1 within 1e-6, with the rule of the engine; a rejected call changes no fraction.

A category name that two stratifications declare must be qualified with its stratification, as in {"group:g1": 0.5}. Calibration writes its results back through these same methods.

Several sources or targets

A transition can have several sources or several targets. It then moves one flow, which each source loses and each target gains, so its rate must be absolute. For independent flows of one formula from several compartments, the $compartment placeholder makes one transition per compartment. See Several sources or targets.

Units of the axis

A model knows only steps: a rate is per step, and a position or a period is a number of steps. AxisUnits converts values in units to steps, and gives the rate that the engine of a model reads. Model.axis_units() gives the one of a model: its step_unit (or step when it declares none), its origin and its engine.

from commol import AxisUnits

hourly = AxisUnits(step_unit="hour")

print(hourly.steps(1, "day"))     # 24.0
print(hourly.steps(3, "day"))     # 72.0
print(hourly.steps(30, "minute")) # 0.5

along_a_line = AxisUnits(step_unit="meter")
print(along_a_line.steps(2, "km"))  # 2000.0

steps works for any dimension, and raises ValueError for a unit of another dimension than the step, naming both units. It returns a float: whether a position must be a whole number of steps is the engine's check, made where the position is used. A result within 1e-9 of an integer is that integer.

The rate conversions ask the engine what a rate means (What a rate means):

Method DifferenceEquations (per_step) DifferentialEquations (continuous)
rate_from_mean_span(span, unit) 1 - exp(-1 / n), with span equal to n steps 1 / n
rate_from_probability(p, period, unit) 1 - (1 - p) ** (1 / n), with period equal to n steps -ln(1 - p) / n
difference = AxisUnits(step_unit="day", engine="DifferenceEquations")
differential = AxisUnits(step_unit="day", engine="DifferentialEquations")

print(difference.rate_from_mean_span(4, "day"))    # 0.2212 = 1 - exp(-1/4)
print(differential.rate_from_mean_span(4, "day"))  # 0.25
print(difference.rate_from_probability(0.3, 1, "week"))
print(differential.rate_from_probability(0.3, 1, "week"))

With either rate, one outflow moves p of its bin over the period, and the bin keeps exp(-n / span) at each step n. In the difference model the mean residence is then 1 / rate steps (4.52 for a span of 4), not the span: a per-step rate cannot keep both the survival and the mean of the continuous process, and these conversions keep the survival.

An axis of time with an origin also converts between dates and positions, and gives the ends of calendar days and weeks; see Dates on the axis.

The axis also gives the window grid. A window has a width, a span in a unit, which must be a whole number of steps; any other width raises ValueError, naming the span, its unit and the step unit. A width in steps is the span in the step unit, such as (7, "day") for 7 steps of a day, or (7, "step") when the model declares no step unit.

Method Returns
window_start(i, span, unit), window_end(i, span, unit) The positions that bound window i: i · width and (i + 1) · width, with the width in steps
window_index(at, span, unit) The window that holds a position, max(ceil(at / width) − 1, 0); the inverse of window_end. Reports place observations with this rule.
window_ends(span, unit, end) Every complete window end up to the position end
print(hourly.window_start(0, 1, "week"), hourly.window_end(0, 1, "week"))  # 0.0 168.0
print(hourly.window_end(2, 1, "week"))       # 504.0
print(hourly.window_index(168, 1, "week"))   # 0
print(hourly.window_index(168.5, 1, "week")) # 1
print(hourly.window_ends(1, "day", 100))     # [24.0, 48.0, 72.0, 96.0]

A window covers the positions (start, end], the same interval that windowed observations use. Windowed totals reads the gain of a series over each window.

Next steps

To learn Read
Every builder option Building Models
What rate formulas can contain Mathematical Expressions
Rates that change along the axis Axis Patterns
Running and reading simulations Running Simulations