Building Models¶
The ModelBuilder class provides a fluent API for constructing compartment models.
ModelBuilder Basics¶
Creating a Builder¶
from commol import ModelBuilder
builder = ModelBuilder(
name="My Model",
version="1.0",
description="Optional description",
bin_unit="person" # Optional: default unit for all bins
)
Parameters¶
name(required): Unique identifier for your modelversion(optional): Version string for tracking model changesdescription(optional): Human-readable description of the modelbin_unit(optional): Default unit for all bins (disease states). When specified, this enables:- Automatic unit assignment to bins, predefined population variables (
N,N_young, etc.), and stratification categories - Unit checking via
model.check_unit_consistency() - Unit annotations in
model.print_equations()output
Common values: "person", "individual", or any custom population unit.
Note: Individual bins can override this with their own unit parameter in add_bin().
Chaining Methods¶
The builder uses method chaining for a clean, readable API:
model = (
ModelBuilder(name="SIR Model")
.add_bin(id="S", name="Susceptible")
.add_bin(id="I", name="Infected")
.add_bin(id="R", name="Recovered")
.add_parameter(id="beta", value=0.3)
.add_transition(id="infection", source=["S"], target=["I"], rate="beta * S * I / N")
.build("DifferenceEquations")
)
Adding Disease States¶
builder.add_bin(
id="S", # Required: Unique identifier
name="Susceptible", # Required: Display name
description="Population susceptible to infection" # Optional
)
Best Practices¶
- Use short, clear IDs (S, I, R, E, etc.)
- Provide descriptive names
Adding Stratifications¶
Stratifications create population subgroups:
builder.add_stratification(
id="age_group",
categories=["0-17", "18-64", "65+"],
description="Age-based stratification"
)
Multiple Stratifications¶
builder.add_stratification(id="age", categories=["young", "old"])
builder.add_stratification(id="location", categories=["urban", "rural"])
This creates compartments: S_young_urban, S_young_rural, S_old_urban, S_old_rural, etc.
Adding Parameters¶
Parameters are global constants used in formulas:
Parameters with Units¶
You can specify units for automatic dimensional analysis and validation:
builder.add_parameter(
id="beta",
value=0.5,
description="Transmission rate",
unit="1/day" # Rate unit
)
builder.add_parameter(
id="seasonal_amplitude",
value=0.2,
description="Seasonal variation amplitude",
unit="dimensionless" # Pure number
)
When all parameters have units, the model will automatically validate dimensional consistency. See Unit Checking below.
Tip: To mark a parameter as unitless (dimensionless) for unit checking, use unit="dimensionless". This is useful for ratios, fractions, scaling factors, and amplitudes. Dimensionless parameters are also required as arguments to mathematical functions like sin(), cos(), exp(), sqrt(), pow(), etc.
Parameter Guidelines¶
- Use meaningful IDs (beta, gamma, R0, etc.)
- Document units and meaning
- Ensure values are realistic for your model
- Specify units for automatic validation (recommended)
Adding Transitions¶
Transitions move populations between states.
Understanding Transition Rates¶
The rate parameter accepts mathematical expressions that can include:
- Parameters: Reference parameter IDs (e.g.,
"gamma") - Disease states: Use state populations (e.g.,
"S","I") - Special variables:
N(total population),steport(current time step),pi,e - Mathematical operations:
+,-,*,/,**(power) - Functions:
sin(),cos(),exp(),log(),sqrt(),max(),min(), etc.
For the complete list of functions and advanced examples, see Mathematical Expressions.
Simple Parameter-Based Rates¶
builder.add_transition(
id="recovery",
source=["I"],
target=["R"],
rate="gamma" # References parameter id
)
Formula-Based Rates¶
builder.add_transition(
id="infection",
source=["S"],
target=["I"],
rate="beta * S * I / N" # Mathematical expression
)
Constant Rates¶
builder.add_transition(
id="birth",
source=[], # Empty = enters system
target=["S"],
rate="0.001" # Fixed rate
)
Time-Dependent Rates¶
builder.add_transition(
id="seasonal_infection",
source=["S"],
target=["I"],
rate="beta * (1 + 0.3 * sin(2 * pi * t / 365)) * S * I / N"
)
See Mathematical Expressions for more complex rate formulas.
Multi-State Transitions¶
# Death from any compartment
builder.add_transition(
id="death",
source=["S", "I", "R"],
target=[], # Empty = leaves system
rate="mu"
)
Using $compartment Placeholder for Per-Compartment Rates¶
When applying the same type of transition to multiple compartments with per-compartment rates (like per-capita death rates), use the $compartment placeholder to avoid repetitive code:
# Instead of writing 4 separate transitions:
# .add_transition("death_S", ["S"], [], rate="d * S")
# .add_transition("death_L", ["L"], [], rate="d * L")
# .add_transition("death_I", ["I"], [], rate="d * I")
# .add_transition("death_R", ["R"], [], rate="d * R")
# Write one transition that automatically expands:
builder.add_transition(
id="death",
source=["S", "L", "I", "R"],
target=[],
rate="d * $compartment" # $compartment gets replaced with S, L, I, R
)
How it works:
- The system detects
$compartmentin the rate formula - Automatically creates one transition per source compartment
- Replaces
$compartmentwith the actual compartment name in each transition - Generated transition IDs use the pattern:
{id}__{compartment}(e.g.,death__S,death__L)
Complex formulas with multiple occurrences:
builder.add_transition(
id="nonlinear_death",
source=["S", "I", "R"],
target=[],
rate="d * $compartment * (1 + 0.1 * $compartment / N)"
)
# Expands to:
# death__S: rate = "d * S * (1 + 0.1 * S / N)"
# death__I: rate = "d * I * (1 + 0.1 * I / N)"
# death__R: rate = "d * R * (1 + 0.1 * R / N)"
With single target (transfers):
builder.add_transition(
id="treatment",
source=["I_mild", "I_severe"],
target=["R"], # All recover to same compartment
rate="treatment_rate * $compartment"
)
With stratified rates:
builder.add_stratification(id="age", categories=["young", "old"])
builder.add_transition(
id="death",
source=["S", "I", "R"],
target=[],
rate="d_base * $compartment", # Fallback rate
stratified_rates=[
{
"conditions": [{"stratification": "age", "category": "young"}],
"rate": "d_young * $compartment" # Lower death rate for young
},
{
"conditions": [{"stratification": "age", "category": "old"}],
"rate": "d_old * $compartment" # Higher death rate for old
}
]
)
# Expands to death__S, death__I, death__R, each with their own stratified rates
Restrictions:
- Only valid with multiple source compartments (2 or more)
- Target must be empty
[]or contain exactly one compartment - Cannot be used if you want different targets for different sources
Comparison with standard multi-source transitions:
Standard multi-source transitions (without $compartment) create a single transition that affects all sources simultaneously:
# This creates ONE transition
.add_transition(
id="interaction",
source=["S", "I"],
target=["I", "I"],
rate="beta * S * I"
)
# Resulting equations:
# dS/dt = ... - (beta*S*I)
# dI/dt = ... - (beta*S*I) + 2*(beta*S*I) = ... + (beta*S*I)
With $compartment, you create multiple independent transitions:
# This creates TWO separate transitions
.add_transition(
id="death",
source=["S", "I"],
target=[],
rate="d * $compartment"
)
# Resulting equations:
# dS/dt = ... - (d*S)
# dI/dt = ... - (d*I)
Stratified Transitions¶
When a model includes stratifications, you often need different transition rates for different subgroups. The add_transition method supports this via the stratified_rates parameter.
This parameter takes a list of dictionaries, where each dictionary defines a rate for a specific combination of stratification categories.
Single Stratification¶
Let's define different recovery rates for different age groups.
builder.add_stratification(id="age", categories=["child", "adult", "elderly"])
builder.add_parameter(id="gamma_child", value=0.15)
builder.add_parameter(id="gamma_adult", value=0.1)
builder.add_parameter(id="gamma_elderly", value=0.08)
builder.add_transition(
id="recovery",
source=["I"],
target=["R"],
stratified_rates=[
{
"conditions": [{"stratification": "age", "category": "child"}],
"rate": "gamma_child"
},
{
"conditions": [{"stratification": "age", "category": "adult"}],
"rate": "gamma_adult"
},
{
"conditions": [{"stratification": "age", "category": "elderly"}],
"rate": "gamma_elderly"
},
]
)
Multi-Stratification Transitions¶
To define rates for intersections of multiple stratifications, add multiple conditions to a single rate entry.
For example, let's model different infection rates for high-risk adults in urban areas.
builder.add_stratification(id="age", categories=["child", "adult"])
builder.add_stratification(id="risk", categories=["low", "high"])
builder.add_stratification(id="location", categories=["urban", "rural"])
builder.add_parameter(id="beta_urban_adult_high_risk", value=0.8)
builder.add_parameter(id="beta_default", value=0.3)
builder.add_transition(
id="infection",
source=["S"],
target=["I"],
rate="beta_default * S * I / N", # Fallback rate
stratified_rates=[
{
"conditions": [
{"stratification": "age", "category": "adult"},
{"stratification": "risk", "category": "high"},
{"stratification": "location", "category": "urban"},
],
"rate": "beta_urban_adult_high_risk * S * I / N"
}
]
)
In this example:
- The
rateparameter acts as a fallback for any compartment that doesn't match a specific stratified rate. - The
stratified_ratesentry defines a high infection rate that only applies to compartments matching all three conditions (e.g.,S_adult_high_urban).
Setting Initial Conditions¶
Basic Setup¶
builder.set_initial_conditions(
population_size=1000,
bin_fractions=[
{"bin": "S", "fraction": 0.99},
{"bin": "I", "fraction": 0.01},
{"bin": "R", "fraction": 0.0}
]
)
With Stratifications¶
builder.set_initial_conditions(
population_size=10000,
bin_fractions=[
{"bin": "S", "fraction": 0.99},
{"bin": "I", "fraction": 0.01},
{"bin": "R", "fraction": 0.0}
],
stratification_fractions=[
{
"stratification": "age_group",
"fractions": [
{"category": "young", "fraction": 0.3},
{"category": "adult", "fraction": 0.5},
{"category": "elderly", "fraction": 0.2}
]
},
{
"stratification": "risk",
"fractions": [
{"category": "low", "fraction": 0.8},
{"category": "high", "fraction": 0.2}
]
}
]
)
Building the Model¶
Once all components are added, build the model:
Validation¶
The build process validates:
- All disease state fractions sum to 1.0
- All stratification fractions sum to 1.0
- Transition sources/targets reference valid states
- Mathematical expressions are syntactically correct
- No security issues in formulas
If validation fails, a descriptive error is raised.
Unit Checking¶
Commol provides automatic dimensional analysis to catch unit errors in your model equations. This validates that rate expressions produce the correct units and that mathematical functions receive dimensionally correct arguments.
Enabling Unit Checking¶
Unit checking is enabled when all parameters have units:
# Build model with units
builder = ModelBuilder(name="SIR with Units", version="1.0")
builder.add_bin("S", "Susceptible")
builder.add_bin("I", "Infected")
builder.add_bin("R", "Recovered")
# Specify units for all parameters
builder.add_parameter("beta", 0.5, "Transmission rate", unit="1/day")
builder.add_parameter("gamma", 0.1, "Recovery rate", unit="1/day")
builder.add_transition(
"infection", ["S"], ["I"],
rate="beta * S * I / N"
)
builder.add_transition("recovery", ["I"], ["R"], rate="gamma * I")
builder.set_initial_conditions(
population_size=1000,
bin_fractions=[
{"bin": "S", "fraction": 0.99},
{"bin": "I", "fraction": 0.01},
{"bin": "R", "fraction": 0.0},
],
)
model = builder.build(typology="DifferenceEquations")
# Validate dimensional consistency
model.check_unit_consistency() # Raises error if units are inconsistent
Common Units¶
# Rate units
unit="1/day" # Per-day rates
unit="1/week" # Per-week rates
# Population units (automatically assigned to disease states)
unit="person" # Population count
# Dimensionless quantities
unit="dimensionless" # Ratios, fractions, amplitudes
Mathematical Functions¶
All standard math functions work with unit checking and validate their arguments:
# Seasonal forcing (sin requires dimensionless argument)
builder.add_parameter("beta_avg", 0.5, unit="1/day")
builder.add_parameter("seasonal_amp", 0.2, unit="dimensionless")
builder.add_transition(
"infection", ["S"], ["I"],
rate="beta_avg * (1 + seasonal_amp * sin(2 * pi * step / 365)) * S * I / N"
)
# Exponential decay (exp requires dimensionless argument)
builder.add_parameter("beta_0", 0.5, unit="1/day")
builder.add_parameter("decay_rate", 0.01, unit="dimensionless")
builder.add_transition(
"infection", ["S"], ["I"],
rate="beta_0 * exp(-decay_rate * step) * S * I / N"
)
Supported functions: sin, cos, tan, exp, log, sqrt, pow, min, max, abs, and more.
Automatic Unit Assignment¶
The system automatically assigns units to:
- Disease states: All have units of
person(S, I, R, etc.) - Population variables:
N,N_young,N_urban, etc. have units ofperson - Time variables:
tandstepare dimensionless - Constants:
piandeare dimensionless
Error Detection¶
Unit checking catches common errors:
# Wrong parameter units
builder.add_parameter("beta", 0.5, unit="day") # Should be "1/day"!
# Error: Unit mismatch: equation has unit 'day * person' but expected 'person/day'
# Dimensional argument to math function
rate="beta * sin(I) * S" # I has units of person!
# Error: Cannot convert from 'person' to 'dimensionless'
# Incompatible units in operations
rate="min(beta, threshold) * S" # beta is 1/day, threshold is person
# Error: Cannot compare incompatible units
Best Practices¶
- Always specify units for physical quantities
- Use "dimensionless" for ratios and fractions
- Ensure math function arguments are dimensionless (divide by appropriate quantities)
- Use consistent time units throughout your model
Unit Display in Equations¶
When you print equations using model.print_equations(), unit annotations are displayed based on unit completeness:
# Model with complete units - shows annotations
model = (
ModelBuilder(name="SIR", bin_unit="person")
.add_bin(id="S", name="Susceptible")
.add_bin(id="I", name="Infected")
.add_parameter(id="beta", value=0.5, unit="1/day")
.add_parameter(id="gamma", value=0.1, unit="1/day")
.add_transition(id="infection", source=["S"], target=["I"], rate="beta * S * I / N")
.build()
)
model.print_equations()
# Output:
# S -> I: beta(1/day) * S(person) * I(person) / N(person) [person/day]
# Model without units - no annotations
model = (
ModelBuilder(name="SIR")
.add_bin(id="S", name="Susceptible")
.add_parameter(id="beta", value=0.5)
.build()
)
model.print_equations()
# Output:
# S -> I: beta * S * I / N
Partial Unit Definitions¶
Important: You must define units for ALL parameters and bins, or for NONE. Partial unit definitions will raise a ValueError:
# This will raise an error!
model = (
ModelBuilder(name="SIR", bin_unit="person")
.add_parameter(id="beta", value=0.5, unit="1/day") # Has unit
.add_parameter(id="gamma", value=0.1) # No unit - INCONSISTENT!
.build()
)
model.print_equations() # ValueError: Some parameters have units but not all
This prevents accidentally mixing unit systems or forgetting to specify units for some parameters.
LaTeX Output Format¶
Export equations in LaTeX format for inclusion in documents and publications:
# Default text format
model.print_equations()
# Output: dS/dt = - (beta * S * I / N)
# LaTeX format
model.print_equations(format="latex")
# Output: \[\frac{dS}{dt} = - (\beta \cdot S \cdot I / N)\]
# Save to file
model.print_equations(output_file="equations.txt", format="latex")
LaTeX features:
- Compact form uses inline math:
$S \to I: \beta \cdot S \cdot I / N$ - Expanded form uses display math:
\[\frac{dS}{dt} = ...\] - Equations are copy-paste ready into LaTeX documents
- Subscripts formatted as:
S_{young,urban} - Multiplication shown as:
\cdot
Advanced: Conditional Transitions¶
Create transitions that only occur under certain conditions:
# Create a condition
condition = builder.create_condition(
logic="and",
rules=[
{"variable": "state:I", "operator": "gt", "value": 100},
{"variable": "step", "operator": "gt", "value": 30}
]
)
# Add conditional transition
builder.add_transition(
id="intervention",
source=["S"],
target=["S"],
rate="0.5 * beta", # Reduced transmission
condition=condition
)
Loading from JSON¶
Load pre-defined models from JSON files:
JSON Structure¶
{
"name": "SIR Model",
"version": "1.0",
"population": {
"disease_states": [
{ "id": "S", "name": "Susceptible" },
{ "id": "I", "name": "Infected" },
{ "id": "R", "name": "Recovered" }
]
},
"parameters": [
{ "id": "beta", "value": 0.3 },
{ "id": "gamma", "value": 0.1 }
],
"dynamics": {
"typology": "difference_equations",
"transitions": [
{
"id": "infection",
"source": ["S"],
"target": ["I"],
"rate": "beta * S * I / N"
}
]
}
}
Complete Example¶
from commol import ModelBuilder, Simulation
# Build SEIR model
model = (
ModelBuilder(name="SEIR Model", version="1.0")
.add_bin(id="S", name="Susceptible")
.add_bin(id="E", name="Exposed")
.add_bin(id="I", name="Infected")
.add_bin(id="R", name="Recovered")
.add_parameter(id="beta", value=0.4, description="Transmission rate")
.add_parameter(id="sigma", value=0.2, description="Incubation rate")
.add_parameter(id="gamma", value=0.1, description="Recovery rate")
.add_transition(id="exposure", source=["S"], target=["E"], rate="beta * S * I / N")
.add_transition(id="infection", source=["E"], target=["I"], rate="sigma")
.add_transition(id="recovery", source=["I"], target=["R"], rate="gamma")
.set_initial_conditions(
population_size=1000,
bin_fractions=[
{"bin": "S", "fraction": 0.999},
{"bin": "E", "fraction": 0.0},
{"bin": "I", "fraction": 0.001},
{"bin": "R", "fraction": 0.0}
]
)
.build(typology="DifferenceEquations")
)
# Run simulation
simulation = Simulation(model)
results = simulation.run(num_steps=200)
Next Steps¶
- Mathematical Expressions - Advanced formulas
- Simulations - Running and analyzing models
- Examples - Complete model examples