SIR epidemic#
The classic compartment model of an epidemic: a population moves from susceptible through infected to recovered, driven by two rates. It is the standard first system dynamics model, and its behavior, a single epidemic wave whose size depends on beta and gamma, is well understood.
Generated from spec/examples/sir-epidemic.osdl.json
sdparadigmsStocks, flows, and dt#
A system dynamics model is continuous: stocks hold quantities, flows move quantity between stocks at rates, and the engine integrates the rates over time.
In OSDL, continuous dynamics run on the same calendar as everything else. time.integration.dt sets the spacing of integration boundaries, and each boundary is a scheduled event on the shared clock. Discrete events at a boundary observe stock values from before the update; the new values become visible once all work at that time completes. This ordering is what makes hybrid models deterministic.
The component contracts are in the sd library reference.
The document, piece by piece#
The model declares its time unit and integration step up front:
{
"unit": "days",
"integration": {
"method": "euler",
"dt": 0.25
}
}Three stocks hold the population. Their initial values are expressions over the model parameters, evaluated at load time:
{
"initial": {
"expr": "population - initialInfected"
},
"nonNegative": true
}Two flows connect them. The infection flow's rate is the standard mass-action term, written in the OSDL expression language over parameter names and state paths:
{
"id": "infection",
"type": "sd.flow",
"params": {
"rate": {
"expr": "beta * susceptible.value * infected.value / population"
}
}
}Recovery is simpler: the infected stock drains at rate gamma.
{
"id": "recovery",
"type": "sd.flow",
"params": {
"rate": {
"expr": "gamma * infected.value"
}
}
}Run it#
View the model document
{
"$schema": "https://osdl.dev/schemas/0.1/osdl.schema.json",
"osdl": "0.1",
"model": {
"name": "sir-epidemic",
"label": "SIR Epidemic",
"description": "Classic susceptible-infected-recovered compartment model as pure system dynamics: two flows move population between three stocks at expression-defined rates.",
"time": {
"unit": "days",
"integration": {
"method": "euler",
"dt": 0.25
}
},
"parameters": [
{
"name": "population",
"description": "Total population.",
"type": "integer",
"default": 1000
},
{
"name": "initialInfected",
"description": "Infected individuals at t0.",
"type": "integer",
"default": 10
},
{
"name": "beta",
"description": "Effective contact rate: contacts per person per day times transmission probability.",
"default": 0.3,
"unit": "1/day"
},
{
"name": "gamma",
"description": "Recovery rate; mean infectious period is 1/gamma days.",
"default": 0.1,
"unit": "1/day"
}
],
"components": [
{
"id": "susceptible",
"type": "sd.stock",
"params": {
"initial": {
"expr": "population - initialInfected"
},
"nonNegative": true
}
},
{
"id": "infected",
"type": "sd.stock",
"params": {
"initial": {
"param": "initialInfected"
},
"nonNegative": true
}
},
{
"id": "recovered",
"type": "sd.stock",
"params": {
"initial": 0,
"nonNegative": true
}
},
{
"id": "infection",
"type": "sd.flow",
"params": {
"rate": {
"expr": "beta * susceptible.value * infected.value / population"
}
}
},
{
"id": "recovery",
"type": "sd.flow",
"params": {
"rate": {
"expr": "gamma * infected.value"
}
}
}
],
"connections": [
{
"from": "susceptible.out",
"to": "infection.in"
},
{
"from": "infection.out",
"to": "infected.in"
},
{
"from": "infected.out",
"to": "recovery.in"
},
{
"from": "recovery.out",
"to": "recovered.in"
}
]
},
"experiments": [
{
"name": "baseline",
"duration": 160,
"outputs": [
{
"path": "susceptible.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "infected.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "recovered.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
}
]
},
{
"name": "beta-sweep",
"description": "How does the epidemic peak respond to the contact rate?",
"duration": 160,
"sweep": [
{
"parameter": "beta",
"values": [
0.2,
0.3,
0.4,
0.5
]
}
],
"outputs": [
{
"path": "infected.value",
"as": "peakInfected",
"records": [
{
"type": "summary",
"statistics": [
"max"
]
}
]
},
{
"path": "recovered.value",
"as": "totalRecovered",
"records": [
{
"type": "final"
}
]
}
]
}
],
"views": [
{
"id": "main",
"label": "Stock & flow diagram",
"type": "canvas",
"elements": [
{
"ref": "susceptible",
"x": 0,
"y": 0
},
{
"ref": "infection",
"x": 200,
"y": 0
},
{
"ref": "infected",
"x": 400,
"y": 0
},
{
"ref": "recovery",
"x": 600,
"y": 0
},
{
"ref": "recovered",
"x": 800,
"y": 0
}
]
}
]
}The curves are smooth because this model is purely continuous: the recorded timeseries sample the integrated stock values. There is no randomness either. The document declares no distributions, so every run with the same parameters is identical, whatever the seed.
Reference#
Parameters#
| Name | Type | Default | Description |
|---|---|---|---|
population |
integer |
1000 |
Total population. |
initialInfected |
integer |
10 |
Infected individuals at t0. |
beta |
number |
0.3 |
Effective contact rate: contacts per person per day times transmission probability. unit: 1/day |
gamma |
number |
0.1 |
Recovery rate; mean infectious period is 1/gamma days. unit: 1/day |
Components#
| Id | Type | Params |
|---|---|---|
susceptible |
sd.stock |
{"initial":{"expr":"population - initialInfected"},"nonNegative":true} |
infected |
sd.stock |
{"initial":{"param":"initialInfected"},"nonNegative":true} |
recovered |
sd.stock |
{"initial":0,"nonNegative":true} |
infection |
sd.flow |
{"rate":{"expr":"beta * susceptible.value * infected.value / population"}} |
recovery |
sd.flow |
{"rate":{"expr":"gamma * infected.value"}} |
Connections#
susceptible.out→infection.ininfection.out→infected.ininfected.out→recovery.inrecovery.out→recovered.in
Experiments#
| Name | Duration | Replications | Sweep | Outputs |
|---|---|---|---|---|
baseline |
160 | 1 | — | susceptible.value, infected.value, recovered.value |
beta-sweep |
160 | 1 | beta × 4 |
peakInfected, totalRecovered |
Source#
spec/examples/sir-epidemic.osdl.json · 4.6 KB
{
"$schema": "https://osdl.dev/schemas/0.1/osdl.schema.json",
"osdl": "0.1",
"model": {
"name": "sir-epidemic",
"label": "SIR Epidemic",
"description": "Classic susceptible-infected-recovered compartment model as pure system dynamics: two flows move population between three stocks at expression-defined rates.",
"time": {
"unit": "days",
"integration": {
"method": "euler",
"dt": 0.25
}
},
"parameters": [
{
"name": "population",
"description": "Total population.",
"type": "integer",
"default": 1000
},
{
"name": "initialInfected",
"description": "Infected individuals at t0.",
"type": "integer",
"default": 10
},
{
"name": "beta",
"description": "Effective contact rate: contacts per person per day times transmission probability.",
"default": 0.3,
"unit": "1/day"
},
{
"name": "gamma",
"description": "Recovery rate; mean infectious period is 1/gamma days.",
"default": 0.1,
"unit": "1/day"
}
],
"components": [
{
"id": "susceptible",
"type": "sd.stock",
"params": {
"initial": {
"expr": "population - initialInfected"
},
"nonNegative": true
}
},
{
"id": "infected",
"type": "sd.stock",
"params": {
"initial": {
"param": "initialInfected"
},
"nonNegative": true
}
},
{
"id": "recovered",
"type": "sd.stock",
"params": {
"initial": 0,
"nonNegative": true
}
},
{
"id": "infection",
"type": "sd.flow",
"params": {
"rate": {
"expr": "beta * susceptible.value * infected.value / population"
}
}
},
{
"id": "recovery",
"type": "sd.flow",
"params": {
"rate": {
"expr": "gamma * infected.value"
}
}
}
],
"connections": [
{
"from": "susceptible.out",
"to": "infection.in"
},
{
"from": "infection.out",
"to": "infected.in"
},
{
"from": "infected.out",
"to": "recovery.in"
},
{
"from": "recovery.out",
"to": "recovered.in"
}
]
},
"experiments": [
{
"name": "baseline",
"duration": 160,
"outputs": [
{
"path": "susceptible.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "infected.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
},
{
"path": "recovered.value",
"records": [
{
"type": "timeseries",
"interval": 1
},
{
"type": "summary"
}
]
}
]
},
{
"name": "beta-sweep",
"description": "How does the epidemic peak respond to the contact rate?",
"duration": 160,
"sweep": [
{
"parameter": "beta",
"values": [
0.2,
0.3,
0.4,
0.5
]
}
],
"outputs": [
{
"path": "infected.value",
"as": "peakInfected",
"records": [
{
"type": "summary",
"statistics": [
"max"
]
}
]
},
{
"path": "recovered.value",
"as": "totalRecovered",
"records": [
{
"type": "final"
}
]
}
]
}
],
"views": [
{
"id": "main",
"label": "Stock & flow diagram",
"type": "canvas",
"elements": [
{
"ref": "susceptible",
"x": 0,
"y": 0
},
{
"ref": "infection",
"x": 200,
"y": 0
},
{
"ref": "infected",
"x": 400,
"y": 0
},
{
"ref": "recovery",
"x": 600,
"y": 0
},
{
"ref": "recovered",
"x": 800,
"y": 0
}
]
}
]
}