This guide is for users who want to run JUNE 2, without having to edit the code. Everything you need to control lives in two directories:
configs/<scenario>/ — YAML + CSV that describe your simulation
data/ — Population-scale CSVs (rates, contact data, geography)
worlds/ — Pre-built population HDF5 files (the "world")
./rebuild.sh # local buildThe executable lands at build/disease_sim.
A run needs one world file and one simulation.yaml (which pulls in
all the other YAML files via config_paths:).
# Serial
./build/disease_sim worlds/world_2021.h5 \
--config configs/config_2021/simulation.yaml
# Parallel (MPI domain decomposition)
mpirun -n 4 ./build/disease_sim worlds/world_2021.h5 \
--config configs/config_2021/simulation.yamlOutput:
simulation_events.h5(HDF5 event log) + the contents ofoutput/(partition map, stats CSVs, etc.).
YAML and CSV are loaded at runtime. You do not have to rebuild after editing them.
If you change anything in src/ or include/, you must ./rebuild.sh.
A typical scenario folder (e.g. configs/config_2021/):
simulation.yaml ← entry point; references everything else
disease.yaml ← what the pathogen does
infection_seeds.yaml ← who gets infected on day 0 (and later seedings)
contact_matrices.yaml ← per-venue contact rates and β (transmission rate)
schedules.yaml ← weekly time-use templates
activity_preferences.yaml ← which leisure venue each person prefers
coordinated_encounters.yaml ← coordinated multi-person encounter recipes
policies.yaml ← lockdowns, isolation, behavioural overrides
vaccines.yaml ← vaccine products and rollout campaigns
parallel.yaml ← MPI domain decomposition
performance.yaml ← schedule pre-compute toggles
bulk_seeds.csv ← optional: many seeding events in one file
Only simulation.yaml is required by name; everything else is referenced
through simulation.yaml's config_paths: block, so you can rename or
relocate sub-files freely as long as the path matches.
Minimal example:
time:
start_date: "2024-01-01"
end_date: "2024-01-31"
config_paths:
disease_file: "configs/config_2021/disease.yaml"
contact_matrices_file: "configs/config_2021/contact_matrices.yaml"
schedules_file: "configs/config_2021/schedules.yaml"
vaccines_file: "configs/config_2021/vaccines.yaml"
activity_preferences_file: "configs/config_2021/activity_preferences.yaml"
coordinated_encounters_file:"configs/config_2021/coordinated_encounters.yaml"
performance_file: "configs/config_2021/performance.yaml"
parallel_file: "configs/config_2021/parallel.yaml"
policies_file: "configs/config_2021/policies.yaml"
infection_seeds_file: "configs/config_2021/infection_seeds.yaml"
random_seed: 12345
output:
stats_interval_days: 1
flush_interval_days: 7
max_event_buffer_size: 100000
save_coordinated_encounters: false
regional_risk:
enabled: false
regional_risk_file: "data/regional_risk.csv"| Field | What it controls |
|---|---|
time.start_date / end_date |
First and last simulated day. Must be ISO YYYY-MM-DD. |
random_seed |
Master RNG seed. Same seed + same world ⇒ bit-identical output (across MPI rank counts too). |
output.stats_interval_days |
How often summary stats are written. |
output.flush_interval_days |
How often the event buffer is flushed to HDF5. |
output.save_coordinated_encounters |
Write coordinated encounters to /events/coordinated_encounters. Default false — it's the largest dataset in simulation_events.h5 and rarely needed for analysis; encounters still happen and still drive transmission either way, only the H5 record is skipped. |
regional_risk |
Optional per-geo multipliers on transmission and severity. |
Paths are resolved relative to the working directory you launched from (typically the repo root). All sample configs assume that.
This file defines the pathogen's biology and clinical course. It has
four blocks: settings, symptom_tags, outcome_rates_csv, trajectories,
transmission, and immunity.
Every state a person can be in. Each tag has a numeric value used to
order them (higher = sicker). Stages with the same name across trajectories
share a tag.
symptom_tags:
- {name: healthy, value: -1}
- {name: exposed, value: 0}
- {name: asymptomatic, value: 1}
- {name: mild, value: 2}
- {name: severe, value: 3}
- {name: hospitalised, value: 4}
- {name: intensive_care, value: 5}
- {name: dead_home, value: 6}
- {name: dead_hospital, value: 7}
- {name: recovered, value: -2}settings then groups these into roles the simulator needs to recognise:
which tags are terminal (fatality_stage), which mean recovery, which
require a hospital bed (hospitalised_stage / intensive_care_stage).
Add a new disease state by adding a tag here AND wiring it into the
relevant settings list AND using it in a trajectory.
A per-(age, sex, population) probability table that decides which
trajectory a newly infected person follows. Defined in
data/infection_outcome_rates_<disease>.csv:
filter.age,filter.sex,asymptomatic,mild,severe,hospital,icu,home_ifr,hospital_ifr,icu_ifr
0-4,male,0.456,0.533,0.0100,0.0009,9.1e-05,1.4e-05,0.0,0.0
...filter.*columns identify the bin (age band, sex, population type).- The remaining columns are probabilities of each outcome for that bin
(must sum to ≤ 1.0; the residual goes to
severeif you declared it incalculated_outcomes). - The
outcome_rates_csv:block indisease.yamltells the loader how to interpret these columns:populations:lets you define population-typed sub-tables (e.g.gpgeneral,chcare-home) using aselection:rule on person attributes. The CSV columns must then be prefixedgp_*,ch_*.outcomes:maps each CSV column to the symptom tag it triggers.calculated_outcomes:defines outcomes whose probability is1 - sum(others)rather than a CSV column.
A trajectory is the clinical path a person walks once their outcome
is sampled. Each has a selection_key (matching an outcome in the CSV),
a severity (0–1, used by vaccines to scale benefit), and an ordered
list of stages. Each stage has a symptom_tag and a completion_time
distribution (how long until the next stage).
- description: "exposed => mild => recovered"
selection_key: mild
severity: 0.2
stages:
- symptom_tag: exposed
completion_time: {type: beta, a: 2.29, b: 19.05, loc: 0.39, scale: 39.8}
- symptom_tag: mild
completion_time: {type: constant, value: 20.0}
- symptom_tag: recovered
completion_time: {type: constant, value: 0.0}Distribution type can be constant, normal, lognormal, beta,
gamma, exponweib. Parameters match SciPy conventions.
To add a new clinical pathway, add a row in the outcome-rates CSV
(or a calculated outcome), then add a corresponding trajectory whose
selection_key matches.
Two modes are supported, chosen by the top-level mode: key:
- Trajectory-Driven (default). A single global infectiousness
curve (gamma PDF, etc.) parameterised by
max_infectiousness,shape,rate,shift. Each symptom stage's relative infectiousness comes frominfectiousness_factoron the trajectory. - Stage-Driven. Each symptom tag has its own curve in
stage_curves:and the clock resets when the person enters that stage. Useful for diseases where infectiousness changes character (e.g. plague's bubonic→pneumonic shift).
Within each mode you list modes: (the transmission modes —
respiratory, physical_contact, fomite, etc.). Each gets its own
susceptibility_multiplier. The per-mode beta is not here — it's
in contact_matrices.yaml, per venue.
immunity:
level: 0.95
waning_rate: 0.001Probability of becoming immune on recovery and the rate at which immunity decays per day.
This file says how often people meet at each venue and how transmissive that venue is, per transmission mode. It is the single biggest knob for outbreak intensity.
contact_matrices:
household:
modes:
respiratory:
beta: 0.0673
bins: &b [Kids, Young Adults, Adults, Old Adults]
contacts:
- [1.63, 0.78, 0.78, 0.78]
- [0.55, 0.45, 0.45, 0.45]
- [0.39, 0.44, 0.44, 0.44]
- [0.35, 0.45, 0.45, 0.71]
physical_contact:
beta: 0.134
bins: *b
contacts:
- [0.84, 0.52, 0.52, 0.52]
- [...]Each venue declares one block per transmission mode:
beta— venue-and-mode transmission rate (probability per contact-second; treat it as a tunable knob).bins— labelled subgroups within the venue. Both modes must use the same bins.contacts[i][j]— daily contacts ani-bin person has with aj-bin person.
The simulator multiplies these: effective[i][j] = beta * contacts[i][j].
Don't double-count. The
mode_susceptibility_multiplierindisease.yamlis folded into per-venuebetahere. Leave it at 1.0 indisease.yamlunless you know what you're doing.
A default_contacts_matrix: block is used as the fallback for any
venue not explicitly listed.
Seeds are the initial spark. JUNE 2 supports four types:
global_parameters:
default_seed_strength: 1.0
base_cases_per_capita: 0.0002
infection_seeds:
# 1. Uniform — random across the whole population
- name: "basic"
type: "uniform"
date: "2024-01-01 08:00"
parameters:
cases_per_capita_multiplier: 15.0
attribute_filters: # optional restriction
properties.ethnicity: "W"
# 2. Exact — N cases in named geographic units, by age group
- name: "targeted"
type: "exact"
date: "2024-01-02 12:00"
geo_level: "MGU"
parameters:
age_groups: ["0-17", "18-64", "65+"]
units:
"E02004292": [5, 10, 2] # 5 kids, 10 adults, 2 seniors
"E02001234": 20 # 20 total
# 3. Clustered — like exact, but cases cluster within households
- name: "household_burst"
type: "clustered"
date: "2024-01-03 18:00"
geo_level: "SGU"
parameters:
age_groups: ["18-30"]
units: {"E01000001": 5}Or for many events at once, point to a CSV:
bulk_csv: "configs/config_2021/bulk_seeds.csv"bulk_seeds.csv columns:
name,date,type,geo_level,geo_unit,trajectory_key,start_symptom,cases,cases_per_capita,
filter.properties.ethnicity,filter.sex,filter.age_groups
trajectory_key lets you force a specific clinical path on seeded
cases (e.g. seed plague cases directly into the pneumonic_perished
trajectory). start_symptom lets you skip the latent period.
For outbreak modelling, prefer
exactorclusteredseeding in high-density geographic units overuniform-with-filter— a uniform seed wastes most cases on people the filter rejects.
A schedule is a weekly time-use template. Every person is assigned exactly one schedule type at world load.
day_type_cycle: [weekday, weekday, weekday, weekday, weekday, weekend, weekend]
default_schedule_type: "has_primary_activity"
schedule_types:
has_primary_activity:
priority: 0
selection: [] # empty = matches everyone (fallback)
weekday:
- {name: morning, start: "08:00", end: "09:00", allowed_activities: [residence]}
- {name: primary_activity, start: "09:00", end: "17:00", allowed_activities: [primary_activity, residence]}
- {name: evening, start: "18:00", end: "21:00", allowed_activities: [leisure, residence],
coordinated_only_activities: [sex]}
- {name: night, start: "21:00", end: "08:00", allowed_activities: [residence]}
weekend: [...]
participation:
primary_activity: {weekday: 1.0, weekend: 0.0}
leisure: {weekday: 0.3, weekend: 0.5}
residence: {weekday: 1.0, weekend: 1.0}priority+selection— first matching schedule wins. Useselection: []for the catch-all fallback.- Each time slot lists
allowed_activities(what the person may do) and optionallycoordinated_only_activities(slots that exist only for coordinated encounters — e.g.sexdoesn't roll on its own; it's only realised when a partner pair commits). participationis the per-day probability the person engages in that activity at all.0.3weekday leisure ⇒ 30% chance to leave the house for leisure on a weekday.
For complex worlds (e.g. medieval) you can drive schedule assignment
from a schedules.csv with filter.* columns and schedule.<name>
probability columns. See configs/config_plague/schedules.csv.
Once a person is going to do leisure, which venue category? This
file holds priority-ranked profiles with selection: criteria and
relative weights: per venue type:
profiles:
- name: "Young Males (Leisure)"
activity: "leisure"
priority: 10
selection:
- {property: age, operator: "<", value: 30}
- {property: sex, operator: "==", value: "M"}
weights: {gym: 5.0, pub: 3.0, cinema: 2.0, grocery: 0.5}The first profile whose selection matches wins; weights are
normalised to a categorical distribution.
Persistent social networks (e.g. friendships) live in the world
HDF5 file built by MAY. They
are static across a run: every person's network ties are read at
startup and accessed via WorldState::getNetworkPartners(person, network_name). There are no formation or dissolution mechanics in
the public engine.
To use a network in a scenario, reference it by name from a coordinated encounter (see §9):
- name: "social_encounters"
network: "friendships" # must match a network type in the world HDF5
...If the named network is absent from the world file, the encounter's partner list will simply be empty.
These are activities where two or more specific people must commit to meet (sex, group encounters, etc.) — distinct from the implicit mass-action contacts that contact matrices model.
coordinated_encounters:
enabled: true
log_commitments: true
encounters:
- name: "social_encounters"
priority: 10
enabled: true
network: "friendships" # which network supplies partners
trigger_slots: ["leisure"] # which schedule slot triggers it
allowed_venues: ["pub", "cinema"] # real venues for the meet-up
daily_max_distribution:
type: poisson
mean: 1.5
proposal_probability: 0.05
invite_distribution: {type: binomial, p: 0.3}
acceptance_probability: 0.8
is_virtual: falseKey fields:
| Field | Purpose |
|---|---|
network |
The network in the world HDF5 whose ties supply partner candidates. |
trigger_slots |
Schedule slot names that activate this encounter. |
proposal_probability / acceptance_probability |
Multipliers on the offer/accept stages. |
allowed_venues |
Real venue names. Empty + is_virtual: true ⇒ encounter happens at a synthetic venue. |
virtual_contact_matrix |
When is_virtual: true, which key in contact_matrices.yaml supplies the contact rates and β. |
Two kinds of policies, evaluated in order:
policies:
symptom_policies: # behavioural responses to illness
- name: "isolate_when_severe"
symptoms: ["severe"]
override_activities: ["primary_activity", "leisure"]
replacement: "residence"
compliance_rate: 0.8
- name: "hospitalize"
symptoms: ["hospitalised", "intensive_care"]
override_activities: "*"
replacement: "medical_facility"
compliance_rate: 1.0
temporal_policies: # date-bounded restrictions
- name: "first_lockdown"
start_date: "2024-03-23"
end_date: "2024-05-15"
override_activities: ["primary_activity", "leisure"]
replacement: "residence"
compliance_rate: 0.85
applies_to: # optional selection
- {property: age, operator: "<", value: 65}- Symptom policies fire when a person has any of the listed symptom tags. They override the activities the schedule asked for.
- Temporal policies are active in date windows (inclusive). If both apply, the first matching policy wins — order them by specificity.
override_activities: "*"overrides everything.compliance_rateis rolled per slot, per person.applies_touses the same selection grammar as everywhere else (property,operator,value).- Use
follow_up_policyto chain policies (e.g. mild ⇒ severe).
enabled: true
vaccines:
Pfizer:
doses:
- days_to_effective: 5
days_to_waning: 6
days_to_finished: 7
waning_factor: 1.0
infection_efficacy:
covid19: {"0-100": 0.52}
symptom_efficacy:
covid19: {"0-100": 0.52}
- {... dose 2 ...}
campaigns:
"Care Home Pfizer":
start_date: "2024-01-01"
end_date: "2024-01-20"
days_to_next_dose: [0, 14]
daily_coverage: 0.3
vaccine_type: "Pfizer"
dose_sequence: [0, 1]
selection:
- {property: "activities.residence.venue_type", operator: "==", value: "care_home"}- A vaccine is a sequence of doses. Each dose has its own ramp-up
(
days_to_effective), waning window, and per-disease, per-age-band efficacy against infection and symptoms. Efficacy multiplies the trajectory'sseverityto compute symptom protection. - A campaign rolls out a vaccine to a selected sub-population at
daily_coverageuntil end-date.last_dose_type_filterlets a booster campaign target only people whose previous dose was a specific brand.
If you run with MPI, this file controls domain decomposition.
parallel:
enabled: true
partitioning:
level: "MGU" # geography unit to partition on
centroids_file: "data/domain_decomposition/2021/mgu_centroids.csv"
adjacency_file: "data/domain_decomposition/2021/mgu_adjacency_graph.json"
metis: {imbalance_tolerance: 0.05}
chunked_loading:
person_metadata_chunk_size: 100000
geo_unit_chunk_size: 1000
communication:
buffer_size_mb: 256
output:
save_partition: true
partition_file: "output/partition.json"level:must match the granularity of your centroid/adjacency files (SGU⊂MGU⊂LGU⊂XLGU). For full-England runs useMGU; for the 1911 Durham world there is currently only oneMGU, so partitioning is done atSGU.- Output is bit-identical across rank counts (with the same
random seed). If
mpirun -n 1andmpirun -n 4disagree, that's a bug — file an issue.
performance:
precompute_schedules: true
deterministic_activities: ["residence", "primary_activity"]
track_active_infections_only: truedeterministic_activitiesare pre-computed once at world load and cached.leisureetc. stay stochastic at runtime.track_active_infections_onlyskips state updates for healthy people. Default on; turn off only if you need full event traces.
Reference data shared across scenarios. Edit (or replace) these CSVs to retune the model without touching code.
| File | Purpose | Schema |
|---|---|---|
infection_outcome_rates_<disease>.csv |
Per (age, sex, pop) outcome probabilities | filter.age,filter.sex,asymptomatic,mild,severe,hospital,icu,home_ifr,hospital_ifr,icu_ifr |
bulk_seeds_<disease>.csv |
Bulk seeding events | see §6 |
regional_risk.csv |
Per-geo transmission/severity multipliers | geo_unit,transmission_factor,severity_factor |
domain_decomposition/<world>/ |
METIS centroids + adjacency | CSV + JSON |
Convention: every column starting filter. is a row-selection key
(equality match); the rest are values.
Pre-built HDF5 population files. You don't edit these directly — they're produced by MAY, the companion world-builder repository.
| World | Use |
|---|---|
worlds/world_2021.h5 |
Full England 2021 census (~60M, partitioned at MGU) |
worlds/medieval.h5 |
Medieval plague world |
worlds/1911_durham_world.h5 |
1911 Durham (1 MGU + 268 SGU parishes) |
Pick a world that matches your scenario's geography. The world defines the population, households, workplaces, schools, geography hierarchy, and pre-existing networks (e.g. friendships). The configs tell that population what to do.
The fastest path:
- Copy an existing scenario folder closest to your use case
(
cp -r configs/config_2021 configs/config_myrun). - Edit
simulation.yaml— dates, output cadence, and update theconfig_paths:to point insideconfigs/config_myrun/. - Pick a disease — either edit
disease.yamland the matchingdata/infection_outcome_rates_<disease>.csv, or swap in plague. Make suredisease.namematches the CSV file you're using. - Tune contacts —
contact_matrices.yamlis your single biggest knob on outbreak intensity. Start there before touching anything else. - Choose seeds — start with one
uniformseed; switch toexact/clusteredonce you want geographic targeting. - (Optional) policies, vaccines, networks — add as needed.
A run can be snapshotted at configurable points and later resumed. The
continuation is bit-identical to the uninterrupted run, regardless of
the MPI rank count used for either the checkpoint or the resume
(set-identical events; the only cross-rank difference is the documented
rank-stamped coordinated_encounters.group_id).
Add a checkpoint: block to simulation.yaml:
checkpoint:
enabled: true
output_dir: checkpoints/ # resolved under the run directory
every_n_days: 30 # interval cadence
on_dates: null # OR an explicit list of "YYYY-MM-DD"
keep_last: 3 # 0 = keep all; otherwise rotate oldestCadence is mutually exclusive. If on_dates is a non-null,
non-empty list it takes precedence and every_n_days is ignored
(a one-line notice is printed at startup). Use null to mark a field
absent. With neither set, no checkpoints are written.
every_n_days: N→ checkpoint at the end of everyN-th completed day ((day+1) % N == 0).on_dates: ["2026-03-01", "2026-06-15"]→ checkpoint at the end of exactly those calendar days. Dates outside the simulation window are warned about at startup and never fire.
Checkpoint frequency has zero effect on results — it is purely an operational knob (the snapshot is read-only and the RNG is stateless).
runs/<run-id>/checkpoints/
checkpoint_20260301_day045/
delta_rank0.h5 … # per-rank mutable-state shard
shard_index.yaml # geo_unit -> (shard, offset, count)
state.h5 # global scalars, applied seeds, …
manifest.yaml # written LAST = commit marker
latest -> checkpoint_20260301_day045
Writes are atomic: a checkpoint is built in *.tmp/ and renamed only
once complete, and manifest.yaml is written last — a crash mid-write
never leaves a selectable, corrupt checkpoint. The 25 GB world is not
copied; the checkpoint stores only the small mutable delta and is
restored on top of the normal (fast) world load.
# resume the newest checkpoint of a previous run (any rank count)
mpirun -np 8 ./build/disease_sim \
--config configs/config_2021/simulation.yaml \
--world worlds/world_2021.h5 \
--restart-from runs/20260301-120000/checkpoints/latest--restart-from accepts a checkpoint directory or the latest
symlink. The resume is a new run directory (the original is left
untouched, so the two can be diffed). The checkpoint's recorded
effective seed is authoritative; passing a conflicting --seed is
rejected (no silent override). Start-of-simulation infection seeding is
not re-applied on resume.
Run length on resume. You may combine --restart-from with
--days (or change end_date) to extend/shorten the continued run,
but --days N/end_date is anchored to the original start_date,
not to the checkpoint. It sets the absolute end (day index from
start); the resumed run fills [completed_day + 1, total_days). To run
M more days after a checkpoint that completed at day D, pass
--days (D + 1 + M). Requesting an end at or before the resume day is
rejected with a descriptive error (not a silent no-op).
- Compartmental-model scenarios are not supported. If a
compartmental_model_sidecaris configured together with checkpointing, the run aborts with a clear error — the external ODE plugin's internal state is opaque to the engine. (Plugin serialize/deserialize hooks are a planned follow-up.) - Checkpoints are valid only against the exact world they were taken from.
tests/checkpoint_determinism_check.py drives the binary through a
(write_ranks -> resume_ranks) matrix and asserts set-identical events
versus an uninterrupted baseline. It is wired as the CTest
test_checkpoint_determinism (skips cleanly without the world file):
ctest -R test_checkpoint_determinism --output-on-failure| Symptom | Most likely cause |
|---|---|
| Output differs across rank counts | Bug or non-deterministic seeding — never accept partial determinism. |
| HDF5 read aborts with "file is locked" | Two disease_sim runs against the same world in parallel — don't. Run sequentially or copy the world. |
| Many "encounter_type=255" events | Normal: sentinel for non-coordinated venue infections (typically ~87%). |
| Low/zero infections despite seeds | Check contact_matrices.yaml betas, not seed counts. β is the dominant knob. |
--restart-from aborts on seed mismatch |
The checkpoint's effective seed is authoritative; drop the conflicting --seed. |
| Checkpointing aborts immediately | A compartmental_model_sidecar is set — checkpointing is unsupported for compartmental scenarios. |
Anything you can't reach through these files is a missing config hook, not a reason to patch C++. File an issue or extend the loader; don't bake a value into the source.