Compare models
Each of the models as its framework’s authors wrote it, next to its Starsim translation. Pick a model:
Atomica: the HIV care cascade, with programs and budgets
Atomica’s dynamic HIV demo (at.demo('hiv_dyn')): the HIV care cascade in two populations, females and males, of a country of 56 million, from 2016 to 2035 in yearly steps. People are born susceptible, are infected, diagnosed, linked to care, put on treatment, and virally suppressed, can fail treatment or be lost to follow-up and come back, and die of HIV or of other causes. Seven programs (two testing, two kinds of initiation counseling, client tracing, and two kinds of adherence support) turn spending into coverage, and coverage into the values of five parameters, under three budgets. As in the SIR demo, the model is spreadsheets, and the Python only loads and runs them.
- Framework: atomica 1.32.4 (MIT)
- Language: Excel spreadsheets (run from Python)
- Source: atomica/library/hiv_dyn_framework.xlsx, hiv_dyn_databook.xlsx, and hiv_dyn_progbook.xlsx, and atomica/demos.py, in the atomica 1.32.4 package from PyPI (https://github.com/atomicateam/atomica/tree/main/atomica/library), run as at.demo(‘hiv_dyn’) does
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 217 | 65 | The original is counted as rows of its three spreadsheets, plus 29 lines of Python; in Starsim, long lines count twice |
| One run | first 140 ms, rerun 4.1 ms | first 32 ms, rerun 0.41 ms (Rust) | Without the programs. The original’s first run includes reading the spreadsheets, and its rerun is Project.run_sim() on the loaded project. Most of Starsim’s first run is building the sim (finding 8) |
| Results | Identical, to rounding error (a largest difference of 1 in 1014), in every compartment and every level of the cascade of both sexes, in all four runs: without programs, and with the default, doubled, and zero budgets |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (atomica)
"""
Defines some defaults for Atomica projects
Version: 2018sep24
"""
import sciris as sc
from .project import Project
from .system import LIBRARY_PATH, logger
from .scenarios import BudgetScenario
from .utils import TimeSeries
__all__ = ["demo", "make_demo_scenarios"]
def demo(which: str = None, do_run: bool = True, addprogs: bool = True) -> Project:
"""
Return a demo project
:param which: A supported demo project type e.g. 'sir', 'tb'
:param do_run: If True, run the model and store a set of results
:param addprogs: If True, load the progbook and create program scenarios
:return: A Project instance
"""
options = [
"udt",
"udt_dyn",
"usdt",
"cervicalcancer",
"sir",
"diabetes",
"combined",
# 'service',
"hypertension",
"hypertension_dyn",
"hiv",
"hiv_dyn",
"tb_simple",
"tb_simple_dyn",
"environment",
"tb",
]
dtdict = sc.odict.fromkeys(options, 1.0)
dtdict["tb"] = 0.5
if which is None or which not in options:
raise Exception("Supported project types are:\n%s" % ("\n".join(options)))
framework = LIBRARY_PATH / f"{which}_framework.xlsx"
databook = LIBRARY_PATH / f"{which}_databook.xlsx"
progbook = LIBRARY_PATH / f"{which}_progbook.xlsx"
logger.debug("Creating a " + which + " project...")
P = Project(framework=framework, databook=databook, do_run=False)
P.settings.sim_dt = dtdict[which]
if do_run:
P.run_sim(store_results=True)
if addprogs:
logger.debug("Loading progbook")
P.load_progbook(progbook)
logger.debug("Creating program scenarios")
make_demo_scenarios(P)
logger.debug("Finished creating demo project")
return P
def make_demo_scenarios(proj: Project) -> None:
"""
Create demo scenarios
This method creates three default budget scenarios
- Default budget
- Doubled budget
- Zero budget
The scenarios will be created and added to the project's list of scenarios
:param proj: A :class:`Project` instance. New scenarios will be added in-place
"""
parsetname = proj.parsets[-1].name
progset = proj.progsets[-1]
start_year = proj.data.end_year
# Come up with the current allocation by truncating after the start year
current_budget = {}
for prog in progset.programs.values():
if prog.spend_data.has_time_data:
current_budget[prog.name] = sc.dcp(prog.spend_data)
else:
current_budget[prog.name] = TimeSeries(start_year, prog.spend_data.assumption)
# Add default budget scenario
# proj.scens.append(CombinedScenario(name='Default budget',parsetname=parsetname,progsetname=progset.name,active=True,instructions=ProgramInstructions(start_year,alloc=current_budget)))
proj.scens.append(BudgetScenario(name="Default budget", parsetname=parsetname, progsetname=progset.name, active=True, alloc=current_budget, start_year=start_year))
# Add doubled budget
doubled_budget = sc.dcp(current_budget)
for ts in doubled_budget.values():
ts.insert(start_year, ts.interpolate(start_year))
ts.remove_after(start_year)
ts.insert(start_year + 1, ts.get(start_year) * 2)
# proj.scens.append(CombinedScenario(name='Doubled budget',parsetname=parsetname,progsetname=progset.name,active=True,instructions=ProgramInstructions(start_year,alloc=doubled_budget)))
proj.scens.append(BudgetScenario(name="Doubled budget", parsetname=parsetname, progsetname=progset.name, active=True, alloc=doubled_budget, start_year=start_year))
# Add zero budget
zero_budget = sc.dcp(doubled_budget)
for ts in zero_budget.values():
ts.insert(start_year + 1, 0.0)
# proj.scens.append(CombinedScenario(name='Zero budget',parsetname=parsetname,progsetname=progset.name,active=True,instructions=ProgramInstructions(start_year,alloc=zero_budget)))
proj.scens.append(BudgetScenario(name="Zero budget", parsetname=parsetname, progsetname=progset.name, active=True, alloc=zero_budget, start_year=start_year))"""
Run Atomica's dynamic HIV demo from its three spreadsheets: what `at.demo('hiv_dyn')` does, then its budget scenarios.
"""
import os
import atomica as at
here = os.path.dirname(os.path.abspath(__file__))
def load():
""" Read the model structure (the framework), its data (the databook), and its programs (the progbook), and add the demo's three budget scenarios """
P = at.Project(framework=os.path.join(here, 'hiv_dyn_framework.xlsx'), databook=os.path.join(here, 'hiv_dyn_databook.xlsx'), do_run=False)
P.settings.sim_dt = 1.0 # In years; the demo sets this, and Atomica's own default is 0.25
P.load_progbook(os.path.join(here, 'hiv_dyn_progbook.xlsx'))
at.make_demo_scenarios(P) # Default, doubled, and zero budget, from the last year of data (see demos.py)
return P
if __name__ == '__main__':
P = load()
baseline = P.run_sim() # Without programs
scenarios = P.run_scenarios()
for result in [baseline] + scenarios:
print(result.name, {pop.name: round(pop.get_variable('all_people')[0].vals[-1]) for pop in result.model.pops})| Annual number of births | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 287992.41 | ||||
| males | Number | OR | 276698.58999999997 | ||||
| Estimated number of new HIV infections annually | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 150000 | ||||
| males | Number | OR | 110000 | ||||
| Annual number of tests done | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 6400000 | ||||
| males | Number | OR | 3600000 | ||||
| Average time taken to be linked to care (years) | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Duration | OR | 0.7 | ||||
| males | Duration | OR | 1.2 | ||||
| Annual number newly initiated onto treatment | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 269448 | ||||
| males | Number | OR | 179632 | ||||
| Loss-to-follow-up rate | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | OR | 0.2 | ||||
| males | Rate (per year) | OR | 0.3 | ||||
| Time after initiating ART to achieve viral suppression (years) | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Duration | 0.2 | OR | ||||
| males | Duration | 0.2 | OR | ||||
| Treatment failure rate | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | 0.16 | OR | ||||
| males | Rate (per year) | 0.16 | OR | ||||
| Death rate for those with untreated HIV | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | 0.053 | OR | ||||
| males | Rate (per year) | 0.053 | OR | ||||
| Death rate for those with on unsuppressive ART | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | 0.0258534 | OR | ||||
| males | Rate (per year) | 0.0258534 | OR | ||||
| Death rate for those with on suppressive ART | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | 0.01219 | OR | ||||
| males | Rate (per year) | 0.01219 | OR | ||||
| Background mortality rate | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Rate (per year) | 0.015 | OR | ||||
| males | Rate (per year) | 0.015 | OR |
| Abbreviation | Full Name |
| females | Females |
| males | Males |
| Population size | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 28514100 | ||||
| males | Number | OR | 27395900 | ||||
| All PLHIV | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 3390000 | ||||
| males | Number | OR | 2540000 | ||||
| Aware of their status | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 2440800 | ||||
| males | Number | OR | 1676400 | ||||
| Ever in care | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 2237400 | ||||
| males | Number | OR | 1384300 | ||||
| Currently in care | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 2034000 | ||||
| males | Number | OR | 1092200 | ||||
| Currently treated | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 1728900 | ||||
| males | Number | OR | 939800 | ||||
| Virally suppressed | Units | Constant | 2016 | 2017 | 2018 | 2019 | |
| females | Number | OR | 1017000 | ||||
| males | Number | OR | 508000 |
| Name | Description |
| HIV with dynamics | Framework for an HIV model with vital dynamics and new infections |
| Instructions | |
| Use this file to specify the structure of your model. Any extra sheets will be ignored, so you can include other information in them | |
| Page Overview | |
| Databook Pages | Specify which worksheets will be present in the databook |
| Compartments | Specify the states that an individual can be in - an individual is only ever in one compartment at a time |
| Transitions | Specify which transitions between compartments are possible |
| Characteristics | Specify groups of people (e.g. groups of compartments) for data entry |
| Parameters | Define how to compute the flows between compartments |
| Cascades | Use the Cascades sheet to define the cascade if it is more complex than just all characteristics in sequence |
| HIV care cascade | Constituents |
| All PLHIV | all_people |
| Aware of their status | all_dx |
| Ever in care | all_ever_linked |
| Currently in care | all_curr_linked |
| Currently treated | all_tx |
| Virally suppressed | all_vs |
| Code Name | Display Name | Components | Setup Weight | Databook Page |
| alive | Population size | sus, undx, dx, linked, tx, lost, vs | 1 | stocks |
| all_people | All PLHIV | undx, dx, linked, tx, lost, vs | 1 | stocks |
| all_dx | Aware of their status | dx, linked, tx, lost, vs | 1 | stocks |
| all_ever_linked | Ever in care | linked, tx, lost, vs | 1 | stocks |
| all_curr_linked | Currently in care | linked, tx, vs | 1 | stocks |
| all_tx | Currently treated | tx, vs | 1 | stocks |
| all_vs | Virally suppressed | vs | 1 | stocks |
| Code Name | Display Name | Is Source | Is Sink | Databook Page |
| source | Source | y | n | |
| sus | Susceptible | n | n | |
| undx | Undiagnosed | n | n | |
| dx | Diagnosed | n | n | |
| linked | Linked to care | n | n | |
| tx | Treated | n | n | |
| lost | Lost to follow-up | n | n | |
| vs | Virally suppressed (comp) | n | n | |
| dead_hiv | Dead (HIV/AIDS) | n | y | |
| dead_other | Dead (other) | n | y |
| Datasheet Code Name | Datasheet Title |
| stocks | Stocks |
| flows | Flows |
| Code Name | Display Name | Format | Default Value | Minimum Value | Maximum Value | Function | Targetable | Databook Page |
| birth_rate | Annual number of births | number | 0 | n | flows | |||
| num_acq | Estimated number of new HIV infections annually | number | 0 | n | flows | |||
| acq_rate | Acquisition rate | rate | 0 | num_acq/max(sus,num_acq) | n | |||
| num_test | Annual number of tests done | number | 0 | n | flows | |||
| test_yield | Test yield | rate | 0 | undx/(undx+sus) | y | |||
| pos_test | Annual number tested positive | number | 0 | num_test*test_yield | n | |||
| diag | Test sensitivity | rate | 0 | pos_test/max(undx,pos_test) | y | |||
| link_time | Average time taken to be linked to care (years) | duration | 0 | y | flows | |||
| num_initiate | Annual number newly initiated onto treatment | number | 0 | n | flows | |||
| initiate | Initiation rate | rate | 0 | num_initiate/max(linked,num_initiate) | y | |||
| loss | Loss-to-follow-up rate | rate | 0 | y | flows | |||
| supp_rate | Time after initiating ART to achieve viral suppression (years) | duration | 0.2 | 0 | n | flows | ||
| fail_rate | Treatment failure rate | rate | 0.16 | 0 | y | flows | ||
| death_hiv | Death rate for those with untreated HIV | rate | 0.053 | 0 | n | flows | ||
| death_hivtx | Death rate for those with on unsuppressive ART | rate | 0.0258534 | 0 | n | flows | ||
| death_hivvs | Death rate for those with on suppressive ART | rate | 0.01219 | 0 | n | flows | ||
| death_other | Background mortality rate | rate | 0.015 | 0 | n | flows |
| Transition Matrix | source | sus | undx | dx | linked | tx | lost | vs | dead_hiv | dead_other |
| source | birth_rate | |||||||||
| sus | acq_rate | death_other | ||||||||
| undx | diag | death_hiv | death_other | |||||||
| dx | link_time | death_hiv | death_other | |||||||
| linked | initiate | loss | death_hiv | death_other | ||||||
| tx | loss | supp_rate | death_hivtx | death_other | ||||||
| lost | link_time | death_hiv | death_other | |||||||
| vs | fail_rate | loss | death_hivvs | death_other | ||||||
| dead_hiv | ||||||||||
| dead_other |
| Test yield | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 0 | Additive | 0.027 | 0.027 | ||||||||
| Males | 0 | Additive | 0.029 | 0.029 | ||||||||
| Test sensitivity | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 0 | Additive | 0.99 | 0.99 | ||||||||
| Males | 0 | Additive | 0.99 | 0.99 | ||||||||
| Average time taken to be linked to care (years) | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 1.5 | Additive | 0.5 | 0.3 | 0.2 | |||||||
| Males | 2.2 | Additive | 0.6 | 0.4 | 0.3 | |||||||
| Initiation rate | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 0 | Additive | 0.98 | 0.92 | ||||||||
| Males | 0 | Additive | ||||||||||
| Loss-to-follow-up rate | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 0.5 | Additive | 0.1 | 0.2 | ||||||||
| Males | 0.7 | Additive | 0.15 | 0.25 | ||||||||
| Treatment failure rate | Baseline value | Coverage interaction | Impact interaction | Uncertainty | Testing - clinics | Testing - outreach | Same-day initiation counselling | Classic initiation counselling | Client tracing | Advanced adherence support | Whatsapp adherence support | |
| Females | 0.3 | Additive | 0.1 | 0.15 | ||||||||
| Males | 0.4 | Additive | 0.15 | 0.2 |
| Targeted to (populations) | Targeted to (compartments) | ||||||||||
| Abbreviation | Display name | Females | Males | Susceptible | Undiagnosed | Diagnosed | Linked to care | Treated | Lost to follow-up | Virally suppressed (comp) | |
| Testing - clinics | Testing - clinics | Y | Y | Y | Y | N | N | N | N | N | |
| Testing - outreach | Testing - outreach | Y | Y | Y | Y | N | N | N | N | N | |
| Same-day initiation counselling | Same-day initiation counselling | Y | Y | N | N | Y | Y | N | N | N | |
| Classic initiation counselling | Classic initiation counselling | Y | Y | N | N | Y | Y | N | N | N | |
| Client tracing | Client tracing | Y | Y | N | N | N | N | Y | Y | N | |
| Advanced adherence support | Advanced adherence support | Y | Y | N | N | N | N | Y | Y | N | |
| Whatsapp adherence support | Whatsapp adherence support | Y | Y | N | N | N | N | Y | Y | N |
| Testing - clinics | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 42000000 | |||||
| Unit cost | $/person | OR | 5 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 8400000 | |||||
| Testing - outreach | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 16000000 | |||||
| Unit cost | $/person | OR | 10 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 1600000 | |||||
| Same-day initiation counselling | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 60000000 | |||||
| Unit cost | $/person | OR | 200 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 300000 | |||||
| Classic initiation counselling | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 45000000 | |||||
| Unit cost | $/person | OR | 300 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 150000 | |||||
| Client tracing | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 600000 | |||||
| Unit cost | $/person | OR | 60 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 10000 | |||||
| Advanced adherence support | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 600000 | |||||
| Unit cost | $/person | OR | 150 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 4000 | |||||
| Whatsapp adherence support | Units | Uncertainty | Assumption | 2016 | 2017 | 2018 | 2019 | |
| Annual spend | $/year | OR | 10000 | |||||
| Unit cost | $/person | OR | 0.05 | |||||
| Capacity constraint | people/year | OR | ||||||
| Saturation | N.A. | OR | ||||||
| Coverage | people/year | OR | 200000 |
Starsim
model.py
"""
The Atomica dynamic HIV demo (docs/models/atomica/hiv_dyn), in Starsim: the HIV care cascade in
females and males, with births, new infections, testing, linkage, treatment, viral suppression,
loss to follow-up, and deaths, stepped a year at a time; and seven programs whose spending sets
the coverage that changes five of its parameters, under three budgets
"""
import numpy as np
import starsim as ss
sex = lambda f, m: ss.by(sex=[f, m])
sus, undx, dx, linked, tx, lost, vs, dead_hiv, dead_other = ss.states('sus undx dx linked tx lost vs dead_hiv dead_other')
# The databook: in 2016, the number alive, living with HIV, diagnosed, ever in care, in care, treated, and suppressed
cascade = np.array([[28514100, 27395900], [3390000, 2540000], [2440800, 1676400], [2237400, 1384300],
[2034000, 1092200], [1728900, 939800], [1017000, 508000]])
# Workaround: Atomica solves for the compartments from the cascade
init = dict(zip(['sus', 'undx', 'dx', 'lost', 'linked', 'tx', 'vs'], -np.diff(cascade, axis=0, append=0)))
births = sex(287_992.41, 276_698.59)
num_acq = sex(150_000, 110_000)
num_test = sex(6_400_000, 3_600_000)
num_initiate = sex(269_448, 179_632)
mort = 0.015 # Background mortality, per year, as are all the rates
# The progbook: each program's unit cost ($ a person), spending ($ a year), and who it reaches, in both sexes
programs = ss.par(0, doc='Whether the programs set the parameters they affect')
budget = ss.par(1, doc='Spending, relative to the progbook')
progs = dict(
clinics = (5, 42e6, sus + undx), # Testing
outreach = (10, 16e6, sus + undx),
sameday = (200, 60e6, dx + linked), # Initiation counseling
classic = (300, 45e6, dx + linked),
tracing = (60, 0.6e6, tx + lost), # Client tracing
advanced = (150, 0.6e6, tx + lost), # Adherence support
whatsapp = (0.05, 10e3, tx + lost),
)
# What a program reaches is shared by everyone eligible in both sexes: .total, where .n is the count in one's own sex
cov = {k: np.minimum(budget*spend/cost/eligible.total, 1) for k,(cost,spend,eligible) in progs.items()}
# Under the programs, a parameter is its baseline plus each program's coverage times its effect (the test yield, which nothing uses then, is left out)
outcome = lambda base, **effects: sex(*base) + sum(cov[k]*(sex(*v) - sex(*base)) for k,v in effects.items())
switch = lambda before, after: (1 - programs)*before + programs*after
pos_test = num_test*undx.n/(undx + sus).n
acq = num_acq/np.maximum(sus.n, num_acq)
diag = switch(pos_test/np.maximum(undx.n, pos_test), outcome([0, 0], clinics=[0.99]*2, outreach=[0.99]*2))
link_time = switch(sex(0.7, 1.2), outcome([1.5, 2.2], clinics=[0.5, 0.6], outreach=[0.3, 0.4], tracing=[0.2, 0.3]))
initiate = switch(num_initiate/np.maximum(linked.n, num_initiate), outcome([0, 0], sameday=[0.98, 0], classic=[0.92, 0]))
loss = switch(sex(0.2, 0.3), outcome([0.5, 0.7], advanced=[0.1, 0.15], whatsapp=[0.2, 0.25]))
fail = switch(0.16, outcome([0.3, 0.4], advanced=[0.1, 0.15], whatsapp=[0.15, 0.2]))
hiv = ss.Disease(
birth = ss.unborn >> sus @ births,
acq = sus >> undx @ acq,
diag = undx >> dx @ diag,
link = dx + lost >> linked @ 1/link_time,
initiate = linked >> tx @ initiate,
loss = linked + tx + vs >> lost @ loss,
supp = tx >> vs @ ss.years(0.2),
fail = vs >> tx @ fail,
death_hiv = undx + dx + linked + lost >> dead_hiv @ 0.053,
death_tx = tx >> dead_hiv @ 0.0258534,
death_vs = vs >> dead_hiv @ 0.01219,
death = sus + undx + dx + linked + tx + lost + vs >> dead_other @ mort,
plhiv = undx + dx + linked + tx + lost + vs,
diagnosed = dx + linked + tx + lost + vs,
treated = tx + vs,
init_n = {k: sex(*v) for k,v in init.items()},
)
hiv.stratify(sex=ss.levels(dict(zip(['females', 'males'], cascade[0]/cascade[0].sum())), kind='space')) # A place, so that .n is the count in each sex
# One step a year, in which Atomica moves at most everyone out of a compartment
sim = ss.Sim(hiv, total_pop=cascade[0].sum(), start=2016, stop=2035, solver=ss.solvers.ode(integrator='euler', substeps=1))
# The budget scenarios: the programs act from 2019, and the budget is doubled or cut to nothing from 2020
on = ss.timeseries({2016: 0, 2019: 1})
budgets = dict(default=1, doubled=2, zero=0)
scenarios = {f'{k}_budget': dict(programs=on, budget=ss.timeseries({2016: 1, 2020: v})) for k,v in budgets.items()}
if __name__ == '__main__':
msim = ss.parallel(dict(baseline=sim, **{k:sim.new(pars=v) for k,v in scenarios.items()}), workers=1)
msim.plot()What was declared and what was not
Everything is declared: the transitions (written as Atomica’s transition matrix, one per parameter), the parameters, the formulas that turn annual numbers into rates, the births by sex, the cascade by sex, the programs’ coverage and effects, and the scenarios, all as expressions of counts and parameters. There are no functions of the sim left. The one thing written out by hand is marked in model.py: the initial state, worked out from the databook’s cascade with np.diff, where Atomica solves for it (as in the SIR demo).
Findings
- Fixed: a step that moved more than everyone created people. Atomica turns each parameter into a share moved in a step, and if the shares leaving a compartment add up to more than 1 it scales them down together. Here that happens every step, since the time to viral suppression is 0.2 years and the step is a year. Starsim’s Euler step now does the same with
integrator='euler'andsubstepsgiven, and a note says so, so the model is plain transitions, one for each parameter of Atomica’s transition matrix. - Fixed: a count within one’s own group, or over everyone. Atomica’s populations have rates made from their own compartment sizes (
num_acq/max(sus, num_acq)), but a program’s coverage is the number reached over everyone eligible in both populations. In Starsim,.nin a rate is the count in the same place, and.totalis now the count in the whole population. This version once needed a function of the sim for the total, which kept the run out of the fast kernels and stoppedsim.restart(). Both are gone. Sex is still stratified as a place (kind='space', with no movement), so that.nmeans the count in each sex. - Fixed: an inflow given by group was shared out again. An inflow whose rate is an
ss.bynow arrives at each level as given, so the births are written by sex, as in the databook. - Programs and budgets have no declared form by hand. Spending buys a number of people reached, coverage is that number over the eligible (capped at 1), and each parameter moves from a baseline toward each program’s outcome in proportion to coverage. Here that is three short expressions (
cov,outcome,switch), with the budget and the switch as parameters set by each scenario. Atomica says this more directly, in its program book. Starsim’s importer reads a program book, but there is noss.program()for a model written by hand. - Fixed:
ss.byis an expression.1/ss.by(sex=[0.7, 1.2])andss.by(...) - ss.by(...)work, and level by level. - Fixed: sums of states are kept by group. A declared sum of states is a stratified result, so
compare.pycompares the cascade (plhiv,diagnosed,treated) for each sex. - Gone: a warning from dividing by nobody. It came from the branching helper that finding 1 removed.
- Most of a first run is building the sim. Each program-affected rate is a switch of two expressions over seven coverages, so the model takes a while to build. A rerun of a built sim is much quicker.
- Fixed: arithmetic on
ss.byswallowed what it was combined with.ss.by(sex=[1, 2]) + I.nis now an expression, and twoss.bys over the same dimension combine level by level, somodel.pyhas lost its workarounds (ss.pararound three values, the expression written first, andnp.subtract).
Atomica: SIR with deaths, defined in spreadsheets
Atomica’s SIR demo (at.demo('sir')): one population of 1,000 adults moving between susceptible, infected, recovered, and dead, over 23 yearly steps. The model structure is in one spreadsheet (the framework) and its data in another (the databook); the Python only loads and runs them.
- Framework: atomica 1.32.4 (MIT)
- Language: Excel spreadsheets (run from Python)
- Source: atomica/library/sir_framework.xlsx and sir_databook.xlsx in the atomica 1.32.4 package from PyPI (https://github.com/atomicateam/atomica/tree/main/atomica/library), run as at.demo(‘sir’) does
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 62 | 16 | The original is counted as rows of its two spreadsheets, plus three lines of Python. A spreadsheet row is easier for a non-programmer to fill in than a line of code, so these counts compare unlike things |
| One run | first 110 ms, rerun 2.2 ms | first 10 ms, rerun 0.21 ms (Rust) | The original’s first run includes reading the spreadsheets, and its rerun is Project.run_sim() on the loaded project |
| Results | Every compartment identical, to rounding error |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (atomica)
"""
Run Atomica's SIR demo from its two spreadsheets: what `at.demo('sir', addprogs=False)` does.
"""
import os
import atomica as at
here = os.path.dirname(os.path.abspath(__file__))
def load():
""" Read the model structure (the framework) and its data (the databook) """
P = at.Project(framework=os.path.join(here, 'sir_framework.xlsx'), databook=os.path.join(here, 'sir_databook.xlsx'), do_run=False)
P.settings.sim_dt = 1.0 # In years; the demo sets this, and Atomica's own default is 0.25
return P
if __name__ == '__main__':
P = load()
result = P.run_sim()
print(result.export_raw())| Transmission probability per contact | Provenance | Units | Uncertainty | Constant | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | |
| adults | Framework-supplied default | Probability | 0.008 | OR | ||||||||||||||||||||
| Number of contacts annually | Provenance | Units | Uncertainty | Constant | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | |
| adults | Framework-supplied default | N.A. | 80 | OR | ||||||||||||||||||||
| Average duration of infections (years) | Provenance | Units | Uncertainty | Constant | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | |
| adults | Framework-supplied default | Duration (years) | 5 | OR | ||||||||||||||||||||
| Death rate for infected people | Provenance | Units | Uncertainty | Constant | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | |
| adults | Framework-supplied default | Rate (per year) | 0.016 | OR | ||||||||||||||||||||
| Death rate for susceptible people | Provenance | Units | Uncertainty | Constant | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 | |
| adults | Framework-supplied default | Rate (per year) | 0.008 | OR |
| Abbreviation | Full Name | Population type |
| adults | Adults | default |
| Susceptible | Provenance | Units | Uncertainty | 2000-07-01 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 |
| adults | Number | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | 700 | ||
| Total number of entities | Provenance | Units | Uncertainty | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 |
| adults | Number | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | 1000 | ||
| Prevalence | Provenance | Units | Uncertainty | 2000 | 2001 | 2002 | 2003 | 2004 | 2005 | 2006 | 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014 | 2015 | 2016 | 2017 | 2018 |
| adults | Fraction | 0.28571429 | 0.25792055 | 0.23218096 | 0.2084968 | 0.1868239 | 0.16708539 | 0.14918209 | 0.13300082 | 0.11842081 | 0.10531866 | 0.09357191 | 0.0830617 | 0.07367449 | 0.06530326 | 0.05784816 | 0.05121681 | 0.04532432 | 0.04009315 | 0.0354528 |
| Name | Description |
| SIR | The SIR model |
| main | Constituents |
| Total number of entities | ch_all |
| Number ever infected | inf,rec |
| Recovered | rec |
| Code Name | Display Name | Databook Page | Databook Order | Setup Weight | Components | Denominator | Default Value |
| ch_all | Total number of entities | state_variables | sus, inf, rec | ||||
| ch_prev | Prevalence | state_variables | inf | ch_all | |||
| ch_infrec | Number ever infected | inf, rec | |||||
| ch_infsus | Number infected or susceptible | inf, sus | |||||
| ch_newinf | Number not at risk of death | sus, rec | |||||
| ch_propinfrec | Proportion ever infected | ch_infrec | ch_all | ||||
| ch_propinfsus | Proportion infected or susceptible | ch_infsus | ch_all | ||||
| ch_propnewinf | Proportion not at risk of death | ch_newinf | ch_all |
| Code Name | Display Name | Is Source | Is Sink | Is Junction | Databook Page | Databook Order | Setup Weight |
| sus | Susceptible | n | n | n | state_variables | ||
| inf | Infected | n | n | n | |||
| rec | Recovered | n | n | n | |||
| dead | Dead | n | y | n |
| Datasheet Code Name | Datasheet Title |
| state_variables | State Variables |
| parameters | Parameters |
| Code Name | Display Name | Format | Databook Page | Targetable | Databook Order | Default Value | Function |
| transpercontact | Transmission probability per contact | Probability | parameters | y | 0.0008 | ||
| contacts | Number of contacts annually | parameters | y | 80 | |||
| recrate | Average duration of infections (years) | Duration | parameters | y | 0.5 | ||
| infdeath | Death rate for infected people | Rate | parameters | y | 0.016 | ||
| susdeath | Death rate for susceptible people | Rate | parameters | y | 0.008 | ||
| foi | Force of infection | Rate | (1 - (1-ch_prev*transpercontact)**floor(contacts)*(1-ch_prev*transpercontact*(contacts-floor(contacts))))*(1-susdeath) |
| sus | inf | rec | dead | |
| sus | foi | susdeath | ||
| inf | recrate | susdeath,infdeath | ||
| rec | susdeath | |||
| dead |
Starsim
model.py
"""
The Atomica SIR demo (docs/models/atomica/sir), in Starsim: infection through a number of contacts
per year, recovery, and deaths, stepped a year at a time
"""
import starsim as ss
transpercontact = ss.par(0.008, doc='Transmission probability per contact')
contacts = ss.par(80, doc='Number of contacts annually')
susdeath = ss.par(ss.peryear(0.008), doc='Death rate for susceptible people')
infdeath = ss.par(ss.peryear(0.016), doc='Death rate for infected people, in addition')
sus, inf, rec, dead = ss.states(4)
prev = inf.n / (sus + inf + rec).n
foi = (1 - (1 - prev * transpercontact)**contacts) * (1 - susdeath)
sir = ss.Disease(
infection = sus >> inf @ foi,
recovery = inf >> rec @ ss.years(5),
death = sus + inf + rec >> dead @ susdeath,
inf_death = inf >> dead @ infdeath,
init_n = dict(sus=700, inf=285.71429, rec=14.28571),
)
sim = ss.Sim(sir, total_pop=1000, start=2000, stop=2023, solver=ss.solvers.ode(integrator='euler', substeps=1)) # Atomica takes one Euler step a year
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. The force of infection is an ordinary expression of counts and parameters, not a function.
Findings
- The step is part of the model. Atomica takes one forward Euler step a year. Matching it needs
integrator='euler', substeps=1; with Starsim’s default integrator there are 18 infected at the end, not 11. - A constant inside the force of infection (fixed). The original multiplies it by one minus the death rate. This version once wrote the number out again, since a typed rate could not be subtracted from 1.
1 - susdeathnow works. - Initial conditions as given. The databook gives the susceptible number, the total, and the prevalence, and Atomica solves for the rest; here the solved values are written in.
- The data is not in a spreadsheet. The original’s strength is that the structure and the data live in spreadsheets a non-programmer can edit. This version is code, and does not offer that.
camdl: SIR with weekly reported cases
A closed SIR epidemic in 10,000 people over 12 weeks, observed through weekly reported cases.
- Framework: camdl 0.1.0+e8219d1c (MIT)
- Language: camdl
- Source: pycamdl/examples/sir.camdl in a local checkout of https://github.com/vsbuffalo/camdl
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 30 | 13 | Long lines count twice |
| One ODE run | first 10 ms, rerun 4.7 ms | first 9.9 ms, rerun 0.25 ms (Rust) | The original is timed as a whole command, since camdl has no rerun within a process: its first run compiles the model, and its rerun is the same command with the model cached. Starsim’s times exclude the import |
| Results, ODE | Agree. Compartments and weekly cases within 0.02% (compare.py asks for 0.1%) |
||
| Results, stochastic | Agree. Mean of 200 runs within 0.6% for I and 0.9% for weekly cases, chain-binomial in both, with whole-day steps (compare.py asks for 3% and 4%) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (camdl)
sir.camdl
#' A closed SIR epidemic, observed through weekly reported cases.
time_unit = 'days
compartments { S, I, R }
parameters {
#' transmission rate
beta : rate in [0.05, 1.0] ~ log_normal(mu = -1.0, sigma = 0.5)
#' recovery rate
gamma : rate in [0.01, 0.5] ~ log_normal(mu = -2.0, sigma = 0.5)
#' reporting fraction
rho : probability
#' dispersion of the reported counts
k : positive
}
let N = S + I + R
transitions {
infection : S --> I @ beta * S * I / N
recovery : I --> R @ gamma * I
}
observations {
cases {
columns { time : time, cases : count }
covers = closing_at(time, 7 'days)
projected = incidence(infection)
emit_schedule = every 7 'days
cases ~ neg_binomial(mean = rho * projected, r = k)
}
}
init {
S = 9990
I = 10
}
simulate {
from = 0 'days
to = 12 'weeks
}Starsim
model.py
"""
The camdl SIR (docs/models/camdl/sir), in Starsim: a closed epidemic, observed through weekly reported cases
"""
import starsim as ss
beta = ss.par(ss.perday(0.4), bounds=[0.05, 1.0], prior=ss.lognormal(log_mean=-1.0, log_std=0.5), doc='transmission rate')
gamma = ss.par(ss.perday(0.15), bounds=[0.01, 0.5], prior=ss.lognormal(log_mean=-2.0, log_std=0.5), doc='recovery rate')
rho = ss.par(0.6, doc='reporting fraction')
k = ss.par(10, doc='dispersion of the reported counts')
S, I, R = ss.states(3)
infection = S >> I @ beta * I
recovery = I >> R @ gamma
cases = ss.outcome(rho * infection, likelihood=ss.nbinom(n=k)) # Compared with weekly data as its total over each week
sir = ss.Disease(infection, recovery, cases, init_n=dict(I=10))
sim = ss.Sim(sir, total_pop=10_000, unit='day', stop=ss.weeks(12))
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: three states, two transitions, and one outcome with its likelihood. Nothing needed a function or a step() method.
Findings
- A likelihood on an outcome. The original says the weekly cases are negative binomial around
rhotimes the infections, with dispersionk. Herelikelihood=ss.nbinom(n=k)says the same, andsim.fit(method='nuts')samples the posterior, using the priors and fittingktoo if it has bounds. From weekly cases drawn around the original’s ODE cases (withbeta0.4,gamma0.15, andk10), the posterior medians and 95% intervals arebeta0.40 (0.32 to 0.46),gamma0.16 (0.07 to 0.23), andk29 (10 to 74); twelve weekly counts say little aboutk. The interval forkonly just reaches the value used, or just misses it, depending on the draws.compare.pychecks that the intervals forbetaandgammahold the values used. - NUTS speed. camdl has its own NUTS in Rust, with symbolic gradients. On 15 October, fitting
betaandgammawith 4 chains of 500 warm-up draws and 500 draws, the two took about the same time per effective draw, but Starsim took about 5.5 s overall, against camdl’s 2.3 s, because of compiling in JAX. This has not been measured again. - A parameter could not multiply a flow (fixed).
rho * infectionworks withrhoas anss.par. - Weekly cases are added up in
compare.py. Thecasesresult is daily. The original’s weekly reporting (covers = closing_at(time, 7 'days)) is only used in Starsim when data is attached; to read weekly cases from a run, you add them up yourself. - Whole days have to be asked for. camdl’s chain binomial takes whole steps of a day. Starsim’s takes smaller steps by default, unless
substeps=1is given, ascompare.pydoes. - The unit comes from
stop=. Withstop=ss.weeks(12)and nounit=, the sim would run in weeks, although the rates are per day. The original statestime_unit = 'daysonce; hereunit='day'does the same.
camdl: SEIR by age and patch
An SEIR in 13,510 people in two patches over 150 days, in camdl’s own language. People mix by an age contact matrix within their own patch, age between three age groups, and move between the patches; 60% of susceptible children can be vaccinated (moved to R) on day 20.
- Framework: camdl 0.1.0+e8219d1c (MIT)
- Language: camdl
- Source: written for this project from the stratified example in pycamdl, camdl’s Python front end, in a local checkout of https://github.com/vsbuffalo/camdl
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 47 | 16 | Long lines count twice |
| One ODE run | first 20 ms, rerun 12 ms | first 14 ms, rerun 0.68 ms (Rust) | The original is timed as a whole command (camdl has no way to rerun a model in the same process), and its first run includes building the model; Starsim’s is in Python, without the import |
| Results, ODE | Agree. The infectious in every cell within 0.4%, with and without vaccination (compare.py asks for 0.5%, since camdl writes whole numbers, which matters in the smallest cells), and the totals of S, E, and R within 0.1%, apart from the day of the vaccination (finding below) |
||
| Results, stochastic | Agree in size, not quite in timing. The peak in every cell is within 2%, but with a step of a day the south’s epidemic rises about a day sooner, so the mean of 200 runs differs by up to 7% of the peak on the way up (compare.py asks for 2% and 8%). The gap is about the two chain-binomial schemes at a coarse step: on 14 October it shrank from 7% to 2% with a quarter-day step |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (camdl)
stratified.camdl
#' An SEIR by age and patch. People mix by an age contact matrix within their own patch, age
#' between three age groups, and move between the patches. 60% of susceptible children can be
#' vaccinated (moved to R) on day 20: switch this on with `--enable vaccinate`.
time_unit = 'days
compartments { S, E, I, R }
dimensions {
age = [child, adult, elder]
patch = [north, south]
}
stratify(by = age)
stratify(by = patch)
parameters {
beta : rate
gamma : rate
move : rate
}
tables {
contacts : age × age = [[12.0, 4.0, 1.0],
[ 4.0, 8.0, 2.0],
[ 1.0, 2.0, 3.0]]
pop : age × patch = [[3000.0, 1000.0],
[5000.0, 2000.0],
[2000.0, 500.0]]
stay : age = [1826.2125, 16435.9125, 10957.275] # days spent in each age group: 5, 45, and 30 years of 365.2425 days
}
let N[a in age, p in patch] = S[a, p] + E[a, p] + I[a, p] + R[a, p]
transitions {
infection[a in age, p in patch] : S[a, p] --> E[a, p]
@ beta * sum(b in age, contacts[a, b] * I[b, p] / N[b, p]) * S[a, p]
onset[a in age, p in patch] : E[a, p] --> I[a, p] @ 0.2 'per_day * E[a, p] # 5 days, on average
recovery[a in age, p in patch] : I[a, p] --> R[a, p] @ gamma * I[a, p]
# Everyone ages and moves, whatever their infection state
aging[c in compartments, (a, a_next) in consecutive(age), p in patch] : c[a, p] --> c[a_next, p]
@ (1 / stay[a]) * c[a, p]
moving[c in compartments, a in age, p in patch, q in patch] : c[a, p] --> c[a, q]
@ move * c[a, p]
where p != q
}
interventions {
vaccinate[p in patch] : transfer(fraction = 0.6, from = S[child, p], to = R[child, p]) at [20]
}
init {
S[a in age, p in patch] = pop[a, p]
I[child, north] = 10
}
simulate {
from = 0 'days
to = 150 'days
}Starsim
model.py
"""
The camdl age- and patch-structured SEIR (docs/models/camdl/stratified), in Starsim: contacts by age
within each patch, aging, movement between patches, and children vaccinated on day 20
"""
import starsim as ss
contacts = [[12, 4, 1], [4, 8, 2], [1, 2, 3]]
pop = ss.by([[3000, 1000], [5000, 2000], [2000, 500]], dims=['age', 'patch'])
S, E, I, R = ss.states(4)
seir = ss.Disease(
infection = S >> E @ ss.perday(0.04) * I,
onset = E >> I @ ss.days(5),
recovery = I >> R @ ss.perday(0.1),
init_n = dict(I=ss.by(age=[10, 0, 0], patch=[1, 0])),
name = 'seir',
)
seir.stratify(age=[0, 5, 50]) # Children, adults, and elders, who age at 1/5 and 1/45 a year
seir.stratify(patch=ss.levels(['north', 'south'], kind='space', fractions=pop, mobility=ss.geo.Mobility(matrix=[[0, 0.01], [0.01, 0]], kind='migrate')))
vaccinate = ss.vaccinate(seir.S >> seir.R, when=[20], coverage=0.6, who=ss.person.age < 5)
sim = ss.Sim(seir, networks=ss.contacts(contacts), total_pop=13_510, unit='day', stop=150, aging=True)
if __name__ == '__main__':
ss.parallel(dict(novax=sim, vax=sim.new(interventions=vaccinate))).plot()What was declared and what was not
Everything is declared: three transitions, two stratifications, the population by age and patch, a contact matrix, movement, and the vaccination as a one-off move.
Findings
- Aging has to be asked for. With no births or deaths, Starsim leaves people in their age group and says so in a note, so this model gives
aging=True. Without it, children have 2% too many infections. The original ages people because it says so in its transitions. - A move between states (fixed). The original moves 60% of susceptible children to
Ron day 20. This version once gave them a fully effective vaccine, which left them inS.ss.vaccinate(move=seir.S >> seir.R, ...)now says what the original says, andSandRagree. - The day a move shows. camdl’s count on day 20 is after the move, and Starsim’s is before it: its move is at the start of the step from day 20 to 21, so it shows on day 21. The two agree from then on. This is why
compare.pyleaves out that row forS,E, andR. - Whole days have to be asked for. Starsim’s chain binomial takes smaller steps by default. Against the original’s whole days that gives a peak about 4 days sooner, so
compare.pygivessubsteps=1. - The contact matrix is not reciprocal. Starsim gives a note that the original’s matrix has different contacts in the two directions for this population, and uses it as given, as camdl does.
- Contacts within places, and populations by two dimensions (fixed). A contact matrix by age now acts within each place, and
fractions=takes anss.byover several dimensions, written as the original writes it. As agents, a contact matrix still mixes people across places. - Results by group (fixed). Sums of stratified results keep their groups where they can, and many runs keep the values of each group (unless they would take more than 500 MB).
EMULSION: the quickstart model
A stochastic, hybrid (individual-based, grouped) model of 100 animals over two years in weekly steps, each animal with three state machines at once: an age group, a gestation cycle, and a health state. It is the model EMULSION’s authors wrote “to provide an overview of several EMULSION features”, shipped as models/quickstart/quickstart.yaml with EMULSION 1.1.1 and used in its quickstart tutorial.
- Framework: emulsion 1.1.1 (Apache-2.0)
- Language: YAML (the EMULSION modeling language)
- Source: models/quickstart/quickstart.yaml in the source distribution at https://pypi.org/project/emulsion/1.1.1/ (the repository, https://git.renater.fr/emulsion-public.git, could not be reached); a copy of the source distribution is unpacked in _envs/emulsion/src/sdist
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 280 | 53 | Long lines count twice. About 80 of the original’s lines are descriptions, names, and colors |
| One run | first 1.2 s, rerun 1.1 s | first 330 ms, rerun 220 ms (Numba) | The original runs as a whole command, so its first run and rerun are the same command, and both include starting Python and reading the model (about 700 ms). Starsim’s exclude the import |
| Results, as written | Do not agree. The mean of 50 runs ends with 55 animals, against 65, and every count of adults is low from early on (finding 1). Up to step 13 the timing is right apart from the quarantine, which starts two steps sooner in EMULSION (finding 5). compare.py asks for 13 animals, so it records this miss and fails if it grows |
||
| Results, with EMULSION’s quarantine | Do not agree. The quarantine now matches (Q within 1.3), and births come on time, but the herd is still too small: 70 animals against 76 at step 53, and 55 against 65 at step 105 (finding 1) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (emulsion)
quickstart.yaml
---
# EMULSION (Epidemiological Multi-Level Simulation framework)
# ===========================================================
#
# Contributors and contact:
# -------------------------
#
# - Sébastien Picault (sebastien.picault@inrae.fr)
# - Yu-Lin Huang
# - Vianney Sicard
# - Sandie Arnoux
# - Gaël Beaunée
# - Pauline Ezanno (pauline.ezanno@inrae.fr)
#
# INRAE, Oniris, BIOEPAR, 44300, Nantes, France
#
#
# How to cite:
# ------------
#
# S. Picault, Y.-L. Huang, V. Sicard, S. Arnoux, G. Beaunée,
# P. Ezanno (2019). "EMULSION: Transparent and flexible multiscale
# stochastic models in human, animal and plant epidemiology", PLoS
# Computational Biology 15(9): e1007342. DOI:
# 10.1371/journal.pcbi.1007342
#
#
# License:
# --------
#
# Copyright 2016 INRAE and Univ. Lille
#
# Inter Deposit Digital Number: IDDN.FR.001.280043.000.R.P.2018.000.10000
#
# Agence pour la Protection des Programmes,
# 54 rue de Paradis, 75010 Paris, France
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# _____ _
# / ____| | |
# | | __ ___ _ __ ___ _ __ __ _| |
# | | |_ |/ _ \ '_ \ / _ \ '__/ _` | |
# | |__| | __/ | | | __/ | | (_| | |
# \_____|\___|_| |_|\___|_| \__,_|_|
# _____ __ _ _
# |_ _| / _| | | (_)
# | | _ __ | |_ ___ _ __ _ __ ___ __ _| |_ _ ___ _ __
# | | | '_ \| _/ _ \| '__| '_ ` _ \ / _` | __| |/ _ \| '_ \
# _| |_| | | | || (_) | | | | | | | | (_| | |_| | (_) | | | |
# |_____|_| |_|_| \___/|_| |_| |_| |_|\__,_|\__|_|\___/|_| |_|
# NAME OF THE DISEASE
model_name: Quickstart
model_info:
abstract: 'This model is aimed at providing an overview of several EMULSION features.'
# _______ _
# |__ __(_)
# | | _ _ __ ___ ___
# | | | | '_ ` _ \ / _ \
# | | | | | | | | | __/
# |_| |_|_| |_| |_|\___|
# TIME INFORMATION
time_info:
time_unit: days
delta_t: 7
origin: 'January 1'
total_duration: '2*365'
calendars:
quarantines:
period: {days: 365}
events:
quarantine_period: {begin: 'April 1', end: 'September 1'}
# _ _
# | | | |
# | | _____ _____| |___
# | | / _ \ \ / / _ \ / __|
# | |___| __/\ V / __/ \__ \
# |______\___| \_/ \___|_|___/
# LEVELS OF AGENTS
levels:
individuals:
desc: 'level of individuals'
population:
desc: 'level of the population'
contains:
- individuals
aggregation_type: hybrid
aggregate_vars:
- name: nb_total_abortions
collect: nb_abortions
operator: 'sum'
# _____
# | __ \
# | |__) | __ ___ ___ ___ ___ ___ ___ ___
# | ___/ '__/ _ \ / __/ _ \/ __/ __|/ _ \/ __|
# | | | | | (_) | (_| __/\__ \__ \ __/\__ \
# |_| |_| \___/ \___\___||___/___/\___||___/
# LIST OF PROCESSES (IN ORDER)
processes:
population:
- aging
- lifecycle
- infection
# _____
# / ____|
# | | __ _ __ ___ _ _ _ __ ___
# | | |_ | '__/ _ \| | | | '_ \/ __|
# | |__| | | | (_) | |_| | |_) \__ \
# \_____|_| \___/ \__,_| .__/|___/
# | |
# |_|
# DESCRIPTION OF COMPARTMENTS ASSOCIATED WITH PROCESSES WHICH ARE NOT
# BASED ON METHODS:
grouping:
population:
infection:
machine_name: health_state
key_variables: [health_state]
lifecycle:
machine_name: life_cycle
key_variables: [life_cycle, age_group]
aging:
machine_name: age_group
key_variables: [age_group]
# _____ _ _ __ __ _ _
# / ____| | | | | \/ | | | (_)
# | (___ | |_ __ _| |_ ___ | \ / | __ _ ___| |__ _ _ __ ___ ___
# \___ \| __/ _` | __/ _ \ | |\/| |/ _` |/ __| '_ \| | '_ \ / _ \/ __|
# ____) | || (_| | || __/ | | | | (_| | (__| | | | | | | | __/\__ \
# |_____/ \__\__,_|\__\___| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/
# DESCRIPTION OF THE STATE MACHINES INVOLVED IN THE MODEL
state_machines:
age_group:
desc: 'Age groups in the population'
states:
- J:
name: 'Juvenile'
desc: 'juvenile animals'
fillcolor: 'orange'
default: yes
on_stay:
- set_var: age
value: 'age + delta_t'
- A:
name: 'Adult'
desc: 'adult animals'
fillcolor: 'brown'
on_stay:
- set_var: age
value: 'age + delta_t'
transitions:
- {from: J, to: A, cond: 'GreaterThan(age, adult_age)', proba: 1}
life_cycle:
desc: 'Life cycle of animals regarding gestation'
states:
- G:
name: 'Gestating'
desc: 'state for gestating animals'
fillcolor: 'green'
duration: 'gestation_duration'
- NG:
name: 'NonGestating'
desc: 'state for empty animals'
fillcolor: 'cyan'
duration: 'delay_before_new_gestation'
default: yes
transitions:
- {from: NG, to: G, cond: is_A, rate: reproduction}
- desc: 'normal farrowing at end of gestation'
from: G
to: NG
amount-all-but: 0
on_cross:
- produce_offspring: [infected_newborn, protected_newborn]
amount: 1
proba:
- 'OR(is_E, is_I, is_Q)*proba_vertical_transmission'
- desc: 'abortion do tue infection'
from: G
to: NG
# abortions can occur only if animal is infectious
escape: is_I
proba: daily_abortion
on_cross:
- set_var: nb_abortions
value: 'nb_abortions + 1'
health_state:
desc: 'Evolution of health states'
states:
- M:
name: 'Maternal protection'
desc: 'animal protected by maternal antibodies'
fillcolor: 'cyan'
duration: 'adult_age'
- S:
name: 'Susceptible'
desc: 'suceptible of becoming infectious'
default: yes
fillcolor: 'wheat'
- E:
name: 'Exposed'
desc: 'already infected but not yet able to transmit the disease'
fillcolor: 'lightcoral'
duration: 'incubation_duration'
- I:
name: 'Infectious'
desc: 'sick and able to transmit the disease'
fillcolor: 'maroon'
- R:
name: 'Resistant'
desc: 'healthy again and resistant to infection'
fillcolor: 'deepskyblue'
- Q:
name: 'Quarantine'
desc: 'sick animals that are put aside for a while'
fillcolor: 'green'
duration: 'quarantine_duration'
- D:
name: 'Dead'
desc: 'dead animals'
fillcolor: 'white'
autoremove: yes
transitions:
- {from: M, to: S, rate: epsilon}
- {from: S, to: E, rate: f}
- {from: E, to: I, rate: alpha}
- {from: I, to: R, rate: gamma}
- {from: I, to: Q, rate: detection, when: 'quarantine_period'}
- {from: R, to: S, rate: delta}
- {from: Q, to: R, proba: 1}
- {from: M, to: D, rate: mortality_healthy, escape: yes}
- {from: S, to: D, rate: mortality_healthy}
- {from: R, to: D, rate: mortality_healthy}
- {from: E, to: D, rate: mortality_sick, escape: yes}
- {from: I, to: D, rate: mortality_sick}
- {from: Q, to: D, rate: mortality_sick, escape: yes}
# _____ _
# | __ \ | |
# | |__) |_ _ _ __ __ _ _ __ ___ ___| |_ ___ _ __ ___
# | ___/ _` | '__/ _` | '_ ` _ \ / _ \ __/ _ \ '__/ __|
# | | | (_| | | | (_| | | | | | | __/ || __/ | \__ \
# |_| \__,_|_| \__,_|_| |_| |_|\___|\__\___|_| |___/
parameters:
initial_population_size:
desc: 'initial number of animals in the population'
value: 100
initial_infected:
desc: 'initial number of infected animals in the population'
value: 10
adult_age:
desc: 'minimal age required to produce offspring (days)'
value: '365*2'
init_age_distrib:
desc: 'distribution of initial ages'
value: 'randint(0, adult_age)'
gestation_duration:
desc: 'duration of the gestation (in days)'
value: '30*8'
delay_before_new_gestation:
desc: 'minimum time to wait before new gestation can start'
value: '30*3'
reproduction:
desc: 'reproduction rate of the animals'
value: 0.05
mortality_healthy:
desc: 'mortality rate for healthy animals'
value: 0.002
mortality_sick:
desc: 'mortality rate for sick animals (E / I / Q)'
value: 0.004
proba_vertical_transmission:
desc: 'probability that the offspring of an infected animal (E/ I /Q) becomes Infectious'
value: 0.3
adult_age:
desc: 'age when individuals become adults (days)'
value: 30
epsilon:
desc: 'maternal antibodies loss rate'
value: 0.04
incubation_duration:
desc: 'minimum duration of the exposed state'
value: 10
alpha:
desc: 'rate for E->I transition'
value: 0.3
beta:
desc: 'contamination rate per sick individual'
value: 0.1
gamma:
desc: 'recovery rate'
value: 0.01
f:
desc: 'force of infection'
value: 'beta * total_I / total_population'
delta:
desc: 'resistance loss rate'
value: 0.01
quarantine_duration:
desc: 'duration of the quarantine (days)'
value: 30
quarantine_size:
desc: 'number of animals that can be put simultaneously in the quarantine area'
value: 20
detection:
desc: 'rate at which animals are detected sick (falls to 0 if
quarantine zone is full)
'
value: '0.01*room_in_quarantine'
room_in_quarantine:
desc: '1 if number of animals in quarantine < quarantine_size, 0
otherwise
'
value: 'StrictLessThan(total_Q, quarantine_size)'
percentage_prevalence:
desc: '% of animals infected (E+I+Q)'
value: '100 * (total_E + total_I + total_Q) / total_population'
view_quarantine:
desc: 'show quarantine period (*100 for visualization)'
value: '100 * quarantine_period'
global_abortion:
desc: 'probability that an infected animal aborts during
gestation
'
value: 0.2
daily_abortion:
desc: 'daily probability that an infected animal aborts during
gestation
'
value: '1 - (1 - global_abortion)**(1/gestation_duration)'
# _____ _ _
# / ____| | | |
# | (___ | |_ __ _| |_ _____ ____ _ _ __ ___
# \___ \| __/ _` | __/ _ \ \ / / _` | '__/ __|
# ____) | || (_| | || __/\ V / (_| | | \__ \
# |_____/ \__\__,_|\__\___| \_/ \__,_|_| |___/
statevars:
age:
desc: 'age of the animal'
nb_abortions:
desc: 'number of abortions experienced by the animal'
# _____ _ _
# | __ \ | | | |
# | |__) | __ ___ | |_ ___ | |_ _ _ _ __ ___ ___
# | ___/ '__/ _ \| __/ _ \| __| | | | '_ \ / _ \/ __|
# | | | | | (_) | || (_) | |_| |_| | |_) | __/\__ \
# |_| |_| \___/ \__\___/ \__|\__, | .__/ \___||___/
# __/ | |
# |___/|_|
prototypes:
individuals:
- protected_newborn:
health_state: M
life_cycle: NG
age_group: J
age: 0
nb_abortions: 0
- infected_newborn:
health_state: I
life_cycle: NG
age_group: J
age: 0
nb_abortions: 0
- default_healthy:
health_state: S
life_cycle: NG
age: init_age_distrib
age_group: J
nb_abortions: 0
- default_infected:
health_state: I
life_cycle: NG
age: init_age_distrib
age_group: J
nb_abortions: 0
# _____ _ _ _ _
# |_ _| (_) | (_) | |
# | | _ __ _| |_ _ __ _| |
# | | | '_ \| | __| |/ _` | |
# _| |_| | | | | |_| | (_| | |
# |_____|_| |_|_|\__|_|\__,_|_|
# _ _ _ _
# | (_) | (_)
# ___ ___ _ __ __| |_| |_ _ ___ _ __ ___
# / __/ _ \| '_ \ / _` | | __| |/ _ \| '_ \/ __|
# | (_| (_) | | | | (_| | | |_| | (_) | | | \__ \
# \___\___/|_| |_|\__,_|_|\__|_|\___/|_| |_|___/
initial_conditions:
population:
- prototype: default_healthy
amount: 'initial_population_size - initial_infected'
- prototype: default_infected
amount: 'initial_infected'
# ____ _ _
# / __ \ | | | |
# | | | |_ _| |_ _ __ _ _| |_ ___
# | | | | | | | __| '_ \| | | | __/ __|
# | |__| | |_| | |_| |_) | |_| | |_\__ \
# \____/ \__,_|\__| .__/ \__,_|\__|___/
# | |
# |_|
outputs:
type: csv
## possible alternative: database
# database_information:
# database: Quickstart
# dialect: mysql
# driver: mysqldb
# server_name: localhost # 10.0.26.250
# username: EMuLSion
# password: 'EMuLSion'
# # port: '' # 3306
population:
period: 1
extra_vars:
- total_population
- percentage_prevalence
- view_quarantine
- nb_total_abortions
...Starsim
model.py
"""
The EMULSION quickstart model (docs/models/emulsion/quickstart), in Starsim: 100 animals, each with
an age group, a gestation cycle, and a health state, where gestation needs adulthood, every
farrowing gives a newborn (infected or protected by maternal antibodies, by the mother's health),
infectious mothers may abort, and sick animals are quarantined from April to September while
the quarantine has room
"""
import numpy as np
import starsim as ss
dt = 7 # Days
adult_age = 30 # Days: the YAML gives adult_age twice, and EMULSION uses the second
whole = lambda days: ss.days(dt*round(days/dt)) # EMULSION rounds a minimum stay to whole steps
class Life(ss.Disease):
""" Workaround: EMULSION's animals start in NG before the first step, not during it """
def init_post(self):
super().init_post()
self.ti_NG[self.NG.uids] = -1
return
NG, G = ss.states('NG G')
J, A = ss.states('J A')
S, E, I, R, Q, M = ss.states('S E I R Q M')
farrowing = ss.Transition(G, NG, dur=ss.constant(ss.days(240)))
newborns = ss.birth(M, when=farrowing) # One for each farrowing, in every module
# Uniform up to adult_age, plus the step EMULSION ages them by before its first test, in years
ppl = ss.People(100, age_data=ss.uniform(low=dt/365, high=(adult_age + dt)/365))
# Workaround for "as soon as": a very high rate
growth = ss.Disease(
maturity = ss.Transition(J, A, rate=ss.perday(1e9), who=ppl.age >= adult_age/365),
newborns = ss.birth(J, when=farrowing),
default_state = 'J',
name = 'growth',
)
dates = {'2021-01-01': 0, '2021-04-01': 1, '2021-09-02': 0, '2022-04-01': 1, '2022-09-02': 0}
quarantine = ss.par(ss.timeseries(dates))
health = ss.Disease(
infection = S >> E @ ss.perday(0.1)*I.n/(S + E + I + R + Q + M).n,
onset = ss.Transition(E, I, rate=ss.perday(0.3), min_stay=whole(10)),
recovery = I >> R @ ss.perday(0.01),
detection = I >> Q @ ss.perday(0.01)*quarantine*(Q.n < 20),
release = Q >> R @ ss.constant(ss.days(30)),
waning = R >> S @ ss.perday(0.01),
loss = ss.Transition(M, S, rate=ss.perday(0.04), min_stay=whole(adult_age)),
deaths = S + R >> ss.dead @ ss.perday(0.002),
sick_deaths = I + Q >> ss.dead @ ss.perday(0.004),
M_deaths = ss.Transition(M, ss.dead, rate=ss.perday(0.002)*(M.time_in < whole(adult_age))), # EMULSION's escape
E_deaths = ss.Transition(E, ss.dead, rate=ss.perday(0.004)*(E.time_in < whole(10))),
newborns = newborns,
vertical = ss.Transition(M, {I: 0.3, M: None}, when=newborns, who=ss.mother(E | I | Q, include_born=True)), # EMULSION's produce_offspring
init_n = dict(I=10),
name = 'health',
)
abortion = -np.log(1 - 0.2)/240 # Per day, for infectious mothers only: a 20% chance over a gestation
life = Life(
conception = ss.Transition(NG, G, rate=ss.perday(0.05), min_stay=whole(90), who=growth.A),
farrowing = farrowing,
abortion = ss.Transition(G, NG, rate=ss.perday(abortion), who=health.I),
newborns = ss.birth(NG, when=farrowing),
default_state = 'NG',
name = 'life',
)
sim = ss.Sim([life, growth, health], people=ppl, unit='day', dt=dt, start='2021-01-01', dur=ss.days(735), aging=True, solver='abm')
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Declared: the three state machines, as three modules on the same agents (life, growth, health); every rate; the minimum stays (min_stay=) and the deaths during them (a rate times E.time_in < ..., EMULSION’s escape); the fixed stays in G (240 days) and Q (30 days); the force of infection, and the quarantine’s room (Q.n < 20) in the rate of detection; who each conditional transition applies to (where=: adults for J to A and for conception, infectious mothers for abortion); one newborn for each farrowing (ss.birth(on=farrowing) in each module), infected or not by the mother’s health (ss.mother()); the deaths; the quarantine period, as a time series of dates; the initial conditions and ages; and the weekly step in days.
Not declared (each marked in model.py):
- “As soon as”:
JtoAhappens on the first step an animal is old enough, written as a rate of 10⁹ a day withwhere= - The animals’ time of entry at the start, set to the step before the first in a small subclass (finding 3)
- Stays in whole steps:
whole()rounds a stay as EMULSION does
Left out: nb_total_abortions, the abortions of the living animals. The original counts these on each animal. Here the abortion result counts all of them, dead or alive, so compare.py does not compare it. The original says this more easily.
Findings
- The herd is too small, because of a Starsim change. Open. As agents, Starsim now gives people half a step of the rates out of a state in the step they arrive in, since on average they arrived halfway through it (decided on 25 October; see
design/10-execution.md). At a step of a week that matters: with a rate of 0.01 a day, 3.4% leave in the step they arrive, where EMULSION has none leave, since its steps are discrete. So animals pass throughIandRsooner and die sooner, and the adults fall behind from the first weeks (78 against 80 at step 13, 59 against 63 at step 27), which leaves 7% fewer pregnancies, and the gap grows with each generation. This has been tested in three ways. With that half step switched off in a test (not in Starsim itself), this same model agrees: 76 animals against 76 at step 53, and 66 against 65 at step 105, with the total never more than 3.1 off (mean of 50 runs). The earlier step-by-step version of this model, ported as it stood, is just as low (51 against 65, mean of 10 runs), so the declared rewrite did not lose the agreement. And smaller steps do not help (50 or 49 animals at the end, at a half and a quarter of the step, with the stays kept as they are): the half step already makes Starsim follow continuous time, and it is EMULSION’s whole steps that it no longer matches. The half step is right for steps that stand for continuous time, but not for a model that is defined in discrete steps. Suggested: an option for whole-step arrivals, assubsteps=1is for the chain binomial. - Minimum stays (fixed, with one difference).
after=andE.timereplace the function this version once used. EMULSION rounds a stay to whole steps (10 days is one step of 7), whilemin_stay=ss.days(10)means two steps, sowhole()is needed to match. With the stays unrounded, the epidemic comes a step late. - The start is the same as the first step. Open. Agents start with entry time 0, the same as those who move during the first step, so a stay from the start lasts a step longer than the same stay entered later. In EMULSION the initial state is before the first step. Without the small subclass, every conception and birth comes a week late. Suggested: give initial agents an entry time before the first step.
- Conditions, offspring, and births (fixed).
where=takes a state of another module, andss.birth(on=)withss.mother()replaces the subclass that made newborns by hand. Offspring in three modules need the module with the farrowing first in the sim, or the newborns are counted a step late in the others (andJcan read -1 for a step: a newborn that dies in its birth step is taken off the count before its birth is added, a small bug in Starsim’s counts). This changes the order of processes from EMULSION’s, with no other effect seen. - The quarantine period differs by EMULSION’s rounding. EMULSION turns April 1 into whole steps by rounding down (step 12, March 26) and applies the condition at the end of the step, so detection starts on what Starsim calls the step of March 19; its year is 52 steps. The model keeps the dates as written, and
compare.pyalso runs it with the window as EMULSION applies it. Not a Starsim problem. - Smaller things.
aging=Truehas to be given for agents to age.adult_ageis given twice in the original YAML; the second value (30 days) is used, as EMULSION does.
EMULSION: SIR as a compartmental, individual-based, and hybrid model
A closed, stochastic, discrete-time SIR epidemic in 100 individuals over 100 days, written three times: once for each of the ways EMULSION can represent a population.
- Framework: emulsion 1.1.1 (Apache-2.0)
- Language: YAML (the EMULSION modeling language)
- Source: models/features/compart_SIR.yaml, IBM_SIR.yaml, and hybrid_SIR.yaml in the source distribution at https://pypi.org/project/emulsion/1.1.1/ (the repository, https://git.renater.fr/emulsion-public.git, could not be reached)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 81 (compartments), 86 (individuals) | 10 | The original is one YAML file per paradigm; here one model is run two ways |
| One compartmental run | first 710 ms, rerun 710 ms | first 8.5 ms, rerun 0.2 ms (Rust) | The original runs as a whole command, so its first run and rerun are the same command, and both include starting Python and reading the model (about 700 ms; it reports 60 ms for the run itself). Starsim’s exclude the import |
| Results, compartments | Agree. Mean of 200 runs: a peak of 38 infectious on day 18, against 37 on day 18; 86 recovered at the end, against 85 (compare.py asks for 1.5 and 2 animals). This needs whole-day steps, substeps=1 |
||
| Results, individuals | Agree, within chance. Mean of 200 runs: a peak of 35 on day 18, against 37 on day 19; 79 recovered at the end, against 81, with 21% of runs dying out early, against about 19% (compare.py asks for 3 and 6 animals) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (emulsion)
---
# EMULSION (Epidemiological Multi-Level Simulation framework)
# ===========================================================
#
# Contributors and contact:
# -------------------------
#
# - Sébastien Picault (sebastien.picault@inrae.fr)
# - Yu-Lin Huang
# - Vianney Sicard
# - Sandie Arnoux
# - Gaël Beaunée
# - Pauline Ezanno (pauline.ezanno@inrae.fr)
#
# INRAE, Oniris, BIOEPAR, 44300, Nantes, France
#
#
# How to cite:
# ------------
#
# S. Picault, Y.-L. Huang, V. Sicard, S. Arnoux, G. Beaunée,
# P. Ezanno (2019). "EMULSION: Transparent and flexible multiscale
# stochastic models in human, animal and plant epidemiology", PLoS
# Computational Biology 15(9): e1007342. DOI:
# 10.1371/journal.pcbi.1007342
#
#
# License:
# --------
#
# Copyright 2016 INRAE and Univ. Lille
#
# Inter Deposit Digital Number: IDDN.FR.001.280043.000.R.P.2018.000.10000
#
# Agence pour la Protection des Programmes,
# 54 rue de Paradis, 75010 Paris, France
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# __ __ _ _
# | \/ | | | | |
# | \ / | ___ __| | ___| |
# | |\/| |/ _ \ / _` |/ _ \ |
# | | | | (_) | (_| | __/ |
# |_| |_|\___/ \__,_|\___|_|
#
# _____ __ _ _
# |_ _| / _| | | (_)
# | | _ __ | |_ ___ _ __ _ __ ___ __ _| |_ _ ___ _ __
# | | | '_ \| _/ _ \| '__| '_ ` _ \ / _` | __| |/ _ \| '_ \
# _| |_| | | | || (_) | | | | | | | | (_| | |_| | (_) | | | |
# |_____|_| |_|_| \___/|_| |_| |_| |_|\__,_|\__|_|\___/|_| |_|
# NAME OF THE MODEL
model_name: IBM_SIR
# DESCRIPTION OF THE MODEL
model_info:
abstract: 'This model is a simple discrete-time, stochastic,
individual-based SIR model.
'
author: 'Sebastien Picault (sebastien.picault@inra.fr)'
# _______ _
# |__ __(_)
# | | _ _ __ ___ ___
# | | | | '_ ` _ \ / _ \
# | | | | | | | | | __/
# |_| |_|_| |_| |_|\___|
# TIME INFORMATION
# This section defines the time unit (used e.g. for expressing
# durations, rates, etc.), the duration of the time step, the origin
# date and total duration of the simulation.
time_info:
time_unit: 'days'
delta_t: 1
origin: 'January 1'
total_duration: '100'
# _ _
# | | | |
# | | _____ _____| |___
# | | / _ \ \ / / _ \ / __|
# | |___| __/\ V / __/ \__ \
# |______\___| \_/ \___|_|___/
# ORGANIZATION LEVELS USED IN THE SIMULATION
# An individual-based model considers two levels: the population and
# its components (individuals)
levels:
population:
desc: 'level of the population'
aggregation_type: 'IBM' # the keyword for designing
# invididual-based models
contains:
- individuals
individuals:
desc: 'level of the individuals'
# _____
# | __ \
# | |__) | __ ___ ___ ___ ___ ___ ___ ___
# | ___/ '__/ _ \ / __/ _ \/ __/ __|/ _ \/ __|
# | | | | | (_) | (_| __/\__ \__ \ __/\__ \
# |_| |_| \___/ \___\___||___/___/\___||___/
# LIST OF PROCESSES (IN ORDER) TO BE EXECUTED DURING EACH TIME STEP
# In an Individual-Based Model, processes take place at the individuals
# level and are thus directly managed by state machines
processes:
individuals:
- health_state # name of a state machine
# _____ _
# / ____| (_)
# | | __ _ __ ___ _ _ _ __ _ _ __ __ _
# | | |_ | '__/ _ \| | | | '_ \| | '_ \ / _` |
# | |__| | | | (_) | |_| | |_) | | | | | (_| |
# \_____|_| \___/ \__,_| .__/|_|_| |_|\__, |
# | | __/ |
# |_| |___/
# DESCRIPTION OF GROUPINGS, ASSOCIATED WITH PROCESSES WHICH ARE NOT
# DIRECTLY BASED ON STATE MACHINES
# Individual-Based Models do not use groupings
# _____ _ _ __ __ _ _
# / ____| | | | | \/ | | | (_)
# | (___ | |_ __ _| |_ ___ | \ / | __ _ ___| |__ _ _ __ ___ ___
# \___ \| __/ _` | __/ _ \ | |\/| |/ _` |/ __| '_ \| | '_ \ / _ \/ __|
# ____) | || (_| | || __/ | | | | (_| | (__| | | | | | | | __/\__ \
# |_____/ \__\__,_|\__\___| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/
# DESCRIPTION OF THE STATE MACHINES INVOLVED IN THE MODEL
# Each state machine is composed at least of several states.
# The name of a state machine defines an individual variable which
# contains the value of the current state in the individual.
# Transitions define how each change can change to another one.
# Productions define how individuals in one state can produce
# new individuals in another state.
state_machines:
# Below, the definition of the state machine used by the process
# 'infection'
health_state:
desc: 'The state machine which defines the evolution of health
states
'
# Below, the list of states with their attributes.
states:
- S:
name: 'Susceptible'
desc: 'suceptible of becoming infected'
fillcolor: 'wheat'
- I:
name: 'Infectious'
desc: 'infected and able to transmit the disease'
fillcolor: 'maroon'
- R:
name: 'Resistant'
desc: 'healthy again and resistant to infection'
fillcolor: 'deepskyblue'
# Below, a list of transitions between states, with rate or
# probabilities. Rates are automatically converted into
# probabilities w.r.t the duration of the time step (delta_t),
# assuming a classical exponential distribution of durations in
# the states.
transitions:
- {from: S, to: I, rate: 'force_of_infection'}
- {from: I, to: R, rate: 'recovery'}
# _____ _
# | __ \ | |
# | |__) |_ _ _ __ __ _ _ __ ___ ___| |_ ___ _ __ ___
# | ___/ _` | '__/ _` | '_ ` _ \ / _ \ __/ _ \ '__/ __|
# | | | (_| | | | (_| | | | | | | __/ || __/ | \__ \
# |_| \__,_|_| \__,_|_| |_| |_|\___|\__\___|_| |___/
# PARAMETERS/FUNCTIONS/EXPRESSIONS USED IN THE MODEL
parameters:
initial_population_size:
desc: 'initial number of individuals in the population'
value: 100
initial_infected:
desc: 'initial number of infectious individuals in the population'
value: 1
transmission_I:
desc: 'transmission rate from infectious individuals (/day)'
value: 0.5
recovery:
desc: 'recovery rate (/day)'
value: 0.1
force_of_infection:
desc: 'infection function'
value: 'transmission_I * total_I / total_population'
source: 'classical function assuming frequency dependence'
percentage_prevalence:
desc: 'proportion of infected+infectious individuals'
value: '100 * total_I / total_population'
# _____ _ _
# | __ \ | | | |
# | |__) | __ ___ | |_ ___ | |_ _ _ _ __ ___ ___
# | ___/ '__/ _ \| __/ _ \| __| | | | '_ \ / _ \/ __|
# | | | | | (_) | || (_) | |_| |_| | |_) | __/\__ \
# |_| |_| \___/ \__\___/ \__|\__, | .__/ \___||___/
# __/ | |
# |___/|_|
# PROTOTYPES describe typical individuals, characterized by specific
# values of their variables
prototypes:
individuals:
- healthy:
desc: 'healthy individuals'
health_state: S
- infected:
desc: 'infected individuals'
health_state: I
# _____ _ _ _ _
# |_ _| (_) | (_) | |
# | | _ __ _| |_ _ __ _| |
# | | | '_ \| | __| |/ _` | |
# _| |_| | | | | |_| | (_| | |
# |_____|_| |_|_|\__|_|\__,_|_|
# _____ _ _ _ _
# / ____| | (_) | (_)
# | | ___ _ __ __| |_| |_ _ ___ _ __ ___
# | | / _ \| '_ \ / _` | | __| |/ _ \| '_ \/ __|
# | |___| (_) | | | | (_| | | |_| | (_) | | | \__ \
# \_____\___/|_| |_|\__,_|_|\__|_|\___/|_| |_|___/
# INITIAL CONDITIONS FOR THE SIMULATION
# In IBM or hybrid models, initial conditions describe the prototypes
# involved with the amount of individuals in each of those prototypes
initial_conditions:
population:
- prototype: healthy
amount: 'initial_population_size - initial_infected'
- prototype: infected
amount: 'initial_infected'
# ____ _ _
# / __ \ | | | |
# | | | |_ _| |_ _ __ _ _| |_ ___
# | | | | | | | __| '_ \| | | | __/ __|
# | |__| | |_| | |_| |_) | |_| | |_\__ \
# \____/ \__,_|\__| .__/ \__,_|\__|___/
# | |
# |_|
# TYPE AND PERIODICITY OF OUTPUTS
# The amount of individuals in each state is automatically recorded
# for all state machines each time step during the simulation.
# Additional variables (e.g. expressions defined in the 'parameters'
# section) can be specified below (as 'extra_vars').
outputs:
type: csv
population:
period: 1
extra_vars:
- percentage_prevalence
- total_population
...---
# EMULSION (Epidemiological Multi-Level Simulation framework)
# ===========================================================
#
# Contributors and contact:
# -------------------------
#
# - Sébastien Picault (sebastien.picault@inrae.fr)
# - Yu-Lin Huang
# - Vianney Sicard
# - Sandie Arnoux
# - Gaël Beaunée
# - Pauline Ezanno (pauline.ezanno@inrae.fr)
#
# INRAE, Oniris, BIOEPAR, 44300, Nantes, France
#
#
# How to cite:
# ------------
#
# S. Picault, Y.-L. Huang, V. Sicard, S. Arnoux, G. Beaunée,
# P. Ezanno (2019). "EMULSION: Transparent and flexible multiscale
# stochastic models in human, animal and plant epidemiology", PLoS
# Computational Biology 15(9): e1007342. DOI:
# 10.1371/journal.pcbi.1007342
#
#
# License:
# --------
#
# Copyright 2016 INRAE and Univ. Lille
#
# Inter Deposit Digital Number: IDDN.FR.001.280043.000.R.P.2018.000.10000
#
# Agence pour la Protection des Programmes,
# 54 rue de Paradis, 75010 Paris, France
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# __ __ _ _
# | \/ | | | | |
# | \ / | ___ __| | ___| |
# | |\/| |/ _ \ / _` |/ _ \ |
# | | | | (_) | (_| | __/ |
# |_| |_|\___/ \__,_|\___|_|
#
# _____ __ _ _
# |_ _| / _| | | (_)
# | | _ __ | |_ ___ _ __ _ __ ___ __ _| |_ _ ___ _ __
# | | | '_ \| _/ _ \| '__| '_ ` _ \ / _` | __| |/ _ \| '_ \
# _| |_| | | | || (_) | | | | | | | | (_| | |_| | (_) | | | |
# |_____|_| |_|_| \___/|_| |_| |_| |_|\__,_|\__|_|\___/|_| |_|
# NAME OF THE MODEL
model_name: compart_SIR
# DESCRIPTION OF THE MODEL
model_info:
abstract: 'This model is a simple discrete-time, stochastic,
compartment-based SIR model.
'
author: 'Sebastien Picault (sebastien.picault@inra.fr)'
# _______ _
# |__ __(_)
# | | _ _ __ ___ ___
# | | | | '_ ` _ \ / _ \
# | | | | | | | | | __/
# |_| |_|_| |_| |_|\___|
# TIME INFORMATION
# This section defines the time unit (used e.g. for expressing
# durations, rates, etc.), the duration of the time step, the origin
# date and total duration of the simulation.
time_info:
time_unit: 'days'
delta_t: 1
origin: 'January 1'
total_duration: '100'
# _ _
# | | | |
# | | _____ _____| |___
# | | / _ \ \ / / _ \ / __|
# | |___| __/\ V / __/ \__ \
# |______\___| \_/ \___|_|___/
# ORGANIZATION LEVELS USED IN THE SIMULATION
# A compartment-based model considers the population level only
levels:
# name of the level (arbitrary name)
population:
desc: 'level of the population'
aggregation_type: 'compartment'
# _____
# | __ \
# | |__) | __ ___ ___ ___ ___ ___ ___ ___
# | ___/ '__/ _ \ / __/ _ \/ __/ __|/ _ \/ __|
# | | | | | (_) | (_| __/\__ \__ \ __/\__ \
# |_| |_| \___/ \___\___||___/___/\___||___/
# LIST OF PROCESSES (IN ORDER) TO BE EXECUTED DURING EACH TIME STEP
# In a Compartment-Based Model, processes take place at the population
# level and are managed by groupings
processes:
population:
- infection # name of a grouping at population level
# _____ _
# / ____| (_)
# | | __ _ __ ___ _ _ _ __ _ _ __ __ _
# | | |_ | '__/ _ \| | | | '_ \| | '_ \ / _` |
# | |__| | | | (_) | |_| | |_) | | | | | (_| |
# \_____|_| \___/ \__,_| .__/|_|_| |_|\__, |
# | | __/ |
# |_| |___/
# DESCRIPTION OF GROUPINGS, ASSOCIATED WITH PROCESSES WHICH ARE NOT
# DIRECTLY BASED ON STATE MACHINES
# A grouping is based on one or more variables. For instance, the
# grouping for managing the 'infection' process is based on the
# variable 'health_state' (coming from the state machine with the same
# name): hence, one group will be defined for all possible values of
# the health_state variable.
grouping:
population:
infection:
machine_name: health_state
key_variables: [health_state]
# _____ _ _ __ __ _ _
# / ____| | | | | \/ | | | (_)
# | (___ | |_ __ _| |_ ___ | \ / | __ _ ___| |__ _ _ __ ___ ___
# \___ \| __/ _` | __/ _ \ | |\/| |/ _` |/ __| '_ \| | '_ \ / _ \/ __|
# ____) | || (_| | || __/ | | | | (_| | (__| | | | | | | | __/\__ \
# |_____/ \__\__,_|\__\___| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/
# DESCRIPTION OF THE STATE MACHINES INVOLVED IN THE MODEL
# Each state machine is composed at least of several states.
# The name of a state machine defines an individual variable which
# contains the value of the current state in the individual.
# Transitions define how each change can change to another one.
# Productions define how individuals in one state can produce
# new individuals in another state.
state_machines:
# Below, the definition of the state machine used by the process
# 'infection'
health_state:
desc: 'The state machine which defines the evolution of health
states
'
# Below, the list of states with their attributes.
states:
- S:
name: 'Susceptible'
desc: 'suceptible of becoming infected'
fillcolor: 'wheat'
- I:
name: 'Infectious'
desc: 'infected and able to transmit the disease'
fillcolor: 'maroon'
- R:
name: 'Resistant'
desc: 'healthy again and resistant to infection'
fillcolor: 'deepskyblue'
# Below, a list of transitions between states, with rate or
# probabilities. Rates are automatically converted into
# probabilities w.r.t the duration of the time step (delta_t),
# assuming a classical exponential distribution of durations in
# the states.
transitions:
- {from: S, to: I, rate: 'force_of_infection'}
- {from: I, to: R, rate: 'recovery'}
# _____ _
# | __ \ | |
# | |__) |_ _ _ __ __ _ _ __ ___ ___| |_ ___ _ __ ___
# | ___/ _` | '__/ _` | '_ ` _ \ / _ \ __/ _ \ '__/ __|
# | | | (_| | | | (_| | | | | | | __/ || __/ | \__ \
# |_| \__,_|_| \__,_|_| |_| |_|\___|\__\___|_| |___/
# PARAMETERS/FUNCTIONS/EXPRESSIONS USED IN THE MODEL
parameters:
initial_population_size:
desc: 'initial number of individuals in the population'
value: 100
initial_infected:
desc: 'initial number of infectious individuals in the population'
value: 1
transmission_I:
desc: 'transmission rate from infectious individuals (/day)'
value: 0.5
recovery:
desc: 'recovery rate (/day)'
value: 0.1
force_of_infection:
desc: 'infection function'
value: 'transmission_I * total_I / total_population'
source: 'classical function assuming frequency dependence'
percentage_prevalence:
desc: 'proportion of infected+infectious individuals (%)'
value: '100 * total_I / total_population'
# _____ _ _
# | __ \ | | | |
# | |__) | __ ___ | |_ ___ | |_ _ _ _ __ ___ ___
# | ___/ '__/ _ \| __/ _ \| __| | | | '_ \ / _ \/ __|
# | | | | | (_) | || (_) | |_| |_| | |_) | __/\__ \
# |_| |_| \___/ \__\___/ \__|\__, | .__/ \___||___/
# __/ | |
# |___/|_|
# NOT USED IN COMPARTMENT-BASED MODELS
# _____ _ _ _ _
# |_ _| (_) | (_) | |
# | | _ __ _| |_ _ __ _| |
# | | | '_ \| | __| |/ _` | |
# _| |_| | | | | |_| | (_| | |
# |_____|_| |_|_|\__|_|\__,_|_|
# _____ _ _ _ _
# / ____| | (_) | (_)
# | | ___ _ __ __| |_| |_ _ ___ _ __ ___
# | | / _ \| '_ \ / _` | | __| |/ _ \| '_ \/ __|
# | |___| (_) | | | | (_| | | |_| | (_) | | | \__ \
# \_____\___/|_| |_|\__,_|_|\__|_|\___/|_| |_|___/
# INITIAL CONDITIONS FOR THE SIMULATION
# In compartment-based models, initial conditions describe the amount
# of population in each state
initial_conditions:
population:
- population:
- total: 'initial_population_size'
- vars: [S]
amount: 'initial_population_size - initial_infected'
- vars: [I]
amount: 'initial_infected'
# ____ _ _
# / __ \ | | | |
# | | | |_ _| |_ _ __ _ _| |_ ___
# | | | | | | | __| '_ \| | | | __/ __|
# | |__| | |_| | |_| |_) | |_| | |_\__ \
# \____/ \__,_|\__| .__/ \__,_|\__|___/
# | |
# |_|
# TYPE AND PERIODICITY OF OUTPUTS
# The amount of individuals in each state is automatically recorded
# for all state machines each time step during the simulation.
# Additional variables (e.g. expressions defined in the 'parameters'
# section) can be specified below (as 'extra_vars').
outputs:
type: csv
population:
period: 1
extra_vars:
- percentage_prevalence
- total_population
...---
# EMULSION (Epidemiological Multi-Level Simulation framework)
# ===========================================================
#
# Contributors and contact:
# -------------------------
#
# - Sébastien Picault (sebastien.picault@inrae.fr)
# - Yu-Lin Huang
# - Vianney Sicard
# - Sandie Arnoux
# - Gaël Beaunée
# - Pauline Ezanno (pauline.ezanno@inrae.fr)
#
# INRAE, Oniris, BIOEPAR, 44300, Nantes, France
#
#
# How to cite:
# ------------
#
# S. Picault, Y.-L. Huang, V. Sicard, S. Arnoux, G. Beaunée,
# P. Ezanno (2019). "EMULSION: Transparent and flexible multiscale
# stochastic models in human, animal and plant epidemiology", PLoS
# Computational Biology 15(9): e1007342. DOI:
# 10.1371/journal.pcbi.1007342
#
#
# License:
# --------
#
# Copyright 2016 INRAE and Univ. Lille
#
# Inter Deposit Digital Number: IDDN.FR.001.280043.000.R.P.2018.000.10000
#
# Agence pour la Protection des Programmes,
# 54 rue de Paradis, 75010 Paris, France
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# __ __ _ _
# | \/ | | | | |
# | \ / | ___ __| | ___| |
# | |\/| |/ _ \ / _` |/ _ \ |
# | | | | (_) | (_| | __/ |
# |_| |_|\___/ \__,_|\___|_|
#
# _____ __ _ _
# |_ _| / _| | | (_)
# | | _ __ | |_ ___ _ __ _ __ ___ __ _| |_ _ ___ _ __
# | | | '_ \| _/ _ \| '__| '_ ` _ \ / _` | __| |/ _ \| '_ \
# _| |_| | | | || (_) | | | | | | | | (_| | |_| | (_) | | | |
# |_____|_| |_|_| \___/|_| |_| |_| |_|\__,_|\__|_|\___/|_| |_|
# NAME OF THE MODEL
model_name: hybrid_SIR
# DESCRIPTION OF THE MODEL
model_info:
abstract: 'This model is a simple discrete-time, stochastic,
"hybrid" SIR model, i.e. with individuals grouped according to
relevant variables such as their health state.
'
author: 'Sebastien Picault (sebastien.picault@inra.fr)'
# _______ _
# |__ __(_)
# | | _ _ __ ___ ___
# | | | | '_ ` _ \ / _ \
# | | | | | | | | | __/
# |_| |_|_| |_| |_|\___|
# TIME INFORMATION
# This section defines the time unit (used e.g. for expressing
# durations, rates, etc.), the duration of the time step, the origin
# date and total duration of the simulation.
time_info:
time_unit: 'days'
delta_t: 1
origin: 'January 1'
total_duration: '100'
# _ _
# | | | |
# | | _____ _____| |___
# | | / _ \ \ / / _ \ / __|
# | |___| __/\ V / __/ \__ \
# |______\___| \_/ \___|_|___/
# ORGANIZATION LEVELS USED IN THE SIMULATION
# A hybrid model considers two levels: the population and
# its components (individuals)
levels:
population:
desc: 'level of the population'
aggregation_type: 'hybrid' # the keyword for designing hybrid models
contains:
- individuals
individuals:
desc: 'level of the individuals'
# _____
# | __ \
# | |__) | __ ___ ___ ___ ___ ___ ___ ___
# | ___/ '__/ _ \ / __/ _ \/ __/ __|/ _ \/ __|
# | | | | | (_) | (_| __/\__ \__ \ __/\__ \
# |_| |_| \___/ \___\___||___/___/\___||___/
# LIST OF PROCESSES (IN ORDER) TO BE EXECUTED DURING EACH TIME STEP
# In a hybrid model, processes take place at the population level
# and are managed by groupings
processes:
population:
- infection # name of a grouping
# _____ _
# / ____| (_)
# | | __ _ __ ___ _ _ _ __ _ _ __ __ _
# | | |_ | '__/ _ \| | | | '_ \| | '_ \ / _` |
# | |__| | | | (_) | |_| | |_) | | | | | (_| |
# \_____|_| \___/ \__,_| .__/|_|_| |_|\__, |
# | | __/ |
# |_| |___/
# DESCRIPTION OF GROUPINGS, ASSOCIATED WITH PROCESSES WHICH ARE NOT
# DIRECTLY BASED ON STATE MACHINES
# A grouping is based on one or more variables. For instance, the
# grouping for managing the 'infection' process is based on the
# variable 'health_state' (coming from the state machine with the same
# name): hence, one group will be defined for all possible values of
# the health_state variable.
grouping:
population:
infection:
machine_name: health_state
key_variables: [health_state]
# _____ _ _ __ __ _ _
# / ____| | | | | \/ | | | (_)
# | (___ | |_ __ _| |_ ___ | \ / | __ _ ___| |__ _ _ __ ___ ___
# \___ \| __/ _` | __/ _ \ | |\/| |/ _` |/ __| '_ \| | '_ \ / _ \/ __|
# ____) | || (_| | || __/ | | | | (_| | (__| | | | | | | | __/\__ \
# |_____/ \__\__,_|\__\___| |_| |_|\__,_|\___|_| |_|_|_| |_|\___||___/
# DESCRIPTION OF THE STATE MACHINES INVOLVED IN THE MODEL
# Each state machine is composed at least of several states.
# The name of a state machine defines an individual variable which
# contains the value of the current state in the individual.
# Transitions define how each change can change to another one.
# Productions define how individuals in one state can produce
# new individuals in another state.
state_machines:
# Below, the definition of the state machine used by the process
# 'infection'
health_state:
desc: 'The state machine which defines the evolution of health
states
'
# Below, the list of states with their attributes.
states:
- S:
name: 'Susceptible'
desc: 'suceptible of becoming infected'
fillcolor: 'wheat'
- I:
name: 'Infectious'
desc: 'infected and able to transmit the disease'
fillcolor: 'maroon'
- R:
name: 'Resistant'
desc: 'healthy again and resistant to infection'
fillcolor: 'deepskyblue'
# Below, a list of transitions between states, with rate or
# probabilities. Rates are automatically converted into
# probabilities w.r.t the duration of the time step (delta_t),
# assuming a classical exponential distribution of durations in
# the states.
transitions:
- {from: S, to: I, rate: 'force_of_infection'}
- {from: I, to: R, rate: 'recovery'}
# _____ _
# | __ \ | |
# | |__) |_ _ _ __ __ _ _ __ ___ ___| |_ ___ _ __ ___
# | ___/ _` | '__/ _` | '_ ` _ \ / _ \ __/ _ \ '__/ __|
# | | | (_| | | | (_| | | | | | | __/ || __/ | \__ \
# |_| \__,_|_| \__,_|_| |_| |_|\___|\__\___|_| |___/
# PARAMETERS/FUNCTIONS/EXPRESSIONS USED IN THE MODEL
parameters:
initial_population_size:
desc: 'initial number of individuals in the population'
value: 100
initial_infected:
desc: 'initial number of infectious individuals in the population'
value: 1
transmission_I:
desc: 'transmission rate from infectious individuals (/day)'
value: 0.5
recovery:
desc: 'recovery rate (/day)'
value: 0.1
force_of_infection:
desc: 'infection function'
value: 'transmission_I * total_I / total_population'
source: 'classical function assuming frequency dependence'
percentage_prevalence:
desc: 'proportion of infected+infectious individuals'
value: '100 * total_I / total_population'
# _____ _ _
# | __ \ | | | |
# | |__) | __ ___ | |_ ___ | |_ _ _ _ __ ___ ___
# | ___/ '__/ _ \| __/ _ \| __| | | | '_ \ / _ \/ __|
# | | | | | (_) | || (_) | |_| |_| | |_) | __/\__ \
# |_| |_| \___/ \__\___/ \__|\__, | .__/ \___||___/
# __/ | |
# |___/|_|
# PROTOTYPES describe typical individuals, characterized by specific
# values of their variables
prototypes:
individuals:
- healthy:
desc: 'healthy individuals'
health_state: S
- infected:
desc: 'infected individuals'
health_state: I
# _____ _ _ _ _
# |_ _| (_) | (_) | |
# | | _ __ _| |_ _ __ _| |
# | | | '_ \| | __| |/ _` | |
# _| |_| | | | | |_| | (_| | |
# |_____|_| |_|_|\__|_|\__,_|_|
# _____ _ _ _ _
# / ____| | (_) | (_)
# | | ___ _ __ __| |_| |_ _ ___ _ __ ___
# | | / _ \| '_ \ / _` | | __| |/ _ \| '_ \/ __|
# | |___| (_) | | | | (_| | | |_| | (_) | | | \__ \
# \_____\___/|_| |_|\__,_|_|\__|_|\___/|_| |_|___/
# INITIAL CONDITIONS FOR THE SIMULATION
# In IBM or hybrid models, initial conditions describe the prototypes
# involved with the amount of individuals in each of those prototypes
initial_conditions:
population:
- prototype: healthy
amount: 'initial_population_size - initial_infected'
- prototype: infected
amount: 'initial_infected'
# ____ _ _
# / __ \ | | | |
# | | | |_ _| |_ _ __ _ _| |_ ___
# | | | | | | | __| '_ \| | | | __/ __|
# | |__| | |_| | |_| |_) | |_| | |_\__ \
# \____/ \__,_|\__| .__/ \__,_|\__|___/
# | |
# |_|
# TYPE AND PERIODICITY OF OUTPUTS
# The amount of individuals in each state is automatically recorded
# for all state machines each time step during the simulation.
# Additional variables (e.g. expressions defined in the 'parameters'
# section) can be specified below (as 'extra_vars').
outputs:
type: csv
population:
period: 1
extra_vars:
- percentage_prevalence
- total_population
...Starsim
model.py
"""
The EMULSION SIR (docs/models/emulsion/sir), in Starsim: one model, run as compartments and as individuals
"""
import starsim as ss
S, I, R = ss.states('S I R')
sir = ss.Disease(
infection = S >> I @ ss.perday(0.5) * I,
recovery = I >> R @ ss.perday(0.1),
init_n = dict(I=1),
name = 'sir',
)
compartment = ss.Sim(sir, total_pop=100, stop=ss.days(100), solver=ss.solvers.binomial(substeps=1)) # Whole days, as EMULSION takes them
ibm = ss.Sim(sir, total_pop=100, stop=ss.days(100), solver='abm')
if __name__ == '__main__':
msim = ss.parallel(dict(compartment=compartment, ibm=ibm), n_runs=20, workers=2)
msim.plot()What was declared and what was not
Everything is declared, once. The paradigm is the solver= argument of the sim, where the original changes aggregation_type and adds a level and prototypes to the file.
Findings
- Initial numbers (fixed).
init_n=dict(I=1)starts exactly one animal infected, as agents and as compartments. - Dying out (better). This version once had noticeably more runs die out early as agents than the original. The gap is now within the chance of 200 runs.
- Whole days have to be asked for. Starsim’s chain binomial takes smaller steps by default, which gives 88 recovered at the end, against 85.
substeps=1takes each day whole, as EMULSION does. - No hybrid. The original’s third form (groups that hold individuals) has no counterpart, and for this model none is needed: its three forms are statistically the same.
- The original documents more. About a quarter of the YAML is names, descriptions, and colors, which is why it is longer, and a reader of the file learns more from it. Here
doc=on a parameter can do some of this.
epidemics: seasonal transmission with interventions and vaccination
The default SEIR-V of epidemics’ “Get started” vignette (the UK in three age groups, mixing by a contact matrix from POLYMOD, with R0 = 1.3), with a transmission rate that varies through the year, beta * (1 + sin(πt/91.25)), so that it peaks twice, under the three scenarios of the vignette on time dependence: no intervention; three overlapping cuts in contacts (schools closed from day 50 to 120, workplaces from day 65 to 90, and schools partly reopened from day 120 to 180), each a reduction for each age group; and vaccination of people aged 40 and over between the waves, from day 120 to 220. This is the vignette that composes the most of epidemics’ scenario elements: a population, time dependence, contact interventions, and a vaccination. See the review.
- Framework: epidemics 0.5.0 (MIT)
- Language: R (models written in odin)
- Source: vignettes/modelling_time_dependence.Rmd (“Modelling time-dependence and seasonality in transmission dynamics”) at https://github.com/epiverse-trace/epidemics (rendered at https://epiverse-trace.github.io/epidemics/articles/modelling_time_dependence.html)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 76 | 24 | Both count their data: 16 of the original’s lines make the contact matrix and demography from the survey, and 2 of Starsim’s give them as numbers |
| One run | first 50 ms, rerun 9 ms | first 21 ms, rerun 1.5 ms (Rust) | The scenario with the interventions: the vignette’s call of model_default() that makes data_npi, which includes checking its inputs and making its table of results. Starsim is 5 times slower, because of its modifiers (finding 3); with none, a run takes 1.1 ms. The vaccination scenario takes 147 ms (finding 4) |
| Results, baseline | Agree to 9.2e-4 of each state’s largest value, in total and in each age group, on every day. I peaks at 2,377,511 on day 275 (original 2,378,561 on day 275). The difference is the original’s: it gives odin the seasonal factor at whole days, and odin interpolates linearly between them. With the same interpolation and steps of a quarter of a day, Starsim agrees to 1e-4 in total. compare.py allows 1e-3 |
||
| Results, with the interventions | Peak 1,374,393 on day 279; 8,364,644 recovered by day 365 | Peak 1,383,754 on day 279; 8,424,253 recovered | Agree to 8.6e-3 of each state’s largest value. The difference is how overlapping cuts combine: epidemics adds them and Starsim multiplies them (finding 1). With the cuts added by hand, Starsim agrees to 1.6e-3, and its peak is 1,374,560. compare.py allows 1e-2 |
| Results, with vaccination | Peak 1,699,837 on day 276; 2,943,844 vaccinated | Peak 1,697,573 on day 276; 2,943,843 vaccinated | Agree to 1.4e-3 of each state’s largest value. compare.py allows 2e-3 |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epidemics)
modelling_time_dependence.R
## ----include = FALSE----------------------------------------------------------
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
message = FALSE,
warning = FALSE,
fig.width = 7,
fig.height = 5
)
## ----setup--------------------------------------------------------------------
library(epidemics)
library(dplyr)
library(ggplot2)
## -----------------------------------------------------------------------------
# set up initial conditions
polymod <- socialmixr::polymod
# demography data from the wpp2024 package
data("popAge1dt", package = "wpp2024")
uk_pop <- popAge1dt |>
dplyr::filter(name == "United Kingdom", year == 2006) |>
dplyr::select(lower.age.limit = age, population = pop) |>
dplyr::mutate(population = population * 1000)
contact_data <- socialmixr::contact_matrix(
polymod,
countries = "United Kingdom",
survey_pop = uk_pop,
age_limits = c(0, 20, 40),
symmetric = TRUE,
return_demography = TRUE
)
contact_matrix <- contact_data[["matrix"]]
demography_vector <- contact_data$demography$population
# Prepare some initial objects
uk_population <- population(
name = "UK population",
contact_matrix = contact_matrix,
demography_vector = demography_vector,
initial_conditions = matrix(
c(1 - 1e-6, 0, 1e-6, 0, 0),
nrow = nrow(contact_matrix), ncol = 5L,
byrow = TRUE
)
)
## -----------------------------------------------------------------------------
# prepare function, refer to transmissiblity as beta in function name
mod_beta <- function(time, x, tmax = 365 / 4) {
x + (x * sinpi(time / tmax))
}
## -----------------------------------------------------------------------------
# get the function output
# calculate beta - this is done automatically inside epidemic_*()
output <- mod_beta(seq(0, 365), 1.3 / 7.0)
# plot the effect of time dependence
ggplot() +
geom_line(
aes(x = seq(0, 365), y = output)
) +
labs(x = "Days", y = "Transmission rate (beta)") +
theme_bw()
## -----------------------------------------------------------------------------
# note transmission_rate is the function/model parameter
# the function that affects it may have any name
data <- model_default(
population = uk_population,
time_dependence = list(
transmission_rate = mod_beta
),
time_end = 365, increment = 1
)
# get data on new infections
data_infections <- new_infections(data)
## ----class.source = 'fold-hide'-----------------------------------------------
# plot data on new infections
ggplot(data_infections) +
geom_line(
aes(x = time, y = new_infections, col = demography_group)
) +
scale_y_continuous(
labels = scales::comma,
name = "New infections"
) +
scale_color_brewer(
palette = "Dark2",
name = "Age group"
) +
theme_bw() +
coord_cartesian(
expand = FALSE
) +
labs(
x = "Time (days)",
title = "Effect of time-dependence of transmission rate"
) +
theme(
legend.position = "top",
plot.title = element_text(face = "bold")
)
## -----------------------------------------------------------------------------
# school closures affecting younger age groups
close_schools <- intervention(
type = "contacts", time_begin = 50, time_end = 120,
reduction = c(0.1, 0.01, 0.01)
)
# workplace closures affecting mostly adults
close_workplaces <- intervention(
type = "contacts", time_begin = 65, time_end = 90,
reduction = c(0.01, 0.2, 0.2)
)
# partially reopen schools
partial_schools <- intervention(
type = "contacts", time_begin = 120, time_end = 180,
reduction = c(0.05, 0.01, 0.01)
)
# combine interventions
npis <- c(close_schools, close_workplaces, partial_schools)
# print to examine
npis
## -----------------------------------------------------------------------------
# run the model with interventions and time-dependence
data_npi <- model_default(
population = uk_population,
intervention = list(contacts = npis),
time_dependence = list(
transmission_rate = mod_beta
),
time_end = 365, increment = 1
)
# get the infections
data_infections_npi <- new_infections(data_npi)
## -----------------------------------------------------------------------------
data_infections$scenario <- "baseline"
data_infections_npi$scenario <- "intervention"
# combine the data
data_npi_compare <- bind_rows(data_infections, data_infections_npi)
## ----class.source = 'fold-hide'-----------------------------------------------
ggplot(data_npi_compare) +
geom_line(
aes(
x = time, y = new_infections,
col = demography_group, linetype = scenario
)
) +
scale_y_continuous(
labels = scales::comma,
name = "New infections"
) +
scale_color_brewer(
palette = "Dark2",
name = "Age group",
labels = c("Baseline", "Intervention")
) +
scale_linetype_manual(
name = "Scenario",
values = c(
baseline = "dashed",
intervention = "solid"
)
) +
theme_bw() +
coord_cartesian(
expand = FALSE
) +
labs(
x = "Time (days)",
title = "Effect of multiple interventions on contacts"
) +
theme(
legend.position = "top",
plot.title = element_text(face = "bold")
)
## -----------------------------------------------------------------------------
# define vaccination object
vax_regime <- vaccination(
nu = matrix(0.001, nrow = 3, ncol = 1),
time_begin = matrix(c(0, 0, 120)),
time_end = matrix(c(0, 0, 220))
)
# view the vaccination object
vax_regime
## -----------------------------------------------------------------------------
# pass time dependence and vaccination. Note no interventions
data_vax <- model_default(
population = uk_population,
vaccination = vax_regime,
time_dependence = list(
transmission_rate = mod_beta
),
time_end = 365, increment = 1
)
# collect data and add scenario
data_vax_infections <- new_infections(
data_vax,
exclude_compartments = c("exposed", "vaccinated")
)
data_vax_infections$scenario <- "vaccination"
# combine data
data_vax_compare <- bind_rows(data_infections, data_vax_infections)
## ----class.source = 'fold-hide'-----------------------------------------------
ggplot(data_vax_compare) +
geom_rect(
aes(
xmin = 120, xmax = 220,
ymin = 0, ymax = 20e3
),
fill = "grey", alpha = 0.1
) +
geom_line(
data = filter(
data_vax, compartment == "vaccinated", demography_group == "[40,Inf)"
),
aes(time, value / 1e2),
colour = "darkblue"
) +
annotate(
geom = "text",
x = 190,
y = 10e3,
label = "Vaccines administered (100 days)",
angle = 90,
colour = "darkblue"
) +
geom_line(
aes(
x = time, y = new_infections,
col = demography_group, linetype = scenario
)
) +
scale_y_continuous(
labels = scales::comma,
name = "New infections",
sec.axis = dup_axis(
trans = function(x) x * 1e2,
name = "Individuals vaccinated",
labels = function(x) {
scales::comma(x, scale = 1e-6, suffix = "M")
}
)
) +
scale_color_brewer(
palette = "Dark2",
name = "Age group"
) +
scale_linetype_manual(
name = "Scenario",
values = c(
baseline = "dashed",
vaccination = "solid"
)
) +
theme_bw() +
coord_cartesian(
ylim = c(0, 20e3),
expand = FALSE
) +
labs(
x = "Time (days)",
title = "Effect of a vaccination regime"
) +
theme(
legend.position = "top",
plot.title = element_text(face = "bold")
)Starsim
model.py
"""
The epidemics vignette on time dependence (docs/models/epidemics/seasonal_scenarios), in Starsim:
the default SEIR-V in three age groups of the UK, with a transmission rate that peaks twice a year,
under three scenarios: no intervention, three overlapping cuts in contacts, and vaccination of
people aged 40 and over between the waves
"""
import numpy as np
import starsim as ss
matrix = np.array([[7.883663, 3.157024, 3.084747], [2.764179, 4.854839, 4.570735], [1.557728, 2.636148, 5.005571]]) # Contacts a day
N = [14865748, 16978469, 29438434] # Aged 0-19, 20-39, and 40+, as the vignette prints them
season = ss.seasonal(amplitude=1, peak=ss.days(365/8), period=ss.days(365/2)) # 1 + sin(πt/91.25)
S, E, I, R, V = ss.states(5)
seirv = ss.Disease(S, E, I, R, V, # V is reached only by the vaccination
infection = S >> E @ 1.3/7/max(np.linalg.eigvals(matrix).real) * season * I,
onset = E >> I @ ss.days(2),
recovery = I >> R @ ss.days(7),
init_n = dict(I=1e-6*sum(N)),
)
seirv.stratify(age=dict(zip([0, 20, 40], np.array(N)/sum(N))))
def contacts(start, stop, reduction):
""" Fewer contacts for each age group, as epidemics' intervention(type='contacts'): both as the infected and as the infectious """
cut = ss.by(age=1 - np.array(reduction))
return [ss.modify(rel, multiply=cut, when=ss.schedule(start=start, stop=stop)) for rel in [seirv.rel_sus, seirv.rel_trans]]
schools = contacts(50, 120, [0.1, 0.01, 0.01])
workplaces = contacts(65, 90, [0.01, 0.2, 0.2])
reopening = contacts(120, 180, [0.05, 0.01, 0.01]) # Schools partly reopened
vaccinate = ss.Delivery(S >> V, capacity=ss.perday(0.001*N[2]), who=ss.person.age >= 40, when=ss.schedule(start=120, stop=220)) # 0.1% of the group a day
baseline = ss.Sim(seirv, networks=ss.contacts(matrix), total_pop=sum(N), unit='day', stop=365)
sim = baseline.new(interventions=schools + workplaces + reopening)
vaccination = baseline.new(interventions=vaccinate)
if __name__ == '__main__':
msim = ss.parallel(dict(baseline=baseline, intervention=sim, vaccination=vaccination))
msim.plot('seirv.I')What was declared and what was not
Everything is declared: five states, three transitions, a stratification by age, the contact matrix, the seasonal factor in the transmission rate (ss.seasonal), each cut in contacts as two modifiers (ss.modify) of the susceptibility and the infectiousness of each age group, made by a three-line function, and the vaccination as a delivery that moves a fixed number of people a day from S to V (ss.Delivery(move=S >> V, capacity=...)). The scenarios are the baseline sim with each set of interventions (baseline.new(interventions=...)). The whole intervention scenario runs in the kernels; the vaccination does not (finding 4).
| Original | Starsim |
|---|---|
mod_beta <- function(time, x, tmax = 365/4) x + x*sinpi(time/tmax), given as time_dependence |
ss.seasonal(amplitude=1, peak=ss.days(365/8), period=ss.days(365/2)), the same curve as a cosine |
intervention(type = "contacts", time_begin, time_end, reduction), three of them, combined with c() |
contacts(start, stop, reduction): ss.modify() of seirv.rel_sus and seirv.rel_trans by ss.by(age=1 - reduction) |
vaccination(nu = matrix(0.001, nrow = 3), time_begin, time_end), with zero-length windows for the two younger groups |
ss.Delivery(move=S >> V, capacity=ss.perday(0.001*N[2]), who=ss.person.age >= 40, start=120, stop=220) |
A call of model_default() for each scenario, and new_infections() of each |
baseline, sim, and vaccination, with new infections as the result seirv.infection |
Findings
No way to say that effects add (disagreement of 0.7%; open). epidemics adds the reductions of interventions that overlap, and caps the total at 1; its design-principles vignette says that additive effects were considered easier for users to understand. So here cuts of 10% and 1% give 11%, not 10.9%. Starsim’s modifiers of a quantity multiply, so from day 65 to 90 the youngest group’s contacts are cut by 10.9% and the others’ by 20.8%, against 11% and 21%, and the epidemic is 0.7% larger.
add=cannot be used instead: it is refused onrel_susandrel_trans(“would replace each person’s own value”), and is for parameters and inflows. Adding the cuts by hand, as one modifier for each period between their starts and stops, agrees (compare.pydoes this), but it gives up stating each intervention on its own, which is the point of epidemics’ elements. Suggested change, as the review of epidemics suggests: let the rule be stated for a set of modifiers, for exampless.modify(..., reduce=0.1)with reductions of the same quantity adding up to a cap of 1, or acombine='add'option.A cut in a group’s contacts is written as two modifiers (open; one bug). In epidemics, a cut of
r[i]for groupimultiplies the contacts between groupsiandjby(1 - r[i])*(1 - r[j]): people in a group meet fewer people, and so are met by fewer. Here this is a cut in the susceptibility and in the infectiousness of the group, which is exact (with the cuts added, the results agree to 1.6e-3) and takes a three-line function. The natural statement would be a modifier of the contacts themselves,ss.modify(mixing, multiply=ss.by(age=1 - r)), but in a compartmental run this fails with a NumPy error (could not broadcast input array from shape (3,) into shape (1,), inset_contacts()). A single number works. As agents, the code applies the same modifier to the people who would be infected, so even once the error is fixed it would cut one side of each contact. Suggested change: make a cut in contacts by group act on both ends of each contact, as a cut in a matrix of contacts usually means, and fix the error.Any modifier makes a run 30 times slower (performance; open). The baseline runs in 1.1 ms. With one modifier, even a constant one with no start or stop, a run takes about 31 ms, and with the six modifiers here 55 ms, against 11 ms for the original. The run stays in the kernels (
sim.kernel_reason()is None), but before it the tables of rates are worked out again in Python for every step and every flow (Compartmental.tables(), throughEngine.value()andheld_value()), on every run, including a rerun of the same sim. Suggested change: keep the tables between runs when nothing they depend on has changed, or work out a modifier that only depends on time (a start and a stop, or a time series) in the kernels, as a seasonal factor already is.Vaccination at a fixed number a day (works; slow; one gap). epidemics’
nuis described as a rate of vaccination, but since version 0.2.0 (#198) it is the share of the group vaccinated a day:nutimes the size of the group, a fixed number a day (29,438 here) for as long as anyone is left inS.ss.Delivery(move=S >> V, capacity=ss.perday(n))says exactly this, and gives 2,943,843 vaccinated against 2,943,844. Two things could be better. The delivery is a step of its own, outside the kernels, so the vaccination scenario takes 147 ms a run. AndVhas to be passed toss.Diseaseas an argument (ss.Disease(S, E, I, R, V, ...)), since no transition of the disease reaches it; without it, the delivery stops the build with “move= names None, which is not a state of people”. The delivery’smove=S >> Vcould add the state itself, as it already names it.The vignette’s text does not match its results (not a Starsim issue). The text says that the epidemic has “a large first wave, followed by a smaller second wave”, and that vaccination gives “approximately 5,000 fewer daily infections” at the peak. The vignette’s own figures, which this run of version 0.5.0 reproduces, have a second wave three times the size of the first (377,000 new infections a day at its peak on day 267, against 113,000 on day 90), and vaccination cuts the second peak by 28,000 to 52,000 new infections a day in each age group. Also, the internal R function
.ode_model_default()inR/model_default.Rstill treatsnuas a rate per susceptible person; the model that is run, in odin, does not.
epidemics: the default SEIR-V by age
The model of epidemics’ “Get started” vignette: the package’s default model (model_default()), an SEIR-V in the UK in three age groups (0–19, 20–39, and 40+), mixing by a contact matrix from the POLYMOD survey, with R0 = 1.3, a latent period of 2 days, and an infectious period of 7, run for 600 days from one infectious person in a million. There is no vaccination, so V stays empty. See the review.
- Framework: epidemics 0.5.0 (MIT)
- Language: R (models written in odin)
- Source: vignettes/epidemics.Rmd (“Getting started with epidemic scenario modelling components”) at https://github.com/epiverse-trace/epidemics (rendered at https://epiverse-trace.github.io/epidemics/articles/epidemics.html)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 36 | 12 | Both count their data: 16 of the original’s lines make the contact matrix and demography from the survey, and 2 of Starsim’s give them as numbers |
| One run | first 20 ms, rerun 9 ms | first 590 ms, rerun 1.1 ms (JAX) | The vignette’s one call of model_default(), which includes checking its inputs and making its table of results; the odin model was compiled when the package was installed. Starsim’s first run includes building the sim |
| Results | Agree to 1.4e-7 of each state’s largest value, in total and in each age group, on every day. I peaks at 1,231,054 on day 369 in both, and 23,438,153 people are infected in both. compare.py allows 1e-6 |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epidemics)
epidemics.R
## ----include = FALSE----------------------------------------------------------
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>",
dpi = 150
)
## ----setup--------------------------------------------------------------------
library(epidemics)
library(dplyr)
library(ggplot2)
## -----------------------------------------------------------------------------
# load contact and population data from socialmixr::polymod
polymod <- socialmixr::polymod
# demography data from the wpp2024 package
data("popAge1dt", package = "wpp2024")
uk_pop <- popAge1dt |>
dplyr::filter(name == "United Kingdom", year == 2006) |>
dplyr::select(lower.age.limit = age, population = pop) |>
dplyr::mutate(population = population * 1000)
contact_data <- socialmixr::contact_matrix(
polymod,
countries = "United Kingdom",
survey_pop = uk_pop,
age_limits = c(0, 20, 40),
symmetric = TRUE,
return_demography = TRUE
)
# prepare contact matrix
contact_matrix <- contact_data[["matrix"]]
# prepare the demography vector
demography_vector <- contact_data$demography$population
names(demography_vector) <- colnames(contact_matrix)
# view contact matrix and demography
contact_matrix
demography_vector
## -----------------------------------------------------------------------------
# initial conditions
initial_i <- 1e-6
initial_conditions <- c(
S = 1 - initial_i, E = 0, I = initial_i, R = 0, V = 0
)
# build for all age groups
initial_conditions <- rbind(
initial_conditions,
initial_conditions,
initial_conditions
)
# assign rownames for clarity
rownames(initial_conditions) <- colnames(contact_matrix)
# view initial conditions
initial_conditions
## -----------------------------------------------------------------------------
uk_population <- population(
name = "UK",
contact_matrix = contact_matrix,
demography_vector = demography_vector,
initial_conditions = initial_conditions
)
uk_population
## -----------------------------------------------------------------------------
# run an epidemic model using `epidemic`
output <- model_default(
population = uk_population,
time_end = 600, increment = 1.0
)
## ----class.source = 'fold-hide'-----------------------------------------------
# plot figure of epidemic curve
filter(output, compartment %in% c("exposed", "infectious")) |>
ggplot(
aes(
x = time,
y = value,
col = demography_group,
linetype = compartment
)
) +
geom_line() +
scale_y_continuous(
labels = scales::comma
) +
scale_colour_brewer(
palette = "Dark2",
name = "Age group"
) +
expand_limits(
y = c(0, 500e3)
) +
coord_cartesian(
expand = FALSE
) +
theme_bw() +
theme(
legend.position = "top"
) +
labs(
x = "Simulation time (days)",
linetype = "Compartment",
y = "Individuals"
)Starsim
model.py
"""
The epidemics "Get started" vignette (docs/models/epidemics/seirv), in Starsim: the default
model, an SEIR in three age groups of the UK mixing by a contact matrix from POLYMOD, with
R0 = 1.3, a latent period of 2 days, and an infectious period of 7, over 600 days
"""
import numpy as np
import starsim as ss
matrix = np.array([[7.883663, 3.157024, 3.084747], [2.764179, 4.854839, 4.570735], [1.557728, 2.636148, 5.005571]]) # Contacts a day
N = [14865748, 16978469, 29438434] # Aged 0-19, 20-39, and 40+, as the vignette prints them
S, E, I, R = ss.states(4)
seir = ss.Disease(
infection = S >> E @ 1.3/7/max(np.linalg.eigvals(matrix).real) * I, # R0 = 1.3
onset = E >> I @ ss.days(2),
recovery = I >> R @ ss.days(7),
init_n = dict(I=1e-6*sum(N)),
)
seir.stratify(age=dict(zip([0, 20, 40], np.array(N)/sum(N))))
sim = ss.Sim(seir, networks=ss.contacts(matrix), total_pop=sum(N), unit='day', stop=600)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: four states, three transitions, a stratification by age with the population of each group, and the contact matrix as ss.contacts(). The original’s transmission rate is R0 over the infectious period, with the contact matrix scaled to a leading eigenvalue of 1; Starsim’s is the same, with the eigenvalue written out in the rate. Starsim prints R0 = 1.3 at the start of the run, from the next-generation matrix, which confirms the scaling.
| Original | Starsim |
|---|---|
socialmixr::contact_matrix(polymod, ...) and wpp2024, 16 lines |
matrix and N, as the vignette prints them |
initial_conditions, a row of proportions for each age group |
init_n=dict(I=1e-6*sum(N)), which is split in proportion to the groups |
population(...) |
seir.stratify(age=[0, 20, 40], fractions=N) and networks=ss.contacts(matrix) |
model_default(population, time_end = 600), with its defaults transmission_rate = 1.3/7, infectiousness_rate = 1/2, recovery_rate = 1/7 |
The three transitions, with the defaults written in |
V, a compartment of the fixed model |
Left out, since nothing reaches it |
Findings
- The contact matrix needs no conversion here. epidemics transposes the matrix, divides it by its leading eigenvalue, and divides each row by the size of its group, so that
lambda[i] = beta * sum(C[j, i]/N[i] * I[j]). Starsim’s frequency-dependent mixing islambda[i] = beta * sum(C[i, j] * I[j]/N[j]). The two are the same when the matrix is reciprocal (C[i, j]*N[i] = C[j, i]*N[j]), as socialmixr’s is withsymmetric = TRUE, as here. A matrix that is not reciprocal would give different epidemics in the two, and a translation would have to transpose and rescale it. This is a difference of convention, not a Starsim issue, and the vignette’s note on the two conventions says the same. - R0 is reported, not given. The original’s parameter is R0, through the scaling of the matrix. Starsim works out R0 from the next-generation matrix and prints it, but the rate is still written as
1.3/7/max(eigenvalues), as in the epydemix models. A way to give the transmission rate by its R0 (for examplebeta=ss.R0(1.3), solved at the start from the same next-generation matrix) would state what the vignette means directly. - Only the states that are used. epidemics’ models are fixed, so this one has a
Vcompartment that stays empty. Starsim states the model the vignette uses, without it; the next model, seasonal_scenarios, adds it with the vaccination.
EpiModel: HIV with a care cascade and PrEP
HIV among 1,500 people over 20 years in weekly steps, transmitted over two networks of partnerships (main and casual) that form and dissolve, with acute, chronic, and AIDS stages, a care cascade from testing to viral suppression, PrEP for people at risk, and arrivals and departures. Four scenarios compare no intervention, a cascade that reaches the UNAIDS 95-95-95 targets, PrEP at 50% coverage among those indicated, and both. This is the HIV example of the EpiModel Gallery, the set of worked examples maintained by EpiModel’s authors, and the one that brings the most of EpiModel together.
- Framework: epimodel 2.6.2 (MIT (the Gallery); EpiModel itself is GPL-3)
- Language: R
- Source: the “HIV Transmission with Care Cascade and PrEP” example of the EpiModel Gallery, examples/hiv/model.R and examples/hiv/module-fx.R in https://github.com/EpiModel/EpiModel-Gallery (rendered at https://epimodel.github.io/EpiModel-Gallery/)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 405 | 56 | About 10 lines are the one remaining workaround (who is indicated for PrEP) |
| One run | first 44 s, rerun 43 s | first 2.3 s, rerun 2 s (Rust) | With no intervention. The rerun of the original is the mean of 10 runs, and its first run adds estimating the two networks (netest), which is done once |
| Results | Agree within the noise of the runs in three scenarios; with no intervention, the epidemic is larger, as before (tolerance: 2.5 percentage points for prevalence, 2 for the care cascade and PrEP; below) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
| Scenario | Cumulative infections | Final prevalence | Incidence per 100 person-years (second half) | Diagnosed, on ART, suppressed (second half) | PrEP among the HIV-negative |
|---|---|---|---|---|---|
| No intervention | 476 (original 459) | 16.0% (14.4%) | 2.01 (1.80) | ||
| Cascade | 72 (74) | 5.1% (5.1%) | 0.14 (0.14) | 97.7%, 92.0%, 89.9% (97.6%, 91.9%, 89.7%) | |
| PrEP | 225 (244) | 6.7% (7.3%) | 0.74 (0.80) | 12.0% (12.0%) | |
| Both | 39 (45) | 3.9% (3.8%) | 0.067 (0.074) | 98.5%, 93.0%, 91.0% (98.0%, 92.1%, 89.9%) | 12.2% (12.0%) |
Prevalence at 5 and 10 years agrees within 0.9 percentage points in every scenario. With no intervention, 236 ± 8 people have HIV at the end against 210 (the original’s 10 runs range from 154 to 247, so its mean has a standard error of about 9); with PrEP alone, 104 ± 5 against 111. The likely cause is the casual network: Starsim’s has the targets (a mean degree of 0.30, and 10.2% with two or more casual partners), while the original’s is below them (0.28 and 8.9% with no intervention, 0.30 and 9.6% with PrEP). The main network is close in both (a mean degree of 0.49 to 0.50 against 0.49, and 4.1% to 4.3% with two main partners against 4.0% to 4.2%).
Original (epimodel)
##
## HIV Transmission Model with Care Cascade and PrEP
## EpiModel Gallery (https://github.com/statnet/EpiModel-Gallery)
##
# Load EpiModel
suppressMessages(library(EpiModel))
# Standard Gallery unit test lines
rm(list = ls())
eval(parse(text = print(commandArgs(TRUE)[1])))
if (interactive()) {
N <- 1500
nsims <- 5
ncores <- 5
nsteps <- 1040 # 20 years on a weekly timestep
} else {
N <- 300
nsims <- 2
ncores <- 2
nsteps <- 100
}
# 1. Network Setup -----------------------------------------------------------
# Two persistent partnership layers represent the HIV-specific partnership
# pattern that an edges-only Bernoulli graph cannot:
#
# - Main: low-degree, long-duration, high per-week act count. Most of
# an individual's infectious time is spent under prolonged
# exposure to a single main partner. Capped at degree 2 by
# degrange because "5 simultaneous main partners" is not a
# plausible main-partnership structure.
# - Casual: lower mean degree, shorter-duration, fewer acts per week,
# but degree distribution is left UNCAPPED. The right tail
# (a small number of nodes with many concurrent casual ties)
# is precisely the high-risk subgroup that PrEP targeting is
# designed for; truncating it would erase the structural
# heterogeneity the intervention is meant to address.
#
# Both layers include a `concurrent` ERGM term to encode overlapping
# partnerships, the structural feature most strongly implicated in
# generalized HIV epidemics (Morris & Kretzschmar 1997).
departure_rate <- 0.0005 # ~38-year mean tenure in the sexually-active pop
nw <- network_initialize(N)
# --- Main partnership layer ---
mean_deg_main <- 0.5
concurrent_main <- round(0.04 * N) # ~4% of nodes have 2+ main partners
formation_main <- ~edges + concurrent + degrange(from = 3)
target_main <- c(mean_deg_main * N / 2, concurrent_main, 0)
diss_main <- dissolution_coefs(~offset(edges), duration = 200,
d.rate = departure_rate)
# --- Casual partnership layer ---
# No degrange cap: the right tail of casual degree is the high-activity
# subgroup that drives transmission and motivates targeted PrEP.
# Concurrent target 10% is ~2.7x the Poisson baseline for mean degree 0.3
# (which would give 1 - exp(-0.3) * 1.3 ~= 3.7% concurrent), so the term
# meaningfully shifts the network above the Bernoulli-graph default
# without over-constraining the degree distribution.
mean_deg_cas <- 0.3
concurrent_cas <- round(0.10 * N)
formation_cas <- ~edges + concurrent
target_cas <- c(mean_deg_cas * N / 2, concurrent_cas)
diss_cas <- dissolution_coefs(~offset(edges), duration = 26,
d.rate = departure_rate)
cat("Fitting main partnership layer ERGM...\n")
est_main <- netest(nw, formation_main, target_main, diss_main, verbose = FALSE)
cat("Fitting casual partnership layer ERGM...\n")
est_cas <- netest(nw, formation_cas, target_cas, diss_cas, verbose = FALSE)
# 2. Disease and Intervention Parameters -------------------------------------
source("examples/hiv/module-fx.R")
# Per-act transmission probability is a single biological parameter; per-
# step per-partnership probability is computed in infect() from the per-
# layer act rate. Stage and ART-status multipliers reflect viral load:
#
# Acute (~12 wk) : 5x -- elevated VL in the seroconversion window
# (Hollingsworth et al., 2008)
# Chronic (~10 yr) : 1x -- reference
# AIDS (~2 yr untx) : 2x -- some viremic rebound, but in practice
# counterbalanced by reduced sexual
# activity in late-stage disease
# ART, not yet suppressed : 0.3x -- 2-3 mo before VL becomes undetectable
# ART, virally suppressed : 0.01x-- U=U: HPTN 052 and PARTNER established
# that durable suppression eliminates
# sexual transmission risk
#
# PrEP efficacy (95%) reflects the high end of demonstrated effectiveness
# with consistent adherence (oral TDF/FTC and injectable cabotegravir).
# Base parameter set holds the defaults for every parameter the modules
# read. Per-scenario overrides are applied via the EpiModel scenarios
# API (create_scenario_list + use_scenario) further below. Cascade and
# PrEP rates default to zero so that the "none" scenario inherits an
# all-off baseline directly from this base set.
param_base <- param.net(
# --- Transmission biology ---
inf.prob.act = 0.0025,
rel.inf.acute = 5,
rel.inf.aids = 2,
rel.inf.art.unsupp = 0.30,
rel.inf.art.supp = 0.01,
prep.efficacy = 0.95,
acts.main = 3,
acts.casual = 1,
# --- Disease progression (per week) ---
acute.to.chronic.rate = 1 / 12, # 12-week acute window
chronic.to.aids.rate = 1 / 520, # ~10-year chronic stage
aids.depart.rate = 1 / 104, # ~2-year AIDS survival untreated
art.prog.mult = 0.5, # suppressive ART halves progression
art.aids.surv.mult = 0.1, # ART extends AIDS survival 10x
# --- Care cascade (off by default) ---
test.rate = 0,
aids.dx.rate = 0,
linkage.rate = 0,
art.reinit.rate = 0,
suppression.rate = 0,
art.disc.rate = 0,
# --- PrEP (off by default; indication threshold set up front) ---
prep.init.cov = 0,
prep.start.rate = 0,
prep.stop.rate = 0,
prep.indic.deg = 2,
# --- Vital dynamics ---
departure.rate = departure_rate,
arrival.rate = 0.00065 # offsets background + AIDS mortality
)
# Initial conditions: 8% seroprevalence, with the seed cohort distributed
# across stages by mean stage duration (handled in init_attrs).
init <- init.net(i.num = round(0.08 * N))
control <- control.net(
type = NULL,
nsims = nsims,
ncores = ncores,
nsteps = nsteps,
tergmLite = TRUE,
resimulate.network = TRUE,
infection.FUN = infect,
progress.FUN = progress,
cascade.FUN = cascade,
prep.FUN = prep,
departures.FUN = dfunc,
arrivals.FUN = afunc,
verbose = FALSE
)
# 3. Scenarios --------------------------------------------------------------
# Scenarios are defined as a flat data frame (one row per scenario) and
# converted to a list via EpiModel's create_scenario_list(). At sim time,
# use_scenario(param_base, scn) returns a parameter object with the
# scenario's row applied to the base defaults. .scenario.id labels the
# scenario; .at = 0 means the override applies before step 1.
#
# Cascade rates are calibrated so the cascade scenario lands near the
# UNAIDS 95-95-95 attainment (95% of PLHIV diagnosed, 95% of those on
# ART, 95% of those on ART suppressed). PrEP scenarios use risk-based
# eligibility set in param_base (prep.indic.deg = 2) and target ~50%
# coverage among indicated susceptibles at equilibrium.
scenarios.df <- data.frame(
.scenario.id = c("none", "cascade", "prep", "both"),
.at = 0,
test.rate = c(0, 0.015, 0, 0.015),
aids.dx.rate = c(0, 0.050, 0, 0.050),
linkage.rate = c(0, 0.100, 0, 0.100),
art.reinit.rate = c(0, 0.030, 0, 0.030),
suppression.rate = c(0, 1 / 12, 0, 1 / 12),
art.disc.rate = c(0, 0.002, 0, 0.002),
prep.init.cov = c(0, 0, 0.50, 0.50),
prep.start.rate = c(0, 0, 0.015, 0.015),
prep.stop.rate = c(0, 0, 0.015, 0.015)
)
scenarios.list <- create_scenario_list(scenarios.df)
labels <- c(none = "No intervention",
cascade = "Cascade (95-95-95)",
prep = "PrEP (50% coverage)",
both = "Cascade + PrEP")
sims <- list()
for (scn in scenarios.list) {
cat(sprintf("Scenario: %s\n", scn$id))
sims[[scn$id]] <- netsim(list(est_main, est_cas),
use_scenario(param_base, scn),
init, control)
}
# 4. Analysis ---------------------------------------------------------------
# Derived measures: prevalence, incidence rate per 100 person-years, and
# UNAIDS cascade attainment (% diagnosed, % on ART, % suppressed).
# NB: mutate_epi rejects ifelse, so we do plain division and let NAs
# propagate where i.num == 0 (handled later via na.rm).
for (s in names(sims)) {
sims[[s]] <- mutate_epi(sims[[s]],
prev = i.num / num,
ir100py = 52 * 100 * si.flow / s.num,
pct.dx = dx.num / i.num,
pct.art = art.num / i.num,
pct.supp = supp.num / i.num,
prep.cov = prep.num / s.num
)
}
# Headline summary: cumulative infections + final-state metrics.
df_summary <- function(s) {
df <- as.data.frame(sims[[s]])
last_t <- max(df$time)
late <- df$time >= 0.5 * last_t # average over the back half (post-burn-in)
data.frame(
Scenario = labels[s],
Cum_infections = round(sum(df$si.flow, na.rm = TRUE) / nsims),
Final_prev = round(mean(df$prev[df$time == last_t], na.rm = TRUE), 3),
Mean_inc_100py = round(mean(df$ir100py[late], na.rm = TRUE), 2),
Pct_diagnosed = round(100 * mean(df$pct.dx[late], na.rm = TRUE), 1),
Pct_on_ART = round(100 * mean(df$pct.art[late], na.rm = TRUE), 1),
Pct_suppressed = round(100 * mean(df$pct.supp[late], na.rm = TRUE), 1),
stringsAsFactors = FALSE
)
}
summary_tbl <- do.call(rbind, lapply(names(sims), df_summary))
rownames(summary_tbl) <- NULL
cat("\n=== Scenario summary ===\n")
print(summary_tbl, row.names = FALSE)
# Infections averted versus the no-intervention baseline.
none_cum <- summary_tbl$Cum_infections[1]
summary_tbl$Infections_averted <- none_cum - summary_tbl$Cum_infections
cat("\n=== Infections averted vs. no intervention ===\n")
print(summary_tbl[, c("Scenario", "Cum_infections", "Infections_averted")],
row.names = FALSE)
# 5. Plots ------------------------------------------------------------------
cols_scn <- c(none = "firebrick", cascade = "steelblue",
prep = "darkorange", both = "darkgreen")
## --- Plot 1: HIV prevalence over time (the headline) ---
par(mfrow = c(1, 1), mar = c(4, 4, 2.5, 1), mgp = c(2.5, 0.8, 0))
prev_max <- max(sapply(sims, function(s) {
df <- as.data.frame(s); max(df$prev, na.rm = TRUE)
}))
plot(sims[[1]], y = "prev",
main = "HIV Prevalence by Scenario",
ylab = "Prevalence", xlab = "Week",
mean.col = cols_scn[1], mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE,
ylim = c(0, 1.05 * prev_max))
for (i in 2:length(sims)) {
plot(sims[[i]], y = "prev", add = TRUE,
mean.col = cols_scn[i], mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE)
}
legend("topleft", legend = labels, col = cols_scn, lwd = 2,
bty = "n", cex = 0.9)
## --- Plot 2: HIV incidence per 100 person-years ---
inc_max <- max(sapply(sims, function(s) {
df <- as.data.frame(s); max(df$ir100py, na.rm = TRUE)
}))
plot(sims[[1]], y = "ir100py",
main = "HIV Incidence Rate by Scenario",
ylab = "New infections per 100 person-years",
xlab = "Week",
mean.col = cols_scn[1], mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE,
ylim = c(0, 1.05 * inc_max)) # lower bound at 0 prevents
for (i in 2:length(sims)) { # the smoothed cascade line
plot(sims[[i]], y = "ir100py", add = TRUE, # from being clipped below 0
mean.col = cols_scn[i], mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE)
}
legend("topright", legend = labels, col = cols_scn, lwd = 2,
bty = "n", cex = 0.9)
## --- Plot 3: Cascade attainment (cascade scenario only) ---
# Single panel because the cascade attainment in the "both" scenario
# is identical by construction (same cascade rates). The reference
# lines mark the UNAIDS 95-95-95 cumulative targets: 95% of PLHIV
# diagnosed, 90.25% on ART, 85.7% virally suppressed.
par(mfrow = c(1, 1), mar = c(4, 4, 2.5, 1), mgp = c(2.5, 0.8, 0))
plot(sims[["cascade"]], y = "pct.dx",
main = "Care Cascade Attainment (cascade scenario)",
ylab = "Fraction of PLHIV", xlab = "Week",
mean.col = "steelblue", mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE, ylim = c(0, 1))
plot(sims[["cascade"]], y = "pct.art", add = TRUE,
mean.col = "darkorange", mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE)
plot(sims[["cascade"]], y = "pct.supp", add = TRUE,
mean.col = "darkgreen", mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE)
abline(h = c(0.95, 0.95 ^ 2, 0.95 ^ 3), lty = 3, col = "gray50")
legend("bottomright",
legend = c("% Diagnosed", "% on ART", "% Suppressed",
"UNAIDS 95-95-95 targets"),
col = c("steelblue", "darkorange", "darkgreen", "gray50"),
lwd = c(2, 2, 2, 1), lty = c(1, 1, 1, 3),
bty = "n", cex = 0.9)
## --- Plot 4: PrEP coverage among indicated vs. all susceptibles ---
# The interesting comparison is between total susceptibles (PrEP fraction
# is small because most susceptibles don't meet indication criteria) and
# the indicated subgroup (PrEP fraction is high because that's where the
# intervention concentrates).
for (s in c("prep", "both")) {
sims[[s]] <- mutate_epi(sims[[s]],
prep.cov.indic = prep.num.indic / prep.indic.num
)
}
plot(sims[["prep"]], y = "prep.cov.indic",
main = "PrEP Coverage Among Indicated vs. All Susceptibles",
ylab = "Fraction on PrEP", xlab = "Week",
mean.col = "darkorange", mean.lwd = 2, mean.smooth = TRUE,
qnts = FALSE, legend = FALSE, ylim = c(0, 1))
plot(sims[["prep"]], y = "prep.cov", add = TRUE,
mean.col = "darkorange", mean.lwd = 2, mean.smooth = TRUE,
mean.lty = 2, qnts = FALSE, legend = FALSE)
legend("topright",
legend = c("Indicated susceptibles (target population)",
"All susceptibles (population coverage)"),
col = c("darkorange", "darkorange"),
lwd = 2, lty = c(1, 2), bty = "n", cex = 0.9)
##
## HIV Transmission Model with Care Cascade and PrEP
## EpiModel Gallery (https://github.com/statnet/EpiModel-Gallery)
##
# Disease progression module -----------------------------------------------
#
# Custom nodal attributes used by these modules:
#
# stage "acute" / "chronic" / "aids" / NA (susceptible)
# stage.time timesteps since current stage entry
# diag.status 0 (HIV-negative or undiagnosed) / 1 (diagnosed)
# art.status 0 / 1 (currently on ART)
# vl.supp 0 / 1 (virally suppressed; meaningful only on ART)
# art.time timestep at which the current ART episode began
# prep.status 0 / 1 (on PrEP; meaningful only for susceptibles)
#
# All of these are initialized inside progress() on its first call.
progress <- function(dat, at) {
# Acute -> Chronic -> AIDS progression.
#
# Uses a snapshot of stage at function entry. All eligibility checks read
# from stage0, so a node cannot cascade through two stages in one step,
# regardless of progression rates. Same pattern as the RSV example.
#
# Acute -> Chronic is gated on infTime < at so brand-new infections from
# this step's infect() module always spend at least one step in acute.
# ---- Attribute initializer (runs once on first call) ----
# Done inline rather than via a separate init module so the helper is
# serialized into each parallel worker along with progress() itself.
if (is.null(get_attr(dat, "stage", override.null.error = TRUE))) {
n <- length(get_attr(dat, "active"))
.active <- get_attr(dat, "active")
.status <- get_attr(dat, "status")
# Seed-infected stage distribution proportional to mean stage duration
# (acute 12 wk, chronic 520 wk, AIDS 104 wk) -- approximates the
# stage-equilibrium distribution and avoids a synchronous cohort wave.
stage0_init <- rep(NA_character_, n)
stage.time0_init <- rep(NA_integer_, n)
inf_ids <- which(.active == 1 & .status == "i")
if (length(inf_ids) > 0) {
durs <- c(acute = 12, chronic = 520, aids = 104)
probs <- durs / sum(durs)
stage0_init[inf_ids] <- sample(names(probs), length(inf_ids),
replace = TRUE, prob = probs)
stage.time0_init[inf_ids] <- 0L
}
dat <- set_attr(dat, "stage", stage0_init)
dat <- set_attr(dat, "stage.time", stage.time0_init)
dat <- set_attr(dat, "diag.status", rep(0L, n))
dat <- set_attr(dat, "art.status", rep(0L, n))
dat <- set_attr(dat, "vl.supp", rep(0L, n))
dat <- set_attr(dat, "art.time", rep(NA_integer_, n))
# prep.status starts NA; the prep() module replaces NA values on
# its first call with an indication-based initial coverage, since
# we need the simulated network to determine who is "high-risk"
# and the network isn't accessible here.
dat <- set_attr(dat, "prep.status", rep(NA_integer_, n))
}
active <- get_attr(dat, "active")
stage <- get_attr(dat, "stage")
stage.time <- get_attr(dat, "stage.time")
art.status <- get_attr(dat, "art.status")
vl.supp <- get_attr(dat, "vl.supp")
infTime <- get_attr(dat, "infTime")
acute.to.chronic.rate <- get_param(dat, "acute.to.chronic.rate")
chronic.to.aids.rate <- get_param(dat, "chronic.to.aids.rate")
art.prog.mult <- get_param(dat, "art.prog.mult")
stage0 <- stage
# Increment stage clock for everyone currently in a stage.
in_stage <- !is.na(stage.time) & active == 1
stage.time[in_stage] <- stage.time[in_stage] + 1L
## Acute -> Chronic ##
ids_ac <- which(active == 1 & !is.na(stage0) & stage0 == "acute" &
!is.na(infTime) & infTime < at)
n_ac <- 0
if (length(ids_ac) > 0) {
# Suppressive ART slows progression (a coarse pedagogical stand-in for
# the real effect of HIV control on CD4 dynamics).
supp <- art.status[ids_ac] == 1 & vl.supp[ids_ac] == 1
rates <- ifelse(supp, acute.to.chronic.rate * art.prog.mult,
acute.to.chronic.rate)
hits <- rbinom(length(ids_ac), 1, rates) == 1
new_chronic <- ids_ac[hits]
if (length(new_chronic) > 0) {
stage[new_chronic] <- "chronic"
stage.time[new_chronic] <- 0L
n_ac <- length(new_chronic)
}
}
## Chronic -> AIDS ##
ids_ca <- which(active == 1 & !is.na(stage0) & stage0 == "chronic")
n_ca <- 0
if (length(ids_ca) > 0) {
supp <- art.status[ids_ca] == 1 & vl.supp[ids_ca] == 1
rates <- ifelse(supp, chronic.to.aids.rate * art.prog.mult,
chronic.to.aids.rate)
hits <- rbinom(length(ids_ca), 1, rates) == 1
new_aids <- ids_ca[hits]
if (length(new_aids) > 0) {
stage[new_aids] <- "aids"
stage.time[new_aids] <- 0L
n_ca <- length(new_aids)
}
}
dat <- set_attr(dat, "stage", stage)
dat <- set_attr(dat, "stage.time", stage.time)
is_act <- active == 1
dat <- set_epi(dat, "ac.flow", at, n_ac)
dat <- set_epi(dat, "ca.flow", at, n_ca)
dat <- set_epi(dat, "acute.num", at,
sum(is_act & stage %in% "acute"))
dat <- set_epi(dat, "chronic.num", at,
sum(is_act & stage %in% "chronic"))
dat <- set_epi(dat, "aids.num", at,
sum(is_act & stage %in% "aids"))
return(dat)
}
# Care cascade module ------------------------------------------------------
cascade <- function(dat, at) {
# Tracks HIV+ individuals through the care continuum:
#
# undiagnosed --(test.rate, or aids.dx.rate for AIDS)--> diagnosed
# diagnosed --(linkage.rate)--> on ART (not yet suppressed)
# on ART --(suppression.rate)--> virally suppressed
# on ART --(art.disc.rate)--> off ART (returns to diagnosed)
#
# Snapshot pattern: all eligibility checks read from diag0/art0/supp0,
# so no node moves more than one cascade step per timestep.
active <- get_attr(dat, "active")
status <- get_attr(dat, "status")
stage <- get_attr(dat, "stage")
diag.status <- get_attr(dat, "diag.status")
art.status <- get_attr(dat, "art.status")
vl.supp <- get_attr(dat, "vl.supp")
art.time <- get_attr(dat, "art.time")
test.rate <- get_param(dat, "test.rate")
aids.dx.rate <- get_param(dat, "aids.dx.rate")
linkage.rate <- get_param(dat, "linkage.rate")
art.reinit.rate <- get_param(dat, "art.reinit.rate")
suppression.rate <- get_param(dat, "suppression.rate")
art.disc.rate <- get_param(dat, "art.disc.rate")
diag0 <- diag.status
art0 <- art.status
supp0 <- vl.supp
is_inf <- active == 1 & status == "i"
## 1. Testing: undiagnosed -> diagnosed.
## AIDS-stage infections are diagnosed at a higher rate to represent
## symptom-driven presentation to care; acute/chronic test at the
## scenario's background screening rate.
ids_undx <- which(is_inf & diag0 == 0)
if (length(ids_undx) > 0) {
rates <- ifelse(!is.na(stage[ids_undx]) & stage[ids_undx] == "aids",
aids.dx.rate, test.rate)
hits <- rbinom(length(ids_undx), 1, rates) == 1
new_dx <- ids_undx[hits]
if (length(new_dx) > 0) diag.status[new_dx] <- 1L
}
## 2. Linkage / re-engagement: diagnosed, not on ART -> on ART.
## Newly-diagnosed (no prior ART history, art.time == NA) link at
## linkage.rate. Previously-on-ART individuals (have an art.time
## from a prior ART episode) re-engage at art.reinit.rate. This
## reflects the empirical pattern that initial linkage tends to be
## faster than re-engagement after disengagement from care.
ids_link_new <- which(is_inf & diag0 == 1 & art0 == 0 &
is.na(art.time))
if (length(ids_link_new) > 0) {
hits <- rbinom(length(ids_link_new), 1, linkage.rate) == 1
new_art <- ids_link_new[hits]
if (length(new_art) > 0) {
art.status[new_art] <- 1L
art.time[new_art] <- at
}
}
ids_link_re <- which(is_inf & diag0 == 1 & art0 == 0 &
!is.na(art.time))
if (length(ids_link_re) > 0) {
hits <- rbinom(length(ids_link_re), 1, art.reinit.rate) == 1
reinit <- ids_link_re[hits]
if (length(reinit) > 0) {
art.status[reinit] <- 1L
art.time[reinit] <- at
}
}
## 3. Suppression: on ART, not yet suppressed -> virally suppressed.
## Reflects the 2-3 month delay between ART initiation and undetectable
## viral load; until suppressed, the partial-suppression infectiousness
## multiplier applies in infect().
ids_sup <- which(is_inf & art0 == 1 & supp0 == 0)
if (length(ids_sup) > 0) {
hits <- rbinom(length(ids_sup), 1, suppression.rate) == 1
new_supp <- ids_sup[hits]
if (length(new_supp) > 0) vl.supp[new_supp] <- 1L
}
## 4. Discontinuation: on ART -> off ART (back to diagnosed only).
## Disengagement clears art.status and vl.supp but leaves art.time
## intact so that ids_link_re picks the node up next step at the
## re-engagement rate.
ids_disc <- which(is_inf & art0 == 1)
if (length(ids_disc) > 0) {
hits <- rbinom(length(ids_disc), 1, art.disc.rate) == 1
disc <- ids_disc[hits]
if (length(disc) > 0) {
art.status[disc] <- 0L
vl.supp[disc] <- 0L
}
}
dat <- set_attr(dat, "diag.status", diag.status)
dat <- set_attr(dat, "art.status", art.status)
dat <- set_attr(dat, "vl.supp", vl.supp)
dat <- set_attr(dat, "art.time", art.time)
# Compartment counts and per-step flows
dat <- set_epi(dat, "dx.flow", at, sum(diag.status == 1 & diag0 == 0))
dat <- set_epi(dat, "link.flow", at, sum(art.status == 1 & art0 == 0))
dat <- set_epi(dat, "supp.flow", at, sum(vl.supp == 1 & supp0 == 0))
dat <- set_epi(dat, "disc.flow", at, sum(art.status == 0 & art0 == 1))
dat <- set_epi(dat, "dx.num", at, sum(is_inf & diag.status == 1))
dat <- set_epi(dat, "art.num", at, sum(is_inf & art.status == 1))
dat <- set_epi(dat, "supp.num", at, sum(is_inf & vl.supp == 1))
return(dat)
}
# PrEP module --------------------------------------------------------------
prep <- function(dat, at) {
# PrEP among indicated HIV-negative individuals. Indication mirrors
# the CDC behavioral-risk criteria for PrEP eligibility in 2025:
#
# - High partnership activity: total current degree across the main
# and casual layers >= prep.indic.deg (default 2, meaning at
# least one concurrent partner).
# - Serodifferent partnership: at least one current partner is HIV+.
#
# The indication is recomputed each step. Initiation applies only to
# currently-indicated susceptibles; discontinuation applies to all
# current PrEP users regardless of current indication, since real-
# world drop-off is driven by adherence and access more than by
# changes in indication.
#
# On the very first call, prep.status is NA (initialized in progress);
# we replace those NAs with an indication-based initial coverage now
# that the simulated network is available.
active <- get_attr(dat, "active")
status <- get_attr(dat, "status")
prep.status <- get_attr(dat, "prep.status")
prep.init.cov <- get_param(dat, "prep.init.cov")
prep.start.rate <- get_param(dat, "prep.start.rate")
prep.stop.rate <- get_param(dat, "prep.stop.rate")
prep.indic.deg <- get_param(dat, "prep.indic.deg")
## ---- Compute PrEP indication ---- ##
n <- length(active)
total_deg <- integer(n)
has_pos_partner <- logical(n)
for (k in 1:2) {
el <- get_edgelist(dat, network = k)
if (is.null(el) || nrow(el) == 0) next
total_deg <- total_deg + get_degree(el)
has_pos_partner[el[, 2][status[el[, 1]] == "i"]] <- TRUE
has_pos_partner[el[, 1][status[el[, 2]] == "i"]] <- TRUE
}
indicated <- active == 1 & status == "s" &
(total_deg >= prep.indic.deg | has_pos_partner)
## ---- First-call initialization (NA -> 0 / 1) ---- ##
needs_init <- is.na(prep.status) & active == 1
if (any(needs_init)) {
prep.status[needs_init] <- 0L
if (prep.init.cov > 0) {
ids_init <- which(needs_init & indicated)
if (length(ids_init) > 0) {
prep.status[ids_init] <- rbinom(length(ids_init), 1, prep.init.cov)
}
}
}
## ---- Initiation among indicated susceptibles ---- ##
if (prep.start.rate > 0) {
ids_off <- which(indicated & prep.status == 0)
if (length(ids_off) > 0) {
hits <- rbinom(length(ids_off), 1, prep.start.rate) == 1
prep.status[ids_off[hits]] <- 1L
}
}
## ---- Discontinuation among current users ---- ##
if (prep.stop.rate > 0) {
ids_on <- which(active == 1 & status == "s" & prep.status == 1)
if (length(ids_on) > 0) {
hits <- rbinom(length(ids_on), 1, prep.stop.rate) == 1
prep.status[ids_on[hits]] <- 0L
}
}
dat <- set_attr(dat, "prep.status", prep.status)
is_sus <- active == 1 & status == "s"
dat <- set_epi(dat, "prep.num", at, sum(is_sus & prep.status == 1))
dat <- set_epi(dat, "prep.indic.num", at, sum(indicated))
dat <- set_epi(dat, "prep.num.indic", at,
sum(indicated & prep.status == 1))
return(dat)
}
# Infection module ---------------------------------------------------------
infect <- function(dat, at) {
# Multilayer HIV transmission on the main and casual partnership
# networks. Per-edge probability of transmission per timestep is:
#
# p_act = inf.prob.act * stage_mult * art_mult
# p_edge = 1 - (1 - p_act)^acts_per_partnership
#
# Susceptible-side PrEP reduces p_edge by (1 - prep.efficacy).
#
# stage_mult is acute/aids/chronic relative infectiousness; art_mult
# captures U=U: suppressed individuals are essentially non-infectious,
# and unsuppressed-on-ART are partially reduced.
active <- get_attr(dat, "active")
status <- get_attr(dat, "status")
stage <- get_attr(dat, "stage")
art.status <- get_attr(dat, "art.status")
vl.supp <- get_attr(dat, "vl.supp")
prep.status <- get_attr(dat, "prep.status")
infTime <- get_attr(dat, "infTime")
stage.time <- get_attr(dat, "stage.time")
inf.prob.act <- get_param(dat, "inf.prob.act")
rel.inf.acute <- get_param(dat, "rel.inf.acute")
rel.inf.aids <- get_param(dat, "rel.inf.aids")
rel.inf.art.unsupp <- get_param(dat, "rel.inf.art.unsupp")
rel.inf.art.supp <- get_param(dat, "rel.inf.art.supp")
prep.efficacy <- get_param(dat, "prep.efficacy")
acts.main <- get_param(dat, "acts.main")
acts.casual <- get_param(dat, "acts.casual")
layer_acts <- c(acts.main, acts.casual)
all_new <- integer(0)
for (k in 1:2) {
# discord_edgelist returns one row per S-I pair across active nodes
# on the requested network layer (columns: at, sus, inf).
del <- discord_edgelist(dat, at, network = k)
if (is.null(del) || nrow(del) == 0) next
stg <- stage[del$inf]
art <- art.status[del$inf]
sup <- vl.supp[del$inf]
stage_mult <- ifelse(!is.na(stg) & stg == "acute", rel.inf.acute,
ifelse(!is.na(stg) & stg == "aids", rel.inf.aids, 1))
art_mult <- ifelse(art == 1 & sup == 1, rel.inf.art.supp,
ifelse(art == 1 & sup == 0, rel.inf.art.unsupp, 1))
p_act <- inf.prob.act * stage_mult * art_mult
# Susceptible-side PrEP reduces acquisition probability.
p_act <- p_act * ifelse(prep.status[del$sus] == 1, 1 - prep.efficacy, 1)
p_act <- pmax(pmin(p_act, 1), 0)
p_edge <- 1 - (1 - p_act)^layer_acts[k]
transmit <- rbinom(length(p_edge), 1, p_edge) == 1
if (any(transmit)) all_new <- c(all_new, del$sus[transmit])
}
all_new <- unique(all_new)
n_new <- length(all_new)
if (n_new > 0) {
status[all_new] <- "i"
stage[all_new] <- "acute"
stage.time[all_new] <- 0L
infTime[all_new] <- at
# PrEP becomes meaningless after seroconversion; clear so prep.num
# tracks only HIV-negative users.
prep.status[all_new] <- 0L
dat <- set_attr(dat, "status", status)
dat <- set_attr(dat, "stage", stage)
dat <- set_attr(dat, "stage.time", stage.time)
dat <- set_attr(dat, "infTime", infTime)
dat <- set_attr(dat, "prep.status", prep.status)
}
dat <- set_epi(dat, "si.flow", at, n_new)
return(dat)
}
# Departure module ---------------------------------------------------------
dfunc <- function(dat, at) {
# Two competing departure processes:
# - Background: applies to all non-AIDS individuals at departure.rate.
# - AIDS-related: applies to AIDS-stage individuals at the elevated
# aids.depart.rate, reduced by art.aids.surv.mult for those on
# suppressive ART (modeling extended survival on treatment).
active <- get_attr(dat, "active")
stage <- get_attr(dat, "stage")
art.status <- get_attr(dat, "art.status")
vl.supp <- get_attr(dat, "vl.supp")
exitTime <- get_attr(dat, "exitTime")
departure.rate <- get_param(dat, "departure.rate")
aids.depart.rate <- get_param(dat, "aids.depart.rate")
art.aids.surv.mult <- get_param(dat, "art.aids.surv.mult")
## Background departure (all active, non-AIDS) ##
ids_bg <- which(active == 1 & (is.na(stage) | stage != "aids"))
bg_dep <- ids_bg[rbinom(length(ids_bg), 1, departure.rate) == 1]
## AIDS-related departure ##
ids_aids <- which(active == 1 & !is.na(stage) & stage == "aids")
aids_dep <- integer(0)
if (length(ids_aids) > 0) {
on_supp <- art.status[ids_aids] == 1 & vl.supp[ids_aids] == 1
rates <- ifelse(on_supp, aids.depart.rate * art.aids.surv.mult,
aids.depart.rate)
aids_dep <- ids_aids[rbinom(length(ids_aids), 1, rates) == 1]
}
all_dep <- c(bg_dep, aids_dep)
if (length(all_dep) > 0) {
active[all_dep] <- 0L
exitTime[all_dep] <- at
dat <- set_attr(dat, "active", active)
dat <- set_attr(dat, "exitTime", exitTime)
}
dat <- set_epi(dat, "dep.flow", at, length(all_dep))
dat <- set_epi(dat, "aids.dep.flow", at, length(aids_dep))
return(dat)
}
# Arrival module -----------------------------------------------------------
afunc <- function(dat, at) {
# New susceptibles arrive at a per-capita Poisson rate. Each custom
# attribute vector is extended in lockstep with the EpiModel core
# attributes via append_core_attr() + append_attr().
active <- get_attr(dat, "active")
a.rate <- get_param(dat, "arrival.rate")
nArr <- rpois(1, sum(active == 1) * a.rate)
if (nArr > 0) {
dat <- append_core_attr(dat, at, nArr)
dat <- append_attr(dat, "status", "s", nArr)
dat <- append_attr(dat, "stage", NA_character_, nArr)
dat <- append_attr(dat, "stage.time", NA_integer_, nArr)
dat <- append_attr(dat, "infTime", NA_integer_, nArr)
dat <- append_attr(dat, "diag.status", 0L, nArr)
dat <- append_attr(dat, "art.status", 0L, nArr)
dat <- append_attr(dat, "vl.supp", 0L, nArr)
dat <- append_attr(dat, "art.time", NA_integer_, nArr)
dat <- append_attr(dat, "prep.status", 0L, nArr)
}
dat <- set_epi(dat, "arr.flow", at, nArr)
return(dat)
}Starsim
model.py
"""
The EpiModel Gallery HIV model (docs/models/epimodel/hiv), in Starsim: HIV among 1,500 people over two
networks of partnerships (main and casual), with acute, chronic, and AIDS stages, a care cascade from
testing to viral suppression, PrEP for those at risk, arrivals and departures, and four scenarios
"""
import numpy as np
import starsim as ss
depart = 0.0005 # Departures a week, for everyone not in AIDS
week = ss.prob # EpiModel uses each rate as the probability of an event in a week (a step)
cascade = ss.par(0, doc='Whether the care cascade runs: testing, linkage, suppression, and stopping ART')
care = lambda move, rate, **kw: ss.transfer(dict(care=move), rate=cascade*week(rate), **kw) # Between the levels of care (below)
S = ss.State()
A = ss.State(rel_trans=5, label='Acute')
C = ss.State(label='Chronic')
D = ss.State(rel_trans=2, label='AIDS')
hiv = ss.Disease(
infection = ss.Transition(S, A, rate=week(0.0025) * (A + C + D)), # Per act
chronic = A >> C @ week(1/12),
aids = C >> D @ week(1/520),
aids_death = D >> ss.dead @ week(1/104),
departure = S + A + C >> ss.outside @ week(depart),
arrival = ss.birth(S, rate=0.00065),
test = care(('undx', 'dx'), 0.015, states=['A', 'C']),
test_aids = care(('undx', 'dx'), 0.05, states=['D']),
link = care(('dx', 'art'), 0.1),
relink = care(('off', 'art'), 0.03),
suppress = care(('art', 'supp'), 1/12),
stop_art = care(('art', 'off'), 0.002),
stop_supp = care(('supp', 'off'), 0.002),
infected = A + C + D,
init_n = dict(A=2, C=98, D=20), # 8%, in proportion to the time spent in each stage
)
hiv.stratify(care=ss.levels(dict(undx=1, dx=0, art=0, supp=0, off=0), states=[A, C, D])) # Off is off ART after being on it
supp = ss.by(care=[1, 1, 1, 0.5, 1]) # Suppression halves progression
adjust = [ss.modify(hiv.rel_trans, multiply=ss.by(care=[1, 1, 0.3, 0.01, 1])), ss.modify(hiv.chronic, multiply=supp), ss.modify(hiv.aids, multiply=supp),
ss.modify(hiv.aids_death, multiply=ss.by(care=[1, 1, 1, 0.1, 1]))]
def indicated(sim):
""" Workaround: who is indicated for PrEP, from both networks: HIV-negative and not on PrEP, with two or more partners or a partner with HIV """
hiv = sim.diseases.hiv
sus = hiv['S'].raw & (sim.people.has_prep.raw == 0)
positive = hiv['A'].raw | hiv['C'].raw | hiv['D'].raw
degree, exposed = np.zeros(len(sus)), np.zeros(len(sus), dtype=bool)
for net in sim.networks.values():
p1, p2 = net.edges.p1, net.edges.p2
degree += np.bincount(p1, minlength=len(sus)) + np.bincount(p2, minlength=len(sus))
exposed[p1[positive[p2]]] = exposed[p2[positive[p1]]] = True
return ss.uids(np.flatnonzero(sus & ((degree >= 2) | exposed)))
# Partnerships last 200 and 26 weeks in all, counting those ended by a departure (ss.DegreeNet allows for them, as EpiModel's d.rate does)
main = ss.DegreeNet(n_contacts=0.5, concurrency=0.04, max_contacts=2, dur=200, acts=ss.freqperday(3)) # A day here is a step, a week
casual = ss.DegreeNet(n_contacts=0.3, concurrency=0.10, dur=26, acts=ss.freqperday(1))
prep = ss.Vaccine(efficacy=0.95, waning=-1/np.log(1 - 0.015)) # Stopped with a probability of 0.015 a week
preps = [ss.vaccinate(prep, when=[0], coverage=0.5, who=indicated, name='start_prep'), # Half of those indicated at the start
ss.vaccinate(prep, when=ss.schedule(every=1), coverage=0.015, who=indicated, name='take_prep')] # And then 0.015 a week
sim = ss.Sim(hiv, networks=dict(main=main, casual=casual), n_agents=1500, stop=1040, connectors=adjust) # A step is a week
scenarios = dict(none=sim, cascade=sim.new(pars=dict(cascade=1)), prep=sim.new(interventions=preps), both=sim.new(pars=dict(cascade=1), interventions=preps))
if __name__ == '__main__':
msim = ss.parallel(scenarios, n_runs=5, workers=1)
msim.plot()What was declared and what was not
Declared: the four disease states, with the infectiousness of acute and AIDS on the states; transmission over both networks, per act, with three acts a week in a main partnership and one in a casual one; progression, AIDS deaths, other departures, and arrivals; the care cascade as a stratification of the infected states (undiagnosed, diagnosed, on ART, suppressed, and off ART after being on it) with seven transfers, and its effects on infectiousness, progression, and AIDS mortality as adjust=; both networks as ss.DegreeNet from the same targets as the original; PrEP as a vaccine with 95% efficacy that people stop taking, delivered to half of those indicated at the start and then to 1.5% of them a week; the scenarios, as pars=dict(cascade=1) and the PrEP deliveries.
Not declared (one workaround, marked in the code): who is indicated for PrEP, a function that reads both networks.
The original’s six modules (305 lines of R) become the disease, the stratification, and two deliveries. Its two attributes for each person, a stage and a care state, are one state and one dimension here, and its history of ART (art.time, which decides whether someone starts or restarts ART) is the level off. EpiModel’s modules can do anything R can, which is why it can state this model at all; here it depends on what Starsim declares.
Findings
- Fixed: scenarios by a parameter. A parameter in a transfer’s rate is now a parameter of the sim, so the model is built once and the cascade is switched with
pars=dict(cascade=1). - Fixed: partnerships ended by departures.
ss.DegreeNetallows for them, as EpiModel’sd.ratedoes, so the mean degree stays on target withdur=200anddur=26and no subclass. - Still open: no conditions on the network (a function). PrEP is indicated for HIV-negative people with two or more partners across both networks, or with a partner who has HIV. This is a 10-line function reading the partnerships. A condition such as
hiv.S & ((main.degree + casual.degree >= 2) | ss.partner_of(hiv.infected)), usable inwhere=, would declare it. - Fixed: networks in a dict.
networks=dict(main=main, casual=casual)works. The two PrEP deliveries still needname=, since they are in a list. - Fixed: acts in a partnership.
acts=onss.DegreeNetmakesbetaa probability per act, so the chance of transmission over a partnership is worked out as in EpiModel. In this sim, with no unit, a day is a step, soss.freqperday(3)means three acts a week, which reads oddly. - Still open: stopping a product at a rate is written as waning. PrEP is stopped with a probability of 0.015 a week; that is
ss.Vaccine(waning=-1/np.log(1 - 0.015)), a mean duration of protection. It works, but a product that people take and stop is not a vaccine, and the duration has to be worked out from the probability. - The networks’ targets are better matched than the original’s, which affects the results. EpiModel’s casual network has a mean degree of 0.28 to 0.30 and 8.9% to 9.6% with two or more partners, against targets of 0.3 and 10%;
ss.DegreeNethas 0.30 and 10.1% to 10.2%. This is probably why the epidemic with no intervention is about 10% larger here (about two standard errors). It is not a fault in either, but agreement on the targets does not imply agreement on the network. PrEP alone now comes out a little below the original (6.7% against 7.3%), which is within the noise of the runs. - Smaller differences. EpiModel uses each rate as a probability per step, written here as
ss.prob(aliasedweek). It starts its seed infections in stages drawn at random in proportion to 12:520:104; here the expected numbers (2, 98, and 20) are given. Its modules run in turn, each from a snapshot of the attributes at its start, so for example someone can be suppressed and stop ART in one step; here the transfers out of a level compete. None of these is visible in the results. - Fixed: a transition named
stop. A transition whose name was also a module parameter used to break the sim; it now runs.
EpiModel: SIS on a dynamic network
A stochastic SIS epidemic among 500 people whose partnerships form and dissolve over time, for 500 time steps. This is the network example from EpiModel’s own introduction: a statistical model of the network is estimated from target statistics, and the epidemic is then simulated over networks drawn from it.
- Framework: epimodel 2.6.2 (GPL-3)
- Language: R
- Source: the “Network Model Pipeline” section (steps 1 to 3) of the vignette “Introduction to EpiModel”, https://cran.r-project.org/web/packages/EpiModel/vignettes/Intro.html (source at https://github.com/EpiModel/EpiModel/blob/v2.6.2/vignettes/Intro.Rmd)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 13 | 9 | |
| One run | first 5.5 s, rerun 4.7 s | first 570 ms, rerun 300 ms (Rust) | The first run of the original includes estimating the network (netest), which is done once; a rerun uses the fitted network |
| Results | Agree, once the order within a step is allowed for (within 5% at equilibrium): 11.7 new infections a step at equilibrium, against 12.0 (means of 100 runs each). Over the last 200 steps 240 of 500 are infected, against 227.5: EpiModel lets people recover in the step in which they are infected and counts them after that, which makes its count 5% lower for the same epidemic (240 × 0.95 = 228). With the same allowance, 118 are infected after step 50, against 113, where the runs vary most |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epimodel)
intro_sis.R
## ----step1, results = "hide", message = FALSE---------------------------------
library(EpiModel)
set.seed(12345)
nw <- network_initialize(n = 500)
formation <- ~edges + concurrent + degrange(from = 4)
target.stats <- c(175, 110, 0)
coef.diss <- dissolution_coefs(
dissolution = ~offset(edges),
duration = 50 # mean partnership duration in time steps
)
est <- netest(nw, formation, target.stats, coef.diss, verbose = FALSE)
## ----step2, results = "hide"--------------------------------------------------
dx <- netdx(est, nsims = 5, nsteps = 500, verbose = FALSE)
## ----step3, results = "hide"--------------------------------------------------
param <- param.net(inf.prob = 0.4, act.rate = 2, rec.rate = 0.05)
init <- init.net(i.num = 10)
control <- control.net(type = "SIS", nsims = 5, nsteps = 500, verbose = FALSE)
sim <- netsim(est, param, init, control)Starsim
model.py
"""
The EpiModel introduction (docs/models/epimodel/sis-network), in Starsim: an SIS epidemic over
partnerships that form and dissolve, among 500 people
"""
import starsim as ss
S, I = ss.states('S I')
sis = ss.Disease(
infection = ss.Transition(S, I, rate=ss.prob(0.4) * I), # A 40% chance for each act
recovery = ss.Transition(I, S, rate=ss.prob(0.05)),
init_n = dict(I=10),
)
partners = ss.DegreeNet(n_contacts=0.7, concurrency=0.22, max_contacts=3, dur=50, acts=ss.freqperday(2)) # 175 partnerships among 500, 110 with two or more, none with four, lasting 50 steps, with two acts a step
sim = ss.Sim(sis, networks=partners, n_agents=500, stop=500)
if __name__ == '__main__':
msim = ss.parallel(sim, n_runs=10)
msim.plot()What was declared and what was not
Everything is declared: two transitions and a network, described by the same targets as the original (ss.DegreeNet(n_contacts=0.7, concurrency=0.22, max_contacts=3, dur=50, acts=ss.freqperday(2))). The chance of transmission is per act, as in EpiModel, and the probabilities per step are written as ss.prob.
Findings
- Described the way EpiModel describes it, but not an ERGM. The original fits a network to target statistics: 175 partnerships, 110 people with two or more partners, nobody with four or more.
ss.DegreeNettakes the same targets: people look for partners at a rate that depends on how many they have, chosen so that the targets are the network’s stable state, and partnerships end at a constant rate. It has 176 partnerships and 109 people with two or more (EpiModel’s network has 177 and 111). It matches the number of partners people have, and not other structure an ERGM can target, such as triangles. EpiModel’s approach is the more general one. - Network diagnostics. The original reports the edges, mean degree, concurrency, and partnership duration of the simulated network over time.
ss.DegreeNetrecords the mean number of partners and the share with two or more as results, andnet.check()compares them with the targets over a few runs, asnetdx()does. - Fixed: acts in a partnership. The first version worked out a probability per partnership by hand;
acts=on the network now says it directly. In this sim, with no unit, a day is a step, soss.freqperday(2)reads a little oddly for “two acts a step”.
epipack: SIARX with contact tracing, on a network, well mixed, and deterministic
The example of contact tracing in epipack’s tutorial on networks: an SIARX model in 10,000 people on a random network with 50 contacts each, where infected people become symptomatic (I) or not (A), symptomatic people are quarantined (X), and each quarantine traces the person’s contacts, quarantining each who is symptomatic with a chance of 25%. The tutorial runs it on the network, then well mixed, then as a deterministic model in which tracing is approximated by a flow. See the review.
- Framework: epipack 0.1.5 (MIT)
- Language: Python
- Source: https://github.com/benmaier/epipack/blob/v0.1.5/docs/tutorial/networks.rst
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 61 | 16 | The original’s lines include the 2 added to run the tutorial’s code blocks as one script. In Starsim the deterministic model reuses the transitions of the stochastic one, with one transition added |
| One run | first 870 ms, rerun 480 ms | first 540 ms, rerun 500 ms (Rust) | Each is one run on the network with tracing, for 40 days, with results. Starsim’s rerun makes the network of 250,000 edges again, and epipack’s reuses it. Well mixed, a run takes 890 ms in the original and 4 s in Starsim; the deterministic model, to day 100, 160 ms and 0.32 ms |
| Results, deterministic | 2,941.6 in S and 2,921.6 in X on day 40 |
2,941.6 and 2,921.6 | Agree to 2e-6 of the population on every day to day 100 |
| Results, on the network (mean of 100 runs) | 3,423 in S and 2,792 in X on day 40 |
Agree to 1.2e-2 of the population on every day, with steps of 0.1 days against epipack’s continuous time. The largest difference is in S as it falls fastest, about three times the standard error of the difference of the two means |
|
| Results, well mixed (mean of 100 runs) | 2,955 in S and 2,919 in X on day 40 |
Agree to 1.2e-2 of the population on every day, as on the network |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epipack)
siarx.py
import numpy as np # Added: the tutorial's code blocks leave out their imports
from epipack import StochasticEpiModel, EpiModel # Added
import networkx as nx
k0 = 50
N = int(1e4)
edges = [ (e[0], e[1], 1.0) for e in \
nx.fast_gnp_random_graph(N,k0/(N-1)).edges() ]
recovery_rate = 1/5
quarantine_rate = 1/5
Reff = 2.0
infection_rate = Reff * (recovery_rate + quarantine_rate) / k0
p = 0.25
compartments = list("SIARX")
node_transition = [
('I', recovery_rate, 'R'),
('A', recovery_rate, 'R'),
('I', quarantine_rate, 'X'),
]
link_transmission = [
('I', 'S', infection_rate*0.7, 'I', 'I'),
('I', 'S', infection_rate*0.3, 'I', 'A'),
('A', 'S', 0.5*infection_rate*0.7, 'A', 'I'),
('A', 'S', 0.5*infection_rate*0.3, 'A', 'A'),
]
conditional_transmission = {
('I', '->', 'X') : [
('X', 'I', p, 'X', 'X'),
]
}
model = StochasticEpiModel(
compartments=compartments,
N=N,
edge_weight_tuples=edges
)\
.set_link_transmission_processes(link_transmission)\
.set_node_transition_processes(node_transition)\
.set_conditional_link_transmission_processes(conditional_transmission)\
.set_random_initial_conditions({
'S': N-100,
'I': 100
})
t, result = model.simulate(40)
# Well mixed
model.set_well_mixed(N, k0)
model.set_random_initial_conditions({'S': N-100, 'I': 100}) # Added: simulate() leaves the model at its end
t_wm, result_wm = model.simulate(40) # Added: the tutorial shows the result, not the call
# Deterministic
link_transmission = [
('I', 'S', k0*infection_rate*0.7, 'I', 'I'),
('I', 'S', k0*infection_rate*0.3, 'I', 'A'),
('A', 'S', k0*0.5*infection_rate*0.7, 'A', 'I'),
('A', 'S', k0*0.5*infection_rate*0.3, 'A', 'A'),
('I', 'I', quarantine_rate*k0*p, 'I', 'X'),
]
model = EpiModel(
compartments=compartments,
initial_population_size=N,
)\
.set_processes(node_transition + link_transmission)\
.set_initial_conditions({
'S': N-100,
'I': 100
})
t = np.linspace(0,100,1000)
result = model.integrate(t)Starsim
model.py
"""
SIARX with contact tracing, from epipack's tutorial on networks: on a random network, well mixed, and deterministic
People infected become symptomatic (I) or not (A), and those without symptoms are half as
infectious. Symptomatic people are quarantined (X) at a rate, and each time someone is, each of
their contacts who is symptomatic is quarantined too, with a chance of 25%. Tracing needs to know
who someone's contacts are, so the deterministic model has the tutorial's own mean-field form of it
instead, a flow from I to X by contact with I.
"""
import starsim as ss
import starsim.library as ssl
S, I, A, R, X = ss.states('S I A R X')
beta = 2*(1/5 + 1/5)/50 # Per contact per day: R = 2 with 50 contacts
flows = dict(
infection = ss.Transition(S, {I: 0.7, A: 0.3}, rate=beta*(I + 0.5*A)),
recovery = (I + A) >> R @ (1/5),
quarantine = I >> X @ (1/5),
)
siarx = ss.Disease(**flows, init_n=dict(I=100), name='siarx')
tracing = ss.Delivery(I >> X, when=siarx.quarantine, trace='contacts', coverage=0.25)
sim = ss.Sim(siarx, networks=ssl.StaticNet(n_contacts=50, name='contacts'), interventions=tracing, n_agents=10_000, unit='day', stop=40, dt=0.1, verbose=0)
well_mixed = sim.new(networks=ss.RandomNet(n_contacts=50, name='contacts'))
mean_field = ss.Disease(**flows, traced=I >> X @ 0.25/5*I*I/(S + I + A + R + X), init_n=dict(I=100), name='siarx') # The whole flow, kappa*p*k0*I*I/N
deterministic = ss.Sim(mean_field, networks=ss.MixingPool(n_contacts=50), total_pop=10_000, unit='day', stop=100, solver='ode', verbose=0)What was declared and what was not
Everything is declared. The infection is one transition that splits 70% to I and 30% to A, at a rate per contact from I + 0.5*A; recovery and quarantine are rates. Tracing is a delivery: ss.Delivery(move=I >> X, who=siarx.quarantine, trace='contacts', coverage=0.25) moves a quarter of the symptomatic contacts of each person quarantined to X, in the same step. The network is ssl.StaticNet(n_contacts=50), a random graph like the original’s, and the well-mixed run is the same sim on ss.RandomNet(n_contacts=50), new contacts every step, as epipack draws k0 new contacts each time someone is quarantined. The deterministic model adds the tutorial’s flow, I >> X at 0.25*(1/5)*50*I/N, written as the whole flow.
Findings
- Tracing has a declaration, and one meaning. epipack’s conditional link transmission,
('I', '->', 'X'): [('X', 'I', p, 'X', 'X')], is a delivery triggered by an event, to contacts, with a coverage. In neither framework do the people traced trigger tracing in turn: in epipack the conditional event is not the eventI -> X, and in Starsim a delivery’s move is not the transitionquarantine. One difference is of orderdt: someone with two contacts quarantined in the same step is offered it once in Starsim and twice in epipack. - No compartmental form of tracing. With
solver='ode', a sim withtrace=raises aCapabilityError(on a mixing pool too), so the deterministic model is written with the extra flow, as the tutorial derives it by hand:I + I -> I + Xatkappa*p*k0. For a route that is random mixing, Starsim could offer that approximation itself, as it approximates a network by its mean number of contacts, and say so. - A flow out of the state that drives it can’t be written per person.
I >> X @ c*Ireads, by the rule that a bare state is contact, asctimes the share of contacts inI; but Starsim reads it as the whole flow, by the rule that a rate with its source as a factor is the flow, so it becomescper person inI, without the share, and nothing says so except a line ofsim.describe(). The model gave 2,520 inSon day 40 instead of 2,942. Written as the whole flow,c*I*I/N, it is right. The importer has the same problem:ss.import_from()reads the tutorial’s deterministic model (2.5*I*Iin epipack’s JSON) as a rate of 2.5 per person, and gives 9,787 inSinstead of 2,942. - Slower as agents. epipack simulates one event at a time, so its cost grows with the number of events; Starsim’s agents take 400 steps, each a pass over the 250,000 edges of the network for transmission, and a rerun makes the network again. The infection is one transition that splits, rather than the original’s four transmissions or two transitions from the same sources, since each transmission is a pass over the edges of its own: with two, a run took about 30% longer.
- Well mixed needs a network to trace on. A mixing pool has no contacts to trace, so the well-mixed run is a random network redrawn every step, which costs more than the static one.
- The tutorial’s code is in pieces. Its code blocks leave out their imports, and the run well mixed is described but not shown;
original/siarx.pyadds the imports and the two lines that set the initial conditions again and run it, each marked.
epipack: SIRS as ODEs, by Gillespie’s method, and on a network
The SIRS of epipack’s README, its first example: infection at 2.5 a day, recovery at 1 a day, and waning immunity at 1/14 a day, in 1,000 people with 10 infectious at the start, integrated as ODEs and simulated by Gillespie’s method for 40 days. The README then runs the same model on a random network of 10,000 people with 50 contacts each, written with the processes of a different class. See the review.
- Framework: epipack 0.1.5 (MIT)
- Language: Python
- Source: https://github.com/benmaier/epipack/blob/v0.1.5/README.md
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 37 | 8 | The original is two scripts, 12 lines for the well-mixed model and 25 for the network, without the plotting. In Starsim they are one disease and three sims |
| One run | first 54 ms, rerun 52 ms | first 8.5 ms, rerun 0.25 ms (Rust) | Each is the ODE integrated to day 40, with results; the original’s at the README’s 1,000 times. A Gillespie run takes 24 ms in the original and 0.72 ms in Starsim. On the network a run is slower in Starsim: 5.2 s again, against 3.3 s (3.9 s for the first run, which makes the network), with 1,600 steps of 0.025 days; see the findings |
| Results, ODE | 411.87 in S on day 40 |
411.87 | Agree to 6.5e-6 of the population on every day, with a daily RK4 step against epipack’s adaptive solver |
| Results, Gillespie (mean of 1,000 runs) | 526 in S on day 40, with 5% to 95% of runs from 344 to 920 |
Agree to 5.6e-3 of the population on every day, within the error of a mean of 1,000 runs | |
| Results, on the network (mean of 100 runs) | A peak of 2,228 in I on day 3, and 4,399 in S on day 40 |
Agree to 2.1e-2 of the population on every day, with steps of 0.025 days against epipack’s continuous time. The largest difference is in the first days, when the epidemic doubles in about 10 hours |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epipack)
from epipack import EpiModel
import matplotlib.pyplot as plt
import numpy as np
S, I, R = list("SIR")
N = 1000
SIRS = EpiModel([S,I,R],N)\
.set_processes([
#### transmission process ####
# S + I (eta=2.5/d)-> I + I
(S, I, 2.5, I, I),
#### transition processes ####
# I (rho=1/d)-> R
# R (omega=1/14d)-> S
(I, 1, R),
(R, 1/14, S),
])\
.set_initial_conditions({S:N-10, I:10})
t = np.linspace(0,40,1000)
result_int = SIRS.integrate(t)
t_sim, result_sim = SIRS.simulate(t[-1])
for C in SIRS.compartments:
plt.plot(t, result_int[C])
plt.plot(t_sim, result_sim[C])from epipack import StochasticEpiModel
import networkx as nx
k0 = 50
R0 = 2.5
rho = 1
eta = R0 * rho / k0
omega = 1/14
N = int(1e4)
edges = [ (e[0], e[1], 1.0) for e in \
nx.fast_gnp_random_graph(N,k0/(N-1)).edges() ]
SIRS = StochasticEpiModel(
compartments=list('SIR'),
N=N,
edge_weight_tuples=edges
)\
.set_link_transmission_processes([
('I', 'S', eta, 'I', 'I'),
])\
.set_node_transition_processes([
('I', rho, 'R'),
('R', omega, 'S'),
])\
.set_random_initial_conditions({
'S': N-100,
'I': 100
})
t_s, result_s = SIRS.simulate(40)Starsim
model.py
"""
The SIRS of epipack's README, run three ways: as ODEs, by Gillespie's method, and on a random network
epipack writes the well-mixed model with the processes of an EpiModel, and the network model with
the different processes of a StochasticEpiModel, at a rate per link of R0*rho/k0. Here there is one
disease, with a rate per contact, and only the route and the solver change: 50 contacts a day at
0.05 each is the README's 2.5 per day, and on the network each person has 50 contacts on average.
"""
import starsim as ss
import starsim.library as ssl
S, I, R = ss.states('S I R')
sirs = ss.Disease(infection=S >> I @ 0.05*I, recovery=I >> R @ 1, waning=R >> S @ (1/14), init_prev=0.01, name='sirs')
sim = ss.Sim(sirs, networks=ss.MixingPool(n_contacts=50), total_pop=1000, unit='day', stop=40, dt=1, solver='ode', verbose=0)
gillespie = sim.new(solver='gillespie')
network = sim.new(networks=ssl.StaticNet(n_contacts=50), solver='abm', total_pop=10_000, n_agents=10_000, dt=0.025)What was declared and what was not
Everything is declared: three transitions, and the route of each sim (ss.MixingPool(n_contacts=50) for the two well-mixed runs, and ssl.StaticNet(n_contacts=50), a random graph with a mean of 50 contacts, for the network). The infection rate is per contact, so the same disease runs on either route; solver= chooses ODEs, Gillespie, or agents.
Findings
- One disease, not two sets of processes. epipack writes the well-mixed model as an
EpiModelwith a transmission at 2.5 a day, and the network model as aStochasticEpiModel, with a link transmission atR0*rho/k0and node transitions, so the model is written twice. Here the disease is written once with a rate of 0.05 per contact, and the routes supply the contacts: 50 a day in the mixing pool, or the edges of the network. Withsolver='ode', Starsim runs the network sim as homogeneous mixing at its mean number of contacts, which is the README’s derivation of the rate, and says that it has done so. - Fixed steps against continuous time. epipack simulates the network in continuous time, event by event, and Starsim’s agents take fixed steps, so someone infected during a step can’t infect anyone until the next one. With infection at 2.5 a day and recovery at 1 a day, the early epidemic is fast, and the step matters: with steps of 0.1 days the mean differs from the original’s by about 6% of the population in the first days, with 0.05 days by 3.5%, and with 0.025 days by 2%, and
sim.check_dt()shows it. Agreement costs steps: 1,600 of them, which is why the network run is slower than epipack’s. An exact, event-by-event solver for agents on a network, as epipack has, would need neither. - A rerun of agents is a rebuild.
sim.restart()builds an agent-based sim again, assim.new()does, so Starsim’s rerun on the network includes making the network of 250,000 edges, which epipack’s rerun reuses. - The README’s network example doesn’t run as written. A line of the README ends in a backslash followed by spaces, which is a syntax error in Python;
original/sirs_network.pyremoves the spaces and nothing else. - The starting infections.
init_prev=0.01is 10 people in 1,000 and 100 in 10,000, as in the README; as agents it starts exactly 100. A number of people,init_n=dict(I=10), is not a parameter, so a sim made from another withsim.new()can’t change it, and the share is used instead. - Importing the original.
ss.import_from()readsoriginal/sirs.py(throughstarsim/converters/epipack.py, run in epipack’s environment) as the same three transitions, and withsolver='ode'gives the same 411.87 inSon day 40; without it, it runs by Gillespie’s method, since the script’s last call issimulate(). The converter looks for a variable calledmodel, and the README’s isSIRS, so it needs a one-line function that returns it. It readsoriginal/sirs_network.pyas well mixed at the mean degree, and lists the network as not translated.
epiworld: COVID-19 and flu, with a vaccine and two measures
COVID-19 and flu spreading at the same time among 50,000 agents for 50 days, in a fully connected population in which each person meets four others a day. Half of the population is vaccinated at the start, with a vaccine that lowers the chance of infection, the chance of passing it on, and the time infectious, and two measures cut the number of contacts, to two a day from day 10 and to 1.5 from day 20. This is the model of epiworldR’s vignette “Advanced Modeling - Multiple Diseases, Tools, and Events”, which builds it a step at a time on the template ModelSIRCONN: it shows what epiworld is known for, several viruses and the tools that protect against them, carried by the same agents.
- Framework: epiworld epiworldR 0.14.0.0 (MIT)
- Language: R
- Source: the vignette “Advanced Modeling - Multiple Diseases, Tools, and Events” of epiworldR, https://uofuepibio.github.io/epiworldR/articles/advanced-modeling.html (source at https://github.com/UofUEpiBio/epiworldR/blob/v0.14.0.0/vignettes/advanced-modeling.qmd)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 29 | 21 | |
| One run | first 69 ms, rerun 67 ms | first 18 ms, rerun 0.47 ms (Rust) | One run of 50 days. The original’s first run includes building the model; Starsim’s runs the agents as counts, as for seirconn |
| Results | Agree while few are infected, and within 5% after (mean of 1,000 runs each): on days 1 to 5 every mean agrees within three standard errors (725 infected on day 5, against 721). Later the epidemic is larger, as expected from the original’s chance of infection (see below): it peaks on day 11 in both, at 9,296 against 8,899 (+4.5%), the flu peaks on day 9 at 162 against 155, and 26,855 have recovered on day 50, against 26,509 (+1.3%) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epiworld)
advanced-modeling.R
## -----------------------------------------------------------------------------
#| label: create-base-model
library(epiworldR)
model_sirconn <- ModelSIRCONN(
name = "COVID-19",
n = 50000,
contact_rate = 4,
recovery_rate = 1 / 4,
prevalence = 0.001,
transmission_rate = 0.5
)
## -----------------------------------------------------------------------------
#| label: run-base-model
verbose_off(model_sirconn)
run(model_sirconn, ndays = 50, seed = 1912)
plot(model_sirconn)
## -----------------------------------------------------------------------------
#| label: create-flu-virus
flu_virus <- virus(name = "Flu", prob_infecting = .35, prevalence = 0.001, as_proportion = TRUE)
## -----------------------------------------------------------------------------
#| label: add-flu
add_virus(model_sirconn, flu_virus)
## -----------------------------------------------------------------------------
#| label: run-model-flu
run(model_sirconn, ndays = 50, seed = 1912)
plot(model_sirconn)
## -----------------------------------------------------------------------------
#| label: create-vaccine
vaccine_tool <- tool(
name = "Vaccine",
susceptibility_reduction = .9,
transmission_reduction = .5,
recovery_enhancer = .5,
death_reduction = .9,
prevalence = 0.5,
as_proportion = TRUE
)
## -----------------------------------------------------------------------------
#| label: set-vaccine-distribution
set_distribution_tool(
tool = vaccine_tool,
distfun = distribute_tool_randomly(0.5, TRUE)
)
## -----------------------------------------------------------------------------
#| label: add-vaccine
add_tool(model_sirconn, vaccine_tool)
## -----------------------------------------------------------------------------
#| label: run-model-vaccine
run(model_sirconn, ndays = 50, seed = 1912)
plot(model_sirconn)
## -----------------------------------------------------------------------------
#| label: set-events
isolation_day_10 <- globalevent_set_params("Contact rate", 2, day = 10)
advertisement_day_20 <- globalevent_set_params("Contact rate", 1.5, day = 20)
## -----------------------------------------------------------------------------
#| label: add-events
add_globalevent(model_sirconn, isolation_day_10)
add_globalevent(model_sirconn, advertisement_day_20)
## -----------------------------------------------------------------------------
#| label: run-full-model
run(model_sirconn, ndays = 50, seed = 1912)
plot(model_sirconn)
## -----------------------------------------------------------------------------
#| label: model-summary
summary(model_sirconn)
## -----------------------------------------------------------------------------
#| label: reproductive-numbers
repnum2 <- get_reproductive_number(model_sirconn)
plot(repnum2, type = "b")Starsim
model.py
"""
The epiworldR advanced modeling vignette (docs/models/epiworld/covid_flu), in Starsim: COVID-19
and flu spreading together among 50,000 people who each meet four others a day, a vaccine given to
half of them, and two measures that cut contacts, on days 10 and 20. As for the SEIR (../seirconn),
it runs as a chain binomial, in whole days
"""
import numpy as np
import starsim as ss
contacts = ss.par(4) # A day
strains = ss.Strains(
['covid', 'flu'],
infection = [1, 0.35/0.5], # A chance of infection for each contact of 0.5 and 0.35
recovery = [1, np.log(0.5)/np.log(0.75)], # A chance of recovery of 1/4 and 1/2 a day
)
vaccine = ss.Vaccine(efficacy=dict(acquisition=0.9, transmission=0.5))
S, I, R = ss.states('S I R')
sir = ss.Disease(
infection = S >> I @ 0.5*contacts*I,
recovery = I >> R @ ss.prob(1/4),
enhanced = ss.Transition(I, R, rate=ss.prob(0.5), who=vaccine.held), # The vaccine's chance
init_n = dict(I=100), # 50 of each
strains = strains,
)
vaccination = ss.vaccinate(vaccine, coverage=0.5, when=0)
isolation = ss.modify(contacts, set=2, when=ss.schedule(start=11, stop=21)) # The original's day 10, from day 11
advertisement = ss.modify(contacts, set=1.5, when=ss.schedule(start=21))
sim = ss.Sim(sir, interventions=[vaccination, isolation, advertisement], total_pop=50_000, stop=50,
solver=ss.solvers.binomial(substeps=1))
if __name__ == '__main__':
sim.run()
sim.plot(show_interventions=False) # Marking them fails for now (see the README)What was declared and what was not
Everything is declared. The two viruses are two strains of one disease (ss.Strains), with their own chances of infection and recovery as multipliers. The vaccine is an ss.Vaccine with efficacy against acquisition and transmission, given to half of everyone at the start, and its recovery enhancer is a second transition from I to R, with a chance of 0.5 a day, for those who hold it (where=vaccine.held). The two measures are each an ss.modify that sets the contact rate, a parameter in the rate of infection. The sim runs as a chain binomial in whole days (solver='binomial', substeps=1), as the SEIR does.
Findings
- Two viruses are two strains. In epiworld a virus is something an agent carries, and the states belong to the model: an agent carries one virus at a time, and recovery from either puts them in
Recovered, protected from both. That is whatss.Strainsdoes with noimmunity=: one course through the states, one strain at a time, and a new infection takes its source’s strain. Two diseases, each with its own states, would let people have both. Each strain’s results are there too (res.I.select(strain='flu')). - A strain’s values are multipliers of rates. The flu’s chance of infection per contact, 0.35 against 0.5, is a plain ratio, since the chance per contact is a factor of the rate. Its chance of recovery, 0.5 a day against 0.25, is not: as a multiplier of the rate it is log(0.5)/log(0.75) = 2.41, which has to be worked out. A value for each strain given as a rate or a chance of its own (
recovery=[ss.prob(1/4), ss.prob(1/2)]) would say it directly. - A recovery enhancer is a second chance of recovery. epiworld combines the virus’s chance of recovery with the tool’s as 1 − (1 − 1/4)(1 − 0.5) for COVID-19 and 1 − (1 − 1/2)(1 − 0.5) for flu. A vaccine’s efficacy on a transition scales its rate, and here the factor would be 3.41 for COVID-19 and 2 for flu, for the same tool. Since rates out of a state compete, a second transition out of
Iwith a chance of 0.5 a day, for those who hold the vaccine, gives the original’s rule exactly, for both strains. It is short and exact, but this effect of the vaccine is then written in the disease, not in the vaccine, with the other two. - The original’s measures act a day late, and are not undone.
globalevent_set_params("Contact rate", 2, day = 10)sets the parameter at the end of day 10, after that day’s update of who is infectious has fixed the next day’s contacts with the old value, so the contact rate is 4 to day 11 and 2 from day 12. In Starsim the step from day 11 to day 12 is the one at day 11, so the modifiers start at 11 and 21. More importantly, the parameter is not set back when the model is run again: eachrun()and each simulation ofrun_multiple()starts with the contact rate the last one left, 1.5. The vignette’s own run is right, since it is the first with the measures, but the ones after it are not: inrun_multiple()of six runs of the model with the two measures alone, the first has 106 infections on day 1 and the others 26 to 45, since they start with 1.5 contacts a day. The reference runs set the rate back to 4 before each run. Anss.modifychanges nothing in place, and parameters are back to their base values after a run, so every run of the Starsim version starts from the same model. - The original’s chance of infection is lower when many are infectious. As in seirconn, epiworld picks at most one infection from a person’s contacts by a “roulette” that leaves out the chance of two or more. With 20% infectious and four contacts a day that infect with a chance of 0.5, it gives a chance of infection of 0.312 a day, as measured in the original, where independent contacts give 1 − exp(−4 × 0.2 × 0.5) = 0.330. Starsim’s chance is the latter, so the agreement is close while few are infected, and the epidemic is about 5% larger at its peak. The translation does not try to match the roulette.
- The flu recovers faster than the vignette says. The vignette says that both viruses have a recovery rate of 1/4, but its
virus()call does not give one, andvirus()hasrecovery_rate = 0.5by default. The translation follows the code. - Bug: plotting a sim with a delivery and a modifier that stops.
sim.plot()raises aValueError(“The truth value of an array … is ambiguous”) for any sim with a delivery at a fixed time and anss.modifywithstart=andstop=:ss.results.intervention_marks()checksmark not in out, which compares the delivery’s time, a NumPy number, with the modifier’s (start, stop) pair.model.pyplots withshow_interventions=Falseuntil this is fixed. - Run as agents, the epidemic is smaller. With
n_agents=50_000andsolver='abm', 20 runs peak at 5,624 infected on day 14 and 21,697 have recovered on day 50, against 8,899 and 26,509: someone infected in a step has half a step’s chance of recovery before they first infect anyone, which epiworld does not have (see seirconn).
epiworld: SEIR in a fully connected population
COVID-19 as an SEIR among 10,000 agents for 50 days, in which everyone can meet everyone: each person meets two others a day at random, an infectious contact infects with a chance of 0.5, and the infection starts from one person exposed. This is the model of epiworldR’s vignette “Run Multiple”, which runs it 50 times with run_multiple(); it is the template ModelSEIRCONN, where CONN means fully connected.
- Framework: epiworld epiworldR 0.14.0.0 (MIT)
- Language: R
- Source: the vignette “Run Multiple” of epiworldR, https://uofuepibio.github.io/epiworldR/articles/run-multiple.html (source at https://github.com/UofUEpiBio/epiworldR/blob/v0.14.0.0/vignettes/run-multiple.qmd)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 12 | 8 | |
| One run | first 3.4 ms, rerun 2.5 ms | first 9 ms, rerun 0.21 ms (Rust) | One run of 50 days. The original’s first run includes building the model; Starsim’s runs the agents as counts (see below), which is why it is faster |
| Results | Agree (within three standard errors; mean of 1,000 runs each): 398 ± 17 recovered on day 50, against 394, and the infection dies out by day 50 in 22.6% of runs, against 20.8%. Every state agrees on days 10 to 50, and the 95% quantile of the recovered on day 50 is 1,601 against 1,403, 2.1 standard errors apart |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epiworld)
run-multiple.R
## -----------------------------------------------------------------------------
#| label: sirconn-setup
library(epiworldR)
model_seirconn <- ModelSEIRCONN(
name = "COVID-19",
n = 10000,
prevalence = 0.0001,
contact_rate = 2,
transmission_rate = 0.5,
incubation_days = 7,
recovery_rate = 1 / 3
)
## -----------------------------------------------------------------------------
#| label: saver-generation
# Generating a saver
saver <- make_saver("total_hist", "reproductive")
# Running and printing
run_multiple(model_seirconn, ndays = 50, nsims = 50, saver = saver, nthreads = 2)
## -----------------------------------------------------------------------------
#| label: retrieving-results
# Retrieving the results
ans <- run_multiple_get_results(model_seirconn, nthreads = 2)
head(ans$total_hist)
head(ans$reproductive)
## -----------------------------------------------------------------------------
#| label: plotting-seirconn-epicurves
seirconn_50 <- ans$total_hist
seirconn_50 <- seirconn_50[seirconn_50$date <= 20, ]
plot(seirconn_50)
## -----------------------------------------------------------------------------
#| label: reproductive-number-plot
seirconn_50_r <- ans$reproductive
plot(seirconn_50_r)
# boxplot(rt ~ source_exposure_date, data = seirconn_50_r,
# main = "Reproductive Number",
# xlab = "Source Exposure Date",
# ylab = "rt",
# border = "black",
# las = 2)Starsim
model.py
"""
The epiworldR SEIR in a fully connected population (docs/models/epiworld/seirconn), in Starsim:
COVID-19 among 10,000 people who each meet two others a day, starting from one person exposed, run
50 times. Every person is alike and everyone meets everyone, so the original's agents are a chain
binomial, which is how this runs: in whole days, with each day's chances worked out at its start
"""
import starsim as ss
S, E, I, R = ss.states('S E I R')
seir = ss.Disease(
infection = S >> E @ 2*0.5*I, # Two contacts a day, each infecting with a chance of 0.5
onset = E >> I @ ss.prob(1/7), # A chance of 1 in 7 a day, so 7 days on average
recovery = I >> R @ ss.prob(1/3),
init_n = dict(E=1),
)
sim = ss.Sim(seir, total_pop=10_000, stop=50, solver=ss.solvers.binomial(substeps=1))
if __name__ == '__main__':
msim = ss.parallel(sim, n_runs=50)
msim.plot()What was declared and what was not
Everything is declared: three transitions, with the chances a day of the original as ss.prob, and one person exposed at the start. The sim runs as a chain binomial in whole days (solver='binomial', substeps=1).
Findings
- The agents are a chain binomial. In
ModelSEIRCONNevery agent is alike and everyone meets everyone, so the number in each state is all that matters: on each day, each susceptible person is infected with the same chance, which depends only on the number infectious, and each exposed or infectious person moves on with the same chance. That is a chain binomial, and run as one the translation agrees with the original’s 1,000 runs, at a fraction of the cost. Starsim’s guide suggests this solver for “a model written in discrete time”, and it is the right one here;solver='auto'would choose the ODE, which has no extinctions. - Run as agents, the model is not the same. With
n_agents=10_000andsolver='abm'the infection dies out by day 50 in 46% of runs, against 21%, and 306 ± 39 are recovered on day 50 (200 runs). Starsim’s agents take a step’s transitions in turn, as an approximation of continuous time: the transitions that are not transmission come first, so someone who becomes infectious during a step has already had half a step’s chance of recovering (1 − (2/3)^½ = 0.18 here) before that step’s transmission, and so infects others for less time. epiworld works out every day’s chances from the states at the start of the day, as the chain binomial does. With chances as large as 1/3 a day this is a large difference. No setting of the sim makes agents work out a step’s chances at its start, so a model defined in discrete time that needs agents (one on a network, say) could not be matched. The guide’s “Solvers and backends” page says that agents, like the chain binomial withsubsteps=1, take each step whole, “everyone’s chance of moving is worked out at the start of the step”, which is not what they do. - The original’s chance of infection is slightly low when people meet several infectious people. epiworld draws each susceptible person’s contacts with infectious people, and then picks at most one infection from them by a “roulette” that takes only the chances of no infection and of exactly one. With two contacts that each infect with a chance of 0.5, the chance of infection is 2/3, not 3/4. Here prevalence stays low, so this is not visible; in covid_flu it is.
epydemix: the first wave of COVID-19 in Massachusetts
An age-structured SEIR with deaths, in the population of Massachusetts (7.0 million people in nine age groups), from 23 February to 2 August 2020. Transmission is scaled each day by a contact reduction computed from Google mobility data and by a seasonal factor; deaths follow the infectious period with the age-specific infection fatality rate, after an Erlang-distributed delay. The model is calibrated by ABC-SMC to weekly deaths up to 31 May, and then projected to 2 August under three reopening scenarios. It is epydemix’s seventh tutorial, “Modeling the first wave of COVID-19 in Massachusetts”, and its most complete worked example.
- Framework: epydemix 1.4.0 (GPL-3.0)
- Language: Python
- Source: https://github.com/epistorm/epydemix/blob/v1.4.0/tutorials/07_Covid-19_Example.ipynb (code cells 2 to 13)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 161 | 43 | Both count the loading of the data, the calibration, and the scenarios; lines over 100 characters count twice. The original is formatted one item per line |
| One run of the calibration period | first 34 ms, rerun 34 ms | first 23 ms, rerun 1.2 ms (Rust) | In process, without the import or the data download. The original’s times are with its model already defined; Starsim’s first run includes building the sim |
| Results at fixed values | Agree (tolerance 3% of the peak). The mean of 200 runs: weekly deaths within 0.7% of the peak (833 against 837, in the same week), and 0.5% and 0.4% in the two reopening scenarios; deaths by age within 3% of the peak in every group from age 40; E, I, R, and D within 0.5%; 10,507 deaths by 2 August against 10,525 |
||
| Results of the calibration | Agree (tolerance: each posterior mean inside the original’s 5% to 95% range, and projected peaks within 10%). Posterior means: R0 2.28 (original 2.25), Delta 18.8 days (19.0), I0 4,777 (4,770), and seasonality_min 0.79 (0.80), with similar ranges. Peak weekly deaths projected from the posterior are 891 (897), 1,366 (1,363), and 2,231 (2,217) in the three scenarios. This is a change from before: a point estimate gave much smaller second waves |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
The calibration is the same method in both: ABC-SMC with 200 particles for 5 generations (the tutorial’s 1,000 for 10 take over an hour). It took 25 s in Starsim and 87 s in the original on a quiet machine (222 s when the machine was busy, so these are a rough guide).
Original (epydemix)
# Extracted from tutorials/07_Covid-19_Example.ipynb in epydemix v1.4.0
# (https://github.com/epistorm/epydemix/blob/v1.4.0/tutorials/07_Covid-19_Example.ipynb).
# Code cells 2 to 13 of the 14 in the notebook, unchanged (the first installs packages on Colab, and the last is empty);
# these comment lines and the cell markers are the only additions.
# --- Cell 2 ---
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime
# Load epidemiological data
epi_data = pd.read_csv(
"https://raw.githubusercontent.com/epistorm/epydemix/refs/heads/main/tutorials/data/massachusetts_data/MA_deaths.csv",
parse_dates=["date"])
# Split data into calibration and projection periods
calibration_cutoff = datetime(2020, 6, 1)
epi_data_calibration = epi_data[epi_data.date < calibration_cutoff]
epi_data_projection = epi_data[epi_data.date >= calibration_cutoff]
# Plot calibration and projection data
plt.figure(figsize=(10, 5))
plt.plot(
epi_data_calibration.date, epi_data_calibration.data,
marker="o", color="k", label="Calibration data"
)
plt.plot(
epi_data_projection.date, epi_data_projection.data,
linestyle="--", color="k", label="Projection data"
)
# Formatting the plot
plt.legend()
plt.title("Massachusetts - Weekly COVID-19 Deaths")
plt.xlabel("Date")
plt.ylabel("Weekly Deaths")
plt.xticks(rotation=45)
plt.grid(True)
# Show the plot
plt.show()
# --- Cell 3 ---
def compute_contact_reductions(mob_data, columns=None):
"""Compute contact reduction factor from mobility data."""
if columns is None:
columns = [
"retail_and_recreation_percent_change_from_baseline",
"grocery_and_pharmacy_percent_change_from_baseline",
"transit_stations_percent_change_from_baseline",
"workplaces_percent_change_from_baseline",
"residential_percent_change_from_baseline",
]
return pd.DataFrame(
{
"date": mob_data.date,
"r": (1 + mob_data[columns].mean(axis=1) / 100) ** 2,
}
)
# Load mobility data
mob_data = pd.read_csv(
"https://raw.githubusercontent.com/epistorm/epydemix/refs/heads/main/tutorials/data/massachusetts_data/MA_google.csv",
parse_dates=["date"])
# Filter mobility data to match the calibration period
mob_data = mob_data.loc[
(mob_data.date >= epi_data_calibration.date.min()) &
(mob_data.date <= epi_data_calibration.date.max())
]
# Compute contact reductions
contact_reductions = compute_contact_reductions(mob_data=mob_data)
# Plot contact reductions over time
plt.figure(figsize=(10, 5))
plt.plot(contact_reductions.date, contact_reductions.r, label="Contact Reduction Factor")
plt.title("Contact Reductions in Massachusetts - COVID-19 First Wave")
plt.xticks(rotation=45)
plt.ylabel("$r(t)$")
plt.xlabel("Date")
plt.legend()
plt.grid(True)
plt.show()
# --- Cell 4 ---
from epydemix.model import EpiModel, simulate
from epydemix.population import load_epydemix_population
from epydemix.utils import compute_simulation_dates, convert_to_2Darray
import numpy as np
# Define Epydemix model with compartments
model = EpiModel(compartments=["S", "E", "I", "R", "D0", "D1", "D2", "D3", "D4"])
# Set Massachusetts population with 10 age groups
population_ma = load_epydemix_population(
"United_States_Massachusetts",
age_group_mapping={
"0-9": np.arange(0, 10).astype(str),
"10-19": np.arange(10, 20).astype(str),
"20-29": np.arange(20, 30).astype(str),
"30-39": np.arange(30, 40).astype(str),
"40-49": np.arange(40, 50).astype(str),
"50-59": np.arange(50, 60).astype(str),
"60-69": np.arange(60, 70).astype(str),
"70-79": np.arange(70, 80).astype(str),
"80+": np.concatenate((np.arange(80, 84), ["84+"])).astype(str),
},
)
model.set_population(population_ma)
# Set model parameters
model.add_parameter(
parameters_dict={
"mu": 1.0 / 2.5, # Recovery rate
"eps": 1.0 / 4.0, # Incubation rate
"r": contact_reductions["r"].values, # Contact reduction factor
"IFR": convert_to_2Darray(
[
0.00161 / 100,
0.00695 / 100,
0.0309 / 100,
0.0844 / 100,
0.161 / 100,
0.595 / 100,
1.93 / 100,
4.28 / 100,
7.80 / 100,
]
), # Infection Fatality Rate per age group
}
)
# Add compartment transitions
model.add_transition(source="S", target="E", params=("beta*r", "I"), kind="mediated") # Infection
model.add_transition(source="E", target="I", params="eps", kind="spontaneous") # Incubation to infectious
model.add_transition(source="I", target="R", params="mu*(1-IFR)", kind="spontaneous") # Recovery
model.add_transition(source="I", target="D0", params="mu*IFR", kind="spontaneous") # Progression to death
# Sequential progression through death compartments
for d in range(4):
model.add_transition(source=f"D{d}", target=f"D{d+1}", params="4./Delta", kind="spontaneous")
# --- Cell 5 ---
def apply_seasonality(start_date, end_date, seasonality_min, seasonality_max=1):
"""Compute seasonal modulation factors over a date range."""
seasonality_factors = []
start_date, end_date = pd.to_datetime(start_date), pd.to_datetime(end_date)
for day in pd.date_range(start_date, end_date):
day_max = datetime(day.year, 1, 15) # Peak seasonality date
s_r = seasonality_min / seasonality_max
factor = 0.5 * ((1 - s_r) * np.sin(2 * np.pi / 365 * (day - day_max).days + 0.5 * np.pi) + 1 + s_r)
seasonality_factors.append(factor)
return np.array(seasonality_factors)
# --- Cell 6 ---
from scipy import stats
# Compute the aggregated contact matrix
C = np.sum([c for _, c in model.population.contact_matrices.items()], axis=0)
def compute_beta(C, mu, R0):
"""Compute the transmission rate beta from R0 and the contact matrix."""
return R0 * mu / np.linalg.eigvals(C).real.max()
def custom_initial_conditions(epimodel, I0):
"""Generate initial conditions for the epidemic model."""
infected_by_age = np.random.multinomial(I0, epimodel.population.Nk / epimodel.population.Nk.sum())
return {
"S": epimodel.population.Nk - infected_by_age,
"E": (infected_by_age / 2).astype(int),
"I": (infected_by_age / 2).astype(int),
}
def simulate_wrapper(parameters):
"""Wrapper function to set up and run the simulation."""
# Set initial conditions
initial_conditions = custom_initial_conditions(parameters["epimodel"], parameters["I0"])
parameters.update({"initial_conditions_dict": initial_conditions})
# Compute beta from R0
beta0 = compute_beta(C, parameters["epimodel"].parameters["mu"], parameters["R0"])
# Compute seasonality factor
seasonal_factor = apply_seasonality(
parameters["start_date"], parameters["end_date"], parameters["seasonality_min"]
)
parameters.update({"beta": beta0 * seasonal_factor})
results = simulate(**parameters)
return {"data": results.transitions["D3_to_D4_total"]}
# Define prior distributions for parameters
priors = {
"R0": stats.uniform(1.5, 6.0 - 1.5),
"Delta": stats.randint(7, 28),
"I0": stats.randint(10, 9990),
"seasonality_min": stats.uniform(0.6, 1.0 - 0.6),
}
# Compute simulation date range
simulation_dates = compute_simulation_dates(
start_date=epi_data_calibration.date.values[0],
end_date=epi_data_calibration.date.values[-1]
)
# Define model parameters
parameters = {
"epimodel": model,
"start_date": epi_data_calibration.date.values[0],
"end_date": epi_data_calibration.date.values[-1],
"resample_frequency": "W",
}
# --- Cell 7 ---
from epydemix.calibration import ABCSampler, wmape
# Initialize the ABCSampler object
abc_sampler = ABCSampler(
simulation_function=simulate_wrapper,
priors=priors,
parameters=parameters,
observed_data=epi_data_calibration["data"].values,
distance_function=wmape,
)
# Run ABC-SMC calibration
results = abc_sampler.calibrate(
strategy="smc",
num_particles=1000,
num_generations=10
)
# --- Cell 8 ---
from epydemix.visualization import plot_quantiles, plot_posterior_distribution, plot_posterior_distribution_2d
ax = plot_quantiles(
results.get_calibration_quantiles(dates=epi_data_calibration.date.values),
data=epi_data_calibration,
columns="data",
title="Fitted COVID-19 Deaths in Massachusetts (ABC-SMC)",
show_data=True,
xlabel="Date",
ylabel="Weekly COVID-19 Deaths",
labels=["Weekly deaths"],
)
ax.tick_params(axis="x", labelrotation=45)
# --- Cell 9 ---
df_posteriors = results.get_posterior_distribution()
for param in ["R0", "Delta", "I0", "seasonality_min"]:
plot_posterior_distribution(
df_posteriors,
parameter=param,
title=f"Posterior Distribution - {param}",
xlabel=param,
ylabel="Density",
kind="kde",
)
# --- Cell 10 ---
# update simulation dates
simulation_dates = compute_simulation_dates(start_date=epi_data_calibration.date.values[0], end_date=epi_data_projection.date.values[-1])
parameters = {"epimodel": model,
"start_date": epi_data_calibration.date.values[0],
"end_date": epi_data_projection.date.values[-1],
"resample_frequency": "W"}
# --- Cell 11 ---
# Generate date ranges for total, calibration, and projection periods
total_dates = pd.date_range(epi_data.date.values[0], epi_data.date.values[-1])
calibration_dates = pd.date_range(epi_data_calibration.date.values[0], epi_data_calibration.date.values[-1])
projection_dates = pd.date_range(epi_data_projection.date.values[0], epi_data_projection.date.values[-1])
# Compute the number of projection time steps
T_proj = len(total_dates) - len(calibration_dates)
# Create a DataFrame for contact reductions over the projection period
contact_reductions_projection = pd.DataFrame({"date": total_dates})
# Define reopening scenarios with different levels of contact increase
r_persistence = contact_reductions["r"].values[-1] * np.ones(T_proj)
r_moderate = 1.3 * contact_reductions["r"].values[-1] * np.ones(T_proj)
r_high = 1.5 * contact_reductions["r"].values[-1] * np.ones(T_proj)
# Extend contact reduction values into the projection period
contact_reductions_projection["r_persistence"] = np.concatenate((contact_reductions["r"].values, r_persistence))
contact_reductions_projection["r_moderate"] = np.concatenate((contact_reductions["r"].values, r_moderate))
contact_reductions_projection["r_high"] = np.concatenate((contact_reductions["r"].values, r_high))
# Plot different reopening scenarios
plt.figure(figsize=(10, 5))
plt.plot(contact_reductions_projection.date, contact_reductions_projection["r_persistence"], label="Persistence")
plt.plot(contact_reductions_projection.date, contact_reductions_projection["r_moderate"], label="Moderate reopening")
plt.plot(contact_reductions_projection.date, contact_reductions_projection["r_high"], label="High reopening")
# Format the plot
plt.legend()
plt.xlabel("Date")
plt.ylabel("Contact Reduction Factor")
plt.title("Projected Contact Reductions under Different Reopening Scenarios")
plt.xticks(rotation=45)
plt.grid(True)
# Show the plot
plt.show()
# --- Cell 12 ---
results_reopening = {}
for reopening in ["persistence", "moderate", "high"]:
print(f"Running projections for reopening scenario: {reopening}")
projection_parameters = parameters.copy()
model.add_parameter("r", contact_reductions_projection[f"r_{reopening}"].values)
projection_parameters.update({"epimodel": model})
results_reopening[reopening] = abc_sampler.run_projections(projection_parameters)
# --- Cell 13 ---
import seaborn as sns
fig, ax = plt.subplots(figsize=(10, 5), dpi=300)
colors = sns.color_palette("Dark2", n_colors=3)
show_data = True
for reopening, color in zip(["persistence", "moderate", "high"], colors):
df_quantiles = results_reopening[reopening].get_projection_quantiles(epi_data.date.values)
plot_quantiles(df_quantiles, columns="data", data=epi_data, ax=ax, colors=color, show_legend=False, show_data=show_data, labels=[reopening])
show_data = False
ax.set_title("Projected Weekly Deaths in Massachusetts - Different Reopening Scenarios")
ax.legend(loc="upper left")
ax.set_ylabel("Weekly COVID-19 Deaths");| date | data |
| 2020-02-23 | 0 |
| 2020-03-01 | 0 |
| 2020-03-08 | 0 |
| 2020-03-15 | 0 |
| 2020-03-22 | 5 |
| 2020-03-29 | 43 |
| 2020-04-05 | 183 |
| 2020-04-12 | 525 |
| 2020-04-19 | 949 |
| 2020-04-26 | 1194 |
| 2020-05-03 | 1105 |
| 2020-05-10 | 975 |
| 2020-05-17 | 818 |
| 2020-05-24 | 575 |
| 2020-05-31 | 474 |
| 2020-06-07 | 470 |
| 2020-06-14 | 308 |
| 2020-06-21 | 231 |
| 2020-06-28 | 186 |
| 2020-07-05 | 142 |
| 2020-07-12 | 142 |
| 2020-07-19 | 100 |
| 2020-07-26 | 104 |
| 2020-08-02 | 80 |
| country_region_code | country_region | sub_region_1 | sub_region_2 | metro_area | iso_3166_2_code | census_fips_code | place_id | date | retail_and_recreation_percent_change_from_baseline | grocery_and_pharmacy_percent_change_from_baseline | parks_percent_change_from_baseline | transit_stations_percent_change_from_baseline | workplaces_percent_change_from_baseline | residential_percent_change_from_baseline |
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-15 | 2.0 | -4.0 | 0.0 | 3.0 | -2.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-16 | 6.0 | -4.0 | 3.0 | -1.0 | 0.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-17 | 11.0 | -2.0 | 32.0 | -26.0 | -42.0 | 9.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-18 | -4.0 | -10.0 | -18.0 | -4.0 | -10.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-19 | 3.0 | -3.0 | 3.0 | -2.0 | -10.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-20 | 3.0 | -2.0 | -1.0 | -2.0 | -8.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-21 | 0.0 | -6.0 | -9.0 | -3.0 | -9.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-22 | 5.0 | -2.0 | 49.0 | 5.0 | 1.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-23 | 9.0 | 1.0 | 66.0 | 6.0 | 3.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-24 | 4.0 | 0.0 | 51.0 | 1.0 | 4.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-25 | 2.0 | -3.0 | 12.0 | 2.0 | 3.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-26 | 5.0 | 0.0 | 3.0 | 5.0 | 2.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-27 | 4.0 | 0.0 | -1.0 | 5.0 | 3.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-28 | 5.0 | 1.0 | -2.0 | 7.0 | 3.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-02-29 | 9.0 | 0.0 | 26.0 | 11.0 | 2.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-01 | 10.0 | 4.0 | -1.0 | 11.0 | 3.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-02 | 7.0 | 5.0 | 21.0 | 2.0 | 3.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-03 | 11.0 | 6.0 | 49.0 | 4.0 | 1.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-04 | 4.0 | 3.0 | 5.0 | 0.0 | 2.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-05 | 7.0 | 7.0 | 30.0 | 1.0 | 4.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-06 | 2.0 | 2.0 | 1.0 | -1.0 | 3.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-07 | 7.0 | 3.0 | 24.0 | 0.0 | 1.0 | -1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-08 | 9.0 | 5.0 | 47.0 | 0.0 | 2.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-09 | 10.0 | 7.0 | 102.0 | -3.0 | 1.0 | 0.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-10 | 5.0 | 7.0 | 33.0 | -7.0 | 0.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-11 | 5.0 | 13.0 | 25.0 | -10.0 | -3.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-12 | 5.0 | 32.0 | 14.0 | -17.0 | -5.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-13 | -5.0 | 30.0 | -16.0 | -26.0 | -14.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-14 | -11.0 | 16.0 | 61.0 | -19.0 | -4.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-15 | -15.0 | 5.0 | 43.0 | -26.0 | -8.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-16 | -12.0 | 16.0 | 8.0 | -41.0 | -30.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-17 | -32.0 | 0.0 | -19.0 | -50.0 | -39.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-18 | -31.0 | 1.0 | 53.0 | -50.0 | -42.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-19 | -39.0 | -3.0 | -36.0 | -59.0 | -44.0 | 21.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-20 | -34.0 | 11.0 | 20.0 | -56.0 | -44.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-21 | -42.0 | -5.0 | 71.0 | -50.0 | -30.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-22 | -46.0 | -19.0 | 22.0 | -59.0 | -33.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-23 | -40.0 | -12.0 | -33.0 | -66.0 | -50.0 | 21.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-24 | -49.0 | -21.0 | -2.0 | -68.0 | -55.0 | 24.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-25 | -52.0 | -25.0 | -21.0 | -70.0 | -57.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-26 | -47.0 | -17.0 | 42.0 | -68.0 | -56.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-27 | -48.0 | -19.0 | 46.0 | -67.0 | -55.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-28 | -52.0 | -23.0 | 57.0 | -63.0 | -41.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-29 | -59.0 | -36.0 | -56.0 | -73.0 | -42.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-30 | -49.0 | -25.0 | -28.0 | -72.0 | -58.0 | 24.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-03-31 | -46.0 | -20.0 | 11.0 | -69.0 | -58.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-01 | -46.0 | -16.0 | -2.0 | -69.0 | -59.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-02 | -50.0 | -17.0 | -30.0 | -73.0 | -59.0 | 27.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-03 | -54.0 | -22.0 | -54.0 | -74.0 | -59.0 | 30.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-04 | -53.0 | -19.0 | 29.0 | -64.0 | -43.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-05 | -53.0 | -27.0 | 25.0 | -68.0 | -42.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-06 | -44.0 | -19.0 | 51.0 | -69.0 | -59.0 | 23.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-07 | -46.0 | -21.0 | 41.0 | -70.0 | -60.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-08 | -51.0 | -22.0 | -13.0 | -73.0 | -61.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-09 | -54.0 | -25.0 | -42.0 | -75.0 | -61.0 | 28.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-10 | -50.0 | -16.0 | -25.0 | -73.0 | -61.0 | 29.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-11 | -49.0 | -12.0 | 33.0 | -64.0 | -44.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-12 | -67.0 | -50.0 | 21.0 | -69.0 | -48.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-13 | -58.0 | -40.0 | -55.0 | -77.0 | -61.0 | 27.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-14 | -45.0 | -20.0 | 25.0 | -69.0 | -59.0 | 24.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-15 | -45.0 | -22.0 | 2.0 | -70.0 | -59.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-16 | -47.0 | -20.0 | -7.0 | -71.0 | -59.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-17 | -48.0 | -21.0 | -7.0 | -70.0 | -58.0 | 27.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-18 | -59.0 | -33.0 | -44.0 | -70.0 | -45.0 | 19.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-19 | -45.0 | -25.0 | 62.0 | -62.0 | -39.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-20 | -45.0 | -25.0 | 5.0 | -72.0 | -62.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-21 | -50.0 | -29.0 | -28.0 | -72.0 | -59.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-22 | -47.0 | -24.0 | -18.0 | -71.0 | -59.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-23 | -45.0 | -20.0 | 21.0 | -69.0 | -58.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-24 | -51.0 | -27.0 | -45.0 | -72.0 | -58.0 | 29.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-25 | -45.0 | -22.0 | 98.0 | -56.0 | -40.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-26 | -53.0 | -32.0 | -34.0 | -71.0 | -42.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-27 | -50.0 | -31.0 | -48.0 | -74.0 | -58.0 | 25.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-28 | -43.0 | -21.0 | 9.0 | -68.0 | -58.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-29 | -41.0 | -20.0 | 30.0 | -67.0 | -58.0 | 23.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-04-30 | -47.0 | -20.0 | -23.0 | -70.0 | -58.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-01 | -45.0 | -18.0 | -13.0 | -68.0 | -56.0 | 26.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-02 | -41.0 | -15.0 | 100.0 | -52.0 | -37.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-03 | -37.0 | -18.0 | 90.0 | -55.0 | -34.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-04 | -37.0 | -17.0 | 23.0 | -66.0 | -55.0 | 21.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-05 | -36.0 | -13.0 | 27.0 | -65.0 | -56.0 | 22.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-06 | -40.0 | -18.0 | 11.0 | -66.0 | -56.0 | 23.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-07 | -39.0 | -12.0 | 38.0 | -65.0 | -55.0 | 23.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-08 | -42.0 | -13.0 | -3.0 | -66.0 | -54.0 | 24.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-09 | -45.0 | -10.0 | -13.0 | -61.0 | -37.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-10 | -37.0 | -14.0 | 26.0 | -59.0 | -31.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-11 | -43.0 | -25.0 | -6.0 | -68.0 | -54.0 | 22.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-12 | -38.0 | -16.0 | 23.0 | -65.0 | -54.0 | 22.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-13 | -36.0 | -15.0 | 36.0 | -63.0 | -54.0 | 22.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-14 | -36.0 | -12.0 | 71.0 | -62.0 | -54.0 | 21.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-15 | -40.0 | -16.0 | 28.0 | -62.0 | -53.0 | 23.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-16 | -39.0 | -17.0 | 99.0 | -50.0 | -33.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-17 | -37.0 | -21.0 | 54.0 | -56.0 | -29.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-18 | -35.0 | -18.0 | 21.0 | -64.0 | -52.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-19 | -33.0 | -14.0 | 40.0 | -62.0 | -52.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-20 | -31.0 | -12.0 | 55.0 | -61.0 | -52.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-21 | -30.0 | -8.0 | 77.0 | -59.0 | -52.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-22 | -33.0 | -8.0 | 81.0 | -57.0 | -51.0 | 19.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-23 | -41.0 | -16.0 | 42.0 | -53.0 | -30.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-24 | -32.0 | -16.0 | 85.0 | -54.0 | -26.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-25 | -36.0 | -21.0 | 75.0 | -70.0 | -76.0 | 24.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-26 | -30.0 | -14.0 | 70.0 | -59.0 | -49.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-27 | -28.0 | -11.0 | 79.0 | -58.0 | -49.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-28 | -30.0 | -8.0 | 53.0 | -59.0 | -49.0 | 19.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-29 | -34.0 | -12.0 | 36.0 | -58.0 | -47.0 | 20.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-30 | -34.0 | -12.0 | 120.0 | -45.0 | -18.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-05-31 | -29.0 | -13.0 | 102.0 | -46.0 | -17.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-01 | -26.0 | -12.0 | 44.0 | -58.0 | -47.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-02 | -28.0 | -11.0 | 52.0 | -56.0 | -47.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-03 | -27.0 | -8.0 | 58.0 | -56.0 | -47.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-04 | -27.0 | -4.0 | 94.0 | -56.0 | -46.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-05 | -31.0 | -8.0 | 45.0 | -56.0 | -45.0 | 19.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-06 | -35.0 | -13.0 | 89.0 | -47.0 | -20.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-07 | -31.0 | -14.0 | 72.0 | -49.0 | -17.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-08 | -22.0 | -9.0 | 94.0 | -55.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-09 | -25.0 | -10.0 | 69.0 | -56.0 | -46.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-10 | -23.0 | -8.0 | 75.0 | -55.0 | -46.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-11 | -29.0 | -9.0 | 17.0 | -58.0 | -46.0 | 19.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-12 | -25.0 | -6.0 | 94.0 | -50.0 | -43.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-13 | -26.0 | -9.0 | 157.0 | -36.0 | -14.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-14 | -21.0 | -11.0 | 122.0 | -42.0 | -12.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-15 | -18.0 | -8.0 | 79.0 | -52.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-16 | -18.0 | -6.0 | 97.0 | -52.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-17 | -17.0 | -5.0 | 102.0 | -50.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-18 | -18.0 | -2.0 | 112.0 | -51.0 | -44.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-19 | -22.0 | -4.0 | 100.0 | -49.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-20 | -24.0 | -3.0 | 163.0 | -35.0 | -15.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-21 | -21.0 | -8.0 | 112.0 | -41.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-22 | -16.0 | -10.0 | 88.0 | -52.0 | -46.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-23 | -15.0 | -8.0 | 99.0 | -51.0 | -46.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-24 | -17.0 | -7.0 | 69.0 | -51.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-25 | -15.0 | -2.0 | 115.0 | -49.0 | -44.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-26 | -21.0 | -6.0 | 94.0 | -47.0 | -43.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-27 | -25.0 | -12.0 | 95.0 | -37.0 | -17.0 | 6.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-28 | -22.0 | -17.0 | 71.0 | -42.0 | -12.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-29 | -17.0 | -11.0 | 31.0 | -53.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-06-30 | -14.0 | -7.0 | 43.0 | -52.0 | -45.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-01 | -11.0 | -3.0 | 65.0 | -48.0 | -45.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-02 | -5.0 | 7.0 | 143.0 | -44.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-03 | -14.0 | 9.0 | 83.0 | -50.0 | -61.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-04 | -40.0 | -19.0 | 178.0 | -42.0 | -32.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-05 | -21.0 | -18.0 | 118.0 | -36.0 | -16.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-06 | -10.0 | -6.0 | 115.0 | -48.0 | -48.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-07 | -12.0 | -5.0 | 110.0 | -47.0 | -46.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-08 | -14.0 | -5.0 | 77.0 | -49.0 | -45.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-09 | -12.0 | -3.0 | 125.0 | -47.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-10 | -19.0 | -6.0 | 55.0 | -48.0 | -44.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-11 | -20.0 | -9.0 | 133.0 | -33.0 | -15.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-12 | -14.0 | -10.0 | 149.0 | -34.0 | -12.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-13 | -12.0 | -7.0 | 79.0 | -50.0 | -45.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-14 | -13.0 | -7.0 | 68.0 | -50.0 | -45.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-15 | -10.0 | -5.0 | 99.0 | -47.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-16 | -11.0 | -2.0 | 122.0 | -47.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-17 | -19.0 | -6.0 | 51.0 | -47.0 | -43.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-18 | -21.0 | -10.0 | 184.0 | -30.0 | -14.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-19 | -16.0 | -12.0 | 134.0 | -35.0 | -13.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-20 | -12.0 | -8.0 | 98.0 | -49.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-21 | -11.0 | -6.0 | 118.0 | -48.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-22 | -12.0 | -7.0 | 77.0 | -49.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-23 | -15.0 | -6.0 | 61.0 | -50.0 | -43.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-24 | -15.0 | -5.0 | 98.0 | -45.0 | -42.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-25 | -19.0 | -9.0 | 180.0 | -29.0 | -15.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-26 | -14.0 | -11.0 | 142.0 | -34.0 | -13.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-27 | -11.0 | -8.0 | 108.0 | -49.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-28 | -12.0 | -7.0 | 86.0 | -50.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-29 | -10.0 | -5.0 | 108.0 | -47.0 | -44.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-30 | -12.0 | -4.0 | 95.0 | -47.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-07-31 | -14.0 | -5.0 | 97.0 | -43.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-01 | -18.0 | -8.0 | 184.0 | -26.0 | -15.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-02 | -15.0 | -12.0 | 100.0 | -34.0 | -15.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-03 | -7.0 | -3.0 | 119.0 | -47.0 | -44.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-04 | -18.0 | -10.0 | 38.0 | -53.0 | -46.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-05 | -9.0 | -4.0 | 107.0 | -48.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-06 | -10.0 | -2.0 | 125.0 | -47.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-07 | -16.0 | -7.0 | 83.0 | -46.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-08 | -18.0 | -8.0 | 169.0 | -30.0 | -15.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-09 | -14.0 | -11.0 | 142.0 | -34.0 | -12.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-10 | -10.0 | -7.0 | 114.0 | -49.0 | -45.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-11 | -13.0 | -7.0 | 104.0 | -50.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-12 | -13.0 | -6.0 | 95.0 | -50.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-13 | -13.0 | -3.0 | 102.0 | -48.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-14 | -17.0 | -7.0 | 101.0 | -46.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-15 | -21.0 | -11.0 | 120.0 | -32.0 | -17.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-16 | -19.0 | -14.0 | 52.0 | -39.0 | -16.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-17 | -11.0 | -8.0 | 94.0 | -49.0 | -45.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-18 | -13.0 | -8.0 | 108.0 | -49.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-19 | -14.0 | -9.0 | 81.0 | -49.0 | -44.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-20 | -11.0 | -3.0 | 122.0 | -46.0 | -43.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-21 | -16.0 | -7.0 | 104.0 | -44.0 | -42.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-22 | -20.0 | -11.0 | 150.0 | -29.0 | -15.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-23 | -17.0 | -14.0 | 107.0 | -34.0 | -13.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-24 | -11.0 | -7.0 | 89.0 | -48.0 | -43.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-25 | -13.0 | -7.0 | 88.0 | -49.0 | -43.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-26 | -10.0 | -6.0 | 107.0 | -46.0 | -42.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-27 | -17.0 | -8.0 | 33.0 | -50.0 | -43.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-28 | -15.0 | -7.0 | 100.0 | -43.0 | -41.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-29 | -21.0 | -13.0 | 34.0 | -36.0 | -17.0 | 6.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-30 | -7.0 | -9.0 | 149.0 | -29.0 | -10.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-08-31 | -10.0 | -8.0 | 93.0 | -46.0 | -43.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-01 | -7.0 | -3.0 | 95.0 | -45.0 | -43.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-02 | -16.0 | -9.0 | 14.0 | -51.0 | -43.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-03 | -10.0 | 1.0 | 71.0 | -45.0 | -41.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-04 | -12.0 | -2.0 | 105.0 | -41.0 | -42.0 | 10.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-05 | -17.0 | -6.0 | 167.0 | -27.0 | -16.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-06 | -11.0 | -10.0 | 146.0 | -33.0 | -13.0 | 1.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-07 | -10.0 | -9.0 | 168.0 | -55.0 | -72.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-08 | -14.0 | -6.0 | 81.0 | -47.0 | -44.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-09 | -14.0 | -6.0 | 73.0 | -48.0 | -42.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-10 | -16.0 | -5.0 | 35.0 | -50.0 | -42.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-11 | -16.0 | -6.0 | 67.0 | -46.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-12 | -14.0 | -7.0 | 148.0 | -29.0 | -12.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-13 | -14.0 | -9.0 | 101.0 | -37.0 | -12.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-14 | -11.0 | -7.0 | 78.0 | -49.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-15 | -10.0 | -5.0 | 63.0 | -49.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-16 | -13.0 | -8.0 | 68.0 | -49.0 | -41.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-17 | -13.0 | -5.0 | 76.0 | -48.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-18 | -17.0 | -8.0 | 45.0 | -46.0 | -38.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-19 | -14.0 | -8.0 | 123.0 | -31.0 | -13.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-20 | -12.0 | -9.0 | 79.0 | -38.0 | -12.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-21 | -14.0 | -10.0 | 54.0 | -50.0 | -41.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-22 | -16.0 | -10.0 | 40.0 | -52.0 | -40.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-23 | -15.0 | -9.0 | 59.0 | -50.0 | -39.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-24 | -15.0 | -6.0 | 60.0 | -50.0 | -38.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-25 | -17.0 | -9.0 | 59.0 | -46.0 | -37.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-26 | -15.0 | -9.0 | 130.0 | -31.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-27 | -16.0 | -12.0 | 77.0 | -38.0 | -11.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-28 | -16.0 | -10.0 | 43.0 | -51.0 | -39.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-29 | -15.0 | -9.0 | 41.0 | -52.0 | -38.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-09-30 | -17.0 | -8.0 | 26.0 | -53.0 | -40.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-01 | -13.0 | -2.0 | 61.0 | -48.0 | -36.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-02 | -18.0 | -5.0 | 23.0 | -46.0 | -36.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-03 | -15.0 | -6.0 | 113.0 | -30.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-04 | -13.0 | -7.0 | 78.0 | -38.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-05 | -17.0 | -8.0 | 33.0 | -51.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-06 | -17.0 | -7.0 | 45.0 | -51.0 | -39.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-07 | -19.0 | -9.0 | 24.0 | -52.0 | -39.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-08 | -15.0 | -4.0 | 47.0 | -49.0 | -37.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-09 | -18.0 | -7.0 | 42.0 | -45.0 | -37.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-10 | -16.0 | -9.0 | 134.0 | -31.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-11 | -13.0 | -11.0 | 84.0 | -37.0 | -11.0 | 2.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-12 | -16.0 | -10.0 | 19.0 | -57.0 | -56.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-13 | -29.0 | -18.0 | -30.0 | -57.0 | -41.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-14 | -17.0 | -5.0 | 54.0 | -49.0 | -38.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-15 | -16.0 | -4.0 | 62.0 | -48.0 | -36.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-16 | -24.0 | -13.0 | -6.0 | -50.0 | -36.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-17 | -17.0 | -7.0 | 69.0 | -35.0 | -13.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-18 | -16.0 | -11.0 | 72.0 | -38.0 | -13.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-19 | -18.0 | -11.0 | 39.0 | -50.0 | -38.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-20 | -20.0 | -10.0 | 28.0 | -52.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-21 | -19.0 | -9.0 | 26.0 | -51.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-22 | -17.0 | -6.0 | 58.0 | -48.0 | -36.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-23 | -19.0 | -9.0 | 36.0 | -46.0 | -35.0 | 12.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-24 | -18.0 | -9.0 | 98.0 | -33.0 | -12.0 | 3.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-25 | -18.0 | -10.0 | 40.0 | -42.0 | -13.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-26 | -21.0 | -12.0 | 0.0 | -53.0 | -39.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-27 | -20.0 | -8.0 | 21.0 | -52.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-28 | -23.0 | -12.0 | -20.0 | -56.0 | -39.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-29 | -23.0 | -10.0 | -22.0 | -55.0 | -37.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-30 | -31.0 | -18.0 | -30.0 | -56.0 | -39.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-10-31 | -21.0 | -2.0 | 33.0 | -40.0 | -15.0 | 5.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-01 | -24.0 | -13.0 | -12.0 | -47.0 | -16.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-02 | -22.0 | -11.0 | -12.0 | -55.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-03 | -15.0 | -5.0 | 5.0 | -54.0 | -40.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-04 | -23.0 | -12.0 | 5.0 | -55.0 | -39.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-05 | -21.0 | -8.0 | 28.0 | -53.0 | -36.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-06 | -22.0 | -12.0 | 23.0 | -50.0 | -35.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-07 | -19.0 | -12.0 | 104.0 | -37.0 | -11.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-08 | -17.0 | -11.0 | 73.0 | -45.0 | -13.0 | 4.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-09 | -20.0 | -12.0 | 32.0 | -54.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-10 | -19.0 | -10.0 | 34.0 | -53.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-11 | -16.0 | -9.0 | 22.0 | -57.0 | -48.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-12 | -25.0 | -10.0 | -14.0 | -55.0 | -38.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-13 | -28.0 | -13.0 | -30.0 | -54.0 | -37.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-14 | -23.0 | -11.0 | 35.0 | -42.0 | -13.0 | 6.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-15 | -23.0 | -13.0 | 4.0 | -49.0 | -15.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-16 | -23.0 | -13.0 | -2.0 | -55.0 | -38.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-17 | -24.0 | -11.0 | -7.0 | -56.0 | -39.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-18 | -24.0 | -13.0 | -22.0 | -56.0 | -40.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-19 | -24.0 | -9.0 | -15.0 | -55.0 | -37.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-20 | -24.0 | -10.0 | 5.0 | -50.0 | -36.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-21 | -22.0 | -10.0 | 51.0 | -37.0 | -11.0 | 6.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-22 | -24.0 | -12.0 | -12.0 | -49.0 | -13.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-23 | -23.0 | -10.0 | -31.0 | -58.0 | -40.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-24 | -14.0 | 6.0 | -5.0 | -52.0 | -39.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-25 | -3.0 | 27.0 | -7.0 | -51.0 | -44.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-26 | -81.0 | -67.0 | -46.0 | -81.0 | -84.0 | 28.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-27 | -30.0 | -21.0 | 6.0 | -59.0 | -62.0 | 21.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-28 | -28.0 | -20.0 | 29.0 | -43.0 | -19.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-29 | -25.0 | -18.0 | 21.0 | -45.0 | -16.0 | 7.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-11-30 | -30.0 | -23.0 | -32.0 | -58.0 | -41.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-01 | -21.0 | -7.0 | -5.0 | -54.0 | -40.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-02 | -23.0 | -9.0 | -12.0 | -56.0 | -40.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-03 | -22.0 | -4.0 | 1.0 | -55.0 | -38.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-04 | -22.0 | -4.0 | -5.0 | -54.0 | -37.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-05 | -43.0 | -27.0 | -49.0 | -59.0 | -21.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-06 | -25.0 | -14.0 | -20.0 | -52.0 | -17.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-07 | -23.0 | -13.0 | -20.0 | -58.0 | -40.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-08 | -25.0 | -13.0 | -26.0 | -59.0 | -39.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-09 | -25.0 | -11.0 | -24.0 | -59.0 | -41.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-10 | -24.0 | -7.0 | -10.0 | -57.0 | -38.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-11 | -24.0 | -9.0 | -9.0 | -55.0 | -37.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-12 | -27.0 | -14.0 | -16.0 | -49.0 | -15.0 | 9.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-13 | -19.0 | -9.0 | 28.0 | -49.0 | -14.0 | 6.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-14 | -24.0 | -11.0 | -29.0 | -60.0 | -40.0 | 15.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-15 | -18.0 | -1.0 | -18.0 | -57.0 | -40.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-16 | -14.0 | 14.0 | -26.0 | -57.0 | -41.0 | 14.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-17 | -67.0 | -58.0 | -52.0 | -76.0 | -72.0 | 32.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-18 | -29.0 | -14.0 | -33.0 | -54.0 | -40.0 | 18.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-19 | -24.0 | -9.0 | -4.0 | -39.0 | -14.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-20 | -24.0 | -12.0 | -28.0 | -48.0 | -15.0 | 8.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-21 | -12.0 | 0.0 | -19.0 | -55.0 | -41.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-22 | -9.0 | 8.0 | -15.0 | -53.0 | -42.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-23 | -1.0 | 26.0 | -14.0 | -51.0 | -44.0 | 13.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-24 | -12.0 | 35.0 | -6.0 | -59.0 | -59.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-25 | -88.0 | -76.0 | -68.0 | -85.0 | -85.0 | 32.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-26 | -38.0 | -27.0 | -5.0 | -51.0 | -29.0 | 11.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-27 | -32.0 | -26.0 | 0.0 | -51.0 | -19.0 | 9.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-28 | -20.0 | -14.0 | 8.0 | -58.0 | -50.0 | 16.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-29 | -22.0 | -12.0 | -11.0 | -60.0 | -50.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-30 | -18.0 | -2.0 | -10.0 | -58.0 | -49.0 | 17.0 | |||
| US | United States | Massachusetts | US-MA | ChIJ_b9z6W1l44kRHA2DVTbQxkU | 2020-12-31 | -12.0 | 15.0 | -7.0 | -60.0 | -54.0 | 17.0 |
Starsim
model.py
"""
The epydemix COVID-19 tutorial (docs/models/epydemix/covid19), in Starsim: an age-structured SEIR
with deaths after an Erlang delay, in Massachusetts, with transmission scaled by mobility and
by season, fitted to weekly deaths and projected under three reopening scenarios
"""
import numpy as np
import pandas as pd
import sciris as sc
import starsim as ss
# Starsim has no bundled population data, so this reads what epydemix downloaded
folder = sc.thispath(__file__)/'../../../models/epydemix/covid19'
data = sc.loadjson(folder/'reference/population.json')
matrix = sum(np.array(layer) for layer in data['contacts'].values()) # Four settings
deaths = pd.read_csv(folder/'original/data/MA_deaths.csv', index_col='date', parse_dates=True) # Weekly, each dated by its last day, a Sunday
deaths.index -= pd.Timedelta(weeks=1) # Dated by the start of the week, as a flow's data is read: the week to the end of the Saturday
deaths = ss.SimData(deaths, aliases=dict(deaths='data'))
mob = pd.read_csv(folder/'original/data/MA_google.csv', index_col='date')
# The contact reduction from mobility, from 23 February to 31 May, and held at its last value after
places = ['retail_and_recreation', 'grocery_and_pharmacy', 'transit_stations', 'workplaces', 'residential']
cols = [f'{place}_percent_change_from_baseline' for place in places]
change = mob.loc['2020-02-23':'2020-05-31', cols].mean(axis=1)
r = ss.timeseries(dict(zip(change.index, (1 + change/100)**2)))
mu = ss.perday(1/2.5) # Recovery rate
IFR = np.array([0.00161, 0.00695, 0.0309, 0.0844, 0.161, 0.595, 1.93, 4.28, 7.80])/100
R0 = ss.par(2.22, bounds=[1.5, 6.0])
Delta = ss.par(20, bounds=[7, 27]) # From recovery to death, in 4 stages
I0 = ss.par(4260, bounds=[10, 9999]) # Initially infected, half exposed and half infectious
smin = ss.par(0.8, bounds=[0.6, 1.0]) # Transmission in mid-July, relative to mid-January
season = ss.seasonal(mean=(1 + smin)/2, amplitude=(1 - smin)/(1 + smin), peak=ss.days(14)) # 15 January
beta = R0*mu/max(np.linalg.eigvals(matrix).real)*season*r
S, E, I, R, D0, D1, D2, D3, Dead = ss.states('S E I R D0 D1 D2 D3 Dead')
covid = ss.Disease(
infection = ss.Transition(S, E, rate=beta * I),
onset = E >> I @ ss.perday(1/4),
outcome = ss.Transition(I, {D0: ss.by(age=IFR), R: None}, rate=mu), # Death with the IFR
stage1 = D0 >> D1 @ 4/Delta,
stage2 = D1 >> D2 @ 4/Delta,
stage3 = D2 >> D3 @ 4/Delta,
deaths = D3 >> Dead @ 4/Delta,
init_n = dict(E=I0/2, I=I0/2),
)
covid.stratify(age=dict(zip([0, 10, 20, 30, 40, 50, 60, 70, 80], data['Nk'])))
sim = ss.Sim(covid, networks=ss.contacts(matrix), total_pop=sum(data['Nk']), data=deaths, unit='day',
start='2020-02-23', stop='2020-05-31', solver=ss.solvers.binomial(substeps=1), aging=False) # The calibration period
reopening = dict(moderate=1.3, high=1.5) # Contacts from 1 June, relative to their level on 31 May
reopening = {k:ss.modify(covid.infection, multiply=v, when=ss.schedule(start='2020-06-01')) for k,v in reopening.items()}
scenarios = dict(persistence=None, **reopening)
if __name__ == '__main__':
fit = sim.fit(method=ss.fitting.method('abc-smc', samples=200, generations=5)) # To weekly deaths to 31 May
fit.plot()
msim = fit.project(scenarios, n_runs=100, stop='2020-08-03') # To the end of 2 August
msim.plot()What was declared and what was not
Everything in the model is declared: nine states, seven transitions (the four stages of the delay to death written out, as in the original, and a split on leaving I with p=ss.by(age=IFR) in place of two competing rates), a stratification by age with a contact matrix, a time series and a seasonal factor in the transmission rate, the weekly deaths as data, four parameters with bounds (one inside the seasonal factor and one in the initial conditions), and the reopening scenarios as ss.modify from 1 June. The weekly totals line up with the data once each week is dated by its start: a flow is summed over the steps from each data date to the next, so model.py moves the data’s dates, the Sundays that end epydemix’s weeks, back a week. Since a date is the time point at the start of that day, this is the week to the end of the Saturday, a day earlier than epydemix’s weeks ending on Sunday (compare.py sums its own weeks to the end of each Sunday).
The mobility data is turned into the contact reduction with pandas, as in the original. The transmission rate’s scaling by R0 and the leading eigenvalue of the contact matrix is written out, as in the original.
Not translated: the random split of the initial infections between age groups (Starsim splits them in proportion to the population, the mean of the original’s draw).
Findings
stages=4is no longer the original’s delay (new). The first version wrote the delay to death asss.Transition(D, Dead, dur=Delta, stages=4). The chain-binomial solver now moves people through such stages so that the delay averages exactlyDelta. epydemix takes one step a day through four compartments, each left with a chance of1 - exp(-4/Delta), which averages about 22 days atDelta = 20. Withstages=4, deaths came about 2 days too soon, and weekly deaths differed by 6% of the peak. Writing the four stages out as transitions matches the original, for 3 more lines. Neither is wrong: this is the difference between a dwell time and a discrete-time model.- Needs
substeps=1. Without it the chain-binomial solver takes several steps a day, and the epidemic differs from epydemix’s, which takes whole days. - Fixed: a parameter used by several modules, in
init_n=, and insidess.seasonal. All four of the tutorial’s parameters are now fitted, includingI0andseasonality_min. - Fixed: a posterior.
sim.fit(method='abc-smc')uses the original’s method and distance (wmape), andfit.project()gives projections from it. Both now agree with the original. A form ofss.seasonalgiven by the minimum would still say what the original means more directly. - Fixed: plotting with dated data.
- Many runs with a modifier run in separate processes.
sim.run(n_runs=)is one call to the kernel for the persistence scenario, but withss.modifyit is aMultiSim, andfit.project()has no way to sayparallel=False, socompare.pywrites out what it does. - Population data for US states are not available yet. Starsim bundles countries, but
ss.population('United States:Massachusetts')cannot be downloaded yet, so the population of Massachusetts and its contact matrices are read from what epydemix downloaded, as a few of the lines.
epydemix: SIR in Kenya with age-structured contacts
A stochastic SIR epidemic in the population of Kenya (55.9 million people in five age groups), mixing through contact matrices for school, work, home, and community, over four months. It is the model of epydemix’s second tutorial, “Using population data from epydemix_data”.
- Framework: epydemix 1.4.0 (GPL-3.0)
- Language: Python
- Source: https://github.com/epistorm/epydemix/blob/v1.4.0/tutorials/02_Modeling_with_Population_Data.ipynb (code cells 4, 11, 12, and 14)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 7 | 15 | Not counting the loading of the data would be 12. The original has the model predefined and the population bundled |
| One stochastic run | first 7 ms, rerun 6.5 ms | first 11 ms, rerun 0.34 ms (Rust) | In process, without imports or loading the data. Both are the time of one run, with the 100 runs of the original taking about 600 ms |
| Results | Agree (peaks within 1%, a day apart at most, final size within 0.1 of a percentage point): the mean of 100 runs peaks 0.4% higher than the original, a day sooner, and the final size is the same (99.63%). On any one day the difference is up to 6% of the peak, since the rise is a day ahead (see the findings) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epydemix)
sir_kenya.py
# Extracted from tutorials/02_Modeling_with_Population_Data.ipynb in epydemix v1.4.0
# (https://github.com/epistorm/epydemix/blob/v1.4.0/tutorials/02_Modeling_with_Population_Data.ipynb).
# Code cells 4, 11, 12, and 14 of the notebook, unchanged; these comment lines and the cell markers are the only additions.
# The cells left out plot the population and contact matrices, build a toy population, and list other geographies.
# --- Cell 4 ---
from epydemix.population import load_epydemix_population
# Load the population of Kenya from GitHub. For offline use, download the population data from
# https://github.com/epistorm/epydemix-data and provide the local path to
# the epydemix_data folder using the argument path_to_data
population = load_epydemix_population("Kenya")
print(population)
# --- Cell 11 ---
from epydemix import load_predefined_model
# create a simple SIR model
model = load_predefined_model("SIR", transmission_rate=0.04)
# set the population (alternatively you can import it using model.import_epydemix_population('Kenya'))
model.set_population(population)
print(model)
# --- Cell 12 ---
from epydemix.visualization import plot_quantiles
# simulate
results = model.run_simulations(start_date="2019-12-01",
end_date="2020-04-01",
percentage_in_agents=10 / model.population.Nk.sum(),
Nsim=100)
# plot results
df_quantiles_comps = results.get_quantiles_compartments()
ax = plot_quantiles(df_quantiles_comps, columns=["Infected_total", "Susceptible_total", "Recovered_total"], legend_loc="center right")
# --- Cell 14 ---
ax = plot_quantiles(df_quantiles_comps, columns=["Infected_0-4", "Infected_5-19", "Infected_20-49", "Infected_50-64", "Infected_65+"], legend_loc="center right")Starsim
model.py
"""
The epydemix tutorial on population data (docs/models/epydemix/sir_kenya), in Starsim: an SIR in
Kenya's population, with contact matrices between five age groups
"""
import numpy as np
import sciris as sc
import starsim as ss
# Starsim bundles populations and contact matrices, but this uses the numbers epydemix downloaded, so that the two models are the same
data = sc.loadjson(sc.thispath(__file__)/'../../../models/epydemix/sir_kenya/reference/population.json')
matrix = sum(np.array(layer) for layer in data['contacts'].values()) # School, work, home, and community
S, I, R = ss.states('S I R')
sir = ss.Disease(
infection = ss.Transition(S, I, rate=0.04 * I),
recovery = ss.Transition(I, R, rate=0.1),
init_n = dict(I=10),
)
sir.stratify(age=dict(zip([0, 5, 20, 50, 65], data['Nk'])))
sim = ss.Sim(sir, networks=ss.contacts(matrix), total_pop=sum(data['Nk']), start='2019-12-01', stop='2020-04-02', # To the end of 1 April
solver=ss.solvers.binomial(substeps=1), aging=False) # Whole days, as epydemix takes them
if __name__ == '__main__':
msim = ss.parallel(sim, n_runs=100)
msim.plot()What was declared and what was not
Everything is declared. The population and the contact matrices are read from the file that the original’s run wrote, so that the two models are the same.
Findings
- Longer than the original. epydemix has the SIR model predefined and the population bundled, so the whole example is seven lines, which is hard to beat. Starsim now bundles populations and contact matrices too:
ss.contacts('Kenya')withpeople=ss.population('Kenya', year=2020)needs no file and removes the loading lines. This vignette does not use them because they are a different population, 52.2 million in Starsim’s data for 2020 against 55.9 million in epydemix’s, so it would not be a test of the same model. substeps=1is needed. epydemix takes one chain-binomial step a day. Starsim’s chain binomial now takes substeps by default, so that no more than about a tenth of a compartment leaves in one, and withoutsubsteps=1the peak comes on day 36 rather than 39 and is 54.0% rather than 54.7%, against the original’s 54.5% on day 40. The default is more accurate as an approximation to continuous time; the original’s model is discrete by design.- Fixed: a result of many runs can be selected by age group.
res.I.select(age='0-4')now works on the runs together. - Fixed: aging can be switched off. Stratifying by age turns aging on in a compartmental run;
aging=Falseturns it off, which is what the original assumes. - Ten infected, not eight. The original spreads ten infections over the age groups and rounds each down, starting with eight. Starsim starts with ten, which is why its peak comes up to a day sooner.
epymorph: influenza driven by humidity in six US states (Pei et al. 2018)
The example in epymorph’s USAGE notebook, its IPM and MM Pei, after Pei et al. (2018): an SIRS of influenza in Florida, Georgia, Maryland, North Carolina, South Carolina, and Virginia, 56 million people, in which transmission falls as absolute humidity rises, in each state on each day, with commuters and other travelers moving between the states for a third of each day. It starts from 10,000 infectious people in Florida. See the review.
- Framework: epymorph 1.2.0 (GPL-3.0)
- Language: Python
- Source: https://github.com/NAU-CCL/epymorph/blob/v1.2.0/USAGE.ipynb
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 22 | 19 | The notebook’s lines; the IPM and MM it takes from epymorph’s library (original/ipm_pei.py and original/mm_pei.py) are 21 and 62 more |
| One run | first 210 ms, rerun 130 ms | first 70 ms, rerun 2 ms (Rust) | Each is one stochastic run of 150 days. A first run excludes imports; the original’s includes building the RUME |
| Results | Peak 2,474,561 infectious in all on day 95; 37,075,485 infections | Peak 2,528,987 on day 92; 37,226,473 infections | Agree to within 3.4% at the peak in each state, 3 days in its timing, and 0.9% in the number infected. Florida, where it starts, peaks on day 59 in both; the other states peak 1 to 3 days earlier in Starsim, for the reason given for the SIRS in Arizona |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epymorph)
| date | FL | GA | MD | NC | SC | VA |
| 2015-01-01 | 0.01003 | 0.008144 | 0.004738 | 0.00681 | 0.007467 | 0.004909 |
| 2015-01-02 | 0.0105 | 0.008211 | 0.004495 | 0.006562 | 0.007302 | 0.004716 |
| 2015-01-03 | 0.010631 | 0.008169 | 0.0053 | 0.007162 | 0.007736 | 0.005407 |
| 2015-01-04 | 0.010797 | 0.007956 | 0.0054 | 0.00712 | 0.00776 | 0.005611 |
| 2015-01-05 | 0.010727 | 0.008105 | 0.005233 | 0.00726 | 0.007819 | 0.005504 |
| 2015-01-06 | 0.01009 | 0.008425 | 0.004814 | 0.007089 | 0.007881 | 0.005119 |
| 2015-01-07 | 0.009815 | 0.008386 | 0.005 | 0.007195 | 0.007867 | 0.00521 |
| 2015-01-08 | 0.010363 | 0.007858 | 0.004857 | 0.006825 | 0.007343 | 0.00491 |
| 2015-01-09 | 0.010242 | 0.007508 | 0.0047 | 0.006633 | 0.007217 | 0.00473 |
| 2015-01-10 | 0.009609 | 0.007048 | 0.004052 | 0.006199 | 0.006764 | 0.004276 |
| 2015-01-11 | 0.009782 | 0.00739 | 0.00429 | 0.006251 | 0.006876 | 0.004457 |
| 2015-01-12 | 0.009573 | 0.007775 | 0.004914 | 0.00681 | 0.007402 | 0.005012 |
| 2015-01-13 | 0.009151 | 0.008051 | 0.005257 | 0.00729 | 0.007793 | 0.005478 |
| 2015-01-14 | 0.009114 | 0.007194 | 0.004824 | 0.006514 | 0.007036 | 0.004898 |
| 2015-01-15 | 0.009586 | 0.00701 | 0.004419 | 0.006087 | 0.006702 | 0.004422 |
| 2015-01-16 | 0.009771 | 0.006976 | 0.004324 | 0.006073 | 0.006602 | 0.004267 |
| 2015-01-17 | 0.009916 | 0.007182 | 0.003924 | 0.005774 | 0.006512 | 0.00395 |
| 2015-01-18 | 0.00951 | 0.007415 | 0.004476 | 0.006351 | 0.006874 | 0.004434 |
| 2015-01-19 | 0.009165 | 0.007085 | 0.003914 | 0.005829 | 0.006433 | 0.004002 |
| 2015-01-20 | 0.009451 | 0.006524 | 0.003748 | 0.005594 | 0.006214 | 0.003876 |
| 2015-01-21 | 0.00939 | 0.007217 | 0.003633 | 0.005592 | 0.006452 | 0.003741 |
| 2015-01-22 | 0.009205 | 0.00759 | 0.00381 | 0.005946 | 0.006779 | 0.004008 |
| 2015-01-23 | 0.008819 | 0.007765 | 0.004548 | 0.006529 | 0.007188 | 0.00471 |
| 2015-01-24 | 0.009298 | 0.007635 | 0.004833 | 0.006829 | 0.007386 | 0.004962 |
| 2015-01-25 | 0.010034 | 0.006387 | 0.004024 | 0.005414 | 0.005981 | 0.003903 |
| 2015-01-26 | 0.009865 | 0.006424 | 0.003424 | 0.004979 | 0.005731 | 0.003404 |
| 2015-01-27 | 0.009244 | 0.006912 | 0.003743 | 0.005405 | 0.006252 | 0.00379 |
| 2015-01-28 | 0.009452 | 0.007364 | 0.004395 | 0.006227 | 0.006857 | 0.004595 |
| 2015-01-29 | 0.009922 | 0.008088 | 0.003971 | 0.006337 | 0.007169 | 0.00427 |
| 2015-01-30 | 0.010102 | 0.008568 | 0.004552 | 0.006879 | 0.00766 | 0.004733 |
| 2015-01-31 | 0.00967 | 0.00694 | 0.003957 | 0.005821 | 0.006364 | 0.00393 |
| 2015-02-01 | 0.009465 | 0.00713 | 0.004038 | 0.005765 | 0.006412 | 0.004019 |
| 2015-02-02 | 0.009473 | 0.007817 | 0.00411 | 0.006108 | 0.0069 | 0.004237 |
| 2015-02-03 | 0.009869 | 0.007693 | 0.004205 | 0.006364 | 0.006993 | 0.004406 |
| 2015-02-04 | 0.009859 | 0.007357 | 0.004557 | 0.006596 | 0.007105 | 0.0048 |
| 2015-02-05 | 0.009472 | 0.006649 | 0.003981 | 0.005577 | 0.00616 | 0.004025 |
| 2015-02-06 | 0.00972 | 0.007214 | 0.003967 | 0.005705 | 0.006469 | 0.004114 |
| 2015-02-07 | 0.009781 | 0.007535 | 0.004129 | 0.006332 | 0.006967 | 0.0044 |
| 2015-02-08 | 0.008998 | 0.006993 | 0.003995 | 0.005981 | 0.006588 | 0.004349 |
| 2015-02-09 | 0.008965 | 0.007864 | 0.0043 | 0.00638 | 0.007248 | 0.004713 |
| 2015-02-10 | 0.009688 | 0.008125 | 0.0045 | 0.006562 | 0.00735 | 0.004655 |
| 2015-02-11 | 0.009386 | 0.008112 | 0.004081 | 0.006412 | 0.007248 | 0.004393 |
| 2015-02-12 | 0.009492 | 0.007551 | 0.003886 | 0.006048 | 0.006869 | 0.004161 |
| 2015-02-13 | 0.009803 | 0.007173 | 0.003852 | 0.00592 | 0.006648 | 0.004171 |
| 2015-02-14 | 0.009468 | 0.007719 | 0.004324 | 0.006504 | 0.007162 | 0.004708 |
| 2015-02-15 | 0.00919 | 0.00809 | 0.004238 | 0.0067 | 0.007405 | 0.00477 |
| 2015-02-16 | 0.009572 | 0.008445 | 0.004719 | 0.007239 | 0.007883 | 0.005179 |
| 2015-02-17 | 0.010095 | 0.008001 | 0.004143 | 0.006375 | 0.007126 | 0.004413 |
| 2015-02-18 | 0.01012 | 0.00777 | 0.0044 | 0.00623 | 0.006993 | 0.004546 |
| 2015-02-19 | 0.010347 | 0.007843 | 0.00461 | 0.0065 | 0.007074 | 0.004821 |
| 2015-02-20 | 0.010309 | 0.008111 | 0.004719 | 0.006764 | 0.007474 | 0.005031 |
| 2015-02-21 | 0.010009 | 0.008877 | 0.00489 | 0.007371 | 0.008162 | 0.005248 |
| 2015-02-22 | 0.010024 | 0.009679 | 0.005 | 0.007724 | 0.008545 | 0.00542 |
| 2015-02-23 | 0.010101 | 0.008848 | 0.004862 | 0.007165 | 0.007981 | 0.005105 |
| 2015-02-24 | 0.010214 | 0.00791 | 0.004452 | 0.006431 | 0.007071 | 0.004664 |
| 2015-02-25 | 0.010278 | 0.008018 | 0.004086 | 0.006123 | 0.00694 | 0.004479 |
| 2015-02-26 | 0.010087 | 0.008654 | 0.004448 | 0.006748 | 0.007605 | 0.004894 |
| 2015-02-27 | 0.010355 | 0.008181 | 0.004571 | 0.006644 | 0.007498 | 0.005017 |
| 2015-02-28 | 0.010427 | 0.008333 | 0.004762 | 0.007206 | 0.007821 | 0.005261 |
| 2015-03-01 | 0.011015 | 0.008748 | 0.004338 | 0.00699 | 0.007924 | 0.00494 |
| 2015-03-02 | 0.010848 | 0.009258 | 0.005414 | 0.007926 | 0.008529 | 0.00593 |
| 2015-03-03 | 0.010208 | 0.008746 | 0.00509 | 0.007583 | 0.008217 | 0.005597 |
| 2015-03-04 | 0.009757 | 0.008039 | 0.004595 | 0.006887 | 0.007631 | 0.005057 |
| 2015-03-05 | 0.010093 | 0.00801 | 0.004838 | 0.006923 | 0.007574 | 0.005283 |
| 2015-03-06 | 0.010081 | 0.008942 | 0.005186 | 0.007589 | 0.008407 | 0.005759 |
| 2015-03-07 | 0.010327 | 0.009295 | 0.005267 | 0.007867 | 0.008633 | 0.005875 |
| 2015-03-08 | 0.010199 | 0.008657 | 0.005767 | 0.007563 | 0.008121 | 0.005987 |
| 2015-03-09 | 0.010143 | 0.008652 | 0.00511 | 0.007189 | 0.007998 | 0.00551 |
| 2015-03-10 | 0.009692 | 0.008573 | 0.005038 | 0.007301 | 0.008052 | 0.005444 |
| 2015-03-11 | 0.009769 | 0.00821 | 0.00491 | 0.007044 | 0.007712 | 0.00533 |
| 2015-03-12 | 0.009577 | 0.008525 | 0.004795 | 0.007027 | 0.00786 | 0.005278 |
| 2015-03-13 | 0.009547 | 0.009267 | 0.005524 | 0.007917 | 0.008676 | 0.006165 |
| 2015-03-14 | 0.009643 | 0.009277 | 0.005695 | 0.008032 | 0.008738 | 0.006319 |
| 2015-03-15 | 0.009895 | 0.00928 | 0.005729 | 0.007868 | 0.008574 | 0.006176 |
| 2015-03-16 | 0.010718 | 0.009733 | 0.00549 | 0.008096 | 0.008912 | 0.00613 |
| 2015-03-17 | 0.010884 | 0.009432 | 0.005295 | 0.007907 | 0.00864 | 0.005974 |
| 2015-03-18 | 0.011186 | 0.009346 | 0.005052 | 0.007533 | 0.008398 | 0.005693 |
| 2015-03-19 | 0.011291 | 0.009375 | 0.005414 | 0.007588 | 0.008374 | 0.005939 |
| 2015-03-20 | 0.011956 | 0.009446 | 0.005329 | 0.007596 | 0.008531 | 0.005845 |
| 2015-03-21 | 0.011348 | 0.009345 | 0.0056 | 0.007965 | 0.008719 | 0.006239 |
| 2015-03-22 | 0.010864 | 0.008571 | 0.005567 | 0.007408 | 0.008069 | 0.005916 |
| 2015-03-23 | 0.010934 | 0.008793 | 0.005129 | 0.007307 | 0.008095 | 0.005749 |
| 2015-03-24 | 0.011081 | 0.009018 | 0.005414 | 0.007615 | 0.0084 | 0.006056 |
| 2015-03-25 | 0.010876 | 0.009925 | 0.005381 | 0.008106 | 0.009105 | 0.006285 |
| 2015-03-26 | 0.010844 | 0.010088 | 0.006033 | 0.008535 | 0.009357 | 0.006739 |
| 2015-03-27 | 0.011068 | 0.010262 | 0.006343 | 0.008819 | 0.009569 | 0.006995 |
| 2015-03-28 | 0.011229 | 0.010521 | 0.006876 | 0.009365 | 0.01 | 0.007534 |
| 2015-03-29 | 0.011388 | 0.010819 | 0.006719 | 0.009286 | 0.010055 | 0.007323 |
| 2015-03-30 | 0.0113 | 0.010336 | 0.006514 | 0.008746 | 0.009414 | 0.006969 |
| 2015-03-31 | 0.011776 | 0.010332 | 0.006338 | 0.008826 | 0.00966 | 0.007092 |
| 2015-04-01 | 0.011904 | 0.01025 | 0.007038 | 0.009333 | 0.009902 | 0.007741 |
| 2015-04-02 | 0.01186 | 0.009836 | 0.006876 | 0.008806 | 0.009355 | 0.007369 |
| 2015-04-03 | 0.011244 | 0.009998 | 0.006767 | 0.008824 | 0.009552 | 0.007385 |
| 2015-04-04 | 0.010541 | 0.010074 | 0.006686 | 0.008862 | 0.009548 | 0.007277 |
| 2015-04-05 | 0.010939 | 0.009864 | 0.005638 | 0.008251 | 0.009198 | 0.006492 |
| 2015-04-06 | 0.011814 | 0.010223 | 0.006452 | 0.008811 | 0.009574 | 0.00719 |
| 2015-04-07 | 0.011852 | 0.010324 | 0.007048 | 0.009201 | 0.009843 | 0.007644 |
| 2015-04-08 | 0.01168 | 0.010515 | 0.00709 | 0.00932 | 0.010024 | 0.007863 |
| 2015-04-09 | 0.011676 | 0.010502 | 0.007014 | 0.009199 | 0.009802 | 0.007687 |
| 2015-04-10 | 0.012229 | 0.01023 | 0.006771 | 0.009155 | 0.009795 | 0.007703 |
| 2015-04-11 | 0.012468 | 0.010901 | 0.006771 | 0.009512 | 0.010455 | 0.00785 |
| 2015-04-12 | 0.011615 | 0.011148 | 0.007324 | 0.009926 | 0.010633 | 0.008174 |
| 2015-04-13 | 0.011394 | 0.01088 | 0.007038 | 0.009379 | 0.010164 | 0.007643 |
| 2015-04-14 | 0.01188 | 0.01056 | 0.006919 | 0.009238 | 0.010031 | 0.007629 |
| 2015-04-15 | 0.01212 | 0.011138 | 0.007829 | 0.010204 | 0.01085 | 0.008755 |
| 2015-04-16 | 0.012775 | 0.010276 | 0.008143 | 0.010008 | 0.010395 | 0.008739 |
| 2015-04-17 | 0.012888 | 0.010226 | 0.007705 | 0.009751 | 0.010245 | 0.008417 |
| 2015-04-18 | 0.012084 | 0.010138 | 0.007081 | 0.009196 | 0.00986 | 0.00785 |
| 2015-04-19 | 0.011739 | 0.010814 | 0.007829 | 0.009602 | 0.010402 | 0.00853 |
| 2015-04-20 | 0.010981 | 0.01149 | 0.007981 | 0.010546 | 0.01119 | 0.009008 |
| 2015-04-21 | 0.011076 | 0.01181 | 0.008405 | 0.011112 | 0.011698 | 0.009591 |
| 2015-04-22 | 0.011556 | 0.011177 | 0.008705 | 0.010769 | 0.011102 | 0.00921 |
| 2015-04-23 | 0.012226 | 0.011181 | 0.008095 | 0.009817 | 0.01056 | 0.008492 |
| 2015-04-24 | 0.012489 | 0.01112 | 0.007462 | 0.009956 | 0.01076 | 0.008383 |
| 2015-04-25 | 0.012573 | 0.011604 | 0.007981 | 0.010581 | 0.011143 | 0.009022 |
| 2015-04-26 | 0.012186 | 0.01162 | 0.008738 | 0.010721 | 0.011219 | 0.009469 |
| 2015-04-27 | 0.012329 | 0.011424 | 0.008381 | 0.010532 | 0.011083 | 0.008934 |
| 2015-04-28 | 0.012918 | 0.011377 | 0.008019 | 0.010204 | 0.0109 | 0.008645 |
| 2015-04-29 | 0.012835 | 0.011214 | 0.007619 | 0.009918 | 0.01065 | 0.008534 |
| 2015-04-30 | 0.013082 | 0.011663 | 0.008267 | 0.010499 | 0.011286 | 0.009168 |
| 2015-05-01 | 0.013213 | 0.012345 | 0.008943 | 0.011529 | 0.012069 | 0.009829 |
| 2015-05-02 | 0.012699 | 0.012381 | 0.0092 | 0.011644 | 0.012226 | 0.010143 |
| 2015-05-03 | 0.012721 | 0.012861 | 0.008586 | 0.011723 | 0.012457 | 0.009722 |
| 2015-05-04 | 0.013005 | 0.012939 | 0.008467 | 0.011207 | 0.012029 | 0.009374 |
| 2015-05-05 | 0.013631 | 0.012986 | 0.008948 | 0.011664 | 0.012288 | 0.00998 |
| 2015-05-06 | 0.013694 | 0.012804 | 0.009138 | 0.01196 | 0.012352 | 0.010051 |
| 2015-05-07 | 0.01355 | 0.012889 | 0.008576 | 0.011577 | 0.01224 | 0.009653 |
| 2015-05-08 | 0.013564 | 0.013223 | 0.009176 | 0.012142 | 0.012743 | 0.010309 |
| 2015-05-09 | 0.013882 | 0.01323 | 0.009562 | 0.011998 | 0.012795 | 0.01065 |
| 2015-05-10 | 0.014093 | 0.013804 | 0.009619 | 0.012176 | 0.013069 | 0.010587 |
| 2015-05-11 | 0.013752 | 0.013831 | 0.009219 | 0.011769 | 0.012907 | 0.010207 |
| 2015-05-12 | 0.013953 | 0.013202 | 0.009905 | 0.011927 | 0.012564 | 0.010598 |
| 2015-05-13 | 0.014349 | 0.013444 | 0.009295 | 0.011729 | 0.01264 | 0.010247 |
| 2015-05-14 | 0.014904 | 0.01332 | 0.008795 | 0.011281 | 0.012262 | 0.009755 |
| 2015-05-15 | 0.014976 | 0.013571 | 0.009576 | 0.012142 | 0.012924 | 0.010584 |
| 2015-05-16 | 0.014882 | 0.014007 | 0.009943 | 0.012442 | 0.013255 | 0.010955 |
| 2015-05-17 | 0.014569 | 0.014274 | 0.009952 | 0.012763 | 0.013595 | 0.011038 |
| 2015-05-18 | 0.014215 | 0.013798 | 0.01009 | 0.012668 | 0.01326 | 0.011097 |
| 2015-05-19 | 0.014428 | 0.01332 | 0.009767 | 0.012045 | 0.01279 | 0.010514 |
| 2015-05-20 | 0.014867 | 0.013473 | 0.009214 | 0.012323 | 0.012969 | 0.010358 |
| 2015-05-21 | 0.015107 | 0.013651 | 0.009238 | 0.012152 | 0.013064 | 0.010161 |
| 2015-05-22 | 0.014847 | 0.013617 | 0.009352 | 0.01178 | 0.01279 | 0.010035 |
| 2015-05-23 | 0.014578 | 0.01397 | 0.009648 | 0.011879 | 0.012952 | 0.010241 |
| 2015-05-24 | 0.01471 | 0.014381 | 0.010433 | 0.012475 | 0.013538 | 0.011157 |
| 2015-05-25 | 0.015124 | 0.01476 | 0.010729 | 0.013529 | 0.014329 | 0.011998 |
| 2015-05-26 | 0.015488 | 0.015054 | 0.011138 | 0.013985 | 0.014736 | 0.012324 |
| 2015-05-27 | 0.015705 | 0.015443 | 0.010671 | 0.013993 | 0.014869 | 0.012063 |
| 2015-05-28 | 0.016168 | 0.015455 | 0.010195 | 0.013577 | 0.01459 | 0.011706 |
| 2015-05-29 | 0.016258 | 0.015467 | 0.010143 | 0.013595 | 0.014571 | 0.011665 |
| 2015-05-30 | 0.016137 | 0.01536 | 0.010371 | 0.013549 | 0.014576 | 0.011645 |
| 2015-05-31 | 0.016281 | 0.015662 | 0.011629 | 0.013974 | 0.014869 | 0.012527 |
| 2015-06-01 | 0.016463 | 0.015933 | 0.011581 | 0.014226 | 0.015114 | 0.012542 |
| 2015-06-02 | 0.016545 | 0.016235 | 0.011571 | 0.014575 | 0.015352 | 0.012759 |
| 2015-06-03 | 0.016747 | 0.01701 | 0.011548 | 0.015126 | 0.016069 | 0.012768 |
| 2015-06-04 | 0.017063 | 0.017375 | 0.01111 | 0.015449 | 0.016481 | 0.01281 |
| 2015-06-05 | 0.017179 | 0.016867 | 0.011395 | 0.015158 | 0.015974 | 0.012788 |
| 2015-06-06 | 0.017423 | 0.016612 | 0.011557 | 0.01472 | 0.015579 | 0.012645 |
| 2015-06-07 | 0.017638 | 0.016349 | 0.011629 | 0.014689 | 0.015538 | 0.012802 |
| 2015-06-08 | 0.017717 | 0.016383 | 0.012357 | 0.015243 | 0.01585 | 0.013627 |
| 2015-06-09 | 0.017514 | 0.016749 | 0.012662 | 0.015337 | 0.015993 | 0.013729 |
| 2015-06-10 | 0.017574 | 0.01716 | 0.012371 | 0.015506 | 0.016457 | 0.013745 |
| 2015-06-11 | 0.017798 | 0.017605 | 0.012138 | 0.015517 | 0.016602 | 0.013619 |
| 2015-06-12 | 0.017733 | 0.0174 | 0.013133 | 0.01583 | 0.016581 | 0.014205 |
| 2015-06-13 | 0.017653 | 0.017294 | 0.013429 | 0.015871 | 0.0165 | 0.014418 |
| 2015-06-14 | 0.017737 | 0.017733 | 0.013586 | 0.016124 | 0.016917 | 0.014603 |
| 2015-06-15 | 0.017914 | 0.017887 | 0.0136 | 0.016571 | 0.017136 | 0.014755 |
| 2015-06-16 | 0.018387 | 0.017757 | 0.013424 | 0.016127 | 0.016855 | 0.014476 |
| 2015-06-17 | 0.018591 | 0.017573 | 0.013243 | 0.01588 | 0.016652 | 0.01407 |
| 2015-06-18 | 0.018337 | 0.017419 | 0.013562 | 0.01572 | 0.016474 | 0.014347 |
| 2015-06-19 | 0.018416 | 0.017435 | 0.014281 | 0.016476 | 0.016862 | 0.014972 |
| 2015-06-20 | 0.018418 | 0.017732 | 0.014071 | 0.016525 | 0.016983 | 0.014682 |
| 2015-06-21 | 0.01849 | 0.017845 | 0.013833 | 0.016314 | 0.01694 | 0.01445 |
| 2015-06-22 | 0.018627 | 0.01799 | 0.014005 | 0.016806 | 0.017269 | 0.014918 |
| 2015-06-23 | 0.018777 | 0.018045 | 0.013281 | 0.016939 | 0.017367 | 0.014484 |
| 2015-06-24 | 0.018698 | 0.017938 | 0.013533 | 0.016652 | 0.017305 | 0.014382 |
| 2015-06-25 | 0.018778 | 0.018239 | 0.014281 | 0.016906 | 0.017567 | 0.015076 |
| 2015-06-26 | 0.018799 | 0.018431 | 0.014938 | 0.017411 | 0.017867 | 0.015658 |
| 2015-06-27 | 0.0188 | 0.018575 | 0.015324 | 0.017595 | 0.018036 | 0.015844 |
| 2015-06-28 | 0.018929 | 0.018767 | 0.014938 | 0.017563 | 0.018055 | 0.015785 |
| 2015-06-29 | 0.018939 | 0.018812 | 0.015005 | 0.017618 | 0.018124 | 0.015857 |
| 2015-06-30 | 0.019059 | 0.01862 | 0.014852 | 0.0176 | 0.017905 | 0.01557 |
| 2015-07-01 | 0.019257 | 0.018618 | 0.014738 | 0.017451 | 0.017833 | 0.015652 |
| 2015-07-02 | 0.019416 | 0.018546 | 0.01409 | 0.016893 | 0.017538 | 0.014951 |
| 2015-07-03 | 0.019318 | 0.018471 | 0.014362 | 0.016687 | 0.017414 | 0.01519 |
| 2015-07-04 | 0.019271 | 0.018512 | 0.015429 | 0.017065 | 0.017688 | 0.015943 |
| 2015-07-05 | 0.019285 | 0.019001 | 0.015671 | 0.018065 | 0.018498 | 0.016567 |
| 2015-07-06 | 0.019295 | 0.019383 | 0.014952 | 0.018432 | 0.0188 | 0.016085 |
| 2015-07-07 | 0.019361 | 0.01918 | 0.014633 | 0.017771 | 0.018276 | 0.015465 |
| 2015-07-08 | 0.019265 | 0.018945 | 0.015171 | 0.017794 | 0.01835 | 0.015837 |
| 2015-07-09 | 0.019379 | 0.01916 | 0.015352 | 0.01847 | 0.018836 | 0.016407 |
| 2015-07-10 | 0.01959 | 0.019505 | 0.015352 | 0.018842 | 0.019202 | 0.016754 |
| 2015-07-11 | 0.019758 | 0.019793 | 0.014095 | 0.01862 | 0.019119 | 0.015783 |
| 2015-07-12 | 0.019676 | 0.019568 | 0.013995 | 0.017971 | 0.018595 | 0.015486 |
| 2015-07-13 | 0.01963 | 0.01934 | 0.015057 | 0.01792 | 0.018467 | 0.015984 |
| 2015-07-14 | 0.019581 | 0.019292 | 0.01559 | 0.018035 | 0.0184 | 0.016158 |
| 2015-07-15 | 0.019635 | 0.019188 | 0.015752 | 0.018145 | 0.018355 | 0.016318 |
| 2015-07-16 | 0.019547 | 0.019318 | 0.015776 | 0.018268 | 0.018507 | 0.016453 |
| 2015-07-17 | 0.019677 | 0.019464 | 0.016305 | 0.018319 | 0.0186 | 0.01659 |
| 2015-07-18 | 0.019778 | 0.019404 | 0.016843 | 0.018613 | 0.018843 | 0.017079 |
| 2015-07-19 | 0.019819 | 0.019561 | 0.017024 | 0.018929 | 0.018955 | 0.017431 |
| 2015-07-20 | 0.019768 | 0.019676 | 0.016262 | 0.019014 | 0.019079 | 0.016991 |
| 2015-07-21 | 0.019957 | 0.019755 | 0.016214 | 0.018649 | 0.019036 | 0.016726 |
| 2015-07-22 | 0.019959 | 0.019654 | 0.016371 | 0.019062 | 0.019221 | 0.017201 |
| 2015-07-23 | 0.02008 | 0.019857 | 0.016781 | 0.019207 | 0.019376 | 0.017295 |
| 2015-07-24 | 0.020069 | 0.019738 | 0.016281 | 0.018985 | 0.019081 | 0.016856 |
| 2015-07-25 | 0.019953 | 0.019532 | 0.016119 | 0.018708 | 0.018881 | 0.016875 |
| 2015-07-26 | 0.019765 | 0.019636 | 0.015905 | 0.018779 | 0.019062 | 0.016878 |
| 2015-07-27 | 0.019587 | 0.019821 | 0.016633 | 0.018944 | 0.01919 | 0.017198 |
| 2015-07-28 | 0.019693 | 0.019948 | 0.01649 | 0.019085 | 0.019395 | 0.01717 |
| 2015-07-29 | 0.019741 | 0.019862 | 0.016748 | 0.019115 | 0.019271 | 0.01723 |
| 2015-07-30 | 0.019812 | 0.019871 | 0.016481 | 0.018808 | 0.018983 | 0.016848 |
| 2015-07-31 | 0.019936 | 0.019561 | 0.016533 | 0.018396 | 0.018729 | 0.016825 |
| 2015-08-01 | 0.020027 | 0.019321 | 0.01621 | 0.017937 | 0.018288 | 0.016388 |
| 2015-08-02 | 0.020056 | 0.019204 | 0.016114 | 0.017727 | 0.018195 | 0.016195 |
| 2015-08-03 | 0.020058 | 0.018943 | 0.016357 | 0.017985 | 0.018236 | 0.01649 |
| 2015-08-04 | 0.019923 | 0.019195 | 0.017005 | 0.018223 | 0.018424 | 0.016849 |
| 2015-08-05 | 0.019809 | 0.019407 | 0.016114 | 0.018281 | 0.018562 | 0.016419 |
| 2015-08-06 | 0.019729 | 0.019442 | 0.015133 | 0.017976 | 0.018464 | 0.015677 |
| 2015-08-07 | 0.019728 | 0.019005 | 0.014519 | 0.017419 | 0.017838 | 0.015188 |
| 2015-08-08 | 0.019813 | 0.018613 | 0.014938 | 0.01724 | 0.017655 | 0.015381 |
| 2015-08-09 | 0.019859 | 0.018724 | 0.015081 | 0.017532 | 0.017969 | 0.015801 |
| 2015-08-10 | 0.019646 | 0.018875 | 0.015852 | 0.017913 | 0.018271 | 0.016219 |
| 2015-08-11 | 0.019451 | 0.019054 | 0.015967 | 0.018046 | 0.018355 | 0.016357 |
| 2015-08-12 | 0.019521 | 0.01917 | 0.01589 | 0.01786 | 0.018286 | 0.0162 |
| 2015-08-13 | 0.019614 | 0.019102 | 0.016219 | 0.018123 | 0.018417 | 0.016581 |
| 2015-08-14 | 0.019803 | 0.019245 | 0.0161 | 0.017764 | 0.018279 | 0.016318 |
| 2015-08-15 | 0.019889 | 0.018998 | 0.015933 | 0.017923 | 0.018195 | 0.016347 |
| 2015-08-16 | 0.019893 | 0.019064 | 0.016343 | 0.018067 | 0.018314 | 0.016693 |
| 2015-08-17 | 0.019912 | 0.019431 | 0.016376 | 0.018367 | 0.018717 | 0.016588 |
| 2015-08-18 | 0.019899 | 0.019743 | 0.016029 | 0.018602 | 0.01899 | 0.016446 |
| 2015-08-19 | 0.019772 | 0.019589 | 0.015324 | 0.018263 | 0.018719 | 0.016158 |
| 2015-08-20 | 0.019801 | 0.01932 | 0.015238 | 0.017868 | 0.018388 | 0.016135 |
| 2015-08-21 | 0.019721 | 0.018933 | 0.01469 | 0.017599 | 0.018117 | 0.01544 |
| 2015-08-22 | 0.019832 | 0.018704 | 0.014095 | 0.017154 | 0.017752 | 0.014774 |
| 2015-08-23 | 0.019828 | 0.018601 | 0.013924 | 0.016846 | 0.017583 | 0.014615 |
| 2015-08-24 | 0.019707 | 0.018561 | 0.0143 | 0.016846 | 0.017605 | 0.01487 |
| 2015-08-25 | 0.019564 | 0.018521 | 0.014405 | 0.016855 | 0.01746 | 0.015111 |
| 2015-08-26 | 0.019482 | 0.018535 | 0.015048 | 0.017351 | 0.017621 | 0.015788 |
| 2015-08-27 | 0.019441 | 0.018825 | 0.015595 | 0.018055 | 0.018105 | 0.016333 |
| 2015-08-28 | 0.01953 | 0.019002 | 0.015924 | 0.018292 | 0.018412 | 0.016667 |
| 2015-08-29 | 0.019526 | 0.018902 | 0.015895 | 0.018045 | 0.018238 | 0.016474 |
| 2015-08-30 | 0.019456 | 0.018877 | 0.01501 | 0.01774 | 0.017938 | 0.01577 |
| 2015-08-31 | 0.019503 | 0.018587 | 0.014438 | 0.017474 | 0.017679 | 0.015358 |
| 2015-09-01 | 0.019586 | 0.018382 | 0.013662 | 0.017221 | 0.017471 | 0.014636 |
| 2015-09-02 | 0.019592 | 0.017979 | 0.013338 | 0.01603 | 0.01671 | 0.013811 |
| 2015-09-03 | 0.019547 | 0.017705 | 0.013729 | 0.016171 | 0.016631 | 0.01401 |
| 2015-09-04 | 0.019416 | 0.017282 | 0.013795 | 0.015801 | 0.01621 | 0.013838 |
| 2015-09-05 | 0.019318 | 0.016994 | 0.012705 | 0.015425 | 0.015893 | 0.013135 |
| 2015-09-06 | 0.019224 | 0.017236 | 0.0128 | 0.015686 | 0.016176 | 0.013503 |
| 2015-09-07 | 0.018935 | 0.017605 | 0.013386 | 0.01611 | 0.016626 | 0.013883 |
| 2015-09-08 | 0.019005 | 0.017586 | 0.013467 | 0.016296 | 0.016707 | 0.013973 |
| 2015-09-09 | 0.018855 | 0.017298 | 0.0137 | 0.015658 | 0.016107 | 0.013819 |
| 2015-09-10 | 0.018807 | 0.017052 | 0.01341 | 0.01561 | 0.015907 | 0.013544 |
| 2015-09-11 | 0.018779 | 0.016719 | 0.01211 | 0.014786 | 0.015345 | 0.012363 |
| 2015-09-12 | 0.018691 | 0.016757 | 0.01109 | 0.014325 | 0.015243 | 0.011609 |
| 2015-09-13 | 0.018516 | 0.016698 | 0.012248 | 0.014795 | 0.015457 | 0.012483 |
| 2015-09-14 | 0.018457 | 0.01703 | 0.013519 | 0.01575 | 0.016131 | 0.013792 |
| 2015-09-15 | 0.018529 | 0.016998 | 0.013286 | 0.015783 | 0.016052 | 0.01343 |
| 2015-09-16 | 0.018568 | 0.016583 | 0.013062 | 0.015215 | 0.015533 | 0.012994 |
| 2015-09-17 | 0.018709 | 0.016536 | 0.012895 | 0.014874 | 0.015274 | 0.012854 |
| 2015-09-18 | 0.018651 | 0.016313 | 0.012319 | 0.014576 | 0.01499 | 0.01241 |
| 2015-09-19 | 0.018505 | 0.016187 | 0.011838 | 0.014393 | 0.014907 | 0.012262 |
| 2015-09-20 | 0.018201 | 0.016365 | 0.01129 | 0.014238 | 0.015017 | 0.011736 |
| 2015-09-21 | 0.018067 | 0.016717 | 0.011057 | 0.014357 | 0.015336 | 0.011732 |
| 2015-09-22 | 0.018144 | 0.016593 | 0.012329 | 0.014901 | 0.015471 | 0.012486 |
| 2015-09-23 | 0.01837 | 0.016233 | 0.012033 | 0.014363 | 0.014819 | 0.011982 |
| 2015-09-24 | 0.017969 | 0.015974 | 0.011019 | 0.013906 | 0.014643 | 0.011401 |
| 2015-09-25 | 0.01779 | 0.015877 | 0.011129 | 0.014383 | 0.014836 | 0.011667 |
| 2015-09-26 | 0.017952 | 0.015788 | 0.012033 | 0.014351 | 0.014821 | 0.012109 |
| 2015-09-27 | 0.018156 | 0.015985 | 0.012652 | 0.014582 | 0.014962 | 0.012543 |
| 2015-09-28 | 0.018203 | 0.015279 | 0.011995 | 0.014088 | 0.014443 | 0.011795 |
| 2015-09-29 | 0.017879 | 0.014385 | 0.010195 | 0.01255 | 0.013124 | 0.010127 |
| 2015-09-30 | 0.017754 | 0.013657 | 0.009519 | 0.011694 | 0.012298 | 0.009417 |
| 2015-10-01 | 0.017408 | 0.013742 | 0.009186 | 0.011564 | 0.012293 | 0.009094 |
| 2015-10-02 | 0.017207 | 0.013761 | 0.009133 | 0.011281 | 0.012224 | 0.009179 |
| 2015-10-03 | 0.017103 | 0.014143 | 0.009952 | 0.011963 | 0.01265 | 0.009793 |
| 2015-10-04 | 0.017158 | 0.01447 | 0.010776 | 0.012549 | 0.013079 | 0.010691 |
| 2015-10-05 | 0.016928 | 0.014414 | 0.010376 | 0.01274 | 0.013314 | 0.010512 |
| 2015-10-06 | 0.016424 | 0.014601 | 0.009376 | 0.012362 | 0.013088 | 0.009622 |
| 2015-10-07 | 0.016401 | 0.013867 | 0.009086 | 0.01172 | 0.012445 | 0.009208 |
| 2015-10-08 | 0.016422 | 0.013846 | 0.009429 | 0.012001 | 0.012705 | 0.009488 |
| 2015-10-09 | 0.016831 | 0.01424 | 0.010424 | 0.01249 | 0.0131 | 0.010379 |
| 2015-10-10 | 0.017054 | 0.014279 | 0.0105 | 0.012885 | 0.013162 | 0.01027 |
| 2015-10-11 | 0.016439 | 0.013832 | 0.009552 | 0.011918 | 0.012505 | 0.009402 |
| 2015-10-12 | 0.015825 | 0.013394 | 0.009162 | 0.011118 | 0.011826 | 0.00885 |
| 2015-10-13 | 0.015627 | 0.013168 | 0.008862 | 0.011094 | 0.011795 | 0.008933 |
| 2015-10-14 | 0.015871 | 0.012768 | 0.009152 | 0.01126 | 0.011788 | 0.009214 |
| 2015-10-15 | 0.016074 | 0.011694 | 0.008719 | 0.010502 | 0.010838 | 0.00835 |
| 2015-10-16 | 0.015771 | 0.011519 | 0.008514 | 0.010082 | 0.010536 | 0.008058 |
| 2015-10-17 | 0.015614 | 0.01148 | 0.008224 | 0.010127 | 0.010521 | 0.008031 |
| 2015-10-18 | 0.015051 | 0.01197 | 0.008438 | 0.010462 | 0.01101 | 0.008318 |
| 2015-10-19 | 0.014631 | 0.011526 | 0.008386 | 0.010214 | 0.010748 | 0.008249 |
| 2015-10-20 | 0.014186 | 0.011938 | 0.008162 | 0.010451 | 0.011 | 0.00822 |
| 2015-10-21 | 0.014418 | 0.011642 | 0.00779 | 0.0098 | 0.0105 | 0.007809 |
| 2015-10-22 | 0.014457 | 0.011927 | 0.007448 | 0.009762 | 0.01065 | 0.00757 |
| 2015-10-23 | 0.01448 | 0.011419 | 0.007814 | 0.009425 | 0.010248 | 0.007595 |
| 2015-10-24 | 0.014345 | 0.011358 | 0.008005 | 0.009983 | 0.010612 | 0.008008 |
| 2015-10-25 | 0.014243 | 0.011212 | 0.00809 | 0.010363 | 0.010686 | 0.008071 |
| 2015-10-26 | 0.014039 | 0.011039 | 0.007929 | 0.010029 | 0.010319 | 0.00797 |
| 2015-10-27 | 0.014096 | 0.011281 | 0.008614 | 0.010179 | 0.010555 | 0.008367 |
| 2015-10-28 | 0.014065 | 0.01112 | 0.007543 | 0.009998 | 0.010548 | 0.00767 |
| 2015-10-29 | 0.013486 | 0.010419 | 0.005981 | 0.008287 | 0.009179 | 0.00608 |
| 2015-10-30 | 0.013031 | 0.010869 | 0.006552 | 0.008895 | 0.009645 | 0.006668 |
| 2015-10-31 | 0.013697 | 0.011132 | 0.007548 | 0.009493 | 0.010069 | 0.007393 |
| 2015-11-01 | 0.013997 | 0.01123 | 0.007862 | 0.009807 | 0.010319 | 0.007778 |
| 2015-11-02 | 0.013559 | 0.010918 | 0.007638 | 0.009696 | 0.010207 | 0.007561 |
| 2015-11-03 | 0.013225 | 0.010029 | 0.007048 | 0.008812 | 0.009221 | 0.006808 |
| 2015-11-04 | 0.013519 | 0.010074 | 0.006552 | 0.008718 | 0.009255 | 0.00652 |
| 2015-11-05 | 0.013245 | 0.01003 | 0.006771 | 0.008704 | 0.009281 | 0.006673 |
| 2015-11-06 | 0.013193 | 0.009656 | 0.006933 | 0.008676 | 0.009012 | 0.006658 |
| 2015-11-07 | 0.013148 | 0.00938 | 0.006262 | 0.008138 | 0.008717 | 0.006098 |
| 2015-11-08 | 0.012884 | 0.009192 | 0.006424 | 0.007794 | 0.008424 | 0.006136 |
| 2015-11-09 | 0.012749 | 0.009283 | 0.006176 | 0.007888 | 0.008529 | 0.006156 |
| 2015-11-10 | 0.011884 | 0.009677 | 0.006767 | 0.0087 | 0.009155 | 0.006664 |
| 2015-11-11 | 0.01148 | 0.009482 | 0.006638 | 0.008281 | 0.008798 | 0.006473 |
| 2015-11-12 | 0.011775 | 0.009177 | 0.006267 | 0.007915 | 0.008457 | 0.006019 |
| 2015-11-13 | 0.011703 | 0.009302 | 0.006295 | 0.007898 | 0.00845 | 0.005938 |
| 2015-11-14 | 0.01181 | 0.009289 | 0.006948 | 0.008345 | 0.008729 | 0.006593 |
| 2015-11-15 | 0.012207 | 0.009271 | 0.006995 | 0.00816 | 0.008607 | 0.006561 |
| 2015-11-16 | 0.012137 | 0.009202 | 0.006443 | 0.008363 | 0.008776 | 0.00652 |
| 2015-11-17 | 0.011707 | 0.008574 | 0.005976 | 0.007802 | 0.008229 | 0.005899 |
| 2015-11-18 | 0.011552 | 0.0084 | 0.005457 | 0.0072 | 0.007814 | 0.00548 |
| 2015-11-19 | 0.012004 | 0.008992 | 0.00609 | 0.007875 | 0.008471 | 0.006175 |
| 2015-11-20 | 0.011993 | 0.009167 | 0.006248 | 0.008014 | 0.00865 | 0.006176 |
| 2015-11-21 | 0.01153 | 0.009773 | 0.006367 | 0.008455 | 0.009145 | 0.006374 |
| 2015-11-22 | 0.011896 | 0.009225 | 0.006814 | 0.008319 | 0.008764 | 0.006564 |
| 2015-11-23 | 0.011666 | 0.00897 | 0.006267 | 0.00803 | 0.0085 | 0.00615 |
| 2015-11-24 | 0.011368 | 0.009163 | 0.005957 | 0.00803 | 0.008588 | 0.005959 |
| 2015-11-25 | 0.011085 | 0.009443 | 0.005976 | 0.008062 | 0.008743 | 0.006027 |
| 2015-11-26 | 0.011072 | 0.009193 | 0.006638 | 0.008548 | 0.008836 | 0.0066 |
| 2015-11-27 | 0.011602 | 0.00905 | 0.005833 | 0.00799 | 0.008619 | 0.005856 |
| 2015-11-28 | 0.01244 | 0.009117 | 0.006814 | 0.008386 | 0.008833 | 0.006632 |
| 2015-11-29 | 0.011874 | 0.009242 | 0.005843 | 0.007852 | 0.008502 | 0.005869 |
| 2015-11-30 | 0.011349 | 0.009207 | 0.00621 | 0.008037 | 0.008669 | 0.006128 |
| 2015-12-01 | 0.011422 | 0.008271 | 0.006067 | 0.007585 | 0.008062 | 0.005868 |
| 2015-12-02 | 0.011062 | 0.00796 | 0.004752 | 0.006706 | 0.007405 | 0.004725 |
| 2015-12-03 | 0.011107 | 0.008276 | 0.005295 | 0.007098 | 0.007683 | 0.005263 |
| 2015-12-04 | 0.01091 | 0.008195 | 0.00541 | 0.0072 | 0.007717 | 0.005366 |
| 2015-12-05 | 0.01112 | 0.008152 | 0.0054 | 0.00717 | 0.00769 | 0.005263 |
| 2015-12-06 | 0.010993 | 0.007307 | 0.00501 | 0.006412 | 0.006886 | 0.004797 |
| 2015-12-07 | 0.010937 | 0.007689 | 0.004933 | 0.006685 | 0.007224 | 0.004825 |
| 2015-12-08 | 0.010624 | 0.007639 | 0.00451 | 0.006338 | 0.006933 | 0.004482 |
| 2015-12-09 | 0.010169 | 0.008821 | 0.00479 | 0.007113 | 0.007929 | 0.004914 |
| 2015-12-10 | 0.010332 | 0.009127 | 0.005543 | 0.007785 | 0.008443 | 0.005641 |
| 2015-12-11 | 0.010528 | 0.007977 | 0.005586 | 0.007189 | 0.007598 | 0.005354 |
| 2015-12-12 | 0.010656 | 0.007851 | 0.005152 | 0.006639 | 0.007274 | 0.004995 |
| 2015-12-13 | 0.010928 | 0.009048 | 0.005333 | 0.007562 | 0.008338 | 0.005418 |
| 2015-12-14 | 0.011414 | 0.008946 | 0.005629 | 0.007704 | 0.008281 | 0.005572 |
| 2015-12-15 | 0.011492 | 0.008282 | 0.005048 | 0.007038 | 0.007724 | 0.005001 |
| 2015-12-16 | 0.01091 | 0.008123 | 0.005086 | 0.007113 | 0.007712 | 0.005007 |
| 2015-12-17 | 0.01069 | 0.008071 | 0.00519 | 0.006805 | 0.007502 | 0.005049 |
| 2015-12-18 | 0.010311 | 0.007923 | 0.004786 | 0.006554 | 0.007293 | 0.004736 |
| 2015-12-19 | 0.010774 | 0.007794 | 0.0048 | 0.007061 | 0.007612 | 0.004977 |
| 2015-12-20 | 0.010538 | 0.007211 | 0.004543 | 0.006189 | 0.006793 | 0.004352 |
| 2015-12-21 | 0.010193 | 0.007412 | 0.004252 | 0.006079 | 0.006833 | 0.004265 |
| 2015-12-22 | 0.010178 | 0.008175 | 0.0045 | 0.006533 | 0.007424 | 0.00459 |
| 2015-12-23 | 0.010176 | 0.008385 | 0.005262 | 0.007142 | 0.007857 | 0.005239 |
| 2015-12-24 | 0.009689 | 0.00764 | 0.004762 | 0.006556 | 0.007105 | 0.004494 |
| 2015-12-25 | 0.009405 | 0.007095 | 0.004105 | 0.005948 | 0.006612 | 0.003906 |
| 2015-12-26 | 0.010011 | 0.006248 | 0.004152 | 0.005548 | 0.005948 | 0.003723 |
| 2015-12-27 | 0.010304 | 0.006649 | 0.003848 | 0.005436 | 0.006088 | 0.0036 |
| 2015-12-28 | 0.009848 | 0.007485 | 0.004376 | 0.006337 | 0.006964 | 0.004437 |
| 2015-12-29 | 0.010184 | 0.008096 | 0.005119 | 0.007088 | 0.007724 | 0.005085 |
| 2015-12-30 | 0.009652 | 0.00765 | 0.004686 | 0.006511 | 0.007274 | 0.004692 |
| 2015-12-31 | 0.009625 | 0.008175 | 0.004614 | 0.006756 | 0.007564 | 0.004848 |
"""Defines a compartmental IPM mirroring the Pei paper's beta treatment."""
from sympy import Max, exp, log
from epymorph.attribute import AttributeDef
from epymorph.compartment_model import CompartmentModel, compartment, edge
from epymorph.data_shape import Shapes
class Pei(CompartmentModel):
"""The 'pei' IPM: an SIRS model driven by humidity."""
compartments = [
compartment("S"),
compartment("I"),
compartment("R"),
]
requirements = [
AttributeDef("infection_duration", float, Shapes.TxN),
AttributeDef("immunity_duration", float, Shapes.TxN),
AttributeDef("humidity", float, Shapes.TxN),
]
def edges(self, symbols):
[S, I, R] = symbols.all_compartments
[D, L, H] = symbols.all_requirements
beta = (exp(-180 * H + log(2.0 - 1.3)) + 1.3) / D
# formulate N so as to avoid dividing by zero;
# this is safe in this instance because if the denominator is zero,
# the numerator must also be zero
N = Max(1, S + I + R)
return [
edge(S, I, rate=beta * S * I / N),
edge(I, R, rate=I / D),
edge(R, S, rate=R / L),
]from functools import cached_property
import numpy as np
from numpy.typing import NDArray
from epymorph.attribute import AttributeDef
from epymorph.data_shape import Shapes
from epymorph.data_type import SimDType
from epymorph.movement_model import EveryDay, MovementClause, MovementModel
from epymorph.simulation import Tick, TickDelta, TickIndex
from epymorph.util import row_normalize
_COMMUTERS_ATTRIB = AttributeDef(
"commuters", int, Shapes.NxN, comment="A node-to-node commuters marix."
)
class Commuters(MovementClause):
"""The commuter clause of the pei model."""
requirements = (
_COMMUTERS_ATTRIB,
AttributeDef(
"move_control",
float,
Shapes.Scalar,
default_value=0.9,
comment=(
"A factor which modulates the number of commuters "
"by conducting a binomial draw with this probability "
"and the expected commuters from the commuters matrix."
),
),
)
predicate = EveryDay()
leaves = TickIndex(step=0)
returns = TickDelta(step=1, days=0)
@cached_property
def commuters_by_node(self) -> NDArray[SimDType]:
"""Total commuters living in each state."""
commuters = self.data("commuters")
return np.sum(commuters, axis=1)
@cached_property
def commuting_probability(self) -> NDArray[np.float64]:
"""
Commuters as a ratio to the total commuters living in that state.
For cases where there are no commuters, avoid div-by-0 errors
"""
commuters = self.data("commuters")
return row_normalize(commuters)
def evaluate(self, tick: Tick) -> NDArray[np.int64]:
move_control = self.data("move_control")
actual = self.rng.binomial(self.commuters_by_node, move_control)
return self.rng.multinomial(actual, self.commuting_probability)
class Dispersers(MovementClause):
"""The dispersers clause of the pei model."""
requirements = (
_COMMUTERS_ATTRIB,
AttributeDef(
"theta",
float,
Shapes.Scalar,
default_value=0.1,
comment=(
"A factor which allows for randomized movement "
"by conducting a poisson draw with this factor "
"times the average number of commuters between two nodes "
"from the commuters matrix."
),
),
)
predicate = EveryDay()
leaves = TickIndex(step=0)
returns = TickDelta(step=1, days=0)
@cached_property
def commuters_average(self) -> NDArray[SimDType]:
"""Average commuters between locations."""
commuters = self.data("commuters")
return (commuters + commuters.T) // 2
def evaluate(self, tick: Tick) -> NDArray[SimDType]:
theta = self.data("theta")
return self.rng.poisson(theta * self.commuters_average)
class Pei(MovementModel):
"""
Modeled after the Pei influenza paper, this model simulates
two types of movers -- regular commuters and more-randomized dispersers.
Each is somewhat stochastic but adhere to the general shape dictated
by the commuters array. Both kinds of movement can be "tuned" through
their respective parameters: move_control and theta.
"""
steps = (1 / 3, 2 / 3)
clauses = (Commuters(), Dispersers())"""
Data for the Pei 2018 flu model, copied from their data files.
https://doi.org/10.1073/pnas.1708856115
We're including it to use as a canonical example: a self-contained
simulation that can be run without loading external data except TIGER state geography,
which is modest in size.
"""
import numpy as np
from epymorph.data_type import CentroidDType
from epymorph.geography.us_census import StateScope
from epymorph.time import TimeFrame
pei_scope = StateScope.in_states(states=["FL", "GA", "MD", "NC", "SC", "VA"], year=2015)
pei_time_frame = TimeFrame.of("2015-01-01", 365)
pei_centroids = np.array(
[
(-81.5158, 27.6648),
(-82.9071, 32.1574),
(-76.6413, 39.0458),
(-79.0193, 35.7596),
(-81.1637, 33.8361),
(-78.6569, 37.4316),
],
dtype=CentroidDType,
)
pei_population = np.array(
[18811310, 9687653, 5773552, 9535483, 4625364, 8001024], dtype=np.int64
)
pei_commuters = np.array(
[
[7993452, 13805, 2410, 2938, 1783, 3879],
[15066, 4091461, 966, 6057, 20318, 2147],
[949, 516, 2390255, 947, 91, 122688],
[3005, 5730, 1872, 4121984, 38081, 29487],
[1709, 23513, 630, 64872, 1890853, 1620],
[1368, 1175, 68542, 16869, 577, 3567788],
],
dtype=np.int64,
)
# humidity in 2015 (365 days)
pei_humidity = np.array(
[
[0.01003, 0.008144, 0.004738, 0.00681, 0.007467, 0.004909],
[0.0105, 0.008211, 0.004495, 0.006562, 0.007302, 0.004716],
[0.010631, 0.008169, 0.0053, 0.007162, 0.007736, 0.005407],
[0.010797, 0.007956, 0.0054, 0.00712, 0.00776, 0.005611],
[0.010727, 0.008105, 0.005233, 0.00726, 0.007819, 0.005504],
[0.01009, 0.008425, 0.004814, 0.007089, 0.007881, 0.005119],
[0.009815, 0.008386, 0.005, 0.007195, 0.007867, 0.00521],
[0.010363, 0.007858, 0.004857, 0.006825, 0.007343, 0.00491],
[0.010242, 0.007508, 0.0047, 0.006633, 0.007217, 0.00473],
[0.009609, 0.007048, 0.004052, 0.006199, 0.006764, 0.004276],
[0.009782, 0.00739, 0.00429, 0.006251, 0.006876, 0.004457],
[0.009573, 0.007775, 0.004914, 0.00681, 0.007402, 0.005012],
[0.009151, 0.008051, 0.005257, 0.00729, 0.007793, 0.005478],
[0.009114, 0.007194, 0.004824, 0.006514, 0.007036, 0.004898],
[0.009586, 0.00701, 0.004419, 0.006087, 0.006702, 0.004422],
[0.009771, 0.006976, 0.004324, 0.006073, 0.006602, 0.004267],
[0.009916, 0.007182, 0.003924, 0.005774, 0.006512, 0.00395],
[0.00951, 0.007415, 0.004476, 0.006351, 0.006874, 0.004434],
[0.009165, 0.007085, 0.003914, 0.005829, 0.006433, 0.004002],
[0.009451, 0.006524, 0.003748, 0.005594, 0.006214, 0.003876],
[0.00939, 0.007217, 0.003633, 0.005592, 0.006452, 0.003741],
[0.009205, 0.00759, 0.00381, 0.005946, 0.006779, 0.004008],
[0.008819, 0.007765, 0.004548, 0.006529, 0.007188, 0.00471],
[0.009298, 0.007635, 0.004833, 0.006829, 0.007386, 0.004962],
[0.010034, 0.006387, 0.004024, 0.005414, 0.005981, 0.003903],
[0.009865, 0.006424, 0.003424, 0.004979, 0.005731, 0.003404],
[0.009244, 0.006912, 0.003743, 0.005405, 0.006252, 0.00379],
[0.009452, 0.007364, 0.004395, 0.006227, 0.006857, 0.004595],
[0.009922, 0.008088, 0.003971, 0.006337, 0.007169, 0.00427],
[0.010102, 0.008568, 0.004552, 0.006879, 0.00766, 0.004733],
[0.00967, 0.00694, 0.003957, 0.005821, 0.006364, 0.00393],
[0.009465, 0.00713, 0.004038, 0.005765, 0.006412, 0.004019],
[0.009473, 0.007817, 0.00411, 0.006108, 0.0069, 0.004237],
[0.009869, 0.007693, 0.004205, 0.006364, 0.006993, 0.004406],
[0.009859, 0.007357, 0.004557, 0.006596, 0.007105, 0.0048],
[0.009472, 0.006649, 0.003981, 0.005577, 0.00616, 0.004025],
[0.00972, 0.007214, 0.003967, 0.005705, 0.006469, 0.004114],
[0.009781, 0.007535, 0.004129, 0.006332, 0.006967, 0.0044],
[0.008998, 0.006993, 0.003995, 0.005981, 0.006588, 0.004349],
[0.008965, 0.007864, 0.0043, 0.00638, 0.007248, 0.004713],
[0.009688, 0.008125, 0.0045, 0.006562, 0.00735, 0.004655],
[0.009386, 0.008112, 0.004081, 0.006412, 0.007248, 0.004393],
[0.009492, 0.007551, 0.003886, 0.006048, 0.006869, 0.004161],
[0.009803, 0.007173, 0.003852, 0.00592, 0.006648, 0.004171],
[0.009468, 0.007719, 0.004324, 0.006504, 0.007162, 0.004708],
[0.00919, 0.00809, 0.004238, 0.0067, 0.007405, 0.00477],
[0.009572, 0.008445, 0.004719, 0.007239, 0.007883, 0.005179],
[0.010095, 0.008001, 0.004143, 0.006375, 0.007126, 0.004413],
[0.01012, 0.00777, 0.0044, 0.00623, 0.006993, 0.004546],
[0.010347, 0.007843, 0.00461, 0.0065, 0.007074, 0.004821],
[0.010309, 0.008111, 0.004719, 0.006764, 0.007474, 0.005031],
[0.010009, 0.008877, 0.00489, 0.007371, 0.008162, 0.005248],
[0.010024, 0.009679, 0.005, 0.007724, 0.008545, 0.00542],
[0.010101, 0.008848, 0.004862, 0.007165, 0.007981, 0.005105],
[0.010214, 0.00791, 0.004452, 0.006431, 0.007071, 0.004664],
[0.010278, 0.008018, 0.004086, 0.006123, 0.00694, 0.004479],
[0.010087, 0.008654, 0.004448, 0.006748, 0.007605, 0.004894],
[0.010355, 0.008181, 0.004571, 0.006644, 0.007498, 0.005017],
[0.010427, 0.008333, 0.004762, 0.007206, 0.007821, 0.005261],
[0.011015, 0.008748, 0.004338, 0.00699, 0.007924, 0.00494],
[0.010848, 0.009258, 0.005414, 0.007926, 0.008529, 0.00593],
[0.010208, 0.008746, 0.00509, 0.007583, 0.008217, 0.005597],
[0.009757, 0.008039, 0.004595, 0.006887, 0.007631, 0.005057],
[0.010093, 0.00801, 0.004838, 0.006923, 0.007574, 0.005283],
[0.010081, 0.008942, 0.005186, 0.007589, 0.008407, 0.005759],
[0.010327, 0.009295, 0.005267, 0.007867, 0.008633, 0.005875],
[0.010199, 0.008657, 0.005767, 0.007563, 0.008121, 0.005987],
[0.010143, 0.008652, 0.00511, 0.007189, 0.007998, 0.00551],
[0.009692, 0.008573, 0.005038, 0.007301, 0.008052, 0.005444],
[0.009769, 0.00821, 0.00491, 0.007044, 0.007712, 0.00533],
[0.009577, 0.008525, 0.004795, 0.007027, 0.00786, 0.005278],
[0.009547, 0.009267, 0.005524, 0.007917, 0.008676, 0.006165],
[0.009643, 0.009277, 0.005695, 0.008032, 0.008738, 0.006319],
[0.009895, 0.00928, 0.005729, 0.007868, 0.008574, 0.006176],
[0.010718, 0.009733, 0.00549, 0.008096, 0.008912, 0.00613],
[0.010884, 0.009432, 0.005295, 0.007907, 0.00864, 0.005974],
[0.011186, 0.009346, 0.005052, 0.007533, 0.008398, 0.005693],
[0.011291, 0.009375, 0.005414, 0.007588, 0.008374, 0.005939],
[0.011956, 0.009446, 0.005329, 0.007596, 0.008531, 0.005845],
[0.011348, 0.009345, 0.0056, 0.007965, 0.008719, 0.006239],
[0.010864, 0.008571, 0.005567, 0.007408, 0.008069, 0.005916],
[0.010934, 0.008793, 0.005129, 0.007307, 0.008095, 0.005749],
[0.011081, 0.009018, 0.005414, 0.007615, 0.0084, 0.006056],
[0.010876, 0.009925, 0.005381, 0.008106, 0.009105, 0.006285],
[0.010844, 0.010088, 0.006033, 0.008535, 0.009357, 0.006739],
[0.011068, 0.010262, 0.006343, 0.008819, 0.009569, 0.006995],
[0.011229, 0.010521, 0.006876, 0.009365, 0.01, 0.007534],
[0.011388, 0.010819, 0.006719, 0.009286, 0.010055, 0.007323],
[0.0113, 0.010336, 0.006514, 0.008746, 0.009414, 0.006969],
[0.011776, 0.010332, 0.006338, 0.008826, 0.00966, 0.007092],
[0.011904, 0.01025, 0.007038, 0.009333, 0.009902, 0.007741],
[0.01186, 0.009836, 0.006876, 0.008806, 0.009355, 0.007369],
[0.011244, 0.009998, 0.006767, 0.008824, 0.009552, 0.007385],
[0.010541, 0.010074, 0.006686, 0.008862, 0.009548, 0.007277],
[0.010939, 0.009864, 0.005638, 0.008251, 0.009198, 0.006492],
[0.011814, 0.010223, 0.006452, 0.008811, 0.009574, 0.00719],
[0.011852, 0.010324, 0.007048, 0.009201, 0.009843, 0.007644],
[0.01168, 0.010515, 0.00709, 0.00932, 0.010024, 0.007863],
[0.011676, 0.010502, 0.007014, 0.009199, 0.009802, 0.007687],
[0.012229, 0.01023, 0.006771, 0.009155, 0.009795, 0.007703],
[0.012468, 0.010901, 0.006771, 0.009512, 0.010455, 0.00785],
[0.011615, 0.011148, 0.007324, 0.009926, 0.010633, 0.008174],
[0.011394, 0.01088, 0.007038, 0.009379, 0.010164, 0.007643],
[0.01188, 0.01056, 0.006919, 0.009238, 0.010031, 0.007629],
[0.01212, 0.011138, 0.007829, 0.010204, 0.01085, 0.008755],
[0.012775, 0.010276, 0.008143, 0.010008, 0.010395, 0.008739],
[0.012888, 0.010226, 0.007705, 0.009751, 0.010245, 0.008417],
[0.012084, 0.010138, 0.007081, 0.009196, 0.00986, 0.00785],
[0.011739, 0.010814, 0.007829, 0.009602, 0.010402, 0.00853],
[0.010981, 0.01149, 0.007981, 0.010546, 0.01119, 0.009008],
[0.011076, 0.01181, 0.008405, 0.011112, 0.011698, 0.009591],
[0.011556, 0.011177, 0.008705, 0.010769, 0.011102, 0.00921],
[0.012226, 0.011181, 0.008095, 0.009817, 0.01056, 0.008492],
[0.012489, 0.01112, 0.007462, 0.009956, 0.01076, 0.008383],
[0.012573, 0.011604, 0.007981, 0.010581, 0.011143, 0.009022],
[0.012186, 0.01162, 0.008738, 0.010721, 0.011219, 0.009469],
[0.012329, 0.011424, 0.008381, 0.010532, 0.011083, 0.008934],
[0.012918, 0.011377, 0.008019, 0.010204, 0.0109, 0.008645],
[0.012835, 0.011214, 0.007619, 0.009918, 0.01065, 0.008534],
[0.013082, 0.011663, 0.008267, 0.010499, 0.011286, 0.009168],
[0.013213, 0.012345, 0.008943, 0.011529, 0.012069, 0.009829],
[0.012699, 0.012381, 0.0092, 0.011644, 0.012226, 0.010143],
[0.012721, 0.012861, 0.008586, 0.011723, 0.012457, 0.009722],
[0.013005, 0.012939, 0.008467, 0.011207, 0.012029, 0.009374],
[0.013631, 0.012986, 0.008948, 0.011664, 0.012288, 0.00998],
[0.013694, 0.012804, 0.009138, 0.01196, 0.012352, 0.010051],
[0.01355, 0.012889, 0.008576, 0.011577, 0.01224, 0.009653],
[0.013564, 0.013223, 0.009176, 0.012142, 0.012743, 0.010309],
[0.013882, 0.01323, 0.009562, 0.011998, 0.012795, 0.01065],
[0.014093, 0.013804, 0.009619, 0.012176, 0.013069, 0.010587],
[0.013752, 0.013831, 0.009219, 0.011769, 0.012907, 0.010207],
[0.013953, 0.013202, 0.009905, 0.011927, 0.012564, 0.010598],
[0.014349, 0.013444, 0.009295, 0.011729, 0.01264, 0.010247],
[0.014904, 0.01332, 0.008795, 0.011281, 0.012262, 0.009755],
[0.014976, 0.013571, 0.009576, 0.012142, 0.012924, 0.010584],
[0.014882, 0.014007, 0.009943, 0.012442, 0.013255, 0.010955],
[0.014569, 0.014274, 0.009952, 0.012763, 0.013595, 0.011038],
[0.014215, 0.013798, 0.01009, 0.012668, 0.01326, 0.011097],
[0.014428, 0.01332, 0.009767, 0.012045, 0.01279, 0.010514],
[0.014867, 0.013473, 0.009214, 0.012323, 0.012969, 0.010358],
[0.015107, 0.013651, 0.009238, 0.012152, 0.013064, 0.010161],
[0.014847, 0.013617, 0.009352, 0.01178, 0.01279, 0.010035],
[0.014578, 0.01397, 0.009648, 0.011879, 0.012952, 0.010241],
[0.01471, 0.014381, 0.010433, 0.012475, 0.013538, 0.011157],
[0.015124, 0.01476, 0.010729, 0.013529, 0.014329, 0.011998],
[0.015488, 0.015054, 0.011138, 0.013985, 0.014736, 0.012324],
[0.015705, 0.015443, 0.010671, 0.013993, 0.014869, 0.012063],
[0.016168, 0.015455, 0.010195, 0.013577, 0.01459, 0.011706],
[0.016258, 0.015467, 0.010143, 0.013595, 0.014571, 0.011665],
[0.016137, 0.01536, 0.010371, 0.013549, 0.014576, 0.011645],
[0.016281, 0.015662, 0.011629, 0.013974, 0.014869, 0.012527],
[0.016463, 0.015933, 0.011581, 0.014226, 0.015114, 0.012542],
[0.016545, 0.016235, 0.011571, 0.014575, 0.015352, 0.012759],
[0.016747, 0.01701, 0.011548, 0.015126, 0.016069, 0.012768],
[0.017063, 0.017375, 0.01111, 0.015449, 0.016481, 0.01281],
[0.017179, 0.016867, 0.011395, 0.015158, 0.015974, 0.012788],
[0.017423, 0.016612, 0.011557, 0.01472, 0.015579, 0.012645],
[0.017638, 0.016349, 0.011629, 0.014689, 0.015538, 0.012802],
[0.017717, 0.016383, 0.012357, 0.015243, 0.01585, 0.013627],
[0.017514, 0.016749, 0.012662, 0.015337, 0.015993, 0.013729],
[0.017574, 0.01716, 0.012371, 0.015506, 0.016457, 0.013745],
[0.017798, 0.017605, 0.012138, 0.015517, 0.016602, 0.013619],
[0.017733, 0.0174, 0.013133, 0.01583, 0.016581, 0.014205],
[0.017653, 0.017294, 0.013429, 0.015871, 0.0165, 0.014418],
[0.017737, 0.017733, 0.013586, 0.016124, 0.016917, 0.014603],
[0.017914, 0.017887, 0.0136, 0.016571, 0.017136, 0.014755],
[0.018387, 0.017757, 0.013424, 0.016127, 0.016855, 0.014476],
[0.018591, 0.017573, 0.013243, 0.01588, 0.016652, 0.01407],
[0.018337, 0.017419, 0.013562, 0.01572, 0.016474, 0.014347],
[0.018416, 0.017435, 0.014281, 0.016476, 0.016862, 0.014972],
[0.018418, 0.017732, 0.014071, 0.016525, 0.016983, 0.014682],
[0.01849, 0.017845, 0.013833, 0.016314, 0.01694, 0.01445],
[0.018627, 0.01799, 0.014005, 0.016806, 0.017269, 0.014918],
[0.018777, 0.018045, 0.013281, 0.016939, 0.017367, 0.014484],
[0.018698, 0.017938, 0.013533, 0.016652, 0.017305, 0.014382],
[0.018778, 0.018239, 0.014281, 0.016906, 0.017567, 0.015076],
[0.018799, 0.018431, 0.014938, 0.017411, 0.017867, 0.015658],
[0.0188, 0.018575, 0.015324, 0.017595, 0.018036, 0.015844],
[0.018929, 0.018767, 0.014938, 0.017563, 0.018055, 0.015785],
[0.018939, 0.018812, 0.015005, 0.017618, 0.018124, 0.015857],
[0.019059, 0.01862, 0.014852, 0.0176, 0.017905, 0.01557],
[0.019257, 0.018618, 0.014738, 0.017451, 0.017833, 0.015652],
[0.019416, 0.018546, 0.01409, 0.016893, 0.017538, 0.014951],
[0.019318, 0.018471, 0.014362, 0.016687, 0.017414, 0.01519],
[0.019271, 0.018512, 0.015429, 0.017065, 0.017688, 0.015943],
[0.019285, 0.019001, 0.015671, 0.018065, 0.018498, 0.016567],
[0.019295, 0.019383, 0.014952, 0.018432, 0.0188, 0.016085],
[0.019361, 0.01918, 0.014633, 0.017771, 0.018276, 0.015465],
[0.019265, 0.018945, 0.015171, 0.017794, 0.01835, 0.015837],
[0.019379, 0.01916, 0.015352, 0.01847, 0.018836, 0.016407],
[0.01959, 0.019505, 0.015352, 0.018842, 0.019202, 0.016754],
[0.019758, 0.019793, 0.014095, 0.01862, 0.019119, 0.015783],
[0.019676, 0.019568, 0.013995, 0.017971, 0.018595, 0.015486],
[0.01963, 0.01934, 0.015057, 0.01792, 0.018467, 0.015984],
[0.019581, 0.019292, 0.01559, 0.018035, 0.0184, 0.016158],
[0.019635, 0.019188, 0.015752, 0.018145, 0.018355, 0.016318],
[0.019547, 0.019318, 0.015776, 0.018268, 0.018507, 0.016453],
[0.019677, 0.019464, 0.016305, 0.018319, 0.0186, 0.01659],
[0.019778, 0.019404, 0.016843, 0.018613, 0.018843, 0.017079],
[0.019819, 0.019561, 0.017024, 0.018929, 0.018955, 0.017431],
[0.019768, 0.019676, 0.016262, 0.019014, 0.019079, 0.016991],
[0.019957, 0.019755, 0.016214, 0.018649, 0.019036, 0.016726],
[0.019959, 0.019654, 0.016371, 0.019062, 0.019221, 0.017201],
[0.02008, 0.019857, 0.016781, 0.019207, 0.019376, 0.017295],
[0.020069, 0.019738, 0.016281, 0.018985, 0.019081, 0.016856],
[0.019953, 0.019532, 0.016119, 0.018708, 0.018881, 0.016875],
[0.019765, 0.019636, 0.015905, 0.018779, 0.019062, 0.016878],
[0.019587, 0.019821, 0.016633, 0.018944, 0.01919, 0.017198],
[0.019693, 0.019948, 0.01649, 0.019085, 0.019395, 0.01717],
[0.019741, 0.019862, 0.016748, 0.019115, 0.019271, 0.01723],
[0.019812, 0.019871, 0.016481, 0.018808, 0.018983, 0.016848],
[0.019936, 0.019561, 0.016533, 0.018396, 0.018729, 0.016825],
[0.020027, 0.019321, 0.01621, 0.017937, 0.018288, 0.016388],
[0.020056, 0.019204, 0.016114, 0.017727, 0.018195, 0.016195],
[0.020058, 0.018943, 0.016357, 0.017985, 0.018236, 0.01649],
[0.019923, 0.019195, 0.017005, 0.018223, 0.018424, 0.016849],
[0.019809, 0.019407, 0.016114, 0.018281, 0.018562, 0.016419],
[0.019729, 0.019442, 0.015133, 0.017976, 0.018464, 0.015677],
[0.019728, 0.019005, 0.014519, 0.017419, 0.017838, 0.015188],
[0.019813, 0.018613, 0.014938, 0.01724, 0.017655, 0.015381],
[0.019859, 0.018724, 0.015081, 0.017532, 0.017969, 0.015801],
[0.019646, 0.018875, 0.015852, 0.017913, 0.018271, 0.016219],
[0.019451, 0.019054, 0.015967, 0.018046, 0.018355, 0.016357],
[0.019521, 0.01917, 0.01589, 0.01786, 0.018286, 0.0162],
[0.019614, 0.019102, 0.016219, 0.018123, 0.018417, 0.016581],
[0.019803, 0.019245, 0.0161, 0.017764, 0.018279, 0.016318],
[0.019889, 0.018998, 0.015933, 0.017923, 0.018195, 0.016347],
[0.019893, 0.019064, 0.016343, 0.018067, 0.018314, 0.016693],
[0.019912, 0.019431, 0.016376, 0.018367, 0.018717, 0.016588],
[0.019899, 0.019743, 0.016029, 0.018602, 0.01899, 0.016446],
[0.019772, 0.019589, 0.015324, 0.018263, 0.018719, 0.016158],
[0.019801, 0.01932, 0.015238, 0.017868, 0.018388, 0.016135],
[0.019721, 0.018933, 0.01469, 0.017599, 0.018117, 0.01544],
[0.019832, 0.018704, 0.014095, 0.017154, 0.017752, 0.014774],
[0.019828, 0.018601, 0.013924, 0.016846, 0.017583, 0.014615],
[0.019707, 0.018561, 0.0143, 0.016846, 0.017605, 0.01487],
[0.019564, 0.018521, 0.014405, 0.016855, 0.01746, 0.015111],
[0.019482, 0.018535, 0.015048, 0.017351, 0.017621, 0.015788],
[0.019441, 0.018825, 0.015595, 0.018055, 0.018105, 0.016333],
[0.01953, 0.019002, 0.015924, 0.018292, 0.018412, 0.016667],
[0.019526, 0.018902, 0.015895, 0.018045, 0.018238, 0.016474],
[0.019456, 0.018877, 0.01501, 0.01774, 0.017938, 0.01577],
[0.019503, 0.018587, 0.014438, 0.017474, 0.017679, 0.015358],
[0.019586, 0.018382, 0.013662, 0.017221, 0.017471, 0.014636],
[0.019592, 0.017979, 0.013338, 0.01603, 0.01671, 0.013811],
[0.019547, 0.017705, 0.013729, 0.016171, 0.016631, 0.01401],
[0.019416, 0.017282, 0.013795, 0.015801, 0.01621, 0.013838],
[0.019318, 0.016994, 0.012705, 0.015425, 0.015893, 0.013135],
[0.019224, 0.017236, 0.0128, 0.015686, 0.016176, 0.013503],
[0.018935, 0.017605, 0.013386, 0.01611, 0.016626, 0.013883],
[0.019005, 0.017586, 0.013467, 0.016296, 0.016707, 0.013973],
[0.018855, 0.017298, 0.0137, 0.015658, 0.016107, 0.013819],
[0.018807, 0.017052, 0.01341, 0.01561, 0.015907, 0.013544],
[0.018779, 0.016719, 0.01211, 0.014786, 0.015345, 0.012363],
[0.018691, 0.016757, 0.01109, 0.014325, 0.015243, 0.011609],
[0.018516, 0.016698, 0.012248, 0.014795, 0.015457, 0.012483],
[0.018457, 0.01703, 0.013519, 0.01575, 0.016131, 0.013792],
[0.018529, 0.016998, 0.013286, 0.015783, 0.016052, 0.01343],
[0.018568, 0.016583, 0.013062, 0.015215, 0.015533, 0.012994],
[0.018709, 0.016536, 0.012895, 0.014874, 0.015274, 0.012854],
[0.018651, 0.016313, 0.012319, 0.014576, 0.01499, 0.01241],
[0.018505, 0.016187, 0.011838, 0.014393, 0.014907, 0.012262],
[0.018201, 0.016365, 0.01129, 0.014238, 0.015017, 0.011736],
[0.018067, 0.016717, 0.011057, 0.014357, 0.015336, 0.011732],
[0.018144, 0.016593, 0.012329, 0.014901, 0.015471, 0.012486],
[0.01837, 0.016233, 0.012033, 0.014363, 0.014819, 0.011982],
[0.017969, 0.015974, 0.011019, 0.013906, 0.014643, 0.011401],
[0.01779, 0.015877, 0.011129, 0.014383, 0.014836, 0.011667],
[0.017952, 0.015788, 0.012033, 0.014351, 0.014821, 0.012109],
[0.018156, 0.015985, 0.012652, 0.014582, 0.014962, 0.012543],
[0.018203, 0.015279, 0.011995, 0.014088, 0.014443, 0.011795],
[0.017879, 0.014385, 0.010195, 0.01255, 0.013124, 0.010127],
[0.017754, 0.013657, 0.009519, 0.011694, 0.012298, 0.009417],
[0.017408, 0.013742, 0.009186, 0.011564, 0.012293, 0.009094],
[0.017207, 0.013761, 0.009133, 0.011281, 0.012224, 0.009179],
[0.017103, 0.014143, 0.009952, 0.011963, 0.01265, 0.009793],
[0.017158, 0.01447, 0.010776, 0.012549, 0.013079, 0.010691],
[0.016928, 0.014414, 0.010376, 0.01274, 0.013314, 0.010512],
[0.016424, 0.014601, 0.009376, 0.012362, 0.013088, 0.009622],
[0.016401, 0.013867, 0.009086, 0.01172, 0.012445, 0.009208],
[0.016422, 0.013846, 0.009429, 0.012001, 0.012705, 0.009488],
[0.016831, 0.01424, 0.010424, 0.01249, 0.0131, 0.010379],
[0.017054, 0.014279, 0.0105, 0.012885, 0.013162, 0.01027],
[0.016439, 0.013832, 0.009552, 0.011918, 0.012505, 0.009402],
[0.015825, 0.013394, 0.009162, 0.011118, 0.011826, 0.00885],
[0.015627, 0.013168, 0.008862, 0.011094, 0.011795, 0.008933],
[0.015871, 0.012768, 0.009152, 0.01126, 0.011788, 0.009214],
[0.016074, 0.011694, 0.008719, 0.010502, 0.010838, 0.00835],
[0.015771, 0.011519, 0.008514, 0.010082, 0.010536, 0.008058],
[0.015614, 0.01148, 0.008224, 0.010127, 0.010521, 0.008031],
[0.015051, 0.01197, 0.008438, 0.010462, 0.01101, 0.008318],
[0.014631, 0.011526, 0.008386, 0.010214, 0.010748, 0.008249],
[0.014186, 0.011938, 0.008162, 0.010451, 0.011, 0.00822],
[0.014418, 0.011642, 0.00779, 0.0098, 0.0105, 0.007809],
[0.014457, 0.011927, 0.007448, 0.009762, 0.01065, 0.00757],
[0.01448, 0.011419, 0.007814, 0.009425, 0.010248, 0.007595],
[0.014345, 0.011358, 0.008005, 0.009983, 0.010612, 0.008008],
[0.014243, 0.011212, 0.00809, 0.010363, 0.010686, 0.008071],
[0.014039, 0.011039, 0.007929, 0.010029, 0.010319, 0.00797],
[0.014096, 0.011281, 0.008614, 0.010179, 0.010555, 0.008367],
[0.014065, 0.01112, 0.007543, 0.009998, 0.010548, 0.00767],
[0.013486, 0.010419, 0.005981, 0.008287, 0.009179, 0.00608],
[0.013031, 0.010869, 0.006552, 0.008895, 0.009645, 0.006668],
[0.013697, 0.011132, 0.007548, 0.009493, 0.010069, 0.007393],
[0.013997, 0.01123, 0.007862, 0.009807, 0.010319, 0.007778],
[0.013559, 0.010918, 0.007638, 0.009696, 0.010207, 0.007561],
[0.013225, 0.010029, 0.007048, 0.008812, 0.009221, 0.006808],
[0.013519, 0.010074, 0.006552, 0.008718, 0.009255, 0.00652],
[0.013245, 0.01003, 0.006771, 0.008704, 0.009281, 0.006673],
[0.013193, 0.009656, 0.006933, 0.008676, 0.009012, 0.006658],
[0.013148, 0.00938, 0.006262, 0.008138, 0.008717, 0.006098],
[0.012884, 0.009192, 0.006424, 0.007794, 0.008424, 0.006136],
[0.012749, 0.009283, 0.006176, 0.007888, 0.008529, 0.006156],
[0.011884, 0.009677, 0.006767, 0.0087, 0.009155, 0.006664],
[0.01148, 0.009482, 0.006638, 0.008281, 0.008798, 0.006473],
[0.011775, 0.009177, 0.006267, 0.007915, 0.008457, 0.006019],
[0.011703, 0.009302, 0.006295, 0.007898, 0.00845, 0.005938],
[0.01181, 0.009289, 0.006948, 0.008345, 0.008729, 0.006593],
[0.012207, 0.009271, 0.006995, 0.00816, 0.008607, 0.006561],
[0.012137, 0.009202, 0.006443, 0.008363, 0.008776, 0.00652],
[0.011707, 0.008574, 0.005976, 0.007802, 0.008229, 0.005899],
[0.011552, 0.0084, 0.005457, 0.0072, 0.007814, 0.00548],
[0.012004, 0.008992, 0.00609, 0.007875, 0.008471, 0.006175],
[0.011993, 0.009167, 0.006248, 0.008014, 0.00865, 0.006176],
[0.01153, 0.009773, 0.006367, 0.008455, 0.009145, 0.006374],
[0.011896, 0.009225, 0.006814, 0.008319, 0.008764, 0.006564],
[0.011666, 0.00897, 0.006267, 0.00803, 0.0085, 0.00615],
[0.011368, 0.009163, 0.005957, 0.00803, 0.008588, 0.005959],
[0.011085, 0.009443, 0.005976, 0.008062, 0.008743, 0.006027],
[0.011072, 0.009193, 0.006638, 0.008548, 0.008836, 0.0066],
[0.011602, 0.00905, 0.005833, 0.00799, 0.008619, 0.005856],
[0.01244, 0.009117, 0.006814, 0.008386, 0.008833, 0.006632],
[0.011874, 0.009242, 0.005843, 0.007852, 0.008502, 0.005869],
[0.011349, 0.009207, 0.00621, 0.008037, 0.008669, 0.006128],
[0.011422, 0.008271, 0.006067, 0.007585, 0.008062, 0.005868],
[0.011062, 0.00796, 0.004752, 0.006706, 0.007405, 0.004725],
[0.011107, 0.008276, 0.005295, 0.007098, 0.007683, 0.005263],
[0.01091, 0.008195, 0.00541, 0.0072, 0.007717, 0.005366],
[0.01112, 0.008152, 0.0054, 0.00717, 0.00769, 0.005263],
[0.010993, 0.007307, 0.00501, 0.006412, 0.006886, 0.004797],
[0.010937, 0.007689, 0.004933, 0.006685, 0.007224, 0.004825],
[0.010624, 0.007639, 0.00451, 0.006338, 0.006933, 0.004482],
[0.010169, 0.008821, 0.00479, 0.007113, 0.007929, 0.004914],
[0.010332, 0.009127, 0.005543, 0.007785, 0.008443, 0.005641],
[0.010528, 0.007977, 0.005586, 0.007189, 0.007598, 0.005354],
[0.010656, 0.007851, 0.005152, 0.006639, 0.007274, 0.004995],
[0.010928, 0.009048, 0.005333, 0.007562, 0.008338, 0.005418],
[0.011414, 0.008946, 0.005629, 0.007704, 0.008281, 0.005572],
[0.011492, 0.008282, 0.005048, 0.007038, 0.007724, 0.005001],
[0.01091, 0.008123, 0.005086, 0.007113, 0.007712, 0.005007],
[0.01069, 0.008071, 0.00519, 0.006805, 0.007502, 0.005049],
[0.010311, 0.007923, 0.004786, 0.006554, 0.007293, 0.004736],
[0.010774, 0.007794, 0.0048, 0.007061, 0.007612, 0.004977],
[0.010538, 0.007211, 0.004543, 0.006189, 0.006793, 0.004352],
[0.010193, 0.007412, 0.004252, 0.006079, 0.006833, 0.004265],
[0.010178, 0.008175, 0.0045, 0.006533, 0.007424, 0.00459],
[0.010176, 0.008385, 0.005262, 0.007142, 0.007857, 0.005239],
[0.009689, 0.00764, 0.004762, 0.006556, 0.007105, 0.004494],
[0.009405, 0.007095, 0.004105, 0.005948, 0.006612, 0.003906],
[0.010011, 0.006248, 0.004152, 0.005548, 0.005948, 0.003723],
[0.010304, 0.006649, 0.003848, 0.005436, 0.006088, 0.0036],
[0.009848, 0.007485, 0.004376, 0.006337, 0.006964, 0.004437],
[0.010184, 0.008096, 0.005119, 0.007088, 0.007724, 0.005085],
[0.009652, 0.00765, 0.004686, 0.006511, 0.007274, 0.004692],
[0.009625, 0.008175, 0.004614, 0.006756, 0.007564, 0.004848],
],
dtype=np.float64,
)
pei_params = {
"centroid": pei_centroids,
"population": pei_population,
"commuters": pei_commuters,
"humidity": pei_humidity,
}| state | population | FL | GA | MD | NC | SC | VA |
| FL | 18811310 | 7993452 | 13805 | 2410 | 2938 | 1783 | 3879 |
| GA | 9687653 | 15066 | 4091461 | 966 | 6057 | 20318 | 2147 |
| MD | 5773552 | 949 | 516 | 2390255 | 947 | 91 | 122688 |
| NC | 9535483 | 3005 | 5730 | 1872 | 4121984 | 38081 | 29487 |
| SC | 4625364 | 1709 | 23513 | 630 | 64872 | 1890853 | 1620 |
| VA | 8001024 | 1368 | 1175 | 68542 | 16869 | 577 | 3567788 |
# epymorph's USAGE.ipynb, at tag v1.2.0 of https://github.com/NAU-CCL/epymorph: the cell that builds the RUME
# and the cell that runs it, unchanged, in order. The cells that draw the IPM, print, and plot are left out.
from epymorph.kit import *
from epymorph.adrio import acs5, commuting_flows, us_tiger
from epymorph.data.pei import pei_humidity
rume = SingleStrataRUME.build(
# Load the Pei IPM
ipm=ipm.Pei(),
# Load the Pei MM
mm=mm.Pei(),
# Describe the geographic scope of our simulation:
scope=StateScope.in_states(["FL", "GA", "MD", "NC", "SC", "VA"], year=2015),
# Create a SingleLocation initializer
init=init.SingleLocation(location=0, seed_size=10_000),
# Set the time-frame to simulate
time_frame=TimeFrame.of("2015-01-01", 150),
# Provide model parameter values
params={
"theta": 0.1,
"move_control": 0.9,
"infection_duration": 4,
"immunity_duration": 90,
# Geographic data can be loaded using ADRIOs
"centroid": us_tiger.InternalPoint(),
"population": acs5.Population(),
"commuters": commuting_flows.Commuters(),
# Except this one...
# We don't have a humidity ADRIO yet, so we need to load it another way.
# For now, we have the Pei data files in the project as a convenience.
"humidity": pei_humidity,
},
)
# Construct a simulator for the RUME
sim = BasicSimulator(rume)
# Run inside a sim_messaging context to display a nice progress bar
with sim_messaging():
# Run and save the simulation Output object for later
out = sim.run(
# Use a seeded RNG (for the sake of keeping this notebook's results consistent)
# This parameter is optional; by default a new RNG is constructed for each run
# using numpy's default_rng
rng_factory=default_rng(1)
)Starsim
model.py
"""
Influenza in six southern US states, driven by humidity (Pei et al. 2018): the Starsim version of the
example in epymorph's USAGE notebook, its IPM and MM `Pei`
An SIRS in which transmission falls as absolute humidity rises, in each state on each day, with two
kinds of travelers for a third of each day: 90% of commuters, and a tenth of the mean number of
commuters between each pair of states in each direction (dispersers).
"""
import os
import numpy as np
import pandas as pd
import starsim as ss
folder = os.path.join(os.path.dirname(__file__), 'original')
states = pd.read_csv(os.path.join(folder, 'states.csv'), index_col='state')
humidity = pd.read_csv(os.path.join(folder, 'humidity.csv'), index_col='date')
commuters = states[states.index].values
travel = (0.9*commuters + 0.1*((commuters + commuters.T)//2))/states.population.values[:, None]/3 # Shares of time away
places = ss.geo.geography(names=states.index, populations=states.population)
S, I, R = ss.states('S I R')
H = ss.by(patch=[ss.timeseries(humidity[state].to_dict()) for state in states.index])
D, L = ss.days(4), ss.days(90)
flu = ss.Disease(
infection = S >> I @ (np.exp(-180*H + np.log(2.0 - 1.3)) + 1.3)/D*I,
recovery = I >> R @ D,
waning = R >> S @ L,
init_n = dict(I=ss.by(patch={'FL': 10_000})),
name = 'flu',
)
flu.stratify(patch=ss.levels(places, mobility=ss.geo.Mobility(matrix=travel)))
sim = ss.Sim(flu, start='2015-01-01', dur=ss.days(150), solver='tau', verbose=0)
if __name__ == '__main__':
sim.run()
sim.results.flu.I.by('patch').plot()What was declared and what was not
Everything is declared. The humidity is a time series for each state, ss.by(patch=[ss.timeseries(...), ...]), and the transmission rate is the IPM’s own expression of it, np.exp() included, so it is worked out for each state on each day. The travel is a matrix of the share of time spent in each other state, one line of NumPy from the commuting data. In the original, the IPM is declared (in SymPy), and the two movement clauses are Python methods that draw the travelers each day.
Findings
- The importer flattens the humidity.
ss.import_from()on this RUME takes a parameter that varies both in time and by place at each place’s mean, and lists that as untranslated, so its runs peak a week late (day 102, against 95), with the seasonal fall in transmission lost. v4 can say this parameter as it is, asmodel.pydoes, so the importer could write it as a time series for each place. - Movement is the expected number of travelers. The original draws its commuters (binomially, 90% of them) and its dispersers (as Poisson, a tenth of the mean number of commuters each way) afresh each day. Starsim moves the expected share. At most 2.1% of a state’s people travel each day (from Maryland, mostly to Virginia), and the runs agree.
- The notebook’s data. It takes the population from
acs5.Population(), which needs a Census API key, and the commuters fromcommuting_flows.Commuters()(the ACS commuting flows for 2011 to 2015). Here both come from the data that epymorph bundles for this example (epymorph.data.pei, kept asoriginal/pei_data.py), copied from Pei’s data files, as the humidity already does in the notebook, so the comparison runs offline and needs no key. The ADRIO’s commuters are 2% to 4% more than the bundled ones; the centroids that the notebook also passes are not used by the Pei IPM or MM. - Two kinds of travelers are one matrix. The commuters and the dispersers leave and return at the same steps, so in Starsim they add up to one matrix of shares of time, and the model doesn’t need to keep them apart.
epymorph: SIRS in Arizona’s counties, with commuting by distance
The centroids exercise of epymorph’s vignette “Intro to Movement Models”: an SIRS in Arizona’s 15 counties, 7.2 million people, starting from 5 infectious people in Maricopa County, with a movement model in which 15% of people commute each day for a third of the day, to a county drawn with a weight that falls off with distance. Their own county is one of those drawn, and the others are far away for the kernel’s scale of 20 miles, so people spend only 0.06% to 0.8% of their time in other counties. See the review.
- Framework: epymorph 1.2.0 (GPL-3.0)
- Language: Python
- Source: https://www.docs.epimorph.org/demo/G2.L0.1-MM_intro.html
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 29 | 10 | The original’s IPM (SIRS) and MM (Centroids) come from epymorph’s library; the Starsim version writes both |
| One run | first 410 ms, rerun 320 ms | first 16 ms, rerun 4 ms (Rust) | Each is one stochastic run of 190 days. A first run excludes imports; the original’s includes building the RUME, with the ADRIOs’ values already fetched |
| Results | Peak 415,899 infectious on day 96 (the mean of the 38 runs of 40 that took off); 3,273,531 recovered at the end | Peak 400,150 on day 93 (39 of 40); 3,240,514 recovered | Agree to within 8% at the peak in each county, and 5 days in its timing; 1.0% in the number recovered. Starsim is 1 to 5 days early in every county; see the findings |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (epymorph)
| geoid | name | population | longitude | latitude |
| 04001 | Apache, AZ | 66054 | -109.4901722 | 35.385084 |
| 04003 | Cochise, AZ | 125504 | -109.7751627 | 31.8401287 |
| 04005 | Coconino, AZ | 144705 | -111.7737277 | 35.8296919 |
| 04007 | Gila, AZ | 53419 | -110.8118696 | 33.7896177 |
| 04009 | Graham, AZ | 38453 | -109.8783103 | 32.9318277 |
| 04011 | Greenlee, AZ | 9483 | -109.2423231 | 33.2388723 |
| 04012 | La Paz, AZ | 16681 | -114.038806 | 33.7276104 |
| 04013 | Maricopa, AZ | 4430871 | -112.4989296 | 33.3451756 |
| 04015 | Mohave, AZ | 214229 | -113.7496891 | 35.717705 |
| 04017 | Navajo, AZ | 107110 | -110.3210248 | 35.3907852 |
| 04019 | Pima, AZ | 1042393 | -111.7836574 | 32.128038 |
| 04021 | Pinal, AZ | 433338 | -111.3663394 | 32.9185207 |
| 04023 | Santa Cruz, AZ | 47838 | -110.8452278 | 31.525733 |
| 04025 | Yavapai, AZ | 237830 | -112.5772247 | 34.631071 |
| 04027 | Yuma, AZ | 204374 | -113.910905 | 32.7739424 |
# epymorph demo vignette "Intro to Movement Models" (G2.L0.1-MM_intro), https://www.docs.epimorph.org/demo/G2.L0.1-MM_intro.html
# The cells of exercise 1 that set up the model, and those of exercise 2 that use the centroids movement model, unchanged, in order;
# the cells of exercise 1 that only run and plot the flat movement model, and exercise 3, are left out.
import matplotlib.pyplot as plt
import numpy as np
from epymorph.kit import *
from epymorph.adrio import acs5
my_scope = CountyScope.in_states(['AZ'], year=2022)
my_scope.labels
# You can also see their FIPS code:
# my_scope.node_ids
from epymorph.data.ipm.sirs import SIRS
sirs_ipm = SIRS()
sirs_ipm.diagram()
# Maricopa is the 8th node in the scope (i.e., index 7)
my_init = init.SingleLocation(location=7, seed_size=5)
time = TimeFrame.of("2022-01-01", duration_days=190)
comm_prop = 0.15
beta = 0.4
gam = 1 / 4
xi = 1 / 200
# From the choropleth cell of exercise 1, which that of exercise 2 also uses
def log10_transform(data_df):
# we have to be careful to avoid log(0)!
log_value = data_df["data"].apply(lambda x: np.log10(x) if x > 0 else nan)
return data_df.assign(data=log_value)
from epymorph.data.mm.centroids import Centroids
from epymorph.adrio import us_tiger
rume = SingleStrataRUME.build(
scope=my_scope,
ipm=sirs_ipm,
mm=Centroids(),
init=my_init,
time_frame=time,
params={
#For IPM
'beta': beta,
'gamma': gam,
'xi': xi,
#For MM
'population': acs5.Population(),
'commuter_proportion': comm_prop,
'centroid': us_tiger.InternalPoint(),
'phi': 20.0,
}
)
print(rume.params_description())
sim = BasicSimulator(rume)
with sim_messaging(live = False):
out = sim.run(
rng_factory=default_rng(5),
)
out.plot.line(
geo=out.rume.scope.select.all(),
time=out.rume.time_frame.select.all().group("day").agg(),
quantity=out.rume.ipm.select.compartments("I"),
title="Infectious in AZ Counties",
legend="outside",
)
out.map.choropleth(
geo=rume.scope.select.all(),
time=rume.time_frame.select.rangex("2022-04-01", "2022-05-01").agg(compartments="max"),
quantity=rume.ipm.select.compartments("I"),
transform=log10_transform,
vmax = 6.0,
vmin = 2.5,
)Starsim
model.py
"""
SIRS in Arizona's 15 counties, with commuting that falls off with distance: the Starsim version of epymorph's
vignette "Intro to Movement Models" (exercise 2, the centroids movement model)
Each day 15% of people commute for a third of the day, to a county drawn with a weight of exp(-distance/phi),
phi = 20 miles, which includes their own; so the shares of their time they spend in the other counties are
15% times a third times those weights.
"""
import os
import numpy as np
import pandas as pd
import starsim as ss
counties = pd.read_csv(os.path.join(os.path.dirname(__file__), 'original', 'arizona_counties.csv'))
places = ss.geo.geography(names=counties['name'], populations=counties['population'], lat=counties['latitude'], lon=counties['longitude'])
kernel = np.exp(-places.distances/(20*1.609344)) # phi = 20 miles, in km
commuting = ss.geo.Mobility(matrix=0.15*(1/3)*kernel/kernel.sum(axis=1, keepdims=True))
S, I, R = ss.states('S I R')
sirs = ss.Disease(infection=S >> I @ 0.4*I, recovery=I >> R @ ss.days(4), waning=R >> S @ ss.days(200), init_n=dict(I=ss.by(patch={'Maricopa, AZ': 5})), name='sirs')
sirs.stratify(patch=ss.levels(places, mobility=commuting))
sim = ss.Sim(sirs, start='2022-01-01', dur=ss.days(190), solver='tau', verbose=0)
if __name__ == '__main__':
sim.run()
sim.results.sirs.I.by('patch').plot()What was declared and what was not
Everything is declared: the counties as a geography, the SIRS, and the commuting as a matrix of the share of time that people spend in each other county. The matrix is two lines of NumPy, the centroids kernel exp(-distance/phi) normalized by row, since Starsim’s movement models are gravity and radiation and have no kernel that decays exponentially with distance. In the original the movement is a clause that draws the commuters each day, written in Python.
Findings
- Fixed steps delay the original. epymorph draws each event as Poisson over each of its two steps a day, a third and two thirds of a day, with the rates fixed over the step, so its epidemics grow more slowly than in continuous time. In one county with the same numbers, deterministic steps of a third and two thirds of a day put the peak 3 days later and 2% higher than continuous time, which is about the difference in the table. Starsim’s
solver='tau'is close to exact, and Starsim has no solver that leaps at fixed steps of given lengths, so the original’s own steps can’t be reproduced. - Commuting is a share of time, not a time of day. The original sends commuters away for the first third of each day and brings them home for the rest; Starsim’s commuting has people spend the same share of their time away, mixed into one force of infection each day. With at most 0.8% of time away, the two are close here, but the time of day can’t be expressed.
- The importer reads this RUME whole.
ss.import_from()on the RUME (written withstarsim/converters/epymorph.py) lists nothing as untranslated, and the mean of 40 runs peaks at 429,837 on day 94, within the same tolerance. It finds the movement by running the clause for four weeks and averaging it, which here gives the same time away from each county asmodel.py, to within 2%. - The data needs a Census API key.
acs5.Population()now needs a key, and the Census API refuses requests without one. The populations here are the same estimates (ACS 5-year, 2022, B01001_001E) read from the Census Bureau’s summary file, and the centroids come from epymorph’s own TIGER ADRIO, which needs no key;fetch.pygets both and keeps them inoriginal/arizona_counties.csv, so the comparison runs offline. - Dying out. With 5 people infectious at the start, the epidemic dies out at once in about one run in twenty, in both (2 of 40 in the original, 1 of 40 in Starsim). The mean of all the runs depends mostly on how many die out, so both sides compare the runs that take off.
- Distances. epymorph measures them in miles and Starsim in kilometers, so
phiof 20 miles is written as 32.2 km.
flepiMoP: SEIR in two subpopulations
A deterministic SEIR epidemic in two provinces, of 9,000 and 1,000 people, coupled by daily travel, over seven months. It starts with 5 exposed people in the large province and reaches the small one only through mobility. The whole model is one YAML configuration and three small CSV files: this is the configuration that flepiMoP’s quick start guide runs first.
- Framework: flepimop gempyor 2.1+ad62f2d0 (GPL-3.0)
- Language: YAML (a flepiMoP configuration)
- Source: https://github.com/HopkinsIDD/flepiMoP/blob/ad62f2d00082de5b60e62100be7b24fddc6931da/examples/tutorials/config_sample_2pop.yml, with model_input/geodata_sample_2pop.csv, mobility_sample_2pop.csv, and ic_2pop.csv from the same folder
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 42, plus 10 lines of CSV | 15 | |
| One run | first 8 s, rerun 7.7 s | first 13 ms, rerun 0.38 ms (Rust) | The original can run only as a whole command, so its first run and rerun are both that command, about 7 s of which is compiling, on every run |
| Results | Agree exactly (tolerance 1e-6 people): every compartment in both provinces, on every day, within 3e-12 people |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (flepimop)
name: sample_2pop
setup_name: minimal
start_date: 2020-02-01
end_date: 2020-08-31
nslots: 1
subpop_setup:
geodata: model_input/geodata_sample_2pop.csv
mobility: model_input/mobility_sample_2pop.csv
initial_conditions:
method: SetInitialConditions
initial_conditions_file: model_input/ic_2pop.csv
allow_missing_subpops: TRUE
allow_missing_compartments: TRUE
compartments:
infection_stage: ["S", "E", "I", "R"]
seir:
integration:
method: rk4
dt: 1
parameters:
sigma:
value: 1 / 4
gamma:
value: 1 / 5
Ro:
value: 2.5
transitions:
- source: ["S"]
destination: ["E"]
rate: ["Ro * gamma"]
proportional_to: [["S"],["I"]]
proportion_exponent: ["1","1"]
- source: ["E"]
destination: ["I"]
rate: ["sigma"]
proportional_to: ["E"]
proportion_exponent: ["1"]
- source: ["I"]
destination: ["R"]
rate: ["gamma"]
proportional_to: ["I"]
proportion_exponent: ["1"]| subpop | population |
| large_province | 9000 |
| small_province | 1000 |
| subpop | mc_name | amount |
| large_province | E | 5 |
| large_province | S | 8995 |
| small_province | S | 1000 |
| ori | dest | amount |
| large_province | small_province | 100 |
| small_province | large_province | 20 |
Starsim
model.py
"""
The flepiMoP two-population tutorial (docs/models/flepimop/seir_2pop), in Starsim: an SEIR in a large
and a small province, whose residents spend part of each day in the other
"""
import starsim as ss
pop = dict(large_province=9000, small_province=1000)
commute = [[0, 100/9000], [20/1000, 0]] # The share of each province's residents who travel to the other
gamma = 1/5
S, E, I, R = ss.states('S E I R')
seir = ss.Disease(
infection = ss.Transition(S, E, rate=2.5*gamma * I),
onset = ss.Transition(E, I, rate=1/4),
recovery = ss.Transition(I, R, rate=gamma),
init_n = dict(E=ss.by(patch=[5, 0])),
)
away = [[0.5*share for share in row] for row in commute] # Travelers are away for half the day
seir.stratify(patch=ss.levels(pop, mobility=ss.geo.Mobility(away, kind='visit')))
sim = ss.Sim(seir, total_pop=sum(pop.values()), start='2020-02-01', stop='2020-08-31', solver=ss.solvers.ode(substeps=1)) # One RK4 step a day, as in the original
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. The populations and the travel between them are written in the script; the original keeps them in three CSV files, which is easier to change for many places.
Findings
- A third way of coupling places. In flepiMoP, people who travel are exposed to the residents of the place they go to, and do not infect anyone there. Starsim’s commuting has them do both, which brought the epidemic to the small province 12 days sooner.
ss.geo.Mobility(kind='visit')matches the original exactly. - Half a day is hard-coded. flepiMoP multiplies the travel matrix by 0.5; here that is written out.
substeps=1gives the same steps. The original takes one RK4 step a day. By default Starsim subdivides a step when rates are high, which gives a difference of 0.007 people here; withsubsteps=1it takes the same steps as the original and they agree to 12 digits.
flepiMoP: vaccination scenarios in two subpopulations
The two-province SEIR of the wave 1 example with most of what else a flepiMoP configuration can declare for a forward run added on top: a vaccinated compartment with waning, three scenarios defined as modifiers that switch parameters off, outcomes (reported cases, hospital admissions, the number in hospital, and deaths) derived from the incidence of infectiousness by a probability and a delay, and parameter uncertainty, with Ro and the probability of detecting a case drawn from truncated normals for each of ten runs (“slots”) of each scenario. It is flepiMoP’s config_sample_2pop_vaccine_scenarios.yml, which brings together the scenarios, outcomes, and parameter uncertainty that the other forward-simulation tutorials show one at a time, and the configuration that flepiMoP’s own tests of scenarios and outcomes run (tests/seir/test_run_parallel_SEIR.py, tests/outcomes/test_run_parallel_outcomes.py).
- Framework: flepimop gempyor 2.1+ad62f2d0 (GPL-3.0)
- Language: YAML (a flepiMoP configuration)
- Source: https://github.com/HopkinsIDD/flepiMoP/blob/ad62f2d00082de5b60e62100be7b24fddc6931da/examples/tutorials/config_sample_2pop_vaccine_scenarios.yml, with model_input/geodata_sample_2pop.csv, mobility_sample_2pop.csv, and ic_2pop.csv from the same folder
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 141, plus 10 lines of CSV | 33 | Lines over 100 characters count twice. Was 38 before the fixes below |
| 6 runs (3 scenarios of 2) | first 21 s, rerun 21 s | first 770 ms, rerun 330 ms (Numba) | The original can run only as a whole command, so its first run and rerun are both that command, once each; after its first run each takes 2.6 s. Both are timed at 2 runs of each scenario, not the 10 of the results, to keep the benchmark short; the times shown here are still for 30 runs |
| Results, with the original’s draws | Agree exactly, by province (tolerance 1e-9). Run with the values of Ro and of the share of cases detected that the original drew, the mean, 5%, and 95% of all 28 series in each province (5 compartments, 5 incidences, and the 4 outcomes, including the number in hospital) agree within 1e-9 of their largest value in all three scenarios |
||
| Results, with Starsim’s own draws | Agree in distribution (tolerance 30%, since the original has only ten runs, drawn afresh each time it is run). With 100 runs of each scenario, the mean peak of I in the large province is 1,099, 466, and 17 (original, from 10 runs: 1,103, 482, and 15), and the mean deaths are 89.0, 60.0, and 2.2 (88.8, 60.6, and 1.9) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (flepimop)
name: sample_2pop
setup_name: minimal
start_date: 2020-02-01
end_date: 2020-08-31
nslots: 10
subpop_setup:
geodata: model_input/geodata_sample_2pop.csv
mobility: model_input/mobility_sample_2pop.csv
initial_conditions:
method: SetInitialConditions
initial_conditions_file: model_input/ic_2pop.csv
allow_missing_subpops: TRUE
allow_missing_compartments: TRUE
compartments:
infection_stage: ["S", "E", "I", "R", "V"]
seir:
integration:
method: rk4
dt: 1
parameters:
sigma:
value: 1 / 4
gamma:
value: 1 / 5
Ro:
value:
distribution: truncnorm
mean: 2.5
sd: 0.1
a: 1.1
b: 3
omega_pess:
value: 0.02
omega_opt:
value: 0.01
nu_pess:
value: 0.01
nu_opt:
value: 0.03
transitions:
#infections
- source: ["S"]
destination: ["E"]
rate: ["Ro * gamma"]
proportional_to: [["S"],["I"]]
proportion_exponent: ["1","1"]
# progression to infectiousness
- source: ["E"]
destination: ["I"]
rate: ["sigma"]
proportional_to: ["E"]
proportion_exponent: ["1"]
# recovery
- source: ["I"]
destination: ["R"]
rate: ["gamma"]
proportional_to: ["I"]
proportion_exponent: ["1"]
#vaccination (offers complete protection)
- source: ["S"]
destination: ["V"]
rate: ["nu_pess + nu_opt"]
proportional_to: ["S"]
proportion_exponent: ["1"]
# waning of vaccine-induced protection
- source: ["V"]
destination: ["S"]
rate: ["omega_pess + omega_opt"]
proportional_to: ["V"]
proportion_exponent: ["1"]
seir_modifiers:
scenarios:
- no_vax
- pess_vax
- opt_vax
modifiers:
pess_vax_nu: # turn off nu_opt, only nu_pess left
method: SinglePeriodModifier
parameter: nu_opt
period_start_date: 2020-02-01
period_end_date: 2020-08-31
subpop: "all"
value: 0
pess_vax_wane: # turn off omega_opt, only omega_pess left
method: SinglePeriodModifier
parameter: omega_opt
period_start_date: 2020-02-01
period_end_date: 2020-08-31
subpop: "all"
value: 0
pess_vax: # turn off all vaccination
method: StackedModifier
modifiers: ["pess_vax_nu","pess_vax_wane"]
opt_vax_nu: # turn off nu_pess, only nu_opt left
method: SinglePeriodModifier
parameter: nu_pess
period_start_date: 2020-02-01
period_end_date: 2020-08-31
subpop: "all"
value: 0
opt_vax_wane: # turn off omega_pess, only omega_opt left
method: SinglePeriodModifier
parameter: omega_pess
period_start_date: 2020-02-01
period_end_date: 2020-08-31
subpop: "all"
value: 0
opt_vax: # turn off all vaccination
method: StackedModifier
modifiers: ["opt_vax_nu","opt_vax_wane"]
no_vax: # turn off all vaccination
method: StackedModifier
modifiers: ["pess_vax","opt_vax"]
outcomes:
method: delayframe
outcomes:
incidCase: #incidence of detected cases
source:
incidence:
infection_stage: "I"
probability:
value:
distribution: truncnorm
mean: 0.5
sd: 0.05
a: 0.3
b: 0.7
delay:
value: 5
incidHosp: #incidence of hospitalizations
source:
incidence:
infection_stage: "I"
probability:
value: 0.05
delay:
value: 7
duration:
value: 10
name: currHosp # will track number of current hospitalizations (ie prevalence)
incidDeath: #incidence of deaths
source: incidHosp
probability:
value: 0.2
delay:
value: 14
# outcome_modifiers:
# scenarios:
# - test_limits
# modifiers:
# # assume that due to limitations in testing, initially the case detection probability was lower
# test_limits:
# method: SinglePeriodModifier
# parameter: incidCase::probability
# subpop: "all"
# period_start_date: 2020-02-01
# period_end_date: 2020-06-01
# value: 0.5| subpop | population |
| large_province | 9000 |
| small_province | 1000 |
| subpop | mc_name | amount |
| large_province | E | 5 |
| large_province | S | 8995 |
| small_province | S | 1000 |
| ori | dest | amount |
| large_province | small_province | 100 |
| small_province | large_province | 20 |
Starsim
model.py
"""
The flepiMoP vaccination scenarios (docs/models/flepimop/vaccine_scenarios), in Starsim: the SEIR in two
provinces with vaccination and waning, three scenarios that switch parts of it off, reported cases,
hospitalizations, and deaths, and ten runs of each with uncertain R0 and case detection
"""
import starsim as ss
pop = dict(large_province=9000, small_province=1000)
commute = [[0, 100/9000], [20/1000, 0]] # The share of each province's residents who go to the other
gamma = 1/5
Ro = ss.par(2.5, prior=ss.truncnorm(mean=2.5, std=0.1, low=1.1, high=3))
p_case = ss.par(0.5, prior=ss.truncnorm(mean=0.5, std=0.05, low=0.3, high=0.7))
nu_pess = ss.par(0.01)
nu_opt = ss.par(0.03)
omega_pess = ss.par(0.02)
omega_opt = ss.par(0.01)
S, E, I, R, V = ss.states('S E I R V')
infection = S >> E @ Ro*gamma*I
onset = E >> I @ 1/4
recovery = I >> R @ gamma
vaccination = S >> V @ nu_pess + nu_opt
waning = V >> S @ omega_pess + omega_opt
cases = ss.outcome(p_case*onset, delay=ss.days(5))
hosp = ss.outcome(0.05*onset, delay=ss.days(7), dur=ss.constant(10, unit='days'))
deaths = ss.outcome(0.2*hosp, delay=ss.days(14))
transitions = [infection, onset, recovery, vaccination, waning]
seir = ss.Disease(*transitions, cases, hosp, deaths, init_n=dict(E=ss.by(patch=[5, 0])))
away = [[0.5*share for share in row] for row in commute] # Travelers are away for half the day
seir.stratify(patch=ss.levels(pop, mobility=ss.geo.Mobility(away, kind='visit')))
sim = ss.Sim(seir, total_pop=sum(pop.values()), start='2020-02-01', stop='2020-08-31', solver=ss.solvers.ode(substeps=1)) # One RK4 step a day, as in the original
# The scenarios: each switches off one vaccination program or both (Starsim's stop is the first day off)
off = lambda par: ss.modify(par, multiply=0, when=ss.schedule(start='2020-02-01', stop='2020-09-01'))
pess_vax = [off(nu_opt), off(omega_opt)]
opt_vax = [off(nu_pess), off(omega_pess)]
scenarios = dict(no_vax=pess_vax + opt_vax, pess_vax=pess_vax, opt_vax=opt_vax)
def runs(n_runs=10, **kwargs):
""" Every scenario, once with each of n_runs draws from the priors """
draws = ss.fitting.prior_draws(sim, n_runs, rand_seed=1)
return [sim.new(label=name, pars=dict(Ro=r, p_case=p), interventions=mods, **kwargs)
for name,mods in scenarios.items() for r,p in zip(draws['Ro'], draws['p_case'])]
if __name__ == '__main__':
msim = ss.parallel(runs(), workers=1)
msim.plot()What was declared and what was not
Declared: the five states and five transitions (the vaccination and waning rates as sums of two named parameters), the four outcomes by province (one chained from another, and the stay in hospital as a fixed duration), the stratification by province with visiting, the three scenarios as ss.modify with a start and a stop, stacked by putting them in a list, and the uncertainty in Ro and the share of cases detected, as prior= on ss.par, with ss.truncnorm as in the original.
runs() makes each scenario’s sims with its draws. ss.parallel(sims, n_runs=10, priors=True) does this in one call, and compare.py uses it; model.py keeps runs() so that the timing can run it as a list of sims.
Findings
- Fixed: parameter uncertainty in forward runs. flepiMoP draws
Ro(and an outcome’s probability) from a distribution once for each run.ss.parallel(..., n_runs=10, priors=True)now does this, andss.fitting.prior_draws()gives the draws. - Fixed: truncated normal.
ss.truncnorm(loc=, scale=, low=, high=)is flepiMoP’s usual way of writing an uncertain parameter, and replaces SciPy’s here. - A
MultiSimgiven toss.parallel()loses its options (a bug, new).ss.parallel(ss.MultiSim(sims, n_runs=2, priors=True))runs each sim once, without any warning, rather than giving an error or the runs asked for. - Fixed: outcomes are kept by group. Every outcome now agrees by province, including their delays and durations.
- A fixed
stay=on an outcome is still written as a distribution.stay=ss.constant(10, unit='days')is a stay of exactly ten days, and agrees to 1e-9. A plainstay=ss.days(10)is an exponential stay, unlikedelay=ss.days(10), which is exact. Whether they should match is still open. stop=is the first day off. flepiMoP’speriod_end_dateis the last day a modifier applies; Starsim’sstop=is the first day it does not ([start, stop), now documented), so the vignette givesstop='2020-09-01'.substeps=1gives the same steps. The original takes one RK4 step a day. By default Starsim subdivides a step when rates are high, which differs by up to 5e-6 of a series’ largest value here; withsubsteps=1the two agree within 1e-9.- Fixed: modifiers that depend only on time stay in one call to the kernel, and are named after their target (
nu_opt_multiply).describe()no longer callsRoa rate.
LASER: measles in England and Wales
An agent-based model of measles in the 954 towns and cities of England and Wales before vaccination, from the laser-generic tutorial notebook “Spatial features of measles transmission in England and Wales during the pre-vaccine era”. Each place has its 1944 population; people are born at the mean crude birth rate of the data, die at an age drawn from a life table, catch measles by a seasonal force of infection that is shared between places by a gravity model, and three infectious people are brought in every 30 days for the first 10 years. The notebook runs 150 simulations of 40 years over a sample of five parameters (transmission, the amplitude of the seasonality, and three of the gravity model), and ranks them against the measles case reports by the share of weeks with no cases in each place (the critical community size) and by the phase of each place’s epidemics relative to London’s (travelling waves).
- Framework: LASER laser-generic 1.1.0, laser-core 1.0.5 (MIT)
- Language: Python (Jupyter notebook)
- Source: https://github.com/InstituteforDiseaseModeling/laser-generic/blob/v1.1.0/docs/tutorials/notebooks/10_England_and_Wales.ipynb (with data/EnglandWalesMeasles.py and data/EnglandWalesDistances.npy from the same repository)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 126 | 46 | Lines over 100 characters count twice. The original’s 126 are for one run; with the notebook’s sweep over 150 parameter sets, 172 |
| One run | first 3.7 s, rerun 2.6 s | first 100 s, rerun 100 s (Rust) | 667,864 agents for 3,650 days. Each uses its default threads: LASER’s on all cores, and Starsim’s for its passes over the agents, with one thread for BLAS. With one thread each, LASER takes 4.7 s and Starsim 19.7 s. LASER has no way to run a built model again, so its rerun builds the model too |
| Results | Agree. Over the 10 years, 107,761 infections against 106,639 (+1.1%; means of 3 and 50 runs, where compare.py now makes the last two of the three to keep the benchmark short; the three Starsim runs are 106,611, 106,913, and 109,760, against the original’s 5% to 95% range of 103,139 to 108,758); in each of seven places from Norwich to Southwold within 3%, except the smallest, Southwold, at +12%; 537,442 people at the end against 529,833 (+1.4%, finding 1). The share of weeks with no infections in each place, in the second half, is 0.631 against 0.628, and correlates at 0.985 across places. compare.py allows 5% in total, 20% in a place, 2% in people at the end, and 0.03 in the share of weeks |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (LASER)
Not written yet.
Starsim
model.py
"""
The LASER England and Wales measles model (docs/models/laser/england_wales), in Starsim: measles in
towns and cities (here the 53 within 100 km of Norwich), with births, deaths from a life table,
seasonal transmission, importations, and the force of infection moving between places by gravity
"""
import os
import sys
import numpy as np
import starsim as ss
folder = os.environ.get('LASER_DATA', os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '_envs', 'laser', 'src', 'data'))
sys.path.insert(0, folder)
from EnglandWalesMeasles import data as engwal
# The run is scaled down in the same way in the original's reference: the places within 100 km of Norwich, for 10 years
center, radius, years = 'Norwich', 100, 10
distances = np.load(os.path.join(folder, 'EnglandWalesDistances.npy'))
keep = distances[engwal.placenames.index(center)] <= radius
distances = distances[np.ix_(keep, keep)]
names = np.array(engwal.placenames)[keep]
pop = np.array([engwal.places[name].population[0] for name in names], dtype=float)
mean_pop = np.array([place.population.mean() for place in engwal.places.values()]) # The birth rate is the mean over all places
cbr = 1000*np.sum(mean_pop*[np.mean(place.births/place.population) for place in engwal.places.values()])/mean_pop.sum()
# Seasonality: 26 values of log beta, each held for 14 days of a 365-day year
log_betas = np.array([0.155, 0.571, 0.46, 0.34, 0.30, 0.34, 0.24, 0.15, 0.31, 0.40, 0.323, 0.238, 0.202, 0.203, 0.074,
-0.095, -0.218, -0.031, 0.433, 0.531, 0.479, 0.397, 0.444, 0.411, 0.291, 0.509])
season = np.exp(log_betas - np.append(np.repeat(log_betas, 14), log_betas[-1]).mean())**1.310744
beta = ss.timeseries({14*i: ss.perday(3.865332*s) for i,s in enumerate(season)}, period=ss.days(365))
# Deaths: the age pyramid is the stable one for the birth rate, and the age at death is drawn from it
r = 365*((1 + cbr/1000)**(1/365) - 1)
ages = np.arange(89)
pyramid = np.exp(-r*ages)
S, E, I, R = ss.states(4)
measles = ss.Disease(
infection = S >> E @ beta * I,
onset = E >> I @ ss.normal(8, 2, unit='days'), # The original's infectious-period distribution, which its transmission is given for the latent period (its gamma is used only for people exposed at the start, of whom there are none)
recovery = I >> R @ ss.normal(7, 2, unit='days'), # 8 in the original, which loses a day, see README
births = ss.birth(S, rate=ss.peryear(r)),
deaths = ss.death(lifetable=ss.lifetable(ages, pyramid)),
init = dict(R=0.95),
init_n = dict(I=ss.by(patch=[3]*len(pop))),
)
towns = ss.geo.geography(names=names, populations=pop, distances=distances)
with np.errstate(divide='ignore'):
gravity = pop**0.251464/distances**1.563193 # LASER's gravity model, with a=0
np.fill_diagonal(gravity, 0)
gravity *= 0.044565/gravity.sum(axis=1).mean() # Scaled so that the mean share exported is 0.0446,
gravity *= np.minimum(1, 0.2/gravity.sum(axis=1))[:, None] # and no place exports more than 0.2
measles.stratify(patch=ss.levels(towns, mobility=ss.geo.Mobility(gravity, kind='force')))
importation = ss.Delivery(S >> I, capacity=3, when=ss.schedule(every=ss.days(30), start=ss.days(30))) # 3 people every 30 days, chosen from everyone susceptible
people = ss.People(int(pop.sum()), age_data=np.array([ages, pyramid]).T)
sim = ss.Sim(measles, people=people, interventions=importation, unit='day', stop=years*365)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: the four states and three transitions, the latent period and the infectious period as normals (the original gives its transmission the infectious period’s normal for the latent period of everyone it infects; its gamma with shape 40 is used only for people exposed at the start, of whom there are none), births into S, deaths from a life table, the importation as a delivery, the places with their populations and the original’s distance matrix, the coupling as a moving force of infection (ss.geo.Mobility(..., kind='force')), the initial conditions in each place, the age pyramid, and the seasonal transmission as a time series that repeats every year.
The one part written by hand is the gravity matrix (5 lines of NumPy, passed as matrix=), since its scaling cannot be given to ss.geo.gravity. The original also builds it in its own code, so this is no extra work.
Findings
- The life table is declared, and differs from LASER’s in the first year (a difference, small for measles). LASER gives someone part way through a year of age the whole year’s chance of dying in what is left of it, which adds deaths at the start only.
ss.death(lifetable=ss.lifetable(ages, pyramid))spreads deaths evenly through each year of age, which is the usual reading of a life table: 31,276 deaths in the first year against LASER’s 43,037. Over the 10 years the gap narrows to 231,525 deaths against 237,762, which leaves 1.4% more people at the end.compare.pydoes not hold the first year’s deaths to a tolerance. An earlier version reproduced LASER’s estimator exactly by writing each person’s time of death by hand (8 lines); the declared form is shorter and agrees as well on measles, so it was kept. - Fixed: importing a fixed number of people on a schedule can be declared.
ss.Delivery(move=S >> I, capacity=3, start=ss.days(30), every=ss.days(30))moves exactly three susceptible people, chosen from everyone, on days 30, 60, and so on to day 3,630, and replaces a function of 7 lines. The people imported stay infectious for the same time as everyone else here, about a day less than in LASER, since the delivery has no dwell time of its own; the total still agrees (+1.1%). - Speed at full size. At this size Starsim is much slower than LASER (see the table), which threads do not explain. The full model (30 million people, 40 years) is left out of this comparison, as it is in the original’s reference.
- Fixed: a stop of 1900 or more was read as a calendar year. With
unit=given (and not'year'), a barestopis now in that unit, however large. - Fixed: no periodic time series. The seasonality is 26 values, each held for 14 days of a year, repeated every year.
ss.timeseries(..., period=ss.days(365))gives the profile once. - The gravity model’s scaling cannot be given. The notebook scales the gravity matrix so that the mean share exported, over places, is
k, and then caps each place at 0.2.ss.geo.Mobility(away=)sets every place’s share to the same value, and the only cap is 1. Also,ss.geo.gravity’s exponentais that ofpop[i]^(a−1), so LASER’sa=0is Starsim’sa=1, which is easy to get wrong. Here the matrix is built in NumPy, as in the original. Suggested:ss.geo.Mobility(ss.geo.gravity(b=0.25, c=1.56), kind='force', away=ss.mean(0.0446), cap=0.2)or similar. ss.gamma(40, 0.25)is not a gamma with shape 40 and scale 0.25. The second positional parameter is SciPy’sloc, so this is a latent period of 40.25 days, and nothing warns. LASER (and NumPy) take(shape, scale). It isn’t used here, since the gamma is not reached (see above). Suggested: makelockeyword-only, or warn when a dwell time has a nonzeroloc.- The original’s infectious period is a day shorter than written (a finding about the original). The notebook lists the components with
ExposedbeforeInfectious, so someone who becomes infectious counts down on the same day, and is infectious forround(normal(8, 2)) − 1days. The translation usesnormal(7, 2); withnormal(8, 2)the reproduction number would be 31 rather than 27.
LASER: spatial SEIR on a hub and spokes
An agent-based SEIR epidemic in 1,750,000 people living in 16 patches (a central city, five towns around it, and two small places on the way to each town), coupled by a gravity model, over 365 days. It is the SEIR model at the end of the “Grid function examples” notebook in the laser-generic documentation.
- Framework: LASER laser-generic 1.1.0, laser-core 1.0.5 (MIT)
- Language: Python
- Source: https://github.com/InstituteforDiseaseModeling/laser-generic/blob/v1.1.0/docs/tutorials/notebooks/grid_examples.ipynb (the last two sections, “Non-grid() custom scenario” and “SEIR model”)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 50 | 17 | |
| One agent-based run | first 2.2 s, rerun 1 s | first 720 ms, rerun 610 ms (Numba) | The original has 1,750,000 agents, on all cores; the Starsim run has 175,000, each standing for ten people, on one core, since Starsim’s passes over the agents take threads only from 200,000 agents. LASER has no way to run a built model again, so its rerun builds the model too |
| Results | Agree. As agents, the means of 6 Starsim runs and 20 LASER runs: 1,529,575 recovered at the end, against 1,532,298, and within 0.5% in each place shown; peaks within 5% in size and 9 days in time. As an ODE: 1,532,507 recovered, peaks within 5% in size and 8 days in time. compare.py allows 2%, 15%, and 10 days. |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (LASER)
grid_examples_seir.py
import geopandas as gpd
from shapely import Point
import math
# We will create a central city of 1,000,000 population with five surrounding towns of 100,000 population each 150 kilometers away.
# Each surrounding town will have two intermediate population centers of 25,000 between it and the central city.
nodes = []
nodeid = 0
# Add central city node at 0,0 with a circle geometry of radius 10 km
nodes.append({"name": "Central City", "population": 1_000_000, "geometry": Point(0, 0).buffer(10_000), "nodeid": nodeid})
nodeid += 1
# Surrounding towns
NTOWNS = 5
for i in range(NTOWNS):
angle = (i * 2 * math.pi / NTOWNS)
tx = 150_000 * (cos := math.cos(angle))
ty = 150_000 * (sin := math.sin(angle))
nodes.append({"name": f"Town {i+1}", "population": 100_000, "geometry": Point(tx, ty).buffer(10_000), "nodeid": nodeid})
nodeid += 1
# Intermediate population centers
ix1 = 50_000 * cos
iy1 = 50_000 * sin
nodes.append({"name": f"Interim {i+1}a", "population": 25_000, "geometry": Point(ix1, iy1).buffer(5_000), "nodeid": nodeid})
nodeid += 1
ix2 = 100_000 * cos
iy2 = 100_000 * sin
nodes.append({"name": f"Interim {i+1}b", "population": 25_000, "geometry": Point(ix2, iy2).buffer(5_000), "nodeid": nodeid})
nodeid += 1
scenario = gpd.GeoDataFrame(nodes, crs="EPSG:3857") # Mark as 3857 since we're working in meters/metres.
scenario.head()
from laser.core import PropertySet
from laser.generic import Model
import laser.generic.SEIR as SEIR
from laser.core.distributions import normal
exp_mean = 7.0
exp_stddev = 1.0
inf_mean = 14.0
inf_stddev = 2.0
R0 = 2.0
beta = R0 / inf_mean
parameters = PropertySet({"nticks": 365, "beta": beta, "gravity_c": 2.5})
scenario["S"] = scenario.population
scenario["E"] = 0
scenario["I"] = 0
scenario["R"] = 0
scenario.loc[scenario.name == "Town 1", "S"] -= 100
scenario.loc[scenario.name == "Town 1", "I"] += 100
prj = scenario.to_crs(4326)
model = Model(prj, parameters)
incubation_distribution = normal(loc=exp_mean, scale=exp_stddev)
infectious_distribution = normal(loc=inf_mean, scale=inf_stddev)
model.components = [
SEIR.Susceptible(model),
SEIR.Recovered(model),
SEIR.Infectious(model, infectious_distribution, 1),
SEIR.Exposed(model, incubation_distribution, infectious_distribution, 1, 1),
SEIR.Transmission(model, incubation_distribution, 1)
]
model.run()
import matplotlib.pyplot as plt
fig, axes = plt.subplots(4, 1, figsize=(14, 18), sharex=True)
compartments = ['S', 'E', 'I', 'R']
for idx, comp in enumerate(compartments):
for node in scenario.index:
axes[idx].plot(getattr(model.nodes, comp)[:, node], label=scenario.loc[node, "name"])
axes[idx].set_ylabel(comp)
axes[idx].legend(loc='upper right', fontsize='small')
axes[-1].set_xlabel("Time (days)")
plt.tight_layout()
plt.show()Starsim
model.py
"""
The LASER hub-and-spoke example (docs/models/laser/hub_spoke_seir), in Starsim: an SEIR in a city
and fifteen smaller places on five spokes, coupled by a gravity model
"""
import numpy as np
import starsim as ss
# The places: a city, and on each of five spokes two interim places and a town
angles = 2*np.pi*np.arange(5)/5
names = ['Central City'] + [f'Town {i+1}' for i in range(5)] + [f'Interim {i+1}{ab}' for i in range(5) for ab in 'AB']
pop = [1_000_000] + [100_000]*5 + [25_000]*10
dist = [0] + [150]*5 + [50, 100]*5 # Distance from the city, in km
angle = [0] + list(angles) + list(np.repeat(angles, 2))
places = ss.geo.geography(names=names, populations=pop, x=dist*np.cos(angle), y=dist*np.sin(angle))
S, E, I, R = ss.states(4)
seir = ss.Disease(
infection = S >> E @ ss.perday(2/14) * I,
onset = E >> I @ ss.normal(7, 1, unit='days'),
recovery = I >> R @ ss.normal(14, 2, unit='days'),
init_n = dict(I=ss.by(patch=[0, 100] + [0]*14)), # 100 infectious people in Town 1
)
seir.stratify(patch=ss.levels(places, mobility=ss.geo.gravity(distance_power=2.5, kind='force', away=3/32)))
sim = ss.Sim(seir, total_pop=sum(pop), n_agents=175_000, stop=ss.days(365))
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: the places, the model, the durations as distributions, and the mobility, which moves the force of infection between places (kind='force'), as LASER does. The same text runs as agents and as an ODE (with the durations as Erlang stages), and the two agree with each other.
Findings
- The two frameworks coupled places differently (fixed). LASER moves the force of infection itself between places, unweighted by their size:
f[j] += Σ f[i]·network[i,j] − f[j]·Σ network[j,i]. Starsim’s commuting moves people, who mix where they are, and with it 80% were infected, against 88%.ss.geo.Mobility(..., kind='force')is LASER’s form, and with it the results agree. The gravity matrix is the same in both (every row of LASER’s is capped at 3/32, which isaway=3/32here). - One run at a tenth of the size says little about timing. The 100 infectious people at the start are 10 agents here, so the day of the peak moved between 156 and 172 over six runs, where LASER’s runs, with 100 agents, move by a few days. The mean of six runs is within 2 days of LASER’s in total, so
compare.pycompares means. Running at LASER’s size would avoid this, at about ten times the time. - LASER is faster per agent. Starsim runs a tenth of the agents in a similar or longer time (see the table above), so LASER is many times faster per agent, and it uses all the cores. As a compartmental model the size doesn’t matter: the same text runs for all 1,750,000 people with
solver='ode'orsolver='binomial'. - Distances. The original measures great-circle distances between map coordinates; this version uses the same layout in kilometers on a plane, which is the same to within a fraction of a percent.
Measles metapopulation: SEIR in 260 districts
A stochastic metapopulation SEIR model of measles in 260 made-up districts, run daily from 2010 to 2025 with binomial draws. The districts and all its values are made up.
- Framework: measles metapopulation as here (as this repository)
- Language: Python
- Source: original/measles_metapop.py
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 89 | 22 | Long lines count twice; two lines read the data |
| One run | first 820 ms, rerun 810 ms | first 440 ms, rerun 390 ms (Rust) | Both with the data already loaded. The original has no separate rerun, so its first run and rerun both build and run the sim |
| Results | Agree. Over 2010 to 2025, 1,734,028 infections against 1,740,643 and 17,325 reported cases against 17,396 (means of 50 runs each); monthly means correlate at 0.999 and 0.998; infections from 2020 in each of the 16 regions within 6% (compare.py asks for totals within 2%, each year within 20%, a correlation over 0.98, regions within 20%, and S and R at the end within 3%) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (measles metapopulation)
| dotname | lat | lon | pop |
| region_07:district_000 | 14.047735913662386 | -33.658679029564475 | 122883 |
| region_08:district_001 | 12.809834751182159 | -33.91952033600654 | 47508 |
| region_12:district_002 | 10.87735099909311 | -34.10256798038919 | 115070 |
| region_15:district_003 | 12.805918953577223 | -32.30383217686666 | 43152 |
| region_00:district_004 | 12.62013840258877 | -35.0788996380709 | 204115 |
| region_02:district_005 | 16.67914746095777 | -33.77067821046182 | 97296 |
| region_13:district_006 | 11.834426726032449 | -37.03479936511625 | 197334 |
| region_15:district_007 | 14.106833342483789 | -31.699329624033904 | 50994 |
| region_03:district_008 | 14.88048558035819 | -32.96419975001461 | 62772 |
| region_04:district_009 | 15.220836193700181 | -35.8536817325713 | 138237 |
| region_13:district_010 | 11.439608345530424 | -36.64898810627318 | 16102 |
| region_06:district_011 | 11.66639868991277 | -38.19902608970744 | 44040 |
| region_04:district_012 | 15.571409935315707 | -36.249869235779514 | 155256 |
| region_13:district_013 | 11.487584033909028 | -35.98852456874413 | 110414 |
| region_04:district_014 | 15.604024213024749 | -35.90906607829382 | 150270 |
| region_06:district_015 | 10.86722293241512 | -39.03541807495199 | 69360 |
| region_10:district_016 | 18.134556429857906 | -37.123146584361166 | 117163 |
| region_08:district_017 | 12.07268190798364 | -33.5566792111994 | 53875 |
| region_01:district_018 | 17.186940456734526 | -33.778219298388954 | 36671 |
| region_00:district_019 | 12.82325845038022 | -35.93820690294028 | 30214 |
| region_13:district_020 | 12.161260944216787 | -37.42400938002866 | 59988 |
| region_12:district_021 | 11.24219673487356 | -34.40903591531064 | 60088 |
| region_13:district_022 | 11.583177597446506 | -37.19806879383492 | 57257 |
| region_08:district_023 | 11.89394138975818 | -33.30467472486306 | 9933 |
| region_13:district_024 | 12.938381609430495 | -36.17229664291425 | 25179 |
| region_05:district_025 | 11.056799778335064 | -35.65812637138205 | 60679 |
| region_07:district_026 | 13.478872045063495 | -33.5403832142088 | 99224 |
| region_12:district_027 | 10.605863702003967 | -34.34539541533268 | 59021 |
| region_01:district_028 | 17.817009429243992 | -33.77544531515002 | 49355 |
| region_04:district_029 | 14.739675677578655 | -35.55479505109076 | 20907 |
| region_01:district_030 | 17.857835008463294 | -32.737200803748486 | 280920 |
| region_07:district_031 | 13.818983602378605 | -33.26286805657343 | 43142 |
| region_15:district_032 | 13.733731715677001 | -32.34266356908905 | 229341 |
| region_02:district_033 | 16.810148917196567 | -33.906839457797915 | 213062 |
| region_06:district_034 | 10.34700524400529 | -39.179702070201614 | 98448 |
| region_06:district_035 | 11.723499255736332 | -39.32962890760041 | 59240 |
| region_14:district_036 | 11.046465277935264 | -33.36229705956989 | 90423 |
| region_03:district_037 | 15.330720843814314 | -32.56065459653014 | 41819 |
| region_08:district_038 | 12.444104477008068 | -32.93187512738788 | 97671 |
| region_04:district_039 | 15.791243969670147 | -35.97113231912834 | 83675 |
| region_00:district_040 | 11.971820506157853 | -34.70772323249467 | 114933 |
| region_12:district_041 | 11.90989777912159 | -33.247531770848006 | 147737 |
| region_00:district_042 | 13.514419554134724 | -35.34699833484653 | 236246 |
| region_04:district_043 | 14.984655722263541 | -35.19324911029691 | 66167 |
| region_07:district_044 | 14.234926941168142 | -33.36184995693989 | 156210 |
| region_07:district_045 | 13.71186816603989 | -33.92808530670414 | 51944 |
| region_01:district_046 | 17.761734836527673 | -33.670096908185705 | 48834 |
| region_15:district_047 | 12.487777925906611 | -32.19101858791922 | 57647 |
| region_11:district_048 | 13.295446802591874 | -33.34018254990264 | 46339 |
| region_15:district_049 | 12.636006659977559 | -32.30184811343915 | 150405 |
| region_01:district_050 | 17.62547789745929 | -33.73058226887365 | 145346 |
| region_11:district_051 | 12.612615510856827 | -33.006922464689055 | 179210 |
| region_04:district_052 | 15.193260904368584 | -36.439589491684394 | 82269 |
| region_08:district_053 | 12.240395327578005 | -33.854519751724766 | 98475 |
| region_14:district_054 | 11.573379104483625 | -34.031353569433406 | 59034 |
| region_04:district_055 | 15.450310866158501 | -35.86675080446493 | 41871 |
| region_11:district_056 | 11.776602727751413 | -32.91221329703947 | 109222 |
| region_02:district_057 | 16.110077547581295 | -33.84387457650346 | 98120 |
| region_05:district_058 | 10.513224005648189 | -35.94947960735644 | 42990 |
| region_15:district_059 | 12.953733413616826 | -31.90748594516185 | 118673 |
| region_06:district_060 | 11.730992940169573 | -39.2709800417342 | 29375 |
| region_08:district_061 | 12.213500432765795 | -33.96935035038896 | 75021 |
| region_04:district_062 | 15.893646947796338 | -35.74168753219714 | 57224 |
| region_01:district_063 | 17.611921842065122 | -33.30174405324557 | 60801 |
| region_06:district_064 | 11.634323164574733 | -38.74096891895307 | 43602 |
| region_09:district_065 | 13.872645517300107 | -37.531186101856704 | 147919 |
| region_07:district_066 | 13.785261960177365 | -33.764997945316765 | 117301 |
| region_12:district_067 | 10.363436264713982 | -33.63144609673295 | 169902 |
| region_05:district_068 | 10.876062928443291 | -35.45904234419077 | 90330 |
| region_09:district_069 | 13.633777176471787 | -38.87450073620519 | 38334 |
| region_12:district_070 | 10.622754461029675 | -34.54153275251175 | 66538 |
| region_14:district_071 | 12.03482135370271 | -33.8614775222245 | 40864 |
| region_06:district_072 | 11.903934645694942 | -38.857291119711114 | 109207 |
| region_00:district_073 | 12.304325495056966 | -34.831256292706975 | 171467 |
| region_11:district_074 | 11.352241140057165 | -33.08817883550246 | 100970 |
| region_08:district_075 | 12.570308186091943 | -33.76339788304179 | 57839 |
| region_13:district_076 | 11.632379669969776 | -37.04967273340569 | 66365 |
| region_07:district_077 | 13.342328772499096 | -33.438802792827545 | 93638 |
| region_05:district_078 | 11.001831509401558 | -37.16032170903767 | 34777 |
| region_00:district_079 | 12.153832970342789 | -35.27021564428903 | 90553 |
| region_07:district_080 | 13.485425322158333 | -33.70525450444785 | 93249 |
| region_10:district_081 | 17.767607307320013 | -37.47208033217273 | 59682 |
| region_12:district_082 | 10.90644744442423 | -33.043477349338445 | 64377 |
| region_13:district_083 | 11.60390994101932 | -37.55377240767663 | 91604 |
| region_03:district_084 | 13.973027815027834 | -33.197222191056525 | 50511 |
| region_09:district_085 | 15.11115124343422 | -39.40804736681071 | 50445 |
| region_12:district_086 | 10.694353352851975 | -33.57609241053103 | 131782 |
| region_04:district_087 | 14.747211589328442 | -36.441190984684624 | 94367 |
| region_05:district_088 | 10.926091562287885 | -35.758077976833704 | 225962 |
| region_13:district_089 | 11.325195875473158 | -36.566310715638714 | 45622 |
| region_09:district_090 | 14.367465428184484 | -38.677780607510236 | 28349 |
| region_08:district_091 | 12.190247327131637 | -34.50361537405512 | 144957 |
| region_10:district_092 | 17.689871771597925 | -37.09296339788577 | 146300 |
| region_08:district_093 | 12.482937964313473 | -33.01326843570963 | 52871 |
| region_15:district_094 | 12.747269973527445 | -32.847834696632994 | 34105 |
| region_12:district_095 | 11.335052458053617 | -34.547103791594594 | 79986 |
| region_00:district_096 | 12.26943282101973 | -35.467525531366576 | 28744 |
| region_02:district_097 | 15.894228013297207 | -33.52135695966467 | 66940 |
| region_08:district_098 | 12.398567753355406 | -33.724006325424234 | 22167 |
| region_13:district_099 | 11.725118579769267 | -36.48904371225167 | 71122 |
| region_01:district_100 | 17.65898013414872 | -34.33633403709614 | 22974 |
| region_10:district_101 | 17.288482058298104 | -36.31452191193385 | 71084 |
| region_12:district_102 | 11.305575443278155 | -33.846761903120964 | 203543 |
| region_12:district_103 | 10.11551826406245 | -34.928558588972194 | 69739 |
| region_13:district_104 | 10.98716587250598 | -36.090107687945704 | 129841 |
| region_03:district_105 | 14.795663289456744 | -31.51226301254507 | 55641 |
| region_08:district_106 | 11.679521843291623 | -34.23411459930002 | 47850 |
| region_12:district_107 | 11.009679792248424 | -34.103449903102025 | 33252 |
| region_05:district_108 | 10.589238908995078 | -35.39369489176204 | 152407 |
| region_03:district_109 | 15.225271738619137 | -33.3990820573463 | 78805 |
| region_07:district_110 | 13.591124204328338 | -34.714247818759205 | 215845 |
| region_01:district_111 | 17.460566543137215 | -34.4511589817101 | 40176 |
| region_03:district_112 | 14.942492686540552 | -32.94199533581073 | 71244 |
| region_13:district_113 | 10.273816145420842 | -37.149286897891784 | 85360 |
| region_10:district_114 | 19.26976310129518 | -36.48323662197056 | 189819 |
| region_13:district_115 | 11.153272575292862 | -36.725797605917805 | 242580 |
| region_13:district_116 | 11.137680414160966 | -37.48310442423387 | 55059 |
| region_14:district_117 | 12.285762783148964 | -32.98648607341648 | 41141 |
| region_04:district_118 | 15.497125322858805 | -35.06010788326443 | 28642 |
| region_07:district_119 | 13.158805070883188 | -33.12079051968919 | 180983 |
| region_09:district_120 | 14.51849987534434 | -38.31206208919253 | 76982 |
| region_04:district_121 | 15.944730160576253 | -35.98705958984435 | 54964 |
| region_14:district_122 | 11.630225890959137 | -32.77836036113863 | 78595 |
| region_00:district_123 | 12.36358269141349 | -35.86946867996117 | 17458 |
| region_13:district_124 | 11.745590239153442 | -36.48455847339781 | 28766 |
| region_10:district_125 | 17.26535182688066 | -36.83053441863243 | 141947 |
| region_04:district_126 | 15.736235525680101 | -35.4316957051653 | 102677 |
| region_11:district_127 | 12.461523300523815 | -32.99100775930123 | 88590 |
| region_06:district_128 | 11.365181654834716 | -39.48336951248188 | 44388 |
| region_13:district_129 | 10.806547003617823 | -36.79606931516258 | 115166 |
| region_15:district_130 | 13.00779350013835 | -31.59334676203579 | 235265 |
| region_04:district_131 | 15.079330114446563 | -36.56176549663843 | 38815 |
| region_07:district_132 | 14.549224493075066 | -33.998887598316664 | 81636 |
| region_03:district_133 | 14.847944528440014 | -33.042798446826566 | 62319 |
| region_11:district_134 | 12.752916306581513 | -33.971836005153044 | 100458 |
| region_10:district_135 | 17.787052919510113 | -36.30627607125701 | 81574 |
| region_13:district_136 | 11.63404849906626 | -36.204567140406326 | 74054 |
| region_12:district_137 | 10.604183201508649 | -33.73125470788186 | 47640 |
| region_15:district_138 | 13.454620128804965 | -32.081172339223045 | 80111 |
| region_15:district_139 | 14.012945552672406 | -32.228473189098914 | 55935 |
| region_14:district_140 | 11.700355249938337 | -33.150485908198576 | 74064 |
| region_02:district_141 | 16.03268432152537 | -34.2916299542289 | 77841 |
| region_00:district_142 | 12.425464519464326 | -34.93947946975055 | 119743 |
| region_07:district_143 | 13.807879238434422 | -33.196571497205134 | 57019 |
| region_05:district_144 | 10.265930818677539 | -35.62613441631385 | 66601 |
| region_14:district_145 | 11.92429584968119 | -33.5356501673203 | 153846 |
| region_12:district_146 | 10.850223893598793 | -34.09207414043697 | 70936 |
| region_06:district_147 | 12.422084759644004 | -39.02132002911538 | 69103 |
| region_09:district_148 | 14.205137006775594 | -37.913328419050636 | 135820 |
| region_09:district_149 | 14.366992174113747 | -38.71776771207302 | 95229 |
| region_14:district_150 | 12.331209961697008 | -34.2112115054261 | 85414 |
| region_00:district_151 | 12.35400494430723 | -35.18714944456085 | 92286 |
| region_07:district_152 | 14.766887650279452 | -32.77818453468884 | 38450 |
| region_10:district_153 | 17.40339484557783 | -36.44432235078058 | 106708 |
| region_07:district_154 | 13.644355529291921 | -33.279577804201224 | 179370 |
| region_14:district_155 | 11.672065518271982 | -33.25214542503645 | 114792 |
| region_15:district_156 | 13.063237719535325 | -33.18815224003428 | 31864 |
| region_13:district_157 | 10.80193884047422 | -37.76519466892581 | 96190 |
| region_07:district_158 | 14.03097198817056 | -34.34229154659227 | 47159 |
| region_14:district_159 | 11.021455732477397 | -33.33359207698077 | 70893 |
| region_01:district_160 | 18.469589005230787 | -33.64768767236448 | 87840 |
| region_10:district_161 | 17.67924347613404 | -36.4422031328221 | 39764 |
| region_04:district_162 | 15.19606238122866 | -36.16737757548739 | 181351 |
| region_03:district_163 | 14.321732785048265 | -32.17999731342715 | 78114 |
| region_10:district_164 | 17.52752671473379 | -36.926784588081674 | 131028 |
| region_12:district_165 | 10.884142925908625 | -34.48352106908389 | 119254 |
| region_14:district_166 | 11.332943544891428 | -32.844355200738455 | 182579 |
| region_03:district_167 | 14.316004297278635 | -33.115684434184786 | 166706 |
| region_13:district_168 | 11.409010437992977 | -38.22460825730989 | 33692 |
| region_13:district_169 | 11.242206066625926 | -37.39004417137329 | 83768 |
| region_05:district_170 | 11.08627701984347 | -36.045326410564364 | 22686 |
| region_01:district_171 | 18.3424549920808 | -35.34277580013497 | 62484 |
| region_12:district_172 | 11.003693261923784 | -34.30984533449966 | 125490 |
| region_13:district_173 | 11.73939772655101 | -37.02611097128084 | 141479 |
| region_07:district_174 | 13.380925193422424 | -34.43254564370897 | 177233 |
| region_02:district_175 | 16.64778044009875 | -34.667103727982266 | 65242 |
| region_02:district_176 | 16.313779850625814 | -34.157082969733 | 99161 |
| region_06:district_177 | 11.944982120698095 | -39.21197970444311 | 86780 |
| region_12:district_178 | 11.795860725920669 | -33.501317056714555 | 175104 |
| region_05:district_179 | 9.476241589832128 | -35.90497466074941 | 212374 |
| region_00:district_180 | 12.634856960086216 | -36.176829992729175 | 66039 |
| region_11:district_181 | 11.812314513717634 | -33.651338769848536 | 793260 |
| region_11:district_182 | 12.65797239100381 | -32.87144227136915 | 81740 |
| region_02:district_183 | 15.67241486666821 | -32.977863611204405 | 65749 |
| region_08:district_184 | 12.11235175894216 | -33.65657617385455 | 117434 |
| region_06:district_185 | 11.804014840602862 | -38.78307334729596 | 129216 |
| region_08:district_186 | 12.666806235661841 | -32.91944968864066 | 113811 |
| region_00:district_187 | 12.541779915227787 | -35.39773799198589 | 55554 |
| region_10:district_188 | 17.32332648234398 | -36.94146068805923 | 198631 |
| region_04:district_189 | 15.240275134718944 | -36.70377294525087 | 84831 |
| region_08:district_190 | 12.802302988068845 | -34.10633396184052 | 206738 |
| region_06:district_191 | 11.699898154879406 | -37.849073248326626 | 189636 |
| region_09:district_192 | 13.363128986877504 | -39.51848650927866 | 38316 |
| region_01:district_193 | 18.195351159092883 | -33.79709106720352 | 45722 |
| region_05:district_194 | 10.824794707861047 | -36.79656989729197 | 45179 |
| region_10:district_195 | 18.156449724463858 | -36.72267233486598 | 95719 |
| region_12:district_196 | 10.827368926935497 | -33.687452709684315 | 85152 |
| region_06:district_197 | 12.11672622193598 | -39.060254279532295 | 96903 |
| region_00:district_198 | 11.973850410558665 | -35.021228634826635 | 50681 |
| region_11:district_199 | 12.646873950734296 | -32.86359112217575 | 106640 |
| region_08:district_200 | 12.114861387630127 | -33.62631351267321 | 191758 |
| region_10:district_201 | 18.05691780429796 | -35.92878620918388 | 37078 |
| region_02:district_202 | 16.83189364359064 | -33.53643321868433 | 73471 |
| region_06:district_203 | 11.33465288671841 | -39.09090940269286 | 95945 |
| region_14:district_204 | 11.829584287947736 | -33.61058783270696 | 76473 |
| region_13:district_205 | 11.522075253751746 | -37.28271896565151 | 73457 |
| region_05:district_206 | 11.211502876924623 | -35.8544724644983 | 239016 |
| region_10:district_207 | 18.075020997522778 | -36.94877111601694 | 178647 |
| region_01:district_208 | 17.16388103295281 | -32.438103401107995 | 137718 |
| region_12:district_209 | 11.224486790478117 | -33.210052508601294 | 52397 |
| region_04:district_210 | 15.889585848266217 | -36.104410553858195 | 317518 |
| region_05:district_211 | 11.67857341596818 | -36.2569718103973 | 102792 |
| region_15:district_212 | 12.281021867141797 | -31.357369220283474 | 58521 |
| region_08:district_213 | 12.09187539247716 | -34.79150581137916 | 67609 |
| region_13:district_214 | 12.169283534567413 | -37.08584132155189 | 61718 |
| region_03:district_215 | 14.098366694886705 | -32.18308346713501 | 295625 |
| region_12:district_216 | 10.395952368161476 | -34.587325096869854 | 93680 |
| region_15:district_217 | 13.379137513209042 | -32.44087248348843 | 86036 |
| region_01:district_218 | 18.28272413539743 | -33.97825750365749 | 22765 |
| region_03:district_219 | 14.441561600502276 | -32.32805769437942 | 128954 |
| region_02:district_220 | 16.599135171528474 | -33.857088553271794 | 43916 |
| region_04:district_221 | 15.575518878291621 | -35.796104848960965 | 42073 |
| region_04:district_222 | 16.276415760586016 | -35.69950669151336 | 119690 |
| region_01:district_223 | 17.77276058510367 | -33.78771780652158 | 49369 |
| region_14:district_224 | 12.35032268368114 | -32.71489295933657 | 136848 |
| region_04:district_225 | 15.065482313919516 | -36.36162798965785 | 162472 |
| region_14:district_226 | 11.762759602966097 | -33.98823680627422 | 28325 |
| region_12:district_227 | 10.947330433242929 | -35.0197122138166 | 76379 |
| region_02:district_228 | 16.898625559125055 | -34.73635688516459 | 32236 |
| region_11:district_229 | 12.651680565061794 | -33.49927089970395 | 79855 |
| region_12:district_230 | 10.619806094477054 | -33.61625286478221 | 143264 |
| region_02:district_231 | 16.67005546722241 | -33.83876861699737 | 36564 |
| region_02:district_232 | 16.71438722359625 | -34.22512530045104 | 244366 |
| region_06:district_233 | 11.646825101562952 | -39.47335741613353 | 68744 |
| region_01:district_234 | 17.644688647442134 | -34.067129746275434 | 40716 |
| region_06:district_235 | 11.605746340011398 | -39.14026281232392 | 76829 |
| region_07:district_236 | 13.201023177240542 | -34.00186735467392 | 133118 |
| region_10:district_237 | 17.814113455900195 | -37.50318236926323 | 36213 |
| region_09:district_238 | 14.322325149911673 | -39.04551972359544 | 82815 |
| region_07:district_239 | 13.615755118355489 | -33.21074959253479 | 36281 |
| region_03:district_240 | 15.147124969127585 | -33.74576641343043 | 170323 |
| region_09:district_241 | 13.51855271738622 | -38.927920149256494 | 90731 |
| region_07:district_242 | 13.772612969538505 | -34.77999183664059 | 64043 |
| region_13:district_243 | 11.26498840602797 | -36.76854198661039 | 153801 |
| region_11:district_244 | 13.355628961296025 | -33.597757730384316 | 38138 |
| region_11:district_245 | 11.562282013672625 | -34.2668568689765 | 65246 |
| region_09:district_246 | 14.486351312901164 | -38.75726471806759 | 250055 |
| region_05:district_247 | 11.087732106798791 | -35.183703506539615 | 76656 |
| region_04:district_248 | 15.705904025596581 | -35.94270278742895 | 186365 |
| region_07:district_249 | 14.63942335241168 | -33.138421917333154 | 92815 |
| region_09:district_250 | 13.703667341734821 | -38.84336801411876 | 54239 |
| region_05:district_251 | 9.476541166848216 | -37.17120159739686 | 71196 |
| region_02:district_252 | 16.712480529867577 | -33.68061025037135 | 86430 |
| region_01:district_253 | 17.175605722622493 | -34.01182657401625 | 64532 |
| region_00:district_254 | 12.341182932286534 | -36.117444733812825 | 50317 |
| region_03:district_255 | 14.186478845477664 | -32.266043605671136 | 51611 |
| region_06:district_256 | 12.228818891197442 | -38.79500083689879 | 33869 |
| region_04:district_257 | 15.949616361010358 | -36.25985337446412 | 88114 |
| region_02:district_258 | 15.969039754428946 | -33.49181728298231 | 104420 |
| region_05:district_259 | 11.06841639030914 | -36.528442167212326 | 129224 |
"""
Write districts.csv: 260 made-up districts in 16 made-up regions
Places are scattered on an empty stretch of the map, with populations from a lognormal, so none of this describes anywhere.
python make_districts.py
"""
import numpy as np
import pandas as pd
import sciris as sc
n_districts, n_regions, mean_pop = 260, 16, 100_000
rng = np.random.default_rng(1)
regions = rng.integers(n_regions, size=n_districts)
centers = rng.uniform(size=(n_regions, 2))*8
lat = 10 + centers[regions, 0] + rng.normal(scale=0.5, size=n_districts)
lon = -40 + centers[regions, 1] + rng.normal(scale=0.5, size=n_districts)
pop = np.round(mean_pop*rng.lognormal(sigma=0.6, size=n_districts)/np.exp(0.18)).astype(int)
ids = [f'region_{r:02d}:district_{i:03d}' for i,r in enumerate(regions)]
pd.DataFrame(dict(dotname=ids, lat=lat, lon=lon, pop=pop)).to_csv(sc.thispath(__file__)/'districts.csv', index=False)"""
Stochastic metapopulation SEIR model of measles, with one patch per district.
The districts and every value are made up: see make_districts.py.
"""
import numpy as np
import pandas as pd
import sciris as sc
__all__ = ['load_districts', 'default_pars', 'distances', 'gravity', 'Sim']
folder = sc.thispath(__file__)
def load_districts():
""" Load the districts as a dataframe indexed by ID, with region, name, population, lat and lon """
df = pd.read_csv(folder/'districts.csv')
df = df.rename(columns={'dotname':'id', 'pop':'population'}).set_index('id')
df['region'] = df.index.str.split(':').str[0]
df['name'] = df.index.str.split(':').str[1]
return df[['region', 'name', 'population', 'lat', 'lon']]
default_pars = sc.objdict(
# Simulation
start = '2010-01-01', # First day; before the years of interest, so the initial conditions wash out
stop = '2025-12-31', # Last day
seed = 1, # Random seed
pop_year = 2020, # Year the district populations are for
# Transmission
r0 = 15.0, # Basic reproduction number
dur_exp = 6.0, # Mean latent period (days)
dur_inf = 8.0, # Mean infectious period (days)
seasonality = 0.2, # Relative amplitude of seasonal forcing
peak_day = 30, # Day of the year when transmission peaks
init_sus = None, # Initial fraction susceptible; if None, use the endemic equilibrium, 1/r0
import_rate = 0.01, # Imported infections per 1000 people per year
# Mixing between districts
gravity_k = 0.01, # Fraction of contacts made outside the home district
gravity_c = 2.0, # Exponent of the decline in contact with distance
# Demographics and vaccination
birth_rate = 20.0, # Births per 1000 people per year
death_rate = 8.0, # Deaths per 1000 people per year
mcv1 = 0.8, # Routine first-dose coverage (a single value, or one per district)
vax_eff = 0.9, # Fraction of vaccinated infants who become immune
# Observation
report_prob = 0.01, # Probability that an infection becomes a confirmed case
)
def distances(lat, lon):
""" Great-circle distances in km between all pairs of points """
lat, lon = np.radians(lat), np.radians(lon)
dlat = lat[:, None] - lat[None, :]
dlon = lon[:, None] - lon[None, :]
a = np.sin(dlat/2)**2 + np.cos(lat[:, None])*np.cos(lat[None, :])*np.sin(dlon/2)**2
return 2*6371*np.arcsin(np.sqrt(a))
def gravity(population, dist, k, c):
"""
Gravity mixing matrix, where mix[i,j] is the fraction of their contacts that
residents of patch i make in patch j.
Contact with other patches is proportional to their population divided by
distance**c, and adds up to k. The remaining 1-k is within the home patch.
"""
mix = population[None, :] / np.maximum(dist, 1e-9)**c
np.fill_diagonal(mix, 0)
mix *= k / mix.sum(axis=1, keepdims=True)
np.fill_diagonal(mix, 1-k)
return mix
class Sim:
"""
A measles simulation.
Args:
districts (df): one row per district, with columns population, lat and lon
pars (dict): any parameters to change from default_pars
**Example**::
import measles_metapop as mm
data = mm.load_districts()
sim = mm.Sim(data.districts, r0=12).run()
sim.plot()
"""
def __init__(self, districts, **pars):
unknown = set(pars) - set(default_pars)
if unknown:
raise KeyError(f'Unknown parameters {unknown}; choices are {list(default_pars)}')
self.districts = districts
self.pars = sc.mergedicts(default_pars, pars)
self.results = None
def run(self):
""" Run the simulation and store the results """
p = self.pars
d = self.districts
rng = np.random.default_rng(p.seed)
dates = pd.date_range(p.start, p.stop)
year = 365
# Daily rates and probabilities
beta = p.r0 / p.dur_inf
season = 1 + p.seasonality*np.cos(2*np.pi*(dates.dayofyear.values - p.peak_day)/year)
mix = gravity(d['population'].values, distances(d['lat'].values, d['lon'].values), p.gravity_k, p.gravity_c)
p_inf = 1 - np.exp(-1/p.dur_exp)
p_rec = 1 - np.exp(-1/p.dur_inf)
p_death = 1 - np.exp(-p.death_rate/1000/year)
p_immune = np.asarray(p.mcv1) * p.vax_eff
births = p.birth_rate/1000/year
imports = p.import_rate/1000/year
# Initial conditions: project the population back from pop_year to the start
growth = (p.birth_rate - p.death_rate)/1000
N = d['population'].values * np.exp(-growth*(p.pop_year - dates[0].year))
init_sus = 1/p.r0 if p.init_sus is None else p.init_sus
S = rng.binomial(N.astype(int), init_sus)
E = np.zeros_like(S)
I = np.zeros_like(S)
R = N.astype(int) - S
states = np.zeros((len(dates), 4), dtype=int)
infections = np.zeros((len(dates), len(d)), dtype=int)
for t in range(len(dates)):
N = S + E + I + R
# Transmission: residents of each district are exposed to the prevalence where they make contacts
foi = beta * season[t] * (mix @ (I/N))
new_exp = rng.binomial(S, 1 - np.exp(-foi))
new_exp += np.minimum(rng.poisson(imports*N), S - new_exp)
new_inf = rng.binomial(E, p_inf)
new_rec = rng.binomial(I, p_rec)
# Births, some of whom are immunized by routine vaccination
new_births = rng.poisson(births*N)
new_immune = rng.binomial(new_births, p_immune)
S += new_births - new_immune - new_exp
E += new_exp - new_inf
I += new_inf - new_rec
R += new_immune + new_rec
for state in [S, E, I, R]:
state -= rng.binomial(state, p_death)
states[t] = [S.sum(), E.sum(), I.sum(), R.sum()]
infections[t] = new_inf # Count infections when they become infectious, i.e. around symptom onset
cases = rng.binomial(infections, p.report_prob)
self.results = sc.objdict(
states = pd.DataFrame(states, index=dates, columns=['S', 'E', 'I', 'R']),
infections = pd.DataFrame(infections, index=dates, columns=d.index),
cases = pd.DataFrame(cases, index=dates, columns=d.index),
)
return self
def monthly(self, key='cases'):
""" Return daily results ('cases' or 'infections') as a months x districts dataframe """
df = self.results[key]
return df.groupby(df.index.to_period('M')).sum()Starsim
model.py
"""
The measles metapopulation model (docs/models/measles_metapop/seir_districts), in Starsim: a stochastic
SEIR in 260 districts, with gravity mixing, seasonality, births, vaccination, deaths,
importation, and reported cases
"""
import os
import numpy as np
import pandas as pd
import starsim as ss
folder = os.path.join(os.path.dirname(__file__), 'original')
districts = pd.read_csv(os.path.join(folder, 'districts.csv'))
pop = districts['pop']*np.exp(-0.012*10) # Projected back from 2020 to 2010
r0 = 15
immune = 0.8*0.9 # The share of births who are vaccinated and immune
S, E, I, R = ss.states(4)
infection = S >> E @ ss.seasonal(mean=ss.perday(r0/8), amplitude=0.2, peak=29/365) * I
importation = S >> E @ ss.peryear(0.01/1000) * (S + E + I + R).n / S.n
onset = E >> I @ ss.days(6)
recovery = I >> R @ ss.days(8)
births = ss.birth(S, rate=ss.peryear(0.02*(1 - immune)))
vaccinated = ss.birth(R, rate=ss.peryear(0.02*immune))
deaths = ss.death(rate=ss.peryear(0.008))
cases = ss.outcome(0.01*onset)
measles = ss.Disease(infection, importation, onset, recovery, births, vaccinated, deaths, cases, init=dict(R=1 - 1/r0), name='measles')
places = ss.geo.geography(names=districts['dotname'], populations=pop, lat=districts['lat'], lon=districts['lon'])
measles.stratify(district=ss.levels(places, mobility=ss.geo.gravity(distance_power=2, kind='visit', away=0.01)))
sim = ss.Sim(measles, total_pop=pop.sum(), start='2010-01-01', stop='2025-12-31', unit='day', solver=ss.solvers.binomial(substeps=1)) # Whole days, as the original takes them
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: seven transitions, an outcome, the districts as a geography, and the mixing between them as visiting (ss.geo.Mobility(ss.geo.gravity(distance_power=2), kind='visit', away=0.01)), which is the original’s form: residents are exposed to the prevalence where they make their contacts, and do not change who is present there.
Findings
- Nothing was missing. This model has places with latitudes and longitudes, gravity, seasonality, births into two states, deaths from every state, and a reporting share at onset, and each has a direct form.
- Importation is awkward to write. The original adds a fixed number of infections per person in the population. A transition’s rate is per person in its source, so this is written as
ss.peryear(0.01/1000) * (S + E + I + R).n / S.n. An inflow into a state from outside the population would read better, butss.null >> Ewould add people rather than move them. - Whole days have to be asked for. The original takes a binomial draw a day. Starsim’s chain binomial takes smaller steps by default, so the model gives
substeps=1. - Results by district are kept weekly by default. A run of 5,844 days over 260 districts would otherwise keep a lot, so Starsim keeps them by group every 7 days, and says so.
compare.pyneeds daily onsets, so it asks for them withgroup_results=['onset'].
MEmilio: ODE SECIR model with severity branches
A deterministic COVID-19-type epidemic in one region and one age group of 83,000 people over 100 days: infection, then a chain of worsening states (no symptoms, symptoms, severe, critical), each of which ends in either the next state or recovery, with death from the critical state. Contacts are cut by 90% on day 30.
- Framework: memilio 2.4.1 (Apache-2.0)
- Language: Python (bindings to C++)
- Source: https://github.com/SciCompMod/memilio/blob/v2.4.1/pycode/examples/simulation/ode_secir_simple.py
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 44 | 14 | |
| One run | first 0.41 ms, rerun 0.02 ms | first 13 ms, rerun 0.47 ms (Rust) | Neither counts starting Python or importing. MEmilio’s solver is C++ code built in advance, so its first run has little to do |
| Results | Agree. Within 67 people of 83,000 at every time, which is the error of the reference itself (it is interpolated between steps of about two days); deaths within 0.5, of which 0.4 is the reference’s error (it remains with a step of 0.01 days) and 0.12 is Starsim’s daily step. compare.py allows 73 people and 0.6 deaths |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (memilio)
ode_secir_simple.py
#############################################################################
# Copyright (C) 2020-2026 MEmilio
#
# Authors: Martin J. Kuehn, Wadim Koslow, Daniel Abele, Khoa Nguyen
#
# Contact: Martin J. Kuehn <Martin.Kuehn@DLR.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#############################################################################
import argparse
from datetime import date, datetime
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from memilio.simulation import AgeGroup, ContactMatrix, Damping, UncertainContactMatrix
from memilio.simulation.osecir import Index_InfectionState
from memilio.simulation.osecir import InfectionState as State
from memilio.simulation.osecir import (Model, Simulation,
interpolate_simulation_result, simulate)
def run_ode_secir_simulation(show_plot=True):
"""Runs the c++ ODE SECIHURD model using one age group
and plots the results
:param show_plot: (Default value = True)
"""
# Define Comartment names
compartments = [
'Susceptible', 'Exposed', 'InfectedNoSymptoms', 'InfectedSymptoms',
'InfectedSevere', 'InfectedCritical', 'Recovered', 'Dead']
# Define population of age groups
populations = [83000]
days = 100 # number of days to simulate
start_day = 1
start_month = 1
start_year = 2019
dt = 0.1
num_groups = 1
num_compartments = len(compartments)
# Initialize Parameters
model = Model(1)
A0 = AgeGroup(0)
# Set parameters
# Compartment transition duration
model.parameters.TimeExposed[A0] = 3.2
model.parameters.TimeInfectedNoSymptoms[A0] = 2.
model.parameters.TimeInfectedSymptoms[A0] = 6.
model.parameters.TimeInfectedSevere[A0] = 12.
model.parameters.TimeInfectedCritical[A0] = 8.
# Initial number of people in each compartment
model.populations[A0, State.Exposed] = 100
model.populations[A0, State.InfectedNoSymptoms] = 50
model.populations[A0, State.InfectedNoSymptomsConfirmed] = 0
model.populations[A0, State.InfectedSymptoms] = 50
model.populations[A0, State.InfectedSymptomsConfirmed] = 0
model.populations[A0, State.InfectedSevere] = 20
model.populations[A0, State.InfectedCritical] = 10
model.populations[A0, State.Recovered] = 10
model.populations[A0, State.Dead] = 0
model.populations.set_difference_from_total(
(A0, State.Susceptible), populations[0])
# Compartment transition propabilities
model.parameters.RelativeTransmissionNoSymptoms[A0] = 0.67
model.parameters.TransmissionProbabilityOnContact[A0] = 1.0
model.parameters.RecoveredPerInfectedNoSymptoms[A0] = 0.09 # 0.01-0.16
model.parameters.RiskOfInfectionFromSymptomatic[A0] = 0.25 # 0.05-0.5
model.parameters.SeverePerInfectedSymptoms[A0] = 0.2 # 0.1-0.35
model.parameters.CriticalPerSevere[A0] = 0.25 # 0.15-0.4
model.parameters.DeathsPerCritical[A0] = 0.3 # 0.15-0.77
# twice the value of RiskOfInfectionFromSymptomatic
model.parameters.MaxRiskOfInfectionFromSymptomatic[A0] = 0.5
model.parameters.StartDay = (
date(start_year, start_month, start_day) - date(start_year, 1, 1)).days
# model.parameters.ContactPatterns.cont_freq_mat[0] = ContactMatrix(np.r_[0.5])
model.parameters.ContactPatterns.cont_freq_mat[0].baseline = np.ones(
(num_groups, num_groups)) * 1
model.parameters.ContactPatterns.cont_freq_mat[0].minimum = np.ones(
(num_groups, num_groups)) * 0
model.parameters.ContactPatterns.cont_freq_mat.add_damping(
Damping(coeffs=np.r_[0.9], t=30.0, level=0, type=0))
# Apply mathematical constraints to parameters
model.apply_constraints()
# Run Simulation
result = simulate(0, days, dt, model)
# interpolate results
result = interpolate_simulation_result(result)
print(result.get_last_value())
num_time_points = result.get_num_time_points()
result_array = result.as_ndarray()
t = result_array[0, :]
group_data = np.transpose(result_array[1:, :])
# sum over all groups
data = np.zeros((num_time_points, num_compartments))
for i in range(num_groups):
data += group_data[:, i * num_compartments: (i + 1) * num_compartments]
# Plot Results
datelist = np.array(
pd.date_range(
datetime(start_year, start_month, start_day),
periods=days, freq='D').strftime('%m-%d').tolist())
tick_range = (np.arange(int(days / 10) + 1) * 10)
tick_range[-1] -= 1
fig, ax = plt.subplots()
ax.plot(t, data[:, 0], label='#Susceptible')
ax.plot(t, data[:, 1], label='#Exposed')
ax.plot(t, data[:, 2], label='#Carrying')
ax.plot(t, data[:, 3], label='#InfectedSymptoms')
ax.plot(t, data[:, 4], label='#Hospitalzed')
ax.plot(t, data[:, 5], label='#InfectedCritical')
ax.plot(t, data[:, 6], label='#Recovered')
ax.plot(t, data[:, 7], label='#Died')
ax.set_title("ODE SECIR model simulation")
ax.set_xticks(tick_range)
ax.set_xticklabels(datelist[tick_range], rotation=45)
ax.legend()
fig.tight_layout
fig.savefig('osecir_simple.pdf')
if show_plot:
plt.show()
plt.close()
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(
'ode_secir_simple',
description='Simple example demonstrating the setup and simulation of the ODE SECIHURD model.')
arg_parser.add_argument('-p', '--show_plot',
action='store_const', const=True, default=False)
args = arg_parser.parse_args()
run_ode_secir_simulation(**args.__dict__)Starsim
model.py
"""
The MEmilio simple SECIR example (docs/models/memilio/secir), in Starsim: infection, then a chain of
increasingly severe states, each of which some people leave by recovering
"""
import starsim as ss
contacts = ss.timeseries({0: 1.0, 29: 1.0, 30: 0.1}, interpolation='cosine') # Cut by 90% over day 29, as MEmilio's damping
S, E, INS, ISy, ISev, ICr, R = ss.states(7)
secir = ss.Disease(
infection = S >> E @ contacts * (0.67 * INS + 0.25 * ISy),
onset = E >> INS @ ss.days(3.2),
symptoms = ss.Transition(INS, {ISy: 0.91, R: None}, dur=ss.days(2)),
severe = ss.Transition(ISy, {ISev: 0.2, R: None}, dur=ss.days(6)),
critical = ss.Transition(ISev, {ICr: 0.25, R: None}, dur=ss.days(12)),
recovery = ss.Transition(ICr, R, dur=ss.days(8), p=0.7),
death = ss.Transition(ICr, ss.dead, dur=ss.days(8), p=0.3),
init_n = dict(E=100, INS=50, ISy=50, ISev=20, ICr=10, R=10),
)
sim = ss.Sim(secir, total_pop=83_000, stop=ss.days(100))
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. Each severity step is one transition with two destinations and a probability; deaths leave the model, so they are counted from the flow and the people who mix are the ones alive, as in the original.
Findings
- Fixed: a value that varies in time was held for a whole step. The original lowers contacts smoothly over day 29, along half a cosine.
ss.timeseries(..., interpolation='cosine')says the same, and the ODE solver reads it at each stage of a step, so the translation no longer gives that day its average by hand. - Two states left out. The original has two “confirmed” states that nothing flows into; they are not in the translation.
- The dead are a flow, not a state. The number dead is
res.death.cum.
MEmilio: age-resolved ODE SECIR, with seasonality and testing capacity
The SECIR model of the wave 1 example in three age groups of 10,000 people over 50 days, with three things the wave 1 example leaves at their defaults: seasonal transmission, a limited capacity to test and trace (so that symptomatic people infect more once it is overwhelmed), and contacts between every pair of age groups. Contacts are cut by 70% on day 30.
- Framework: memilio 2.4.1 (Apache-2.0)
- Language: Python (bindings to C++)
- Source: https://github.com/SciCompMod/memilio/blob/v2.4.1/pycode/examples/simulation/ode_secir_ageres.py
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 45 | 22 | 18 lines, three of them over 100 characters, so counted twice |
| One run | first 0.77 ms, rerun 0.06 ms | first 19 ms, rerun 0.67 ms (Rust) | Neither counts starting Python or importing. Starsim’s first run is nearly all building the sim. MEmilio’s solver is C++ code built in advance |
| Results | Agree. Within 1.5 people of 10,000 at every time (1.3 in Susceptible, 1.5 in Recovered), the same with a step of a day or a tenth of a day, which is the error of the reference itself (it is interpolated between solver steps); deaths within 0.02. compare.py allows 1.6 people and 0.02 deaths |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (memilio)
ode_secir_ageres.py
#############################################################################
# Copyright (C) 2020-2026 MEmilio
#
# Authors: Maximilian Betz
#
# Contact: Martin J. Kuehn <Martin.Kuehn@DLR.de>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#############################################################################
import argparse
import os
from datetime import date, datetime
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from memilio.simulation import ContactMatrix, Damping, UncertainContactMatrix, AgeGroup
from memilio.simulation.osecir import Index_InfectionState
from memilio.simulation.osecir import InfectionState as State
from memilio.simulation.osecir import Model, Simulation, simulate
def run_ode_secir_ageres_simulation(show_plot=True):
"""Runs the c++ ODE SECIHURD model using multiple age groups
and plots the results
:param show_plot: (Default value = True)
"""
model = Model(3)
t0 = 0
tmax = 50
dt = 0.1
cont_freq = 10
nb_total_t0, nb_exp_t0, nb_inf_t0, nb_car_t0, nb_hosp_t0, nb_icu_t0, nb_rec_t0, nb_dead_t0 = 10000, 100, 50, 50, 20, 10, 10, 0
nb_comp = 10
nb_groups = 3
fact = 1.0/nb_groups
model.parameters.StartDay = 60
model.parameters.Seasonality.value = 0.2
model.parameters.TestAndTraceCapacity.value = 35
for i in range(0, 3):
Ai = AgeGroup(i)
model.parameters.TimeExposed[Ai] = 3.2
model.parameters.TimeInfectedNoSymptoms[Ai] = 2.0
model.parameters.TimeInfectedSymptoms[Ai] = 5.8
model.parameters.TimeInfectedSevere[Ai] = 9.5
model.parameters.TimeInfectedCritical[Ai] = 7.1
model.populations[Ai, State.Exposed] = fact * nb_exp_t0
model.populations[Ai, State.InfectedNoSymptoms] = fact * nb_car_t0
model.populations[Ai, State.InfectedSymptoms] = fact * nb_inf_t0
model.populations[Ai, State.InfectedSevere] = fact * nb_hosp_t0
model.populations[Ai, State.InfectedCritical] = fact * nb_icu_t0
model.populations[Ai, State.Recovered] = fact * nb_rec_t0
model.populations[Ai, State.Dead] = fact * nb_dead_t0
model.populations.set_difference_from_group_total_AgeGroup(
(Ai, State.Susceptible), fact * nb_total_t0)
model.parameters.TransmissionProbabilityOnContact[Ai] = 0.05
model.parameters.RelativeTransmissionNoSymptoms[Ai] = 0.7
model.parameters.RecoveredPerInfectedNoSymptoms[Ai] = 0.09
model.parameters.RiskOfInfectionFromSymptomatic[Ai] = 0.25
model.parameters.MaxRiskOfInfectionFromSymptomatic[Ai] = 0.45
model.parameters.SeverePerInfectedSymptoms[Ai] = 0.2
model.parameters.CriticalPerSevere[Ai] = 0.25
model.parameters.DeathsPerCritical[Ai] = 0.3
model.apply_constraints()
contacts = ContactMatrix(
np.full((nb_groups, nb_groups), fact * cont_freq))
contacts.add_damping(
Damping(coeffs=np.full((nb_groups, nb_groups), 0.7), t=30.0, level=0, type=0))
model.parameters.ContactPatterns.cont_freq_mat[0] = contacts
model = model
result = simulate(t0=t0, tmax=tmax,
dt=dt, model=model)
print(result.get_last_value())
num_time_points = result.get_num_time_points()
result_array = result.as_ndarray()
t = result_array[0, :]
group_data = np.transpose(result_array[1:, :])
# sum over all groups
data = np.zeros((num_time_points, nb_comp))
for i in range(nb_groups):
data += group_data[:, i * nb_comp: (i + 1) * nb_comp]
start_day = 1
start_month = 1
start_year = 2019
if (show_plot):
# Plot Results
datelist = np.array(
pd.date_range(
datetime(start_year, start_month, start_day),
periods=tmax, freq='D').strftime('%m-%d').tolist())
tick_range = (np.arange(int(tmax / 10) + 1) * 10)
tick_range[-1] -= 1
fig, ax = plt.subplots()
ax.plot(t, data[:, 0], label='#Susceptible')
ax.plot(t, data[:, 1], label='#Exposed')
ax.plot(t, data[:, 2], label='#Carrying')
ax.plot(t, data[:, 3], label='#InfectedSymptoms')
ax.plot(t, data[:, 4], label='#Hospitalzed')
ax.plot(t, data[:, 5], label='#InfectedCritical')
ax.plot(t, data[:, 6], label='#Recovered')
ax.plot(t, data[:, 7], label='#Died')
ax.set_title("ODE SECIR model simulation")
ax.set_xticks(tick_range)
ax.set_xticklabels(datelist[tick_range], rotation=45)
ax.legend()
fig.tight_layout
fig.savefig('osecir_simple.pdf')
plt.show()
plt.close()
for timestep in range(num_time_points):
print(timestep, ": ", result.get_time(timestep), "\n")
if __name__ == "__main__":
arg_parser = argparse.ArgumentParser(
'ode_secir_ageres',
description='Example demonstrating the setup and simulation of the ODE SECIHURD model with multiple age groups.')
arg_parser.add_argument('-p', '--show_plot',
action='store_const', const=True, default=False)
args = arg_parser.parse_args()
run_ode_secir_ageres_simulation(**args.__dict__)Starsim
model.py
"""
The MEmilio age-resolved SECIR example (docs/models/memilio/secir_ageres), in Starsim: the SECIR of
docs/models/memilio/secir in three age groups, with seasonality, a cut in contacts, and symptomatic
people who infect more once testing and tracing is overwhelmed
"""
import numpy as np
import starsim as ss
beta = ss.seasonal(mean=0.05, amplitude=0.2, peak=ss.days(-60)) # Highest on 1 Jan; day 0 is 2 Mar
contacts = ss.timeseries({0: 1.0, 29: 1.0, 30: 0.3}, interpolation='cosine') # Cut by 70% over day 29, as MEmilio's damping
S, E, INS, ISy, ISev, ICr, R = ss.states(7)
excess = np.clip((0.91/2*INS.n - 35)/(5*35 - 35), 0, 1) # People becoming symptomatic each day, beyond the 35 that can be traced (to 5 times that)
risk = 0.35 - 0.1*np.cos(np.pi*excess) # So symptomatic people infect more: from 0.25 to 0.45, along half a cosine
secir = ss.Disease(
infection = S >> E @ beta * contacts * (0.7 * INS + risk * ISy),
onset = E >> INS @ ss.days(3.2),
symptoms = ss.Transition(INS, {ISy: 0.91, R: None}, dur=ss.days(2)),
severe = ss.Transition(ISy, {ISev: 0.2, R: None}, dur=ss.days(5.8)),
critical = ss.Transition(ISev, {ICr: 0.25, R: None}, dur=ss.days(9.5)),
recovery = ss.Transition(ICr, R, dur=ss.days(7.1), p=0.7),
death = ss.Transition(ICr, ss.dead, dur=ss.days(7.1), p=0.3),
init_n = dict(E=100, INS=50, ISy=50, ISev=20, ICr=10, R=10),
)
secir.stratify(age=['0', '1', '2']) # Three groups of equal size, each with a third of every state
mixing = ss.contacts(np.full((3, 3), 10/3)) # 10 contacts a day, spread evenly over the groups
sim = ss.Sim(secir, networks=mixing, total_pop=10_000, stop=ss.days(50))
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: the seven states and seven transitions (as in the wave 1 model), the three age groups and their contact matrix, the initial counts (split evenly between the groups), seasonality as an ss.seasonal, the cut in contacts as an ss.timeseries with interpolation='cosine', and the rise in the risk of infection from symptomatic people as testing and tracing is overwhelmed, as an expression of the count in INS (np.clip and np.cos of INS.n) used as the weight of ISy.
Left out, as having no effect here: the two confirmed states (nothing flows into them), the ICU capacity (unlimited), and DeathsPerSevere (0). The dead leave the model, as in the wave 1 model.
Findings
- Fixed: a value that varies in time was held for a whole step. The ODE read
ss.seasonalandss.timeseriesonce at the start of each step, while MEmilio evaluates them at every stage of its solver, which gave differences of 9 people at a step of a day. The ODE now reads them at each stage, and the results no longer depend on the step. - Fixed: a rate could not clip or smooth a count. The risk is 0.25 below a threshold of the count in
INS, 0.45 above five times it, and half a cosine between. Rates now acceptnp.clipandnp.cosof counts and build them into the run, so the risk is two declared lines,sim.restart()works, anddescribe()prints the whole formula. - A state’s count cannot be read inside a function through the state itself (still open, but not needed here). In a function of the sim,
INS.nis a symbolic expression, not a number, sonp.clip(INS.n, 0, 1)inside a function raises aTypeError; the function has to usesim.diseases.secir.INS.n. The smallest fix: let the declared states it closes over evaluate to their current counts, or give the error a hint. - The threshold is on a flow, which has to be written again as a count (open). MEmilio’s quantity to trace is the number becoming symptomatic each day, the flow of
symptomstoISy. Starsim has no way to name a transition’s current flow in a rate, so the expression writes it as0.91/2*INS.n, repeating the probability and the time fromsymptoms. Asymptoms.flowthat can be used in an expression would remove the repetition. - Fixed: a change in contacts smoothed over a day.
ss.timeseries({0: 1.0, 29: 1.0, 30: 0.3}, interpolation='cosine')is written as MEmilio’s damping. - A note that doesn’t apply. Building the sim says that
secir.infectionusesINS.nliterally, socontactsdoes not apply to it. That is what’s wanted here, since the risk depends on the count in the whole population, but the note reads like a warning. The results show that it is harmless. - Seasonality relative to a start day (fine, but obscure). MEmilio places day 0 at
StartDay = 60of a 365-day year; with no dates,ss.seasonalstarts at the beginning of a year, so the peak is writtenpeak=ss.days(-60). It matches exactly. - Age groups as labels (worked). The groups are named
'0','1', and'2', as MEmilio numbers them; Starsim treats them as labels, not ages, and does not age anyone.
odin: age-structured SEIR with a delay
A deterministic SEIR in 10 million people in five age groups (0–1, 1–5, 5–15, 15–30, and 30+ years), over one year in days, with births, deaths, aging, waning immunity, and a latent period of exactly 14 days written as a delay differential equation. It is odin’s own age-structured example (the README’s “more complicated example”, array_odin.R, with E and a delay added), and one of the six models odin’s tests check against hand-written deSolve code.
- Framework: odin 1.2.7 (MIT)
- Language: R (the odin modeling language)
- Source: tests/testthat/examples/seir_array_odin.R at https://github.com/mrc-ide/odin (the age-structured model the README points to, array_odin.R, with a latent period written as a delay), run over the year of its deSolve counterpart, seir_array_deSolve.R
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 53 | 18 | No line is over 100 characters |
| One run | first 710 ms, rerun 1.4 ms | first 19 ms, rerun 3.2 ms (Rust) | The original’s first run is mostly compiling the C code. Its run of the delay and Starsim’s conveyor are both quick |
| Results, in total | Agree. S, E, I, and R within 0.23%, 0.36%, 0.25%, and 0.15% of their largest values on every day (a quarter of that with a step of half a day), and I peaks at 6.19 million on day 125, as in the original. It needs the workaround of finding 1: without it the epidemic peaks 15 days late. compare.py allows 0.5% |
||
| Results, by age | Agree, except in the youngest group, where E, I, and R differ by up to 15%, 5%, and 8%. The original’s delay does not let the latent age (finding 2); Starsim’s does. compare.py allows 1% in the other groups |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (odin)
seir_array_odin.R
N_age <- 5
## mean no. of days in each age compartment (0 - 1 yr, 1 - 5 yr, 5 -
## 15 yr, 15 - 30yr, 30 +)
age_width[1] <- 365 * 1
age_width[2] <- 365 * 4
age_width[3] <- 365 * 10
age_width[4] <- 365 * 15
age_width[5] <- 365 * 20
age_rate[1:(N_age - 1)] <- 1 / age_width[i]
age_rate[N_age] <- 0
den[1] <- 1 / (1 + age_rate[1] / b)
## to work out the % of the population in each age group
den[2:N_age] <- age_rate[i - 1] * den[i - 1] / (age_rate[i] + b)
initial(S[1:N_age]) <- den[i] * (N - I0)
initial(E[1:N_age]) <- 0
initial(I[1:N_age]) <- den[i] * I0
initial(R[1:N_age]) <- 0
I0 <- user(1)
Births <- b * N
b <- 1 / (365 * 50)
N <- 1e7
beta <- 1
sigma <- 1 / 30
delta <- 1 / 60
lat_hum <- 14
I_tot <- sum(I)
surv <- exp(-b * lat_hum)
new_inf[] <- beta * S[i] * I_tot / N
lag_inf[] <- delay(new_inf[i] * surv, lat_hum)
deriv(S[1]) <- - new_inf[i] + delta * R[i] - b * S[i] +
(Births - age_rate[i] * S[i])
deriv(S[2:N_age]) <- - new_inf[i] + delta * R[i] - b * S[i] +
(age_rate[i - 1] * S[i - 1] - age_rate[i] * S[i])
deriv(E[1]) <- new_inf[i] - lag_inf[i] - b * E[i] + (- age_rate[i] * E[i])
deriv(E[2:N_age]) <- new_inf[i] - lag_inf[i] - b * E[i] +
(age_rate[i - 1] * E[i - 1] - age_rate[i] * E[i])
deriv(I[1]) <- lag_inf[i] - (b + sigma) * I[i] +
(- age_rate[i] * I[i])
deriv(I[2:N_age]) <- lag_inf[i] - (b + sigma) * I[i] +
(age_rate[i - 1] * I[i - 1] - age_rate[i] * I[i])
deriv(R[1]) <- sigma * I[i] - b * R[i] - delta * R[i] +
(- age_rate[i] * R[i])
deriv(R[2:N_age]) <- sigma * I[i] - b * R[i] - delta * R[i] +
(age_rate[i - 1] * R[i - 1] - age_rate[i] * R[i])
dim(den) <- N_age
dim(age_width) <- N_age
dim(age_rate) <- N_age
dim(S) <- N_age
dim(E) <- N_age
dim(I) <- N_age
dim(R) <- N_age
dim(new_inf) <- N_age
dim(lag_inf) <- N_ageStarsim
model.py
"""
The odin age-structured SEIR with a delay (docs/models/odin/seir_array), in Starsim: an SEIR in 10
million people in five age groups, with births, deaths, aging, waning immunity, and a latent
period of exactly 14 days
"""
import numpy as np
import starsim as ss
b = 1/50 # Births and deaths, per year
aging = np.array([1, 1/4, 1/10, 1/15, 0]) # Per year: one over the width of each age group
split = b*np.cumprod(np.append(1, aging[:-1])/(aging + b)) # The stable age distribution
surv = np.exp(-b*14/365) # The share of the latent who live for 14 days
S, E, I, R = ss.states(4)
seir = ss.Disease(
infection = S >> E @ ss.perday(1) * I,
onset = E >> I @ ss.constant(ss.days(14)), # Exactly 14 days, as in the original
recovery = I >> R @ ss.days(30),
waning = R >> S @ ss.days(60),
births = ss.birth(S, rate=ss.peryear(b)),
deaths = ss.death(rate=ss.peryear(b)),
history = ss.unborn >> I @ ss.timeseries({0: ss.perday(surv), 14: 0}), # Workaround: see README
init_n = dict(I=1),
)
seir.stratify(age=dict(zip([0, 1, 5, 15, 30], split)))
sim = ss.Sim(seir, total_pop=1e7, unit='day', stop=365)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: seven transitions and one stratification, whose aging between groups is generated, and the delay is a fixed dwell time. The stable age distribution is worked out by hand in two lines of NumPy, as the original works it out in two lines of odin (den[1] and den[2:N_age]). One transition, history, is a workaround (finding 1).
| Original | Starsim |
|---|---|
dim(S) <- N_age and so on, for 9 arrays |
seir.stratify(age=[0, 1, 5, 15, 30], fractions=split) |
age_width, age_rate, and the aging terms in all eight deriv() equations |
Generated by the age stratification, at one over each width |
new_inf[] <- beta * S[i] * I_tot / N with I_tot <- sum(I) |
S >> E @ ss.perday(1) * I |
lag_inf[] <- delay(new_inf[i] * surv, lat_hum) |
E >> I @ ss.constant(ss.days(14)), with deaths from E doing the work of surv |
Births <- b * N into S[1], and - b * X[i] in every equation |
ss.birth(S, ...) and ss.death(...) |
den[] |
split, worked out the same way |
delay()’s value before day 14 |
history, an inflow to I for 14 days (finding 1) |
Findings
The history of a delay (blocks agreement; worked around). For the first 14 days, odin’s
delay(new_inf * surv, 14)looks back to before day 0 and takes the value at day 0, so about one person a day reachesIas if infections had been going on before the start. A model whose latent state starts empty cannot do this, and with a reproduction number near 30, the 14 extra cases bring the epidemic forward by 15 days (without them,Idiffers by up to 66% of its peak). The workaround is a transition that adds them:history = ss.null >> I @ ss.timeseries({0: ss.perday(surv), 14: 0}). It adds 14 people that the original takes fromE, a difference of one part in a million. Suggested change: letinit_nfill a fixed delay as if people had been arriving at a constant rate (for exampleinit_n=dict(E=ss.arrived(rate=1))), since the conveyor below has a natural place for them.The original’s latent do not age (disagreement by age, not a Starsim issue). In the original,
Eis aged like every state, butlag_inf[i]takes fromE[i]everyone infected in groupi14 days earlier, including the 1 in 26 of the youngest who have aged out since. SoE[1]falls short andI[1]gets more than it should; summed over ages nothing is lost. Starsim’s conveyor ages with everyone else. This was checked with a separate Python version of the original’s equations: as written, it matches the reference to within 0.2% in every group; with the latent aging, it differs from the reference in the youngest group by about what Starsim does. Both are reasonable, and the difference comes from how the original is written.Fixed: a fixed delay was many stages, which were slow and still approximate. The delay used to be 200 Erlang stages, which took 1.6 s a run and was 1.6% off in
I. A fixed dwell time,ss.constant(ss.days(14)), is now a conveyor of whole steps under every solver: people who enter in a step wait in one slot for each step of the delay, thinned by deaths and moved along by aging. It is exact at the step, is not stiff, and takes milliseconds. The largest differences fall by about four times when the step is halved.Fixed: the first age group was labeled
0-0. An age group one year wide is now labeled by its single year, so the groups are0, 1-4, 5-14, 15-29, 30+, andcompare.pyselects by label.describe()leaves out aging (half fixed). Inflows are now printed as inflows, with their rate. Still open: the aging transfers generated by the age stratification are not listed, though spec 05 saysdescribe()lists them, so nothing in the output says that people age, or at what rates.Fixed: assigning a distribution to a transition after it was made broke the build. Assigning
durnow sets the value of the transition’s parameter. One thing to know:ss.Sim(seir)keeps its own copy of the disease, so assigningseir.onset.durafter the sim is made changes nothing in that sim, silently. It would be clearer if the sim either used the disease it was given or said that it had copied it.
odin: discrete-time stochastic SIR, written with arrays
A closed, stochastic SIR epidemic in discrete time, with every variable an array of length 100. It is the third model of odin’s “discrete” vignette, one step up from its scalar stochastic SIR.
- Framework: odin 1.2.7 (MIT)
- Language: R (the odin modeling language)
- Source: inst/examples/discrete_stochastic_sir_arrays.R at https://github.com/mrc-ide/odin, run as in the “discrete” vignette (vignettes/discrete.Rmd)
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 27 | 11 | |
| 100 runs | first 650 ms, rerun 0.95 ms | first 13 ms, rerun 0.91 ms (Rust) | Each of the original’s runs holds 100 replicates in its arrays. The Starsim times are for one run of 100 replicates, held as places that nobody moves between, which is the form of the original. As 100 separate runs (ss.parallel(sim, n_runs=100) in one process), a rerun takes 13 ms. The original’s first run includes compiling C code |
| Results | Agree. The mean of 20,000 replicates is within 6.1 people of 1,001 at every time, against the original’s 20,000 replicates; 44.8% die out. compare.py allows 12 people, three standard errors of the difference |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (odin)
discrete_stochastic_sir_arrays.R
## Core equations for transitions between compartments:
update(S[]) <- S[i] - n_SI[i]
update(I[]) <- I[i] + n_SI[i] - n_IR[i]
update(R[]) <- R[i] + n_IR[i]
## Individual probabilities of transition:
p_SI[] <- 1 - exp(-beta * I[i] / N[i])
p_IR <- 1 - exp(-gamma)
## Draws from binomial distributions for numbers changing between
## compartments:
n_SI[] <- rbinom(S[i], p_SI[i])
n_IR[] <- rbinom(I[i], p_IR)
## Total population size
N[] <- S[i] + I[i] + R[i]
## Initial states:
initial(S[]) <- S_ini
initial(I[]) <- I_ini
initial(R[]) <- 0
## User defined parameters - default in parentheses:
S_ini <- user(1000)
I_ini <- user(1)
beta <- user(0.2)
gamma <- user(0.1)
## Number of replicates
nsim <- user(100)
dim(N) <- nsim
dim(S) <- nsim
dim(I) <- nsim
dim(R) <- nsim
dim(p_SI) <- nsim
dim(n_SI) <- nsim
dim(n_IR) <- nsimStarsim
model.py
"""
The odin stochastic SIR with arrays (docs/models/odin/stochastic_sir_arrays), in Starsim: an SIR in
1,001 people, 100 times over. As in the original's array, the 100 replicates are held in one run, as
places that nobody moves between
"""
import starsim as ss
n_groups = 100 # The length of the arrays in the original
S, I, R = ss.states(3)
sir = ss.Disease(
infection = S >> I @ 0.2 * I,
recovery = I >> R @ 0.1,
init_n = dict(I=ss.by(run=[1]*n_groups)),
)
sir.stratify(run=ss.levels(list(map(str, range(n_groups))), kind='space')) # kind='space', or else the groups would mix as one population
sim = ss.Sim(sir, total_pop=1001*n_groups, stop=100, solver=ss.solvers.binomial(substeps=1)) # One draw a step, as in the original
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. The original writes out the binomial draws and the update of each compartment by hand; here the stochastic solver does the same thing from two transitions, with substeps=1 for one draw a step, as the original takes.
Findings
- Whole steps have to be asked for. Starsim’s chain binomial now chooses its own substeps, to reduce the error of taking a whole step, but the original’s model is defined by its whole steps. Without
substeps=1, people infected in a step can recover in the same step, and the meanSat the end is 22 people too high (5 standard errors), with 47.6% of runs dying out rather than 45.2%. That is a closer answer to the continuous-time model, but a different model from this one. A separate NumPy simulation of the original’s equations, 2 million runs, gave the check. - Many runs are fast in one process, and not with the default.
ss.parallel(sim, n_runs=20_000, parallel=False)is one call to the kernels and takes about 3 s.sim.run(n_runs=20_000)has no such option, and took about 300 s on 20 workers with a great deal of memory, since it starts a process for each worker. It would be better if a run the kernels take whole never started workers, or ifsim.run()tookparallel=False. - The original’s compiled model is faster. The original compiles code for this model; Starsim’s kernels are general, working through arrays that describe the model. See the table above for how much.
- The arrays are not coupled groups. This example was chosen as a step up from odin’s deterministic SIR, but its arrays hold replicates. They are written as places since that is 12× faster to rerun than separate runs, and
kind='space'is what keeps them independent: as plain groups they mix, and the mean finalSis 220, with no warning. The age-structured SEIR has coupled groups.
Pandemic Simulator: Ebola with community, hospital, and burial transmission
The framework’s ebola_seihfr_burial_legrand_model, its port of Legrand et al. (2007): an SEIHFR model of the 1995 outbreak in Kikwit, in 200,000 people, with measures from 4 May 1995. See the review.
- Framework: pandemic_simulator 0.1.0 (Apache-2.0)
- Language: Python
- Source: https://github.com/WHO-Collaboratory/pandemic-simulator-compartment/tree/main/compartment/models/ebola_seihfr_burial_legrand_model
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 306 | 30 | Most of the original’s lines are the declaration that also serves the web form and model card |
| One run | first 310 ms, rerun 5.8 ms | first 20 ms, rerun 1.1 ms (Rust) | Each is one run with the measures, and includes results. A first run excludes imports; the original’s includes compiling with JAX, and Starsim’s uses kernels already compiled. A rerun solves in about the same time on each side (the original’s solve alone is 0.98 ms); most of the original’s rerun is building its results |
| Results, without the measures | 17,044 left in S |
17,044 left in S |
Agree to 2.9e-6 of the population on every day, with a daily RK4 step against an adaptive solver |
| Results, with the measures | 179,445 left in S |
179,407 left in S |
Agree to 1.9e-4 of the population, or 3.1e-7 with the original in float64 (JAX_ENABLE_X64=1), where it leaves 179,407. See the findings for the difference |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (pandemic_simulator)
{
"start_date": "1995-01-06",
"end_date": "1996-01-06",
"Disease": {
"disease_type": "ebola-seihfr-burial-legrand",
"betaI": 0.084,
"betaH": 0.113,
"betaF": 1.093,
"gamma_h": 5.0,
"gamma_i": 10.0,
"gamma_d": 9.6,
"theta_target": 80.0,
"delta_target": 81.0
},
"TransmissionEdges": {
"items": [
{
"transmission_edge": {
"source": "E",
"target": "I",
"value_type": "DAYS"
},
"value": 7.0,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": false,
"disease_param": "ALPHA"
}
]
}
},
{
"transmission_edge": {
"source": "F",
"target": "R",
"value_type": "DAYS"
},
"value": 2.0,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": false,
"disease_param": "GAMMA_F"
}
]
}
}
]
},
"Interventions": {
"items": [
{
"Intervention": {
"name": "COMMUNITY_INTERVENTION",
"display_name": "Community Transmission Control"
},
"adherence_min": 100.0,
"transmission_percentage": 12.0,
"start_date": "1995-05-04",
"end_date": null
},
{
"Intervention": {
"name": "HOSPITAL_INTERVENTION",
"display_name": "Hospital Isolation / Barrier Nursing"
},
"adherence_min": 100.0,
"transmission_percentage": 100.0,
"start_date": "1995-05-04",
"end_date": null
},
{
"Intervention": {
"name": "FUNERAL_INTERVENTION",
"display_name": "Safe Burial Practices"
},
"adherence_min": 100.0,
"transmission_percentage": 100.0,
"start_date": "1995-05-04",
"end_date": null
}
]
},
"admin_zones": [
{
"name": "Kikwit, DRC (1995)",
"center_lat": -5.039,
"center_lon": 18.815,
"population": 200000,
"infected_population": 0.0015
}
]
}{
"start_date": "1995-01-06",
"end_date": "1996-01-06",
"Disease": {
"disease_type": "ebola-seihfr-burial-legrand",
"betaI": 0.084,
"betaH": 0.113,
"betaF": 1.093,
"gamma_h": 5.0,
"gamma_i": 10.0,
"gamma_d": 9.6,
"theta_target": 80.0,
"delta_target": 81.0
},
"TransmissionEdges": {
"items": [
{
"transmission_edge": { "source": "E", "target": "I", "value_type": "DAYS" },
"value": 7.0,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": true,
"distribution_type": "UNIFORM",
"disease_param": "ALPHA",
"min": 4.0,
"max": 10.0
}
]
}
},
{
"transmission_edge": { "source": "F", "target": "R", "value_type": "DAYS" },
"value": 2.0,
"FieldConfigs": {
"items": [
{ "field_key": "value", "has_variance": false, "disease_param": "GAMMA_F" }
]
}
}
]
},
"Interventions": {
"items": [
{
"Intervention": { "name": "COMMUNITY_INTERVENTION", "display_name": "Community Transmission Control" },
"adherence_min": 100.0,
"transmission_percentage": 12.0,
"start_date": "1995-05-04",
"end_date": null
},
{
"Intervention": { "name": "HOSPITAL_INTERVENTION", "display_name": "Hospital Isolation / Barrier Nursing" },
"adherence_min": 100.0,
"transmission_percentage": 100.0,
"start_date": "1995-05-04",
"end_date": null
},
{
"Intervention": { "name": "FUNERAL_INTERVENTION", "display_name": "Safe Burial Practices" },
"adherence_min": 100.0,
"transmission_percentage": 100.0,
"start_date": "1995-05-04",
"end_date": null
}
]
},
"admin_zones": [
{
"name": "Kikwit, DRC (1995)",
"center_lat": -5.039,
"center_lon": 18.815,
"population": 200000,
"infected_population": 0.0015
}
]
}"""Ebola SEIHFR model with community / hospital / burial transmission routes.
Port of the deterministic mean-field limit of the stochastic compartmental
model in Legrand J, Grais RF, Boelle PY, Valleron AJ, Flahault A.
"Understanding the dynamics of Ebola epidemics." Epidemiol Infect.
2007;135(4):610-621. doi:10.1017/S0950268806007217 (PMC2870608).
The paper splits the infectious period into three phases with distinct
transmission coefficients — illness in the community (I), hospitalisation
(H), and traditional burial of the dead (F) — and fits the model to the
1995 DRC (Kikwit) and 2000 Uganda (Gulu) outbreaks.
Compartments: S, E, I, H, F, R.
Two structural features of the source model don't fit the framework's
standard "declarative transmission edge" pattern and are therefore
computed manually in ``equation()`` (see the Pitfalls section of
``.claude/MODEL_AUTHORING_REFERENCE.md`` for the general pattern, and
``hantavirus_jax_model`` for a precedent):
1. The force of infection mixes three distinct betas across three
distinct infectious compartments (community/hospital/funeral) into a
single S -> E flow. ``_compute_equations`` only supports one shared
``infective_sum`` per frequency-dependent edge, so this can't be
expressed as ordinary transmission edges.
2. The three-way split of exits from I (to H, R, or F) and the two-way
split of exits from H (to R or F) use rates theta1, delta1, delta2
that are themselves *derived* at every step from the raw
epidemiological parameters (gamma_h, gamma_i, gamma_d, and the target
hospitalisation/case-fatality proportions theta, delta) via the
algebraic relationships in the paper's Table 2 / Appendix. These are
not standalone "rate * source" edges.
The E -> I (incubation, alpha) and F -> R (burial, gamma_f) transitions
*are* simple single-rate flows and are declared as ordinary transmission
edges.
WARNING: Like the other models in this repo, this implementation is
intended for local experimentation; it is not yet supported by the
pandemic simulator app.
"""
import logging
import jax.numpy as jnp
from compartment.model import Model, ValueType
from compartment.parameters import CompartmentDef
logger = logging.getLogger(__name__)
class EbolaSeihfrBurialLegrandModel(Model):
"""SEIHFR Ebola model with community/hospital/burial transmission (Legrand et al. 2007)."""
# Cumulative inflow trackers. Declared here rather than in
# define_parameters() (and computed explicitly in equation()) because most
# of the inflows they track are manual multi-rate flows, not transmission
# edges — the framework's per-edge auto-generation would only produce
# I_total and R_total. Same pattern as dengue_jax_model.
_TOTAL_COMPARTMENTS = (
("E_total", "Exposed Total",
"Cumulative number of individuals ever exposed (cumulative infections)."),
("I_total", "Infectious (community) Total",
"Cumulative number of individuals who ever became infectious."),
("H_total", "Hospitalised Total",
"Cumulative number of individuals ever hospitalised."),
("F_total", "Deaths Total",
"Cumulative number of individuals who died of Ebola."),
("R_total", "Removed Total",
"Cumulative number of individuals removed (recovered or safely buried)."),
)
@classmethod
def _add_total_compartments(cls, schema):
"""Append this model's fixed cumulative ``_total`` compartments.
Replaces the framework's per-edge auto-generation, which would only
produce ``I_total`` and ``R_total``; the remaining trackers follow manual
multi-rate flows and are assigned explicitly in ``equation``.
Args:
schema (ModelParameterSchema): Schema whose ``compartments`` list
receives one entry per ``_TOTAL_COMPARTMENTS`` definition.
"""
# Replaces the framework's per-edge auto-generation entirely: every
# _total this model needs is declared below, in a fixed order, and its
# derivative is assigned explicitly at the end of equation().
for cid, label, description in cls._TOTAL_COMPARTMENTS:
schema.compartments.append(
CompartmentDef(id=cid, label=label, description=description)
)
@classmethod
def define_parameters(cls, schema):
"""Declare the SEIHFR compartments, parameters, and control interventions.
Declares the two simple transmission edges (E->I incubation, F->R burial),
the community/hospital/burial transmission coefficients as day-scaled
Legrand et al. 2007 DRC 1995 estimates, the raw durations feeding the
derived exit-split rates, and one intervention per transmission route.
Args:
schema (ParameterSchemaBuilder): Schema builder to populate.
"""
schema.set_model_info(
disease_type="ebola-seihfr-burial-legrand",
label="Ebola (SEIHFR, Community/Hospital/Burial) — Legrand et al. 2007",
description=(
"SEIHFR compartmental model of Ebola virus disease with explicit "
"community, hospital, and traditional-burial transmission routes, "
"fitted to the 1995 DRC and 2000 Uganda outbreaks (Legrand et al., "
"Epidemiol. Infect. 2007)."
),
)
schema.set_model_metadata(
authors=[
{
"name": "Judith Legrand, R F Grais, P Y Boelle, A J Valleron, A Flahault",
"affiliation": "INSERM UMR-S 707 / Universite Pierre et Marie Curie",
}
],
license="N/A (public research model; see paper for terms)",
citations=[
"Legrand J, Grais RF, Boelle PY, Valleron AJ, Flahault A. "
"Understanding the dynamics of Ebola epidemics. "
"Epidemiol Infect. 2007;135(4):610-621. "
"doi:10.1017/S0950268806007217"
],
model_type="Compartmental",
diseases=["Ebola virus disease"],
transmission_routes=["Direct contact (community)", "Nosocomial (hospital)", "Traditional burial"],
questions_answered=[
"How much do community, hospital, and burial transmission each contribute to R0?",
"How does the timing and strength of control interventions change epidemic size?",
],
key_assumptions=[
"Homogeneous mixing within the population (no age/spatial structure).",
"Entire population initially susceptible.",
"Interventions are fully efficient from their start date onward "
"(step-function activation, not gradual).",
"After interventions, hospital and burial transmission are eliminated "
"and community transmission is reduced by a fixed factor.",
],
)
# --- Compartments ---------------------------------------------------
schema.add_compartment(
"S", "Susceptible", "Population susceptible to Ebola infection",
)
schema.add_compartment(
"E", "Exposed",
"Infected but not yet infectious (incubating).",
)
schema.add_compartment(
"I", "Infectious (community)",
"Symptomatic and infectious in the community.",
infective=True,
)
schema.add_compartment(
"H", "Hospitalised",
"Hospitalised (including isolation ward); infectious.",
infective=True,
)
schema.add_compartment(
"F", "Dead, awaiting burial",
"Died of Ebola but not yet buried; infectious during traditional burial.",
infective=True,
)
schema.add_compartment(
"R", "Removed",
"Removed from the chain of transmission (recovered or safely buried).",
)
# --- Transmission edges (simple single-rate flows only) ------------
schema.add_transmission_parameter(
source="E", target="I",
variable_name="alpha",
label="Incubation Period (E->I)",
description="Mean duration of the incubation (exposed, non-infectious) period.",
default=7.0,
default_min=5.0, default_max=9.0,
min_value=1.0, max_value=30.0,
unit="days",
value_type=ValueType.DAYS,
)
schema.add_transmission_parameter(
source="F", target="R",
variable_name="gamma_f",
label="Death-to-Burial Period (F->R)",
description="Mean duration between death and (safe or traditional) burial.",
default=2.0,
default_min=1.0, default_max=3.0,
min_value=1.0, max_value=14.0,
unit="days",
value_type=ValueType.DAYS,
)
# --- Disease parameters (multi-rate flows computed manually) -------
# Transmission coefficients. DRC 1995 point estimates from Table 4 of
# the paper: beta_I=0.588, beta_H=0.794, beta_F=7.653 week^-1,
# converted here to day^-1 (/7) since the framework's solver runs in
# days. 95% CIs (also /7) become default_min/default_max.
schema.add_parameter(
name="betaI",
label="Transmission Rate — Community (βI)",
description=(
"Transmission coefficient for contact with symptomatic cases in "
"the community, before interventions."
),
value_type=ValueType.RATE,
default=0.084,
default_min=0.064, default_max=0.104,
min_value=0.0, max_value=5.0,
unit="per day",
)
schema.add_parameter(
name="betaH",
label="Transmission Rate — Hospital (βH)",
description=(
"Transmission coefficient for contact with hospitalised cases "
"(including isolation ward), before interventions."
),
value_type=ValueType.RATE,
default=0.113,
default_min=0.07, default_max=0.15,
min_value=0.0, max_value=5.0,
unit="per day",
)
schema.add_parameter(
name="betaF",
label="Transmission Rate — Traditional Burial (βF)",
description=(
"Transmission coefficient for contact with the body during "
"traditional burial, before interventions."
),
value_type=ValueType.RATE,
default=1.093,
default_min=0.08, default_max=1.4,
min_value=0.0, max_value=5.0,
unit="per day",
)
# Raw duration/rate parameters that feed the derived theta1/delta1/
# delta2/gamma_ih/gamma_dh quantities used to split exits from I and H.
# NOTE: these three use ValueType.FLOAT rather than ValueType.DAYS.
# ValueType.DAYS on add_parameter() (unlike on add_transmission_parameter())
# maps to a Pydantic *int* field in the auto-generated disease config
# (see schema_generator.py's _VALUE_TYPE_TO_PYTHON), which rejects the
# fractional published estimate gamma_d=9.6 and non-integer bounds.
# FLOAT keeps native-unit (days) semantics; the mean-duration ->
# per-day-rate conversion is still done manually via
# self._to_rate(value, ValueType.DAYS) in equation().
schema.add_parameter(
name="gamma_h",
label="Onset to Hospitalisation (γh⁻¹, days)",
description=(
"Mean duration from symptom onset to hospitalisation, for cases "
"who are hospitalised."
),
value_type=ValueType.FLOAT,
default=5.0,
default_min=1.0, default_max=9.0,
min_value=0.5, max_value=30.0,
unit="days",
)
schema.add_parameter(
name="gamma_i",
label="Infectious Period, Survivors (γi⁻¹, days)",
description=(
"Mean duration from symptom onset to end of infectiousness for "
"cases who survive. Must exceed γh⁻¹ (patients are hospitalised "
"before their infectious period would otherwise end)."
),
value_type=ValueType.FLOAT,
default=10.0,
default_min=7.0, default_max=15.0,
min_value=1.0, max_value=40.0,
unit="days",
)
schema.add_parameter(
name="gamma_d",
label="Onset to Death, Non-Hospitalised (γd⁻¹, days)",
description=(
"Mean duration from symptom onset to death for cases who die "
"without being hospitalised. Must exceed γh⁻¹."
),
value_type=ValueType.FLOAT,
default=9.6,
default_min=7.0, default_max=12.0,
min_value=1.0, max_value=40.0,
unit="days",
)
schema.add_parameter(
name="theta_target",
label="Hospitalisation Proportion (θ)",
description=(
"Target proportion of infectious cases who are hospitalised. "
"The per-step hospitalisation rate (theta1) is derived from this "
"target together with γh, γi, γd, and δ — it is not set directly."
),
value_type=ValueType.PERCENTAGE,
default=80.0,
default_min=50.0, default_max=100.0,
min_value=0.0, max_value=100.0,
unit="%",
)
schema.add_parameter(
name="delta_target",
label="Case-Fatality Ratio (δ)",
description=(
"Target overall case-fatality ratio across both hospitalised and "
"non-hospitalised cases. The per-compartment death-split rates "
"(delta1, delta2) are derived from this target."
),
value_type=ValueType.PERCENTAGE,
default=50.0,
default_min=10.0, default_max=90.0,
min_value=0.0, max_value=100.0,
unit="%",
)
# --- Interventions ---------------------------------------------------
# The paper assumes interventions are fully efficient once active
# (assumption b) and, from that date on: hospital and burial
# transmission are eliminated, community transmission is reduced by a
# fixed factor (assumption d). Three separate interventions are used
# (rather than one shared one) because the reduction magnitude differs
# per rate, and add_intervention applies one reduction % to all of its
# target_rates.
schema.add_intervention(
id="community_intervention",
label="Community Transmission Control",
description=(
"Community education, household protective equipment, and "
"contact-tracing measures that reduce (but do not eliminate) "
"community transmission."
),
target_rates=["betaI"],
adherence=50.0,
transmission_reduction=90.0,
)
schema.add_intervention(
id="hospital_intervention",
label="Hospital Isolation / Barrier Nursing",
description=(
"Isolation ward and barrier-nursing procedures at the hospital. "
"The paper assumes these eliminate hospital transmission once "
"active."
),
target_rates=["betaH"],
adherence=50.0,
transmission_reduction=90.0,
)
schema.add_intervention(
id="funeral_intervention",
label="Safe Burial Practices",
description=(
"Replacement of traditional burial with safe burial practices "
"(e.g. by trained response teams). The paper assumes this "
"eliminates funeral transmission once active."
),
target_rates=["betaF"],
adherence=50.0,
transmission_reduction=100.0,
)
# ------------------------------------------------------------------
# Init
# ------------------------------------------------------------------
def __init__(self, config):
"""Initialise the model from the validated configuration.
The source model has no inter-zone travel and no demographic
stratification, so no extra setup is needed beyond the base class.
Args:
config (dict): Validated simulation configuration.
"""
super().__init__(config)
# No inter-zone travel and no demographic stratification in the
# source model — nothing further to set up here.
# ------------------------------------------------------------------
# Simulation setup
# ------------------------------------------------------------------
def prepare_initial_state(self):
"""Return the initial compartment populations for the solver.
Returns:
jnp.ndarray: Population matrix of shape (n_compartments, n_zones).
"""
return self.population_matrix
# ------------------------------------------------------------------
# ODE
# ------------------------------------------------------------------
def equation(self, y, t, p):
"""Compute the SEIHFR compartment derivatives for one integration step.
Mixes the community, hospital, and burial transmission coefficients into a
single S->E force of infection, then derives the theta1/delta1/delta2 exit
splits for I and H from gamma_h, gamma_i, gamma_d and the target
hospitalisation and case-fatality proportions (Legrand et al. 2007).
Args:
y (jnp.ndarray): Current compartment values, ordered by
``compartment_list``.
t (float): Current time in days since the simulation start date.
p (tuple): Packed transmission-edge parameter tuple (alpha, gamma_f),
unpacked via ``_unpack_params``.
Returns:
jnp.ndarray: Stacked per-compartment derivatives (dy/dt).
"""
states = {c: y[i] for i, c in enumerate(self.compartment_list)}
params = self._unpack_params(p)
S, E, I, H, F = (
states["S"], states["E"], states["I"], states["H"], states["F"],
)
non_total = [c for c in self.compartment_list if not c.endswith("_total")]
N_total = sum(states[c] for c in non_total)
prop_infective = (I + H + F).sum() / (N_total.sum() + 1e-10)
# --- Interventions on the three transmission coefficients ----------
disease_rates = {"betaI": self.betaI, "betaH": self.betaH, "betaF": self.betaF}
disease_rates, self.travel_matrix = self._apply_interventions(
t, disease_rates, prop_infective
)
betaI, betaH, betaF = (
disease_rates["betaI"], disease_rates["betaH"], disease_rates["betaF"],
)
# --- Framework-handled simple edges: E->I (alpha), F->R (gamma_f) --
derivs = self._compute_equations(states, params)
# --- Manual multi-source FOI: S -> E --------------------------------
foi = (betaI * I + betaH * H + betaF * F) / (N_total + 1e-10)
flow_S_to_E = S * foi
self._apply_flow(derivs, "S", "E", flow_S_to_E)
# --- Derived split rates for I's and H's exits ----------------------
gamma_h = self._to_rate(self.gamma_h, ValueType.DAYS)
gamma_i = self._to_rate(self.gamma_i, ValueType.DAYS)
gamma_d = self._to_rate(self.gamma_d, ValueType.DAYS)
theta = self._to_rate(self.theta_target, ValueType.PERCENTAGE)
delta = self._to_rate(self.delta_target, ValueType.PERCENTAGE)
eps = 1e-10
# 1/gamma_ih = 1/gamma_i - 1/gamma_h ; 1/gamma_dh = 1/gamma_d - 1/gamma_h
gamma_ih = 1.0 / jnp.maximum(1.0 / gamma_i - 1.0 / gamma_h, eps)
gamma_dh = 1.0 / jnp.maximum(1.0 / gamma_d - 1.0 / gamma_h, eps)
delta1 = (delta * gamma_i) / (delta * gamma_i + (1.0 - delta) * gamma_d + eps)
delta2 = (delta * gamma_ih) / (delta * gamma_ih + (1.0 - delta) * gamma_dh + eps)
hosp_weight = theta * (gamma_i * (1.0 - delta1) + gamma_d * delta1)
theta1 = hosp_weight / (hosp_weight + (1.0 - theta) * gamma_h + eps)
# --- Manual exits from I: to H, R (survivors), F (deaths) ----------
flow_I_to_H = gamma_h * theta1 * I
flow_I_to_R = gamma_i * (1.0 - theta1) * (1.0 - delta1) * I
flow_I_to_F = gamma_d * (1.0 - theta1) * delta1 * I
self._apply_flow(derivs, "I", "H", flow_I_to_H)
self._apply_flow(derivs, "I", "R", flow_I_to_R)
self._apply_flow(derivs, "I", "F", flow_I_to_F)
# --- Manual exits from H: to R (survivors), F (deaths) -------------
flow_H_to_R = gamma_ih * (1.0 - delta2) * H
flow_H_to_F = gamma_dh * delta2 * H
self._apply_flow(derivs, "H", "R", flow_H_to_R)
self._apply_flow(derivs, "H", "F", flow_H_to_F)
# --- Cumulative inflow trackers -------------------------------------
# Assigned (not incremented) after every flow above, so these
# deliberately overwrite the implicit _total bookkeeping done inside
# _compute_equations / _apply_flow instead of double-counting it.
# Each is the sum of all inflows to the corresponding compartment.
derivs["E_total"] = flow_S_to_E
derivs["I_total"] = params["alpha"] * E
derivs["H_total"] = flow_I_to_H
derivs["F_total"] = flow_I_to_F + flow_H_to_F
derivs["R_total"] = flow_I_to_R + flow_H_to_R + params["gamma_f"] * F
return jnp.stack([derivs[c] for c in self.compartment_list])Starsim
model.py
"""
Ebola with community, hospital, and burial transmission (Legrand et al. 2007): the Starsim version of the Pandemic Simulator's port
The values are those of the example, for Kikwit in 1995. The shares theta and delta are what the
paper sets; the rates of each exit from I and H are derived from them, as in the paper.
"""
import starsim as ss
S, E, I, H, F, R = ss.states('S E I H F R')
betaI, betaH, betaF = ss.par(0.084), ss.par(0.113), ss.par(1.093)
alpha, gamma_f = ss.par(1/7), ss.par(1/2)
gamma_h, gamma_i, gamma_d = ss.par(1/5), ss.par(1/10), ss.par(1/9.6)
theta, delta = ss.par(0.80), ss.par(0.81)
gamma_ih = 1/(1/gamma_i - 1/gamma_h)
gamma_dh = 1/(1/gamma_d - 1/gamma_h)
delta1 = delta*gamma_i/(delta*gamma_i + (1 - delta)*gamma_d)
delta2 = delta*gamma_ih/(delta*gamma_ih + (1 - delta)*gamma_dh)
weight = theta*(gamma_i*(1 - delta1) + gamma_d*delta1)
theta1 = weight/(weight + (1 - theta)*gamma_h)
ebola = ss.Disease(
infection = S >> E @ (betaI*I + betaH*H + betaF*F),
onset = E >> I @ alpha,
admitted = I >> H @ (gamma_h*theta1),
recovered = I >> R @ (gamma_i*(1 - theta1)*(1 - delta1)),
died = I >> F @ (gamma_d*(1 - theta1)*delta1),
discharged = H >> R @ (gamma_ih*(1 - delta2)),
died_in_hospital = H >> F @ (gamma_dh*delta2),
buried = F >> R @ gamma_f,
init_n = dict(I=3),
name = 'ebola',
)
measures = [
ss.modify(ebola.pars[name], multiply=factor, when=ss.schedule(start='1995-05-04'), name=name)
for name,factor in [('betaI', 0.88), ('betaH', 0), ('betaF', 0)]
]
sim = ss.Sim(ebola, interventions=measures, total_pop=200_000, start='1995-01-06', stop='1996-01-06', dt=ss.days(1), solver='ode', verbose=0)
control = sim.new(interventions=None)What was declared and what was not
Everything is declared. The split rates are expressions of parameters, so they follow any change to theta or delta; the measures are ss.modify() with a start date. The original computes the force of infection and every exit from I and H by hand in equation(), as its schema can’t state them.
Findings
- A rounding error in the original moves its measures. It compares
start_date_ordinal + twith the start date, and JAX’s default float32 resolves a number near 728,000 only to 1/16 of a day, so a measure can start up to that much away from its date. That is the whole difference in the table: 38 fewer people infected, and it does not shrink with a smaller step in Starsim. Every dated measure in the framework is compared in this way, in any model; the size of the error depends on the epidemic at that moment. - Checked against the source’s arithmetic, not the paper. The derived rates recover what they should: 80% hospitalized, a case fatality of 81%, and R0 of 2.69 (0.50 community, 0.42 hospital, and 1.77 burial), against the 2.7 in the original’s notes. The paper itself was not available when this was written, so the equations are compared with the original’s code only.
- Larger than the outbreak. The original’s notes say that the epidemic is far larger than the 315 cases observed, as the paper’s own mean-field version is. The translation keeps this.
Pandemic Simulator: SIR with a reactive intervention
The framework’s declarative teaching example, example_parameter_uncertainty_declarative_model: a closed SIR in one zone of 1,000,000 people, with a measure that responds to prevalence, run with and without the measure and with three uncertain inputs. See the review.
- Framework: pandemic_simulator 0.1.0 (Apache-2.0)
- Language: Python
- Source: https://github.com/WHO-Collaboratory/pandemic-simulator-compartment/tree/main/compartment/models/example_parameter_uncertainty_declarative_model
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 99 | 8 | Most of the original’s lines are the declaration that also serves the web form and model card, which the Starsim version doesn’t have |
| One run | first 230 ms, rerun 4.6 ms | first 10 ms, rerun 0.38 ms (Rust) | Each is one run with the measure, and includes results. A first run excludes imports; the original’s includes compiling with JAX, and Starsim’s uses kernels already compiled. A condition on a count is part of the rate, so the whole run is in the kernels, and JAX can run it too |
| Results, without the measure | Agree to 1.2e-5 of the population on every day (6 people in I), with a daily RK4 step against an adaptive solver |
||
| Results, with the measure | Peak 198,528 on day 59; 860,705 infections | Peak 198,578 on day 59; 860,644 infections | Agree to 1.7e-3 of the population. The difference is when the threshold is noticed: Starsim reads it at each stage of its daily step, and the original within its adaptive step |
| Results, 20 draws (median at each time) | 23.9% to 16.5% at the peak, 7 days later, and about 938,000 to 844,000 infections, in one run; the guide on interventions gives 25% to 19%, and 939,000 to 865,000 | About 25% to 18%, 8 days later, and 940,000 to 857,000 infections, with 200 draws | Consistent: inside the original’s 95% band on every day. The original’s draws have no seed, so two of its runs differ by this much |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (pandemic_simulator)
{
"Disease": {
"disease_type": "example_parameter_uncertainty_declarative"
},
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"TransmissionEdges": {
"items": [
{
"transmission_edge": {
"source": "susceptible",
"target": "infected",
"value_type": "RATE"
},
"value": 0.3,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": false,
"distribution_type": "UNIFORM",
"disease_param": "BETA",
"default": 0.3,
"min": 0.2,
"max": 0.4
}
]
}
},
{
"transmission_edge": {
"source": "infected",
"target": "recovered",
"value_type": "DAYS"
},
"value": 10.0,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": false,
"distribution_type": "UNIFORM",
"disease_param": "GAMMA"
}
]
}
}
]
},
"Interventions": {
"items": [
{
"Intervention": {
"name": "MY_INTERVENTION",
"display_name": "My intervention"
},
"adherence_min": 50.0,
"transmission_percentage": 50.0,
"start_threshold": 2.0,
"end_threshold": 1.0,
"FieldConfigs": {
"items": [
{
"field_key": "adherence_min",
"has_variance": false,
"distribution_type": "UNIFORM",
"default": 50.0,
"min": 40.0,
"max": 60.0
},
{
"field_key": "transmission_percentage",
"has_variance": false,
"distribution_type": "UNIFORM",
"default": 50.0,
"min": 45.0,
"max": 55.0
}
]
}
}
]
},
"admin_zones": [
{
"name": "Example Zone",
"center_lat": 40.7128,
"center_lon": -74.006,
"population": 1000000,
"infected_population": 0.01
}
]
}{
"n_simulations": 20,
"Disease": {
"disease_type": "example_parameter_uncertainty_declarative"
},
"start_date": "2026-01-01",
"end_date": "2026-12-31",
"TransmissionEdges": {
"items": [
{
"transmission_edge": {
"source": "susceptible",
"target": "infected",
"value_type": "RATE"
},
"value": 0.3,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": true,
"distribution_type": "UNIFORM",
"disease_param": "BETA",
"default": 0.3,
"min": 0.2,
"max": 0.4
}
]
}
},
{
"transmission_edge": {
"source": "infected",
"target": "recovered",
"value_type": "DAYS"
},
"value": 10.0,
"FieldConfigs": {
"items": [
{
"field_key": "value",
"has_variance": false,
"distribution_type": "UNIFORM",
"disease_param": "GAMMA"
}
]
}
}
]
},
"Interventions": {
"items": [
{
"Intervention": { "name": "MY_INTERVENTION", "display_name": "My intervention" },
"adherence_min": 50.0,
"transmission_percentage": 50.0,
"start_threshold": 2.0,
"end_threshold": 1.0,
"FieldConfigs": {
"items": [
{
"field_key": "adherence_min",
"has_variance": true,
"distribution_type": "UNIFORM",
"default": 50.0,
"min": 40.0,
"max": 60.0
},
{
"field_key": "transmission_percentage",
"has_variance": true,
"distribution_type": "UNIFORM",
"default": 50.0,
"min": 45.0,
"max": 55.0
}
]
}
}
]
},
"admin_zones": [
{
"name": "Example Zone",
"center_lat": 40.7128,
"center_lon": -74.006,
"population": 1000000,
"infected_population": 0.01
}
]
}import jax.numpy as jnp
import numpy as np
import logging
from compartment.model import Model, ValueType
# from compartment.helpers import get_gravity_model_travel_matrix
logger = logging.getLogger(__name__)
class ExampleParameterUncertaintyDeclarativeModel(Model):
"""A simple SIR compartmental model for Example Disease with Parameter Uncertainty."""
@classmethod
def define_parameters(cls, schema):
"""Declare the model's compartments, transmission edges, and parameters.
Called once by the framework to build the model schema, from which the
config validator and parameter set are generated.
Args:
schema (ParameterSchemaBuilder): Schema builder to populate with
model info, metadata, compartments, transmission edges,
and interventions.
"""
schema.set_model_info(
disease_type="example_parameter_uncertainty_declarative",
label="Example Disease with Declarative Parameter Uncertainty",
description="A SIR model for an example disease with declarative parameter uncertainty",
)
schema.set_model_metadata(
authors=[
{
"name": "Example Author",
"email": "example@example.com",
"affiliation": "Example LLC",
}
],
license="MIT",
model_type="Compartmental",
diseases=["Example disease"],
transmission_routes=["Airborne"],
questions_answered=[
"How wide are the outcome intervals when transmission and recovery are only known within a range?",
"How much does a transmission-reducing intervention shift the median trajectory?",
"How do uncertainty ranges on intervention adherence and transmission reduction affect the outcome band?",
],
key_assumptions=[
"Closed population — no births or deaths.",
"Frequency-dependent transmission (force of infection scales with the proportion infectious).",
"Recovered individuals are fully immune with no waning (no R→S transition).",
"Parameter uncertainty is uniform: fields marked with variance in the config are drawn from their min/max range by Latin Hypercube Sampling.",
"No inter-zone mobility — the travel matrix is the identity, so zones evolve independently.",
],
applicability=(
"A teaching example for the declarative authoring workflow and "
"parameter uncertainty. Copy it as the starting point for a new model."
),
not_for=(
"Real-world forecasting or policy advice — the compartments and "
"parameter ranges are illustrative, not fitted to any disease."
),
)
# --- Compartments ---
# Mark infective=True on compartments that contribute to force of infection.
schema.add_compartment("S", "Susceptible", "Population susceptible to infection")
schema.add_compartment("I", "Infected", "Currently infectious population", infective=True)
schema.add_compartment("R", "Recovered", "Recovered and immune")
# --- Transmission edges ---
# The framework auto-generates I_total, R_total cumulative compartments
# for the targets of these edges — do not declare them by hand.
schema.add_transmission_parameter(
source="susceptible",
target="infected",
variable_name="beta",
frequency_dependent=True,
label="Transmission Rate (S->I)",
description="Rate at which susceptibles become infected through contact",
default=0.3,
default_min=0.1,
default_max=0.5,
min_value=0.01,
max_value=2.0,
unit="per day",
)
schema.add_transmission_parameter(
source="infected",
target="recovered",
variable_name="gamma",
label="Recovery Period (I->R)",
description="Average number of days to recover",
default=10.0,
default_min=5.0,
default_max=20.0,
min_value=1.0,
max_value=100.0,
value_type=ValueType.DAYS,
unit="days",
)
# --- Optional: spatial travel support ---
# Declare your mobility parameters as custom fields, then define how
# they build the matrix in build_travel_matrix() below. Without this,
# the base class supplies an identity matrix (no inter-zone travel).
# schema.add_parameter(
# name="travel_sigma",
# label="Travel Rate (σ)",
# description="Percentage of each zone's population away from home per day.",
# value_type=ValueType.PERCENTAGE,
# default=20.0,
# min_value=0.0,
# max_value=100.0,
# unit="%",
# )
# --- Optional: interventions ---
schema.add_intervention(
id="my_intervention",
label="My Intervention",
description="Reduces transmission while active",
target_rates=["beta"],
adherence=50.0,
transmission_reduction=50.0,
)
def __init__(self, config):
"""Initialize the model from a validated simulation config.
Args:
config (dict): Validated simulation configuration produced by the
framework's config loader.
"""
super().__init__(config)
# Add any model-specific initialisation here (e.g. temperature).
# --- Optional: spatial travel support ---
# The framework calls this before prepare_initial_state() and stores the
# result on self.travel_matrix. The default returns the identity matrix,
# so only override it if your model has inter-zone mobility.
#
# def build_travel_matrix(self, admin_zones):
# # PERCENTAGE params arrive as 20.0, not 0.2 — convert first.
# sigma = self._to_rate(self.travel_sigma, ValueType.PERCENTAGE)
# return get_gravity_model_travel_matrix(admin_zones, sigma)
def prepare_initial_state(self):
"""Return the initial compartment populations for the solver.
Returns:
jnp.ndarray: Population matrix (compartments x admin zones) used as
the solver's initial state.
"""
return self.population_matrix
def equation(self, y, t, p):
"""Compute the compartment derivatives for one integration step.
Args:
y (jnp.ndarray): Current compartment values, ordered by
``compartment_list``.
t (float): Current time in days since the simulation start date.
p (tuple): Packed parameter tuple, unpacked via ``_unpack_params``.
Returns:
jnp.ndarray: Stacked per-compartment derivatives (dy/dt).
"""
C = self.COMPARTMENTS
params = self._unpack_params(p)
states = {c: y[i] for i, c in enumerate(self.compartment_list)}
I = states[C.I]
non_total = [c for c in C if not c.endswith("_total")]
N_total = sum(states[c] for c in non_total)
prop_infective = I.sum() / (N_total.sum() + 1e-10)
# _apply_interventions scales target_rates and returns the updated travel
# matrix. With no interventions configured it returns both unchanged.
rates, self.travel_matrix = self._apply_interventions(
t, {"beta": params["beta"]}, prop_infective
)
rates["gamma"] = params["gamma"]
# _compute_equations handles mass-action / frequency-dependent FOI,
# _total accumulation, and skips compartments not active in this model.
derivs = self._compute_equations(states, rates)
return jnp.stack([derivs[c] for c in self.compartment_list])Starsim
model.py
"""
An SIR with a reactive intervention: the Starsim version of the Pandemic Simulator's declarative teaching example
Everything is the example's central values. The measure removes 25% of transmission (50% adherence
times a 50% reduction) whenever at least 2% of the population is infectious. The example's release
threshold of 1% is left out because the original's default solver does not use it.
"""
import starsim as ss
S, I, R = ss.states('S I R')
beta = ss.par(0.3, bounds=[0.2, 0.4])
sir = ss.Disease(infection=S >> I @ beta*I, recovery=I >> R @ ss.days(10), init_n=dict(I=100), name='sir')
measure = ss.modify(sir.pars.beta, multiply=1 - 0.5*0.5, who=sir.I.n >= 20_000)
sim = ss.Sim(sir, interventions=measure, total_pop=1e6, start='2026-01-01', stop='2026-12-31', dt=ss.days(1), solver='ode', verbose=0)
control = sim.new(interventions=None)What was declared and what was not
Everything is declared. The measure is ss.modify() with a condition on a count, and the solver is solver='ode'. The original’s solver is adaptive, and this one takes fixed daily steps.
Findings
- A median of runs is not a run. The 25% in the guide is the peak of the median curve over draws of
beta. With the centralbetathe peak is 30%, and the draws peak at different times, so the median curve is lower. A comparison with these numbers has to summarize in the same way. - The release threshold. The config sets a trigger at 2% and a release at 1%, but the guide says that the default solver does not carry the status between steps, and the original’s results confirm it: the decline slows as soon as prevalence falls below 2%. So the translation has no release. With
until=sir.I.n <= 10_000, the release as configured, the peak is the same, with 857,944 infections rather than 860,644, and is applied a step at a time, since it needs a memory of whether the measure is on. - Uncertainty.
ss.parallel(sim, priors=True)draws bounded parameters afresh, but not on a Latin hypercube, and the draws can be given directly aspriors=. The two factors of the measure aren’t parameters here, socompare.pymakes its sims itself.
SimInf: SIR in 1,600 cattle herds with births, deaths, and movements
A stochastic SIR epidemic in 1,600 cattle herds (nodes) over four years, where the only link between herds is a schedule of animal movements. It is the example in SimInf’s documentation of events_SIR(), using the data that come with the package.
- Framework: siminf 11.1.0 (GPL-3.0)
- Language: R
- Source: the example in man/events_SIR.Rd (written in R/SIR.R) at https://github.com/stewid/SimInf, with the data that the package provides through u0_SIR() and events_SIR()
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 12 | 21 | 7 of the Starsim lines turn SimInf’s table of events into a movement register, and 4 read the data, which the original gets from the package |
| One run | first 170 ms, rerun 75 ms | first 270 ms, rerun 140 ms (Rust) | Gillespie’s method in both, on one thread. Neither time counts starting up or reading the data (nor, for Starsim, turning the events into a register). A rerun of the original is run(model) again |
| Results | Agree. The mean of 200 runs (compare.py now makes 100, to keep the benchmark short) is within three standard errors (at most 2.7) of the original’s mean of 200 at every day checked, for the totals, the number of herds infected, and the first herd, including the total susceptible on day 100, where little else varies (199,901 ± 3 against 199,906). The infection dies out early in 4% of runs (7% in the original), so it takes about 200 runs to compare the means: 20 runs can easily have none of these runs, and then differ a lot |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (siminf)
events_SIR.R
## For reproducibility, call the set.seed() function and specify the
## number of threads to use. To use all available threads, remove the
## set_num_threads() call.
set.seed(123)
set_num_threads(1)
## Create an 'SIR' model with 1600 cattle herds (nodes) and initialize
## it to run over 4*365 days. Add one infected animal to the first
## herd to seed the outbreak. Define 'tspan' to record the state of
## the system at daily time-points. Load scheduled events for the
## population of nodes with births, deaths and between-node movements
## of individuals.
u0 <- u0_SIR()
u0$I[1] <- 1
model <- SIR(
u0 = u0,
tspan = seq(from = 1, to = 4*365, by = 1),
events = events_SIR(),
beta = 0.16,
gamma = 0.01
)
## Display the number of cattle affected by each event type per day.
plot(events(model))
## Run the model to generate a single stochastic trajectory.
result <- run(model)
## Plot the median and interquartile range of the number of
## susceptible, infected and recovered individuals.
plot(result)
## Plot the trajectory for the first herd.
plot(result, index = 1)
## Summarize the trajectory. The summary includes the number of events
## by event type.
summary(result)Starsim
model.py
"""
The SimInf herds example (docs/models/siminf/sir_herds), in Starsim: an SIR in each of 1,600 cattle
herds, linked only by a register of animals entering, leaving, and moving between herds
"""
import os
import pandas as pd
import starsim as ss
ref = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'reference')
u0 = pd.read_csv(os.path.join(ref, 'u0.csv'))
events = pd.read_csv(os.path.join(ref, 'events.csv.gz'))
# SimInf's events as a register: entering animals are susceptible, and the others are chosen from every state
register = pd.DataFrame({
'date': events.time,
'from': events.node.where(events.event != 'enter'),
'to': events.node.where(events.event == 'enter', events.dest.where(events.event == 'extTrans')),
'n': events.n,
'states': (events.event == 'enter').map({True:'S', False:None}),
})
S, I, R = ss.states('S I R')
sir = ss.Disease(
infection = S >> I @ ss.perday(0.16) * I,
recovery = I >> R @ ss.perday(0.01),
init_n = dict(I=ss.by(herd=u0.I.values)),
)
size = u0[['S', 'I', 'R']].sum(axis=1)
sir.stratify(herd=ss.levels(dict(zip(u0.node.astype(str), size/size.sum())), kind='space', mobility=ss.geo.register(register)))
sim = ss.Sim(sir, solver='gillespie', unit='day', start=0, stop=1460, total_pop=size.sum())
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. The herds are a stratification by place, with an SIR in each that mixes only within the herd, and the 466,692 events are a movement register (ss.geo.Mobility(data=...)): a row with a from and a to moves animals between herds, one with no from adds them (births), and one with no to removes them (deaths). The same model runs as agents, one for each of the 196,169 animals (sim.new(solver='abm', n_agents=196_169)), which takes a minute or two.
Findings
- Scheduled events (fixed). SimInf’s
exit,enter, andextTransevents, with theirselectmatrix, become one table with named columns:date,from,to,n, andstates(hereSfor the animals that enter, and blank, meaning every state, for the rest). The events dated at a time point are in the numbers at that time point, so Starsim’s count on daythas the transitions of SimInf’s daytand that day’s events, as SimInf’s does. The events of a day happen in the order of the table, which here is SimInf’s order (exits, then entries, then movements). - A count is a count (fixed). Initial numbers go in
init_n, soss.by(herd=u0.I.values)is one infected animal in the first herd, with no chance of reading the 1 as a share of the herd. - Gillespie with many groups (fixed). Starsim used to work out every rate again after each event, over all 1,600 herds. It now runs each herd on its own between events, as SimInf does, since herds do not mix within a day.
- Births as agents (fixed).
ss.birth()used to add nobody in an agent-based run, and a sim with no dimensions crashed once its agents grew past the space first set aside for them.
SimInf: SIS by age in 1,600 cattle herds, with an environmental infectious pressure
A stochastic SIS in 1,600 cattle herds (nodes) over four years, in three age categories (calves, young stock, and adults), where animals are not infected by each other but by an environmental infectious pressure phi in their herd: infected animals shed into it, and it decays at a rate that changes with the season. The herds are linked by a schedule of births, deaths, movements, and aging. It is SimInf’s built-in SISe3() model, run as in the example in SimInf’s documentation of events_SISe3 and u0_SISe3, with the data that come with the package.
- Framework: siminf 11.1.0 (GPL-3.0)
- Language: R (and C, for the built-in model)
- Source: the example in man/events_SISe3.Rd (the same as in man/u0_SISe3.Rd; written in man/examples/SISe3.R and R/SISe3.R) at https://github.com/stewid/SimInf, with the data that the package provides as u0_SISe3 and events_SISe3; the model itself is src/models/SISe3.c
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 27 | 39 | 13 of the Starsim lines read the data and turn SimInf’s events, all four kinds, into a movement register, which the original gets from the package. The disease itself has four lines that turn SimInf’s daily update of phi into rates and seasons. The original’s equations are in SimInf’s C code, and are not counted |
| One run | first 280 ms, rerun 89 ms | first 760 ms, rerun 510 ms (Rust) | Gillespie’s method on one thread in both, as compartments (whole animals by herd, with phi a real number). Neither time counts starting up or reading the data (nor, for Starsim, turning the events into a register). A rerun of the original is run(model) again |
| Results | Agree, within a herd, between herds, and in the demography. With 100 runs (compare.py now makes 50, to keep the benchmark short, and allows the same three standard errors), every series is within two standard errors of the difference from SimInf’s mean on every day checked (three is the tolerance), and 81% to 97% of the runs are inside SimInf’s 5% to 95% range for every series on every day. On day 1,457 the mean of 100 runs has 181 ± 13 herds infected (SimInf: 166, range 24 to 352) and 4,175 ± 309 animals (3,770; 519 to 8,138). The spread between herds runs a little high on every day (175 herds in 200 runs), within the noise, so a small difference there cannot be ruled out |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
python compare.py 100 (100 runs one after another, a few minutes; by default it makes 50) gives, for the mean of the runs (± its standard error) against the mean of SimInf’s 200:
| Day | Herds infected | Animals infected | phi in node 1 |
Infected in node 1 | S_1 |
|---|---|---|---|---|---|
| 92 | 1.28 ± 0.05 (1.16) | 43 ± 2 (41) | 2.27 ± 0.09 (2.28) | 40 ± 2 (40) | 17,088 (17,088) |
| 183 | 2.5 ± 0.1 (2.3) | 81 ± 3 (75) | 3.84 ± 0.06 (3.90) | 62 ± 1 (63) | 17,822 (17,822) |
| 365 | 4.0 ± 0.3 (3.4) | 99 ± 5 (88) | 2.43 ± 0.04 (2.39) | 45 ± 1 (44) | 12,084 (12,084) |
| 729 | 15.1 ± 1.1 (13.0) | 332 ± 25 (288) | 2.35 ± 0.04 (2.33) | 42 ± 1 (41) | 12,034 (12,036) |
| 1,093 | 54 ± 4 (49) | 1,184 ± 94 (1,059) | 2.36 ± 0.04 (2.32) | 43 ± 1 (43) | 11,952 (11,959) |
| 1,457 | 181 ± 13 (166) | 4,175 ± 309 (3,770) | 2.38 ± 0.04 (2.33) | 47 ± 1 (45) | 11,795 (11,819) |
A count at index t is the number on day t, with the transitions of SimInf’s day t and that day’s scheduled events. With aging in the register, in SimInf’s order (exits, entries, aging, then movements between herds), the susceptible counts agree to within a few animals in the first years.
Original (siminf)
events_SISe3.R
## For reproducibility, call the set.seed() function and specify the
## number of threads to use. To use all available threads, remove the
## set_num_threads() call.
set.seed(123)
set_num_threads(1)
## Create an 'SISe3' model with 1600 cattle herds (nodes) stratified
## by age, initialize it to run over 4*365 days and record data at
## weekly time-points. Add ten infected animals to age category 1 in
## the first herd to seed the outbreak. Define 'tspan' to record the
## state of the system at weekly time-points. Load scheduled events
## events for the population of nodes with births, deaths and
## between-node movements of individuals.
u0 <- u0_SISe3
u0$I_1[1] <- 10
model <- SISe3(
u0 = u0,
tspan = seq(from = 1, to = 4*365, by = 7),
events = events_SISe3,
phi = rep(0, nrow(u0)),
upsilon_1 = 1.8e-2,
upsilon_2 = 1.8e-2,
upsilon_3 = 1.8e-2,
gamma_1 = 0.1,
gamma_2 = 0.1,
gamma_3 = 0.1,
alpha = 1,
beta_t1 = 1.0e-1,
beta_t2 = 1.0e-1,
beta_t3 = 1.25e-1,
beta_t4 = 1.25e-1,
end_t1 = 91,
end_t2 = 182,
end_t3 = 273,
end_t4 = 365,
epsilon = 0
)
## Display the number of cattle affected by each event type per day.
plot(events(model))
## Run the model to generate a single stochastic trajectory.
result <- run(model)
## Plot the median and interquartile range of the number of
## susceptible and infected individuals.
plot(result)
## Plot the proportion of nodes with at least one infected individual.
plot(result, I_1 + I_2 + I_3 ~ ., level = 2, type = "l")
## Plot the trajectory for the first herd.
plot(result, index = 1)
## Summarize the trajectory. The summary includes the number of events
## by event type.
summary(result)Starsim
model.py
"""
The SimInf SISe3 example (docs/models/siminf/sise3), in Starsim: an SIS in three age categories in each
of 1,600 cattle herds, where animals are infected from an environmental infectious pressure that
the infected shed into and that decays by season, with a register of births, deaths, movements, and
aging between age categories
"""
import os
import numpy as np
import pandas as pd
import starsim as ss
ref = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'reference')
u0 = pd.read_csv(os.path.join(ref, 'u0.csv'))
events = pd.read_csv(os.path.join(ref, 'events.csv.gz'))
# SimInf's events as a register: entering animals are S_1; the others are chosen from one age category (select 4 to 6),
# and internal transfers (shift 1 or 2) move them up an age category in their herd, keeping S or I
ages = events['select'].map({4:'S_1,I_1', 5:'S_2,I_2', 6:'S_3,I_3'})
register = pd.DataFrame({
'date': events.time,
'from': events.node.where(events.event != 'enter'),
'to': events.node.where(events.event.isin(['enter', 'intTrans']), events.dest.where(events.event == 'extTrans')),
'n': events.n,
'states': ages.where(events.event != 'enter', 'S_1'),
'to_states': events['shift'].map({1:'S_2,I_2', 2:'S_3,I_3'}),
})
S_1, I_1, S_2, I_2, S_3, I_3 = ss.states('S_1 I_1 S_2 I_2 S_3 I_3')
phi = ss.State(people=False) # The environmental infectious pressure
# SimInf updates phi once a day, to phi*(1 - b) + alpha*I/n, with b by season; the continuous rates that give the same phi
# at the end of each day are a decay of -log(1 - b) and a shedding of alpha*(-log(1 - b))/b
upsilon, gamma, alpha = ss.perday(0.018), ss.perday(0.1), 1.0
seasons = {0: 0.1, 181: 0.125, 364: 0.1} # SimInf's daily decay by the day that ends a step, here by the day that starts it, in its 365-day year
rate = {day:-np.log(1 - b) for day,b in seasons.items()}
beta = ss.timeseries({day:ss.perday(rate[day]) for day in seasons}, period=ss.days(365))
shed = ss.timeseries({day:ss.perday(alpha*rate[day]/b) for day,b in seasons.items()}, period=ss.days(365))
sise3 = ss.Disease(
infection_1 = S_1 >> I_1 @ upsilon * phi.n,
recovery_1 = I_1 >> S_1 @ gamma,
infection_2 = S_2 >> I_2 @ upsilon * phi.n,
recovery_2 = I_2 >> S_2 @ gamma,
infection_3 = S_3 >> I_3 @ upsilon * phi.n,
recovery_3 = I_3 >> S_3 @ gamma,
shedding = ss.outside >> phi @ shed * (I_1 + I_2 + I_3).n / (S_1 + I_1 + S_2 + I_2 + S_3 + I_3).n,
decay = phi >> ss.dead @ beta,
init_n = {key:ss.by(herd=u0[key].values) for key in ['I_1', 'S_2', 'S_3']}, # The rest in S_1
)
size = u0[['S_1', 'I_1', 'S_2', 'S_3']].sum(axis=1)
sise3.stratify(herd=ss.levels(dict(zip(u0.node.astype(str), size/size.sum())), kind='space', states=[S_1, I_1, S_2, I_2, S_3, I_3, phi], mobility=ss.geo.register(register)))
sim = ss.Sim(sise3, total_pop=size.sum(), solver='gillespie', unit='day', start=0, stop=1460)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared. The six compartments are states, and the infectious pressure phi is a state that is not people, stratified by herd with the people, so that each herd has its own phi. Infection is a spontaneous transition at rate upsilon * phi.n (the herd’s own phi), and recovery goes back to S_k. Shedding into phi is an inflow at rate alpha * I.n / N.n, and its decay an outflow at a seasonal rate, an ss.timeseries that repeats every 365 days. The initial conditions are by herd (ss.by). All 783,773 events are one movement register: births have no from, deaths no to, movements a from and a to, and aging the same herd as from and to with to_states (states='S_1,I_1', to_states='S_2,I_2'); states names the age category the animals are chosen from.
Findings
Fixed: a state that is not people was a whole number under the stochastic solvers.
phiused to be shed and decayed one unit at a time, so the infection died out, and only agents could run the model. Such a state is now a real number undergillespie,tau, andbinomial, with its flows as expected values, while the animals are drawn, as in SimInf.Fixed: the update of
phi. Earlier versions gave too muchphiat equilibrium, and as agents advanced it inconsistently. Counts are now integrated over each step with the people as they are.Fixed: a movement register could not move people between states. SimInf’s
intTransevents, 40% of all the events, age animals fromS_1toS_2and fromI_1toI_2, which used to need a 30-line custom intervention. The register now has ato_statescolumn, under every solver, and the events happen in the order of the table, as in SimInf.Still open: age categories as states. With the age categories as six states, the register can age animals. Making age a stratification instead (
age=['1', '2', '3'],aging=False) would be more natural, but the register can name only states, not a level of another dimension. Suggested change: let a register choose and change the level of any dimension (columns named for the dimension, such asageandto_age). The states no longer needpeople=True, which they used to.Fixed: no yearly cycle for a step function. The seasonal decay is now one line, an
ss.timeserieswithperiod=.Spread between herds. This was about 25% too slow in an earlier version, for two reasons. SimInf updates
phionce a day, after the day’s scheduled events, so an infected animal that moves sheds into its new herd from the day it arrives; Starsim now does the same. And SimInf’s update is a daily map,phi*(1 - beta) + alpha*I/n, not the ODE it approximates, so the model uses the continuous rates that give the samephiat the end of each day.Season dates are a day out if copied directly. SimInf picks the decay for the day that ends a step, and uses a 365-day year; a step in Starsim starts on its date. Written as days
{0: 0.1, 182: 0.125}with a period ofss.years(1), the season changed a day late and drifted, which madephiin node 1 1.5% too high around the change (4.4 standard errors in 200 runs, day 547). The model now gives{0: 0.1, 181: 0.125, 364: 0.1}withperiod=ss.days(365), and the largest difference in 200 runs is 2.0 standard errors. A modeler converting by hand has to work this out.
summer2: SEIR by age and clinical status, with derived outputs
An SEIR epidemic with disease deaths in 1,000 people over 20 time units, stratified by age (young, old), with the infectious further stratified by clinical status (asymptomatic, symptomatic, isolated, in hospital). Which clinical status a new case takes depends on their age, and each status has its own infectiousness and death rate. The model is then built and run seven times, each time with a different set of derived outputs: cumulative deaths by age, incidence by age, by clinical status, and by both, notifications, hospital occupancy, and the proportion of the infectious who are in hospital. It is the “Derived outputs in stratified models” notebook in summer2’s documentation, the most detailed of its stratification tutorials (notebooks 06 to 10).
- Framework: summer2 1.3.6 (BSD-2-Clause)
- Language: Python
- Source: https://github.com/monash-emu/summer2/blob/main/docs/examples/10-derived-outputs-stratified.ipynb
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 134 (55 for the model alone) | 16 | The original builds and runs the model seven times to request its outputs one group at a time. In Starsim every output but one is there without being asked for, by selecting groups of a result (the proportion in hospital is a division in compare.py) |
| One ODE run | first 2.8 s, rerun 0.29 ms | first 14 ms, rerun 0.53 ms (Rust) | Neither time counts importing. The original’s first run is all seven models, each built and compiled with JAX, as it takes all seven to get its outputs (reference/timing.json of the example gives the first alone); Starsim’s is one model with all its results. The original’s rerun is the last of the seven, with four outputs |
| Results, ODE | Every compartment within 0.0006 people of 1,000; every derived output within 0.006% of its peak. From the same start, the stratified model and the same model written as four states (as_states() in compare.py) agree to 2e-14 people |
||
| Results, other solvers | At time 20 the mean of 100 stochastic runs has 542 ± 9 recovered and of 100 agent-based runs 547 ± 10, against 580 in the ODE, since the epidemic is still under way and runs take off at different times. No run died out. By time 100 they end with 848 recovered, as does the ODE (847) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
The derived outputs are compared after converting them to summer2’s conventions: a flow is divided by the step to give a rate, and is compared with the original’s value at the end of the step. The original’s cumulative deaths are 10 times the deaths (it sums rates without multiplying by the step; see the example’s README), so they are compared with 10 times Starsim’s.
Original (summer2)
derived_outputs.py
# Extracted from docs/examples/10-derived-outputs-stratified.ipynb in
# https://github.com/monash-emu/summer2 (commit d1537d6): every code cell, in order, unchanged.
# Each cell after the first builds the model afresh, requests some derived outputs, and runs it.
# --- "Derived outputs in stratified models"
from summer2 import CompartmentalModel, Stratification, Multiply, Overwrite
import pandas as pd
pd.options.plotting.backend = "plotly"
def build_model():
"""Returns a new SIR model"""
# Create basic SEIR model.
model = CompartmentalModel(
times=[0, 20],
compartments=["S", "E", "I", "R"],
infectious_compartments=["I"],
timestep=0.1,
)
model.set_initial_population(distribution={"S": 990, "I": 10})
model.add_infection_frequency_flow(name="infection", contact_rate=2, source="S", dest="E")
model.add_transition_flow(name="incidence", fractional_rate=0.5, source="E", dest="I")
model.add_transition_flow(name="recovery", fractional_rate=0.3, source="I", dest="R")
model.add_death_flow(name="infection_death", death_rate=0.05, source="I")
# Stratify by age.
age_strat = Stratification('age', ['young', 'old'], ['S', 'E', 'I', 'R'])
age_strat.set_population_split({'young': 0.5, 'old': 0.5})
model.stratify_with(age_strat)
# Stratify by clinical status for infected people.
strata = ['asymptomatic', 'symptomatic', 'isolated', 'hospital']
clinical_strat = Stratification('clinical', strata, ['I'])
clinical_strat.set_population_split({'asymptomatic': 1, 'symptomatic': 0, 'isolated': 0, 'hospital': 0})
# Half of young people become asymptomatic.
young_incidence_adjustments = {
"asymptomatic": Multiply(0.4),
"symptomatic": Multiply(0.3),
"isolated": Multiply(0.2),
"hospital": Multiply(0.1),
}
clinical_strat.set_flow_adjustments(
"incidence",
young_incidence_adjustments,
source_strata={'age': 'young'}
)
# A higher proporiton of old people go to hospital
old_incidence_adjustments = {
"asymptomatic": Multiply(0.3),
"symptomatic": Multiply(0.3),
"isolated": Multiply(0.2),
"hospital": Multiply(0.3),
}
clinical_strat.set_flow_adjustments(
"incidence",
old_incidence_adjustments,
source_strata={'age': 'old'}
)
# Adjust risk of dying given clinical status
clinical_strat.set_flow_adjustments("infection_death", {
"asymptomatic": Overwrite(0), # Can't die if no symptoms
"symptomatic": None,
"isolated": None,
"hospital": Multiply(2), # Severe cases go to hospital
})
# Adjust infectiousness given clinical status
clinical_strat.add_infectiousness_adjustments("I", {
"asymptomatic": Multiply(0.5),
"symptomatic": None,
"isolated": Multiply(0.2),
"hospital": Multiply(0.1),
})
model.stratify_with(clinical_strat)
return model
# --- "Tracking cumulative deaths by age group"
model = build_model()
age_strata = ["young", "old"]
for age_stratum in age_strata:
output_name = f"death_{age_stratum}"
model.request_output_for_flow(
output_name,
flow_name="infection_death",
source_strata={"age": age_stratum},
save_results=False
)
model.request_cumulative_output(f"accum_{output_name}", output_name)
model.run()
model.get_derived_outputs_df().plot()
# --- "Tracking disease incidence"
model = build_model()
age_strata = ["young", "old"]
for age_stratum in age_strata:
model.request_output_for_flow(
f"incidence_{age_stratum}",
flow_name="incidence",
source_strata={"age": age_stratum}
)
model.run()
model.get_derived_outputs_df().plot()
# --- "Tracking disease incidence"
model = build_model()
clinical_strata = ["asymptomatic", "symptomatic", "isolated", "hospital"]
output_names = [f"incidence_{s}" for s in clinical_strata]
for clinical_stratum, output_name in zip(clinical_strata, output_names):
model.request_output_for_flow(
output_name,
flow_name="incidence",
dest_strata={"clinical": clinical_stratum}
)
model.run()
model.get_derived_outputs_df().plot()
# --- "Tracking disease incidence"
from itertools import product
model = build_model()
age_strata = ["young", "old"]
clinical_strata = ["asymptomatic", "symptomatic", "isolated", "hospital"]
strata = list(product(age_strata, clinical_strata))
output_names = [f"incidence_{a}_{c}" for a, c in strata]
for (age, clinical), output_name in zip(strata, output_names):
model.request_output_for_flow(
output_name,
flow_name="incidence",
dest_strata={"clinical": clinical, "age": age}
)
model.run()
model.get_derived_outputs_df().plot()
# --- "Tracking daily notifications"
model = build_model()
# Step 1: Get model to track the incidence flows for the notification strata
notify_strata = ["isolated", "hospital"]
output_names = [f"incidence_{s}" for s in notify_strata]
for notify_strata, output_name in zip(notify_strata, output_names):
model.request_output_for_flow(
output_name,
flow_name="incidence",
dest_strata={"clinical": notify_strata}
)
# Step 2: Aggregate the notification strata
model.request_aggregate_output(
name="notifications",
sources=output_names,
)
model.run()
model.get_derived_outputs_df().plot()
# --- "Tracking hospital occupancy"
model = build_model()
model.request_output_for_compartments(
'hospital_occupancy',
compartments=["I"],
strata={"clinical": "hospital"}
)
model.run()
model.get_derived_outputs_df().plot()
# --- "Using DerivedOutput GraphObjects"
from summer2.parameters import DerivedOutput
# --- "Using DerivedOutput GraphObjects"
model = build_model()
model.request_output_for_compartments(
'hospital_occupancy',
compartments=["I"],
strata={"clinical": "hospital"},
save_results=False
)
model.request_output_for_compartments(
'all_infected',
compartments=["I"],
save_results=False
)
model.request_function_output("prop_infected_hosp", DerivedOutput("hospital_occupancy") / DerivedOutput("all_infected") )
model.run()
model.get_derived_outputs_df().plot()Starsim
model.py
"""
The summer2 derived-outputs example (docs/models/summer2/seir-age-clinical), in Starsim: an SEIR by
age, in which the infectious are asymptomatic, symptomatic, isolated, or in hospital, with outputs
by age and by clinical status
"""
import starsim as ss
S, E, I, R = ss.states('S E I R')
seir = ss.Disease(
infection = S >> E @ 2 * I,
incidence = E >> I @ 0.5 * ss.by(age=[1, 1.1]), # The old's shares sum to 1.1
recovery = I >> R @ 0.3,
infection_death = ss.death(I, rate=0.05 * ss.by(clinical=[0, 1, 1, 2])),
init_n = dict(I=ss.by([[5, 0, 0, 0], [5, 0, 0, 0]], dims=['age', 'clinical'])),
)
seir.stratify(age=['young', 'old'])
seir.stratify(clinical=ss.levels( # Clinical status, which those who fall ill take by age
['asymptomatic', 'symptomatic', 'isolated', 'hospital'],
states = ['I'],
fractions = ss.by([[0.4, 0.3, 0.2, 0.1], [0.3, 0.3, 0.2, 0.3]], dims=['age', 'clinical']),
))
rel_trans = ss.modify(seir.rel_trans, multiply=ss.by(clinical=[0.5, 1, 0.2, 0.1]))
sim = ss.Sim(seir, connectors=rel_trans, total_pop=1000, dt=0.1, stop=20)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: four transitions and two stratifications, as in the original. Every output of the original is a selection of the automatic results; only the proportion in hospital is a division of two of them, in compare.py.
| Original | Starsim |
|---|---|
Stratification('age', ...) |
seir.stratify(age=['young', 'old'], adjust={'incidence': [1, 1.1]}) |
Stratification('clinical', ..., ['I']) |
seir.stratify(clinical=[...], states=['I'], ...) |
incidence adjusted by clinical status, given age (source_strata) |
fractions=ss.by([[...], [...]], dims=['age', 'clinical']), a row for each age, with the old’s rows summing to 1.1 given as the adjust on age |
infection_death with Overwrite(0) and Multiply(2) |
adjust={'infection_death': [0, 1, 1, 2]} |
add_infectiousness_adjustments |
adjust={'rel_trans': [0.5, 1, 0.2, 0.1]} |
set_population_split (everyone in I asymptomatic) |
init_n=dict(I=ss.by([[5, 0, 0, 0], [5, 0, 0, 0]], dims=['age', 'clinical'])) |
request_output_for_flow by age |
Automatic: res.incidence.select(age='old') |
request_output_for_flow by clinical status, or both |
Automatic: res.incidence.select(age='old', clinical='hospital') |
request_cumulative_output |
Automatic: res.infection_death.cum.select(age='old') |
request_aggregate_output (notifications) |
Automatic: res.incidence.select(clinical=['isolated', 'hospital']) |
request_output_for_compartments (hospital occupancy) |
Automatic: res.I.select(clinical='hospital') |
request_function_output (proportion in hospital) |
res.I.select(clinical='hospital') / res.I in compare.py |
The stratification by clinical status is also written as four states (Ia, Is, Ii, Ih), each entered by its own transition from E, in as_states() in compare.py, as a check.
Findings
All of these were found in earlier versions of this translation and have been fixed in Starsim, except that the last is not a problem.
- Fixed: entering a stratification by a split that depends on another dimension. People entering a state with a dimension their old state lacked used to go to its levels by one
splitfor everyone. Nowfractions=ss.by(table, dims=['age', 'clinical'])gives one row per age. The rows for the old sum to 1.1 in the original; normalizing them, and giving the 1.1 as anadjuston age, is exact. - Fixed: infectiousness by level.
adjust={'rel_trans': [...]}onstratify()replaces a modifier for each level. - Fixed: outcomes. A sum of flows given to a disease is now an outcome, can be selected by group, and can have a weight by level (
ss.outcome(ss.by(clinical=[0, 0, 1, 1]) * incidence)would declare notifications). Selecting the groups of the incidence result does the same job here. - Fixed: an initial count by level of a dimension of some states. It used to take the wrong number of people out of
S. Now each level gets exactly that many people. SinceI’s own split is the entry split,ss.by(clinical=[10, 0, 0, 0])would put 5.95 young and 4.05 old people inI, not 5 and 5 as in the original, so the vignette gives the count by both dimensions. - Fixed: a flow into a dimension of some states was recorded only at its first level. Such a flow is now recorded at the level people arrive at, so
res.incidence.select(clinical='hospital')works directly. - Not a problem: no unit of time, and no overwrite. The original names no unit, and neither version needs one. summer2’s
Overwrite(0)is the multiplier 0.
summer2: SIR stratified by age and location, with mixing matrices
An SIR epidemic with disease deaths in 1,000 people over 20 time units, stratified by age (young, old) and by location (urban, rural), each with its own mixing matrix. It is the “Multiple mixing matrices for multiple stratifications” example in summer2’s documentation.
- Framework: summer2 1.3.6 (BSD-2-Clause)
- Language: Python
- Source: https://github.com/monash-emu/summer2/blob/main/docs/examples/09-mixing-matrices.ipynb
- Full detail: the model’s page
| Measure | Original | Starsim | |
|---|---|---|---|
| Lines of model code | 35 | 12 | One Starsim line is over 100 characters, so counts twice |
| One ODE run | first 780 ms, rerun 0.22 ms | first 12 ms, rerun 0.43 ms (Rust) | Neither time counts importing. The original’s first run builds and compiles the model with JAX, which takes much longer than Starsim’s; its rerun, once compiled, is faster |
| Results, ODE | Every compartment within 0.08 people of 1,000 | ||
| Results, other solvers | The stochastic and agent-based runs give the same number recovered as the ODE, to within 1% (mean of 100 runs) |
Measured on 9 November 2026 with Starsim 4.0.0rc3 (the v4 branch, commit be38b9f6), on a 20-core Linux machine.
Original (summer2)
mixing_matrices.py
# Extracted from docs/examples/09-mixing-matrices.ipynb in https://github.com/monash-emu/summer2
# (commit d1537d6): the first code cell (the base model) and the fifth (two stratifications,
# each with a mixing matrix). The code is unchanged, except for the one line marked "Added".
# --- Cell 1: "Using a mixing matrix"
from summer2 import CompartmentalModel
import pandas as pd
pd.options.plotting.backend = "plotly"
def build_model():
"""Returns a model for the mixing matrix examples"""
model = CompartmentalModel(
times=[0, 20],
compartments=["S", "I", "R"],
infectious_compartments=["I"],
timestep=0.1
)
model.set_initial_population(distribution={"S": 990, "I": 10})
model.add_infection_frequency_flow(name="infection", contact_rate=2, source="S", dest="I")
model.add_transition_flow(name="recovery", fractional_rate=1/3, source="I", dest="R")
model.add_death_flow(name="infection_death", death_rate=0.05, source="I")
return model
# --- Cell 5: "Multiple mixing matrices for multiple stratifications"
import numpy as np # Added: in the notebook, this import is in the third code cell
from summer2 import Stratification, Multiply
from summer2.parameters import Data
# Age stratification with young/old mixing
age_strata = ["young", "old"]
age_strat = Stratification(name="age", strata=age_strata, compartments=["S", "I", "R"])
age_mixing_matrix = Data(np.array([
[0.2, 0.3],
[0.5, 0.7],
]) )
age_mixing_matrix.node_name = "age_mixing"
age_strat.set_mixing_matrix(age_mixing_matrix)
# Location stratification with urban/rural mixing
loc_strata = ["urban", "rural"]
loc_strat = Stratification(name="location", strata=loc_strata, compartments=["S", "I", "R"])
# Rural people have worse health care, higher mortality rates,
loc_strat.set_flow_adjustments("infection_death", {
"urban": None,
"rural": Multiply(3),
})
loc_strat.set_population_split({"urban": 0.7, "rural": 0.3})
loc_mixing_matrix = Data(np.array([
[0.8, 0.2],
[0.2, 0.8],
]))
loc_mixing_matrix.node_name = "loc_mixing"
loc_strat.set_mixing_matrix(loc_mixing_matrix)
# Build and run model with the stratifications we just defined
model = build_model()
# Apply age, then location stratifications
model.stratify_with(age_strat)
model.stratify_with(loc_strat)
model.run()
model.get_outputs_df().plot()Starsim
model.py
"""
The summer2 mixing-matrix example (docs/models/summer2/sir-age-location), in Starsim: an SIR
stratified by age and by location, with a mixing matrix for each
"""
import starsim as ss
S, I, R = ss.states('S I R')
sir = ss.Disease(
infection = S >> I @ 2 * I,
recovery = I >> R @ 1/3,
infection_death = ss.death(I, rate=0.05 * ss.by(location=[1, 3])), # Rural people have worse health care
init_n = dict(I=10),
)
sir.stratify(age=['young', 'old'])
sir.stratify(location={'urban': 0.7, 'rural': 0.3})
mixing = ss.contacts(age=[[0.2, 0.3], [0.5, 0.7]], location=[[0.8, 0.2], [0.2, 0.8]])
sim = ss.Sim(sir, networks=mixing, total_pop=1000, dt=0.1, stop=20)
if __name__ == '__main__':
sim.run()
sim.plot()What was declared and what was not
Everything is declared: three transitions, two stratifications, one adjustment, and the mixing.
Findings
- Contact matrices by dimension (fixed).
ss.contactstook one matrix with its own age groups. It now also takes a matrix for each of the model’s dimensions (ss.contacts(dict(age=..., location=...))), multiplied together as summer2 does. - Age groups as labels (fixed).
age=['young', 'old']clashed with the age every agent already has, which broke an agent-based run. A labeled age dimension is now kept apart from it, and nobody ages between its groups. - No unit of time. The original names none, and neither version needs one: both run in steps of 0.1.
- The mixing is not reciprocal. Starsim notes that both matrices give different contacts between two groups, depending on which group is counted. It uses them as given, as summer2 does, with rows as the contacts of a person in each group.
- Different ways of being fast. summer2 compiles each model with JAX, which takes a while the first time, and reruns it very quickly. Starsim runs every model on the same precompiled kernels, so it has no such wait.