Skip to content

Transitions

Back to Building Models

A transition moves an amount at every step: from its source compartments to its target compartments, at the rate you give. This page documents ModelBuilder.add_transition completely: rates, several sources, transitions between categories, stratified rates, impulses, amounts below zero, and what build checks.

Quick path

from commol import Aggregate, ModelBuilder, Simulation

model = (
    ModelBuilder(name="Transitions")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_bin(id="C", name="State C")
    .add_parameter(id="k1", value=0.3)
    .add_parameter(id="k2", value=0.1)
    .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")
    .build(
        Aggregate(
            bin_amounts={"A": 900, "B": 100, "C": 0},
        ),
        engine="DifferenceEquations",
    )
)

print(model.describe().render("text"))  # every flow, as the engine computes it

add_transition

Argument Type Default Meaning
id str required The identifier.
source list[str] required Bins (or one category) that lose the flow. [] means the flow enters the system.
target list[str] required Bins (or one category) that gain the flow. [] means the flow leaves the system.
rate str, float, AxisPattern or None None The rate formula; see Rates. With stratified_rates, the fallback rate. None with no stratified rate gives a transition with no flows.
stratified_rates list[dict] \| None None Rates for some categories; see Stratified rates.
per_compartment bool \| None None Replaces bin names in the rate with each flow's own compartment; see per_compartment.
accumulators list[str] \| None None Accumulators that count the flow; see Building Models.
impulse_at list[float] \| None None Makes the transition an impulse at these steps; see Impulses.
impulse_every tuple[float, float] \| None None (period, offset): an impulse at offset, offset + period, …; see Impulses.

Rates

A rate is a formula (a string), a number, or a AxisPattern. A formula can use parameters, compartments, bin totals, total, step, constants and functions; see Mathematical Expressions.

Absolute and proportional rates

The engine decides for each flow whether its rate is the flow itself or a rate per member of the source:

Case Kind Flow
The rate names a compartment, a bin total or a total_… variable Absolute rate
No source Absolute rate
A stratified rate sets "absolute": True or False As set —
Otherwise Proportional rate × source

A rate that reads total and no compartment, such as k1 / total, is proportional (plan_transition_flows in crates/commol-model/src/flows.rs).

Rate Kind Flow from B to C
"k2" Proportional k2 * B
"k2 * B" Absolute k2 * B
"k1 * A * B / total" Absolute k1 * A * B / total
"0.5" with source=[] Absolute 0.5 enters the target

The description of the model shows every flow after this decision, so a proportional rate appears multiplied by its source.

Rates that change along the axis

A formula may use step (or t), the current step:

periodic = (
    ModelBuilder(name="Periodic")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_parameter(id="k1", value=0.3)
    .add_transition(
        id="t_ab",
        source=["A"],
        target=["B"],
        rate="k1 * (1 + 0.3 * sin(2 * pi * step / 365)) * A * B / total",
    )
    .build(
        Aggregate(
            bin_amounts={"A": 990, "B": 10},
        ),
        engine="DifferenceEquations",
    )
)

For pulses, windows, ramps and schedules per group, use Axis Patterns.

Several sources or targets

A transition with several sources or targets moves one flow: every entry in source loses it and every entry in target gains it. A bin listed twice loses or gains it twice. The rate must be absolute.

merge = (
    ModelBuilder(name="Merge")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_bin(id="C", name="State C")
    .add_parameter(id="k", value=0.001)
    .add_transition(id="t1", source=["A", "B"], target=["C", "C"], rate="k * A * B")
    .build(
        Aggregate(
            bin_amounts={"A": 500, "B": 500, "C": 0},
        ),
        engine="DifferenceEquations",
    )
)
print(merge.describe().render("text"))
# A loses k * A * B, B loses k * A * B, C gains 2 * (k * A * B)

The $compartment placeholder

For independent flows with one formula, write $compartment in the rate. The builder creates one transition per source (or per target), named {id}__{compartment}, with $compartment replaced by that compartment:

outflow = (
    ModelBuilder(name="Outflow")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_bin(id="C", name="State C")
    .add_parameter(id="mu", value=0.01)
    .add_transition(
        id="outflow",
        source=["A", "B", "C"],
        target=[],
        rate="mu * $compartment * (1 + 0.1 * $compartment / total)",
    )
    .build(
        Aggregate(
            bin_amounts={"A": 400, "B": 300, "C": 300},
        ),
        engine="DifferenceEquations",
    )
)

for transition in outflow.process.transitions:
    print(transition.id, transition.rate)
# outflow__A mu * A * (1 + 0.1 * A / total)
# outflow__B mu * B * (1 + 0.1 * B / total)
# outflow__C mu * C * (1 + 0.1 * C / total)
Rule Otherwise
Several sources with one or no target, or several targets with one or no source ValueError: the mapping would be ambiguous
At least two sources or two targets ValueError: write the compartment name instead
A rate is given ValueError

stratified_rates may also use $compartment; each generated transition gets its own copy. Each generated transition keeps per_compartment, accumulators and the impulse schedule of the call. With per_compartment=True, $compartment reads the compartment that flows: in a flow from A_g1, "k * $compartment" is k * A_g1.

