Quick start: Hello world¶
If you're new to laser-measles or an AI assistant looking for a quick start example, start here.
We build a minimal but complete spatial ABM:
- 8 patches arranged in a line
- Heterogeneous population sizes
- Infection seeded in one patch
- Gravity-based spatial mixing
- 50 simulated days, no births or deaths
The goal is clarity, not realism.
1. Scenario¶
Every laser-measles model begins with a scenario: a Polars DataFrame with one row per patch.
Required columns:
id— unique patch identifierlat— latitudelon— longitudepop— population sizemcv1— routine vaccination coverage
import numpy as np
import polars as pl
def create_linear_scenario():
n_patches = 8
populations = np.array([50_000, 80_000, 120_000, 200_000, 150_000, 100_000, 70_000, 40_000])
return pl.DataFrame({
"id": [f"patch_{i}" for i in range(n_patches)],
"lat": np.zeros(n_patches),
"lon": np.linspace(0, 7, n_patches),
"pop": populations,
"mcv1": np.zeros(n_patches),
})
scenario = create_linear_scenario()
scenario
| id | lat | lon | pop | mcv1 |
|---|---|---|---|---|
| str | f64 | f64 | i64 | f64 |
| "patch_0" | 0.0 | 0.0 | 50000 | 0.0 |
| "patch_1" | 0.0 | 1.0 | 80000 | 0.0 |
| "patch_2" | 0.0 | 2.0 | 120000 | 0.0 |
| "patch_3" | 0.0 | 3.0 | 200000 | 0.0 |
| "patch_4" | 0.0 | 4.0 | 150000 | 0.0 |
| "patch_5" | 0.0 | 5.0 | 100000 | 0.0 |
| "patch_6" | 0.0 | 6.0 | 70000 | 0.0 |
| "patch_7" | 0.0 | 7.0 | 40000 | 0.0 |
Patches are arranged along a straight line (longitude 0–7) with varying population sizes. Latitude is fixed at 0. This creates a simple 1-dimensional spatial structure.
2. Model and parameters¶
Instantiate the ABM with three core parameters: simulation duration, random seed, and start date.
from laser.measles.abm import ABMModel, ABMParams
params = ABMParams(
num_ticks=50,
seed=42,
start_time="2000-01",
)
model = ABMModel(scenario=scenario, params=params)
3. Components define model behavior¶
laser-measles models are component-based: you assemble behaviors from reusable building blocks. Each tick, the model executes its components in order.
In this example we use four components:
NoBirthsProcess— static population (no births or deaths)InfectionSeedingProcess— introduce initial infectionsInfectionProcess— handle transmission and disease progressionStateTracker— record SEIR state counts over time
4. Spatial mixing¶
The key new concept in a spatial model is how infection crosses patch boundaries.
Unified mixing API: Both the ABM and the compartmental model accept an explicit
mixer=object onInfectionParams— for example,InfectionParams(mixer=GravityMixing(...))orInfectionParams(mixer=RadiationMixing(...)). The model assigns the patch scenario to the mixer automatically at initialization, so callers don't setmixer.scenariothemselves.
ABM default-gravity convenience. If you leave mixer=None on the ABM's InfectionParams,
the ABM builds a default GravityMixing internally from two parameters on InfectionParams.
This example takes that convenience path:
distance_exponent- Controls how quickly mixing declines with distance. A high value (for example, 20) means nearly all transmission is local; patches only interact significantly with their immediate neighbors.
mixing_scale- Controls the overall strength of cross-patch infection pressure.
When mixer= is set explicitly, distance_exponent and mixing_scale are ignored — the
explicit mixer takes precedence. For radiation mixing or any custom mixer, the explicit
mixer= path is the recommended one.
Each day the ABM (1) counts infectious agents per patch, (2) applies the mixing matrix to get force of infection per patch, then (3) applies infection probability to each susceptible agent. Agents themselves do not move between patches.
from laser.measles.abm import InfectionParams, InfectionSeedingParams, NoBirthsProcess, InfectionSeedingProcess, InfectionProcess, StateTracker, StateTrackerParams
from laser.measles.components import create_component
infection_params = InfectionParams(
beta=2.0,
seasonality=0.0,
distance_exponent=20.0, # strongly local transmission
mixing_scale=0.01, # moderate cross-patch pressure
)
5. Seed the infection¶
Seed 5 infections in the last patch (patch_7) to observe the outbreak spreading
outward from one end of the line.
seeding_params = InfectionSeedingParams(
target_patches=["patch_7"],
infections_per_patch=5,
)
6. Assemble and run¶
Attach components in execution order and run the simulation.
Two StateTracker instances are registered:
- Default (
aggregation_level=-1): sums across all patches → global SEIR time series aggregation_level=0: tracks each patch separately → spatial dynamics
model.components = [
NoBirthsProcess,
create_component(InfectionSeedingProcess, seeding_params),
create_component(InfectionProcess, infection_params),
StateTracker,
create_component(
StateTracker,
StateTrackerParams(aggregation_level=0),
),
]
model.run()
|████████████████████████████████████████| 50/50 [100%] in 0.3s (193.15/s)
7. Results¶
Extract the two tracker instances and generate three panels:
- Global SEIR fractions — overall epidemic curve
- Spatial attack rate — which patches were hit hardest
- Infectious agents per patch over time — how infection spread across space
import matplotlib.pyplot as plt
global_tracker = model.get_instance("StateTracker")[0]
patch_tracker = model.get_instance("StateTracker")[1]
pops = scenario["pop"].to_numpy()
total_pop = pops.sum()
ticks = np.arange(params.num_ticks)
# Global SEIR time series (shape: num_ticks,)
S = global_tracker.S
E = global_tracker.E
I = global_tracker.I
R = global_tracker.R
# Per-patch time series (shape: num_ticks × num_patches)
I_by_patch = patch_tracker.I
R_by_patch = patch_tracker.R
# Attack rate: fraction of each patch's population infected by the end
attack_rate = (I_by_patch[-1] + R_by_patch[-1]) / pops * 100
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# Panel 1: Global SEIR fractions
ax = axes[0]
ax.plot(ticks, S / total_pop, label="S")
ax.plot(ticks, E / total_pop, label="E")
ax.plot(ticks, I / total_pop, label="I")
ax.plot(ticks, R / total_pop, label="R")
ax.set_xlabel("Day")
ax.set_ylabel("Fraction of population")
ax.set_title("Global SEIR")
ax.legend()
ax.grid(alpha=0.3)
# Panel 2: Spatial attack rate
ax = axes[1]
ax.bar([f"patch_{i}" for i in range(8)], attack_rate)
ax.set_xlabel("Patch")
ax.set_ylabel("Attack rate (%)")
ax.set_title("Spatial attack rate")
ax.tick_params(axis="x", rotation=45)
ax.grid(alpha=0.3, axis="y")
# Panel 3: Infectious agents per patch over time
ax = axes[2]
for i in range(8):
ax.plot(ticks, I_by_patch[:, i], label=f"patch_{i}", alpha=0.8)
ax.set_xlabel("Day")
ax.set_ylabel("Infectious agents")
ax.set_title("Infection spread by patch")
ax.legend(fontsize=7, ncol=2)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
Reading the quickstart three-panel results¶
A single fig, axes = plt.subplots(1, 3, figsize=(15, 4)) figure summarising the 50-day ABM run with 8 linear patches, infection seeded in patch_7:
- Left (Global SEIR): Four curves — S, E, I, R — each plotted as a fraction of total population over day. With
beta=2.0, no births, and a 50-day window, the susceptible fraction should drop visibly (an outbreak has occurred) but not collapse to zero (this is too short for a full epidemic). E and I make low peaks under 10% each; R climbs to whatever fraction got infected. - Center (Spatial attack rate): Bar chart, one bar per patch (
patch_0throughpatch_7), height in percent. Becausedistance_exponent=20makes transmission strongly local and the seed is inpatch_7, the bars should be tallest on the right and fall off sharply moving left — illustrating gravity-mixing geometry. The exact gradient depends on the seed-day RNG draws. - Right (Infection spread by patch): Eight
I-count curves overlaid, one per patch, vs day. The wave originates inpatch_7(its curve rises first and highest), then neighbouring patches' curves rise with a lag proportional to their distance. The lag-and-peak pattern is the clearest visual signature of the gravity-mixing parameters.
Together the three panels show the canonical 1D gravity-mixing wavefront — a useful sanity-check pattern when reasoning about more complex spatial ABMs.
What this example demonstrates¶
- Scenario construction: Building geographic patch data from scratch
- Component architecture: Assembling model behavior from reusable pieces
- Spatial mixing: How ABM gravity mixing differs from the compartmental approach
- State tracking: Extracting global and per-patch time series
The model is deliberately simple—no demographics, no vaccination—so the spatial spreading dynamics are easy to observe.
Next steps¶
Extend this model by:
- Adding vital dynamics: Replace
NoBirthsProcesswithVitalDynamicsProcess - Adding vaccination campaigns: Use
SIACalendarProcess - Comparing ABM vs compartmental: See the
tut_basic_modeltutorial - Exploring mixing models in depth: See the
tut_spatial_mixingtutorial