TB SEILR — a spatial agent-based model that extends laser-generic's SEIR with a slow-latent reservoir compartment L and per-agent reactivation timers. The reservoir sustains transmission by itself, long after the initial epidemic wave has burned out.
Status: prototype; not calibrated to any real-world dataset. Parameters were chosen from published TB natural-history literature (Vynnycky-Fine 1997, Blower 1995, Andrews 2012, Menzies 2018), then subjected to a 1D per-parameter sensitivity sweep — see
sensitivity/SENSITIVITY.md— to identify which parameters materially move the model outcomes and which don't.
The latent-TB reservoir sustains transmission by itself, long after the initial epidemic wave has burned out.
Two-timescale infection dynamics, extending laser-generic's SEIR with a slow latent compartment L on a decade-to-century timescale:
S --β--> E --p_fast--> I ("fast progression", ~10% of infections)
E --1-p_fast--> L ("slow progression", ~90% — goes straight to reservoir)
I --p_cure--> R (self-cure w/ permanent immunity)
I --1-p_cure--> L (self-cure w/ residual bacilli → reservoir)
L --λ_react--> I (rare reactivation, ~0.5% per year here)
Only I contributes to force of infection. L is silent — a decades-long reservoir of latent M. tuberculosis. Reactivation is a Poisson process with per-agent time-to-reactivation sampled ~ Exponential(1/λ_react) on entry to L.
Real-geography demonstration on Eswatini's 4 admin-1 regions (Hhohho, Manzini, Lubombo, Shiselweni). Boundaries come from GADM and populations from WorldPop 2020 via the laser-init microservices; the cached responses ship under laser_tb/data/eswatini/ so the runner works fully offline.
Eswatini has one of the highest TB incidence rates in the world (~650 per 100k/yr per WHO Global TB Report), driven by co-epidemic HIV (~27% adult prevalence). Notification rates are higher in industrial Manzini than in rural Shiselweni. The 4-region setup is small enough to be computationally tractable while still showing the reservoir-driven dynamics on real geography. Not calibrated to Eswatini-specific notification data.
Six panels: aggregate S / L / R compartments over time; active TB per region; primary (E→I) vs reactivation (L→I) flux with 30-day smoothing on a log axis; latent-reservoir choropleth at year 10 (during outbreak wave); latent-reservoir choropleth at year 50 (endemic); end-state L-fraction and 100×I-fraction bars per region.
Animations:
Same model on a synthetic 5×5 grid. Useful for isolating what the reservoir dynamics look like without the confound of real-geography population imbalances.
- Ever-infected: ~99% of the population.
- L (latent reservoir): ~89% of the endpoint population; R (cured): ~10%; I (active TB): ~0.2% — endemic equilibrium.
- The
E→I(primary progression) flux dominates until year ~10; by year 15,L→I(reactivation) takes over as the dominant source of new active TB.
pip install .[geo] # Eswatini runner needs geopandas + shapely + requests
python -c "from laser_tb.run_eswatini import run_eswatini; r = run_eswatini({'YEARS': 30}, verbose=True); print(list(r['scenario'].name.values))"Or as scripts:
laser-tb-eswatini --years 50 --out out_eswatini.png # real Eswatini geography
laser-tb --years 30 --seed 42 # synthetic 5×5 gridfrom laser_tb import DEFAULTS, run_model, metrics
# DEFAULTS is the tabled baseline — see docs below.
result = run_model(overrides={"R0_TRANSMISSION": 12.0, "YEARS": 40}, verbose=True)
model = result["model"] # laser-generic Model instance
print(metrics(result))overrides is a dict of parameter names → values. Unknown keys raise KeyError early. The metrics function extracts the six outcome scalars used by the sensitivity harness (endemic active-TB prevalence, latent-reservoir fraction, reservoir-dominance year, reactivation fraction, primary-wave peak, final N).
From published TB natural-history literature; not calibrated to any specific dataset. See sensitivity/SENSITIVITY.md for the per-parameter perturbation study.
| symbol | value | source / justification |
|---|---|---|
| R0_TRANSMISSION | 15 | high-burden setting; Vynnycky-Fine 1997, Blower 1995 |
| P_FAST | 0.10 | fast-progressor fraction; Vynnycky-Fine 1997, Andrews 2012 |
| P_CURE | 0.30 | untreated self-cure to R; Menzies 2018 |
| EXP_SCALE_DAYS | 90 d | mean E period (fast-latent progression time) |
| INF_SCALE_DAYS | 180 d | mean untreated smear-positive I duration |
| LAT_SCALE_DAYS | 200 yr | mean L→I time; 0.5%/yr reactivation hazard (Andrews 2012 report 0.1–0.3%/yr for immunocompetent LTBI; we use 0.5%/yr to keep reactivation visible in the sim window) |
| CBR | 25/1000/yr | high-burden setting (sub-Saharan Africa) |
| CDR | 12/1000/yr | net +1.3%/yr population growth |
| GRID_M × GRID_N | 5×5 = 25 patches | central urban core (40k), ring (15k each), rural periphery (5k each) |
| YEARS | 50 | long enough to see the reservoir take over |
| SEED | 20260710 |
R0_active ≈ R0_TRANSMISSION × P_FAST ≈ 1.5 — the effective reproduction number for active TB cases spawning further active cases via the fast pathway. This is what sustains ongoing transmission during the early years.
A 1D per-parameter perturbation study (±30% around each science parameter, three stochastic seeds per cell, 30-year sims, LAT_SCALE_DAYS swept alongside the others despite its wide baseline) landed alongside this repo — see sensitivity/SENSITIVITY.md for the tornado charts and per-parameter interpretation. Rerun the sweep with:
python -m sensitivity.sensitivity --years 30 --seeds 3 --workers 3
python -m sensitivity.make_tornado- Standard SEIR models can't reproduce TB's decade-scale endemic persistence because they lack the reservoir compartment. Vaccine-preventable diseases like measles burn out in a small population; TB doesn't, even though it has a short infectious period, because the reservoir is unaffected by depletion of the susceptible pool.
- The
R0_active = R0_transmission × P_FASTtrick is what distinguishes TB from an SIR outbreak. Naively plugging R0=3 (a reasonable TB contact R0) into an SEIR gives active TB dying out. TB persistence depends on the reservoir. - Intervention-relevant: this framework supports LTBI screening + isoniazid preventive therapy (model as an artificial L→R flow with imperfect efficacy) and BCG vaccination (reduces
P_FASTwithout eliminating latent infection).
- HIV coinfection — increase
λ_reactby 20–30× for HIV+ agents (Andrews 2012) via an additional per-agent flag. - BCG cohort — bump
P_FASTdown by ~50% for vaccinated children; requires an age-structured scenario. - Treatment — add a
Tcompartment for treated agents with 90-day duration and imperfect cure (drop-out fraction goes back to I). - MDR — add a resistant compartment
I_rthat is treatment-resistant with lower cure probability. - More country runners — the Eswatini runner is a template. Adding e.g. India (IND) at admin-1 or South Africa (ZAF) is a
_laser_init.load_real_scenario("<ISO>", level=1, year=2020)call plus settingSEED_DIVISIONfor the new country.
- Blower SM et al. 1995. The intrinsic transmission dynamics of tuberculosis epidemics. Nat Med 1:815-821 — canonical two-progression TB model.
- Vynnycky E, Fine PEM. 1997. The natural history of tuberculosis. Epidemiol Infect 119:183-201 — natural history parameters.
- Andrews JR et al. 2012. Risk of progression to active tuberculosis. Clin Infect Dis 54:784-791 — reactivation-rate meta-analysis.
- Dowdy DW et al. 2013. Heterogeneity in tuberculosis transmission. PNAS 110:9557-9562 — LTBI reservoir maintenance in high-burden settings.
- Menzies NA et al. 2018. Progression from latent infection to active disease in dynamic tuberculosis transmission models. PLoS Med 15:e1002591.
LATENT = np.int8(4)extends laser-generic'sStateenum (which uses -1..3). Adding an entry to the enum itself is fragile, so we use a raw numeric.- The
additional_states=["L"]argument onModel.__init__makes_initialize_flowsautomatically propagatemodel.nodes.L[tick] → L[tick+1]. E→(I or L)branching: assigning the "fate" at exposure time (FATE_TO_I/FATE_TO_L, stored inpeople.fate) lets us run two independent single-target countdown-transitions inside numba, instead of a two-way branch.- Reactivation timer is drawn
Exponential(scale)at L-entry (ltimer, uint32 days). ThenSlowLatentReactivation.step()decrements daily and transitions to I on zero — exact for a homogeneous Poisson process. - The mortality mappings passed to
MortalityByCDRinclude the LATENT state sonodes.Lis decremented on death (default mortality only knows S/E/I/R).