Transitions between categories

When source and target are categories of one stratification, the transition moves amounts between them in every bin:

moving = (
    ModelBuilder(name="Moving")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2"])
    .add_parameter(id="k", value=0.1)
    .add_transition(id="move", source=["g1"], target=["g2"], rate="k")
    .build(
        Aggregate(
            bin_amounts={"A": 500, "B": 500},
            stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}},
        ),
        engine="DifferenceEquations",
    )
)
print(moving.describe().render("text"))
# move (g1 -> g2):  A_g1 -> A_g2: k * A_g1   and   B_g1 -> B_g2: k * B_g1

A category transition needs exactly one source and one target category. If the target category is split by a conditional stratification, every flow must say which of its categories it enters, with a to condition (see Redirecting the target with to); otherwise build raises.

per_compartment

With stratifications, a bin id in a rate means the total of the bin. In a flow from A_g1, "k * A" uses A_g1 + A_g2. per_compartment=True replaces each source and target bin id with the flow's own compartment:

Flow rate="k * A" with per_compartment=True
A_g1 -> B_g1 k * A (the total) k * A_g1
A_g2 -> B_g2 k * A (the total) k * A_g2
own_compartment = (
    ModelBuilder(name="Per compartment")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2"])
    .add_parameter(id="k", value=0.1)
    .add_transition(id="t", source=["A"], target=["B"], rate="k * A", per_compartment=True)
    .build(
        Aggregate(
            bin_amounts={"A": 500, "B": 500},
            stratification_fractions={"group": {"g1": 0.5, "g2": 0.5}},
        ),
        engine="DifferenceEquations",
    )
)
print(own_compartment.describe().render("text"))
# A_g1 -> B_g1: k * A_g1   and   A_g2 -> B_g2: k * A_g2

It applies to the fallback rate and to every stratified rate, and only to transitions between bins. Any other bin in the rate keeps its total: in the flow A_g1 -> B_g1, "k * A * C / total" reads A_g1 and the total of C.

Stratified rates

stratified_rates gives some categories their own rate. Each entry has conditions and a rate, and may set absolute:

group_rates = (
    ModelBuilder(name="Group rates")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_stratification(id="group", categories=["g1", "g2", "g3"])
    .add_parameter(id="k_g1", value=0.15)
    .add_parameter(id="k_g2", value=0.10)
    .add_parameter(id="k_default", value=0.05)
    .add_transition(
        id="t_ab",
        source=["A"],
        target=["B"],
        rate="k_default",
        stratified_rates=[
            {"conditions": [{"stratification": "group", "category": "g1"}], "rate": "k_g1"},
            {"conditions": [{"stratification": "group", "category": "g2"}], "rate": "k_g2"},
        ],
    )
    .build(
        Aggregate(
            bin_amounts={"A": 900, "B": 0},
            stratification_fractions={"group": {"g1": 0.3, "g2": 0.3, "g3": 0.4}},
        ),
        engine="DifferenceEquations",
    )
)
print(group_rates.describe().render("text"))
# A_g1 -> B_g1: k_g1 * A_g1,  A_g2 -> B_g2: k_g2 * A_g2,  A_g3 -> B_g3: k_default * A_g3
Entry key Type Meaning
conditions list[dict] All must match the source compartment (AND).
rate str or float The rate for matching compartments.
absolute bool, optional Forces the rate to be absolute or proportional.
Condition key Meaning
stratification The stratification id.
category Optional. The category the source must have.
to Optional. The category the target gets instead; see below.

Which rate applies: for each source compartment, the entry that matches with the most conditions wins; on a tie, the first one in the list. With no match, the fallback rate applies. With three stratifications and one entry for g2, t1, v2:

Source compartment Conditions matched Rate
A_g2_t1_v2 3 of 3 The entry
A_g2_t1_v1 not all Fallback
A_g1_t1_v1 not all Fallback

Redirecting the target with to

A condition with category filters the source. A condition with to changes the target category. A condition with only to filters nothing, so it can send the flow into a stratification that the source does not have:

redirect = (
    ModelBuilder(name="Redirect")
    .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="k", value=0.1)
    .add_transition(
        id="move",
        source=["g1"],
        target=["g2"],
        stratified_rates=[{"conditions": [{"stratification": "subtype", "to": "s1"}], "rate": "k"}],
    )
    .build(
        Aggregate(
            bin_amounts={"A": 500, "B": 500},
            stratification_fractions={
                "group": {"g1": 0.5, "g2": 0.5},
                "subtype": {"s1": 0.5, "s2": 0.5},
            },
        ),
        engine="DifferenceEquations",
    )
)
print(redirect.describe().render("text"))
# A_g1 -> A_g2_s1: k * A_g1   and   B_g1 -> B_g2_s1: k * B_g1

A transition without a source starts from the compartments it enters, so its category conditions choose them and build rejects a to.

Limiting when a transition runs

