Simcraft Docs

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

sdparadigms
5components
4connections
2experiments
daystime unit

Stocks, 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:

spec/examples/sir-epidemic.osdl.json · model.time
{
  "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:

spec/examples/sir-epidemic.osdl.json · model.components[0].params
{
  "initial": {
    "expr": "population - initialInfected"
  },
  "nonNegative": true
}
The susceptible stock starts at the whole population minus the initially infected.

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:

spec/examples/sir-epidemic.osdl.json · model.components[3]
{
  "id": "infection",
  "type": "sd.flow",
  "params": {
    "rate": {
      "expr": "beta * susceptible.value * infected.value / population"
    }
  }
}
beta × S × I ÷ N: infections per day, recomputed continuously as the stocks change.

Recovery is simpler: the infected stock drains at rate gamma.

spec/examples/sir-epidemic.osdl.json · model.components[4]
{
  "id": "recovery",
  "type": "sd.flow",
  "params": {
    "rate": {
      "expr": "gamma * infected.value"
    }
  }
}

Run it#

SIR epidemicloading…
The specification document, verbatim. beta ÷ gamma is the basic reproduction number: at the defaults it is 3, and the epidemic infects most of the population. Drag beta below 0.1 and the outbreak never takes off.
View the model document
osdl · json
{
  "$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#

NameTypeDefaultDescription
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#

IdTypeParams
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.outinfection.in
  • infection.outinfected.in
  • infected.outrecovery.in
  • recovery.outrecovered.in

Experiments#

NameDurationReplicationsSweepOutputs
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
json
{
  "$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
        }
      ]
    }
  ]
}