⚠️ Work in progress: not ready for real use.
fleetis published early so the approach, and its comparison againstmalariasimulation, can be examined and argued with, not so that anyone can rely on its numbers: the API is unstable and nothing here has been peer reviewed. The known discrepancies against the IBM are open rather than resolved, the largest being all-age severe incidence, which runs about 4 to 6% below the IBM, alongside a roughly 9% excess on clinical and severe incidence across the 63-country site files that is not yet explained. The full statement is on the package front page; if you need results you can defend today, use malariasimulation.
fleet is a mean field. It carries the same mechanisms as
the individual-based model and, for most outcomes, lands inside its
replicate spread, but a stratum mean is not a population of individuals
and some things do not survive the averaging. What follows is where that
bites: what to do, what to leave alone, and which results to treat with
caution.
It assumes you can already run the model; if not, start with
vignette("fleet"). Section references of the form §B.4 are
to vignette("model"), which carries the formal system;
function names link into vignette("parameters"); the
measurements behind the claims here are in fleetcheck, which is
kept current as the model changes, and this article is not.
Things to do
Call set_equilibrium() last. It freezes
all 56 equilibrium parameters, 51 of them translated from the IBM list,
at the values they held when it ran, and fleet merges that
stored set over the live list. An edit made after it — another
set_*() builder, or parameters$du <- 10 by
hand — is a silent no-op, and the run comes out bit-identical to one
without the edit. The IBM reads those fields directly and would honour
the edit, so this is a real difference between the two models, not a
quirk of this one. fleet warns when a live value disagrees
with the stored one. If a calibration loop varies any of them, re-call
set_equilibrium() each iteration.
Burn in before calibrating. The seed is a close
approximation, not the exact fixed point (§F), and a seasonal run needs
~10 years to settle onto its limit cycle. The seasonal annual-mean EIR
sits a few percent below the aseasonal init_EIR target
through nonlinear averaging. Discard the transient.
Match the age grid to your demography. If the oldest
model age group runs past the top deathrate_agegroups,
fleet applies the top death rate to those ages while the
IBM removes them. Fix it either way round (widen the grid, or extend the
death rates):
run_simulation_ode(t, p, tuning = list(age_lower = default_age_lower(max_age = 100)))A warning fires when they diverge; don’t ignore it, because it
changes the equilibrium age structure the whole run is seeded on (set_demography()).
Make your output bands a partition.
postie treats every column as an independent age stratum,
so overlapping bands are double-counted by any person-day-weighted
aggregate (§G). Set each family’s own fields:
p$prevalence_rendering_min_ages <- c(0, 2, 10) * 365
p$prevalence_rendering_max_ages <- c(2, 10, 100) * 365Loosen the solver for long projections. The equilibrium-preserving defaults are slow and only needed near equilibrium:
run_simulation_ode(t, p, tuning = list(rtol = 1e-6, step_size_max = 10))Convert EIR before comparing it. fleet
reports EIR per adult per year. The IBM reports a
per-species daily total, so the like-for-like comparison is
EIR_<species> / human_population * 365.
Sanity-check conservation.
S_count + D_count + A_count + U_count + Tr_count + Ph_count
should equal human_population to numerical tolerance.
Things to leave alone
acquired_immunity_offset. Leave it at
0. Setting 0.5 reproduces the IBM’s literal per-individual offset but is
a worse match to the IBM ensemble mean (§B.3).
bite_dedup. Leave it at 1 for any
scientific run. Set 0 only for an equilibrium test, where it cuts the
drift off the seed to well under 1% (§B.2).
Erlang stage counts (n_eir, n_foim,
n_eip). The defaults are fine. Raising them
sharpens the gamma-shaped lags toward the IBM’s fixed delays at linear
cost in state size; equilibrium is exact for any value. The prophylaxis
chains (n_ph, n_phc) are sized from the drug’s
Weibull by default (§B.4) and capped at 20; each stage adds
states and the solver slows steeply beyond that.
parameterise_total_M() /
parameterise_mosquito_equilibrium(). They have no
effect; fleet re-derives total_M itself (set_equilibrium():
from init_EIR under the default demography, as the IBM’s
own value under a custom one).
Results to treat with caution
Severe incidence
fleet runs below the IBM here, and DALYs derived from it
inherit that. The size of the gap is not quoted in this article on
purpose: it moves whenever the model does, and a number written here
would go stale silently. The measurement that is kept current is the
severe-allage-eir claim in fleetcheck.
The obvious explanation is the wrong one:
is the shallowest of the three acquired-immunity Hill
functions, not the steepest. What singles it out is position.
is sixteen times smaller than
while the two acquired immunities take nearly the same values at every
age, so by age 3
is already out on its convex tail while
is still nearly linear. fleet evaluates
once per stratum, at that stratum’s mean immunity; the IBM averages it
over individuals whose bite histories differ within the
stratum. Averaging a convex function the second way gives the larger
answer, so the mean field returns the smaller one and severe incidence
comes out low. §B.3 derives this and gives the numbers.
Two things make severe incidence uniquely exposed. is a post-hoc multiplier — severe counts are times an infection count the state equations have already determined (§G), so nothing downstream absorbs the error, where splits the infection inflow and feeds back through infectivity, treatment and immunity. And the bias needs the tail: at EIR 1 sits near its plateau and all-age severe incidence is within 1% of the IBM, the gap opening only over the EIR 20–120 range where the tail is where the population is.
None of this is a correction you can apply. It sets the sign and the order of magnitude, and it says which comparisons to distrust first: severe incidence in narrow age bands, and any scenario difference taken on severe incidence.
Vector-control eras
Population-averaged coverage loses the correlation of protection
within individuals across bites, so fleet tends to sit
above the IBM while nets or IRS are active (§2.2).
Repeated mass campaigns
Overlapping mass PEV or TBV campaigns combine as independent
protections rather than most-recent-receipt, and min_wait
re-vaccination exclusion is not applied (set_mass_pev(), set_tbv()). A single
campaign is fine.
Cost model
Seconds for one complete run_simulation_ode() call:
building the inputs, seeding the equilibrium, integrating and rendering
the outputs, which is what you actually pay. Single core, at
tuning = list(rtol = 1e-6).
| Scenario | 5 years | 10 years | 30 years |
|---|---|---|---|
| No interventions | 0.5 s | 1.0 s | 3.1 s |
| Seasonal | 0.9 s | 1.7 s | 5.5 s |
| Seasonal + treatment (AL) | 1.0 s | 1.9 s | 5.8 s |
| Seasonal + nets + IRS | 1.6 s | 1.8 s | 4.8 s |
| Seasonal + SMC (4 rounds/yr) | 1.4 s | 2.5 s | 7.8 s |
| All of the above + RTS,S | 1.5 s | 3.0 s | 11.2 s |
Three things to read off it.
Seasonality adds about 80% (1.8x, 1.7x and 1.8x on the three horizons), because rainfall drives the larval carrying capacity on a daily interpolation grid, and the oscillating solution costs the adaptive stepper 4.7 steps per output day against 2.0 on an aseasonal run.
Chemoprevention is the expensive intervention, and not because of the compartments it adds: each round splits the integration into a fresh segment (§E), so a schedule with many rounds pays for many solver restarts. A 20-year monthly PMC schedule means ~240 of them. Prefer a coarser cadence when the extra resolution buys nothing.
Cost is close to proportional to the horizon. Fit the no-intervention row and you get 0.102 s per simulated year on an intercept of -0.01 s, so the equilibrium solve is not a meaningful fixed charge. Three rows are dearer per simulated year at 5 years than at 30 (seasonal + nets + IRS by a wide margin, 0.32 s/y against 0.16 s/y; then seasonal + SMC and seasonal + treatment by a little). What is fixed in those rows is building the intervention time series, not the seed.
Population size is irrelevant, as it should be: the compartments are
per-capita densities, and human_population only rescales
the count columns on the way out.
| Population | 1,000 | 10,000 | 100,000 | 1,000,000 | 10,000,000 |
|---|---|---|---|---|---|
| 30-year seasonal run | 5.6 s | 5.8 s | 5.6 s | 5.6 s | 5.6 s |
For long projections the solver settings are worth knowing. The same
30-year seasonal run takes 7.9 s at the
ode_tuning() defaults and 5.5 s at
rtol = 1e-6. It is the relative tolerance that buys this,
by roughly halving the step count; step_size_max is a
safety rail rather than a speed control, and changes neither the step
count nor the outputs. Loosening atol as well saves almost
nothing (5.4 s) and is not worth having: the prophylaxis chain stages
hold occupancies of order 1e-6, which a looser absolute
tolerance lets go slightly negative.
Measured as the minimum of five repeats, since contention can only
add time, on R 4.5.2, aarch64-w64-mingw32. Treat them as
indicative: one machine, one core, and a laptop under load will be
slower. For how this compares with the IBM’s cost, see the
speed claim in fleetcheck.
Index of approximations
Every 🟡 in vignette("parameters"), and when it matters.
The first column links to the argument-by-argument account; § refers to
vignette("model").
| Mechanism | Where | Matters when |
|---|---|---|
Ages above the top deathrate_agegroups take the top
death rate |
set_demography() |
A custom demography whose age grid stops short of the model’s oldest group; the IBM removes those people instead. Warned |
| Weibull prophylaxis → Erlang chain (moment-matched) |
set_drugs(),
§B.4 |
The far tail of chemoprevention protection; for post-treatment protection the exponential stage in front of the chain spreads AL’s sharp 10-day protection, reproducing its integral rather than its shape |
| Multi-drug blend rather than parallel sub-populations | set_clinical_treatment() |
A genuinely mixed first line (a switch is modelled fine) |
| Single scalar slow-clearance rate blended across drugs |
set_antimalarial_resistance(),
§B.4 |
High artemisinin resistance with several treatment drugs |
| Population-averaged nets | set_bednets() |
Any nets era, the largest routine gap |
| Population-averaged IRS | set_spraying() |
Any IRS era |
| Carrying-capacity floor at | set_carrying_capacity() |
Modelling complete vector elimination |
| Chemoprevention as pulses; half-open age bands |
set_mda(),
set_smc(), §E |
Very narrow target bands |
| PMC age trigger → monthly pulses | set_pmc() |
PMC dose timing specifically |
| Seasonal PEV boosters → fixed delay | set_pev_epi() |
Seasonally-timed booster campaigns (warned) |
Repeated campaigns: no min_wait, independent
combination, no ageing out of band |
set_mass_pev() |
Repeated mass PEV |
| TBV campaign combination | set_tbv() |
Repeated TBV campaigns |