A transition runs at every step. To limit a flow, write the condition in the rate with if, as in "if(step > 30, k, 0)", or use a AxisPattern, a series parameter or a stratified rate. A comparison alone is a boolean, so "k * (step > 30)" is rejected.

Impulses

An impulse moves its amount at once, at given steps, and nothing between them. Give the steps with impulse_at=[…] or impulse_every=(period, offset). The rate is then a fraction of the source (proportional) or an amount (absolute), with the same rules, sources, targets, stratified rates and accumulators as any transition.

impulse_model = (
    ModelBuilder(name="Impulses")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_accumulator(id="moved", name="Moved by the impulse")
    .add_parameter(id="p", value=0.25)
    .add_parameter(id="k", value=0.1)
    .add_transition(id="jump", source=["A"], target=["B"], rate="p", impulse_at=[1], accumulators=["moved"])
    .add_transition(id="flow", source=["A"], target=["B"], rate="k")
    .build(
        Aggregate(
            bin_amounts={"A": 1000, "B": 0},
        ),
        engine="DifferenceEquations",
    )
)

jumped = Simulation(impulse_model).run(num_steps=2)
print([round(a, 1) for a in jumped["A"]])  # [1000.0, 675.0, 607.5]
print(jumped["moved"][2])  # 225.0: a quarter of the 900 left at step 1
Rule In every engine
Order At step n the state jumps first; then the flows of the step run from the new state. The output at n shows the state after the jump (CompiledModel::apply_impulses in crates/commol-model/src/system.rs).
Several impulses at one step Every jump is computed from the state before any of them.
Steps A DifferentialEquations or StochasticEvents model takes any steps, such as 0.5: the engine stops there, applies the jump and restarts. A DifferenceEquations or StochasticSteps model needs integer steps: ModelBuilder.build raises ValidationError, which names the transition.
Units The rate of an impulse is an amount, <bin unit>, not a flow per step (Units).
describe() Lists impulses apart from the equations, with their steps (Describing a Model).

An impulse is not a pulse rate. An AxisPattern.pulse is a rate, which acts with the other flows for one step, and in a differential model moves less than p (Time inside a step). An impulse moves exactly p of its source in both deterministic engines; a stochastic engine moves each unit with probability p.

Amounts below zero

A DifferenceEquations step never leaves a compartment below zero. After the impulses of a step and after its flows, the engine checks every compartment. An amount below −10⁻¹² × total (the total at the start of the step) stops the run with InfeasibleStateError, a ValueError. Its message gives the compartment, the step and the amount before the step, and lists the flows out of the compartment, largest first. The tolerance lets a flow that empties a compartment exactly pass. Accumulators are not checked: a net flow may count negative (DifferenceEquations::step in crates/commol-difference/src/simulation.rs).

from commol import InfeasibleStateError

too_fast = (
    ModelBuilder(name="Too fast")
    .add_bin(id="A", name="State A")
    .add_bin(id="B", name="State B")
    .add_parameter(id="k", value=1.5)
    .add_transition(id="t", source=["A"], target=["B"], rate="k")
    .build(
        Aggregate(
            bin_amounts={"A": 1000, "B": 0},
        ),
        engine="DifferenceEquations",
    )
)

try:
    Simulation(too_fast).run(num_steps=3)
except InfeasibleStateError as error:
    print(error)  # The flows of step 0 leave compartment 'A' at -500, ...

A step moves what the flows give, so the usual causes are in the rates:

Cause Example Fix
Proportional rates that sum above 1 rate="k" with k = 1.5 moves 1.5 times its source in one step A smaller rate per step, or a shorter step
An absolute rate on a bin total With stratifications, "k1 * A * B / total" moves from each compartment of A an amount computed from the total of A per_compartment=True, so each compartment loses an amount from its own amount (per_compartment)
A rate that turns negative "k * B * (1 - C / capacity)" once C > capacity: the flow runs backwards and drains its target Bound the factor: "k * B * max(0, 1 - C / capacity)"

A calibration gives a run that stops this way a penalty and goes on; see Calibration. A DifferentialEquations model is not checked: its solver may step below zero by its tolerance.

What building checks

build plans every flow in the engine and rejects a transition it cannot simulate as written:

Rejected Message names
A source or target that is neither a bin nor a category of one stratification The transition
A category transition without exactly one source and one target The transition
A rate variable that is not a parameter, bin, compartment or special variable The variable and the available names
Several sources with a proportional rate The transition; use an absolute rate or $compartment
A stratified rate whose category conditions match no source compartment The transition; use to to reach another stratification
A to condition on a transition without a source The transition; use category to choose the compartment it enters
A flow that ends in categories no compartment has The transition; add a to condition
impulse_at and impulse_every together, an impulse_at with no step, a step that is negative or not finite, a period that is not positive, or a negative offset; the Transition fails when it is made, with the rule of the engine (check_impulse in crates/commol-model/src/rules.rs) The transition
A non-integer impulse step in a DifferenceEquations or StochasticSteps model The transition and the step

Next steps

To learn Read
Rates that change along the axis Axis Patterns
Everything a formula can contain Mathematical Expressions
Units Units
The equations of a model Describing a Model