fleet 0.0.0.9006
-
Vivax hypnozoite batch counts 4 to 9 share levels in pairs. Every vivax compartment and immunity stock is held at each level of the batch ladder, so its length sets most of a vivax run’s cost. Counts 0 to 3 and
kmaxkeep a level each, and the counts between share levels in pairs, the people inside a pair spread as a Poisson whose rate is fitted to the levels either side: eight levels for the default eleven counts, and a vivax run takes about a quarter less time. Against a ladder with a level per count, whichode_tuning(batch_bins = FALSE)still gives, no output of thefleetcheckvivax suite moves by more than 0.4% and no vivax reference value by more than 0.32%. Fitting the shape to the neighbouring levels, rather than reading a rate off counts 0 and 1, is what holds it under radical cure, which empties the high counts into 0. - Primaquine radical cure has no liver-stage-protected levels. Its 4.7 days of liver-stage protection end inside the 16 days of chloroquine’s blood-stage protection given with it, during which no bite infects and no batch can form, so its four levels protected only the few whose blood stage the drug did not clear. Outputs move by under 0.2%. With the paired ladder, a primaquine run takes about half the time it did. Tafenoquine’s 27 days outlast chloroquine’s, and it keeps its eleven levels.
- Falciparum is untouched: its reference values are bit-identical.
fleet 0.0.0.9005
-
A parameter draw with
b0near 1 runs. The check that no age group can lose more people in a day than it holds bounded a day’s infections byb0, as if everyone were bitten every day. The default grid’s 16.6-day infant groups lose 0.06 of their people a day to ageing and death, so it refused anyb0above 0.94:set_parameter_draw()draws 410 and 464 (b00.959 and 0.990) on the default grid, 9 draws on 209 groups and 43 on 300. None of those runs goes negative, at EIR 20 or 120, nor draws 410 and 464 at a seasonal EIR of 400 or an aseasonal 673: the people ageing into a group every day make good what its infections take. The check before the run now covers only what does not depend on transmission: a group narrower than about a day, progression and clearance, and the prophylaxis chains. The vivax check, which bounds infection at the seed’s EIR, is unchanged. -
Positivity is checked as the run goes. Each day the model finds its smallest human compartment, and
run_simulation_ode()stops with an error giving the first day one fell below -1e-12 of the population, its value and its age group. It takes several consecutive groups a day or two wide at an EIR in the hundreds: ten 1.3-day groups from birth go negative from EIR 300, and not at 120. The check adds no output column and no measurable time: over seven runs of each build, a 30-year falciparum run took 1.80 s on average with it and without, and a 10-year vivax run 9.74 s against 9.75 s at the median, inside the runs’ own spread of about 4%. Every output of a run that ran before is bit-identical to 0.0.0.9004’s.
fleet 0.0.0.9004
-
Severe incidence follows a parameter draw’s
iv0.fleettook the scale of severe-disease immunity fromset_equilibrium()’s stored copy, which holdsmalariaEquilibrium’s defaultiv0whatever the parameter list says:malariasimulation’s back-translation table has that entry the wrong way round. The IBM’s severe disease reads the liveiv0, so underset_parameter_draw()fleet’s severe incidence ran from 45% low to 63% high across the middle 90% of draws. It reads the live value now, as the IBM does; at the default parameters nothing changes.tests/testthat/test-parameter-draws.Rchecks that every parameter a draw sets reachesfleet’s dynamic inputs, and at its live value whereverfleettakes it unchanged.
fleet 0.0.0.9003
fleet now advances one day at a time, in the order malariasimulation resolves a day, instead of integrating an ODE; it runs P. vivax as well as P. falciparum; and its default age grid is four times finer. Four falciparum mechanisms were brought into line with the IBM along the way, and the output table is now malariasimulation’s, column for column. Every model output moves.
P. vivax
-
run_simulation_ode()runs P. vivax. A list frommalariasimulation::get_parameters(parasite = "vivax")runs a vivax block in the same daily model, dispatched onparameters$parasiteasmalariasimulationdoes. It carries the hypnozoite-batch dimension (0 tokmaxbatches, relapse atk * f, one batch lost a day with probability1 - exp(-k * gammal)), radical cure with liver-stage protection (CQ_PQ_params_vivax,CQ_TQ_params_vivax), and no severe disease. MDA, SMC and PMC are refused under vivax, becausemalariasimulationitself fails on them.vignette("model")§V specifies it. - Each day is the IBM’s: every bite counts, relapse adds to the bite hazard and PEV reduces both, and infection competes with each state’s progression in one draw a day. People who share an age, heterogeneity group and batch count differ in immunity by their batch history, and the vivax curves are steep, so each cell carries the second moment of immunity and the curves are averaged over a gamma (
parameters$immunity_spread, defaultTRUE). -
Refractory windows are carried as stocks, moved with the people. A bite boosts a person and moves them up a batch, into a cell whose relapse hazard is higher, so a cell’s newcomers are freshly boosted; a renewal rate
p / (p u + 1)cannot see that, and put young children’s anti-parasite immunity 10-11% above the IBM’s. Against four 50,000-person IBM runs at EIR 10, IAA by age now agrees but for the age grid’s share, and so does its within-cell spread. -
The vivax clinical curve takes the IBM’s per-person +0.5, at quadrature nodes that stand for people:
acquired_immunity_offsetdefaults to 0.5 for vivax, 0 for falciparum. - A bite that forms a batch keeps the day’s batch decay from happening. The IBM queues the decay first and the bite’s new batch count later, and the later update overwrites the earlier. The decay is taken after the day’s other events, from all but the day’s batch-formers, so someone who relapses and loses a batch on the same day ends a batch down, infected, as in the IBM.
- The radically cured carry the immunity of the nodes that fell ill, the less immune, rather than their cell’s mean, and ICA’s within-cell spread gains the square of the day’s boost probability, which makes it exact with no refractory window.
- With heterogeneity off, vivax is seeded as the IBM seeds it: everyone from the heterogeneous equilibrium’s first group, among mosquitoes sized from the whole of it.
- Against the same IBM runs, infections, relapses, clinical incidence, hypnozoite carriage, the batch count, and LM and PCR prevalence agree by age to within about 3%. School-age LM prevalence and clinical incidence run 1-3% high: the IBM’s immunity-dependent transitions sort people by immunity within a cell, which one immunity distribution per cell does not carry.
- A vivax run costs about 0.9 s per simulated year on the default grid, 2 s with primaquine radical cure and 3.6 s with tafenoquine: the hypnozoite dimension and the immunity spread make it about sixteen times a falciparum run.
- The output is the IBM’s vivax table:
n_relapses,n_with_hypnozoites,n_inc_relapse_*andn_with_hypnozoites_*over their rendering lists, no severe columns unless a severe band is set, and nop_detect_lm_*.
Output
-
The immunity means are rendered, as the IBM renders them:
ica_mean,icm_mean,ib_mean,iva_mean,ivm_mean,id_meanfor falciparum,ica_mean,icm_mean,iaa_mean,iam_mean,hypnozoites_meanfor vivax, and each by age over its<name>_rendering_*list.
The default age grid
-
default_age_lower()now has 118 groups (was 53), weighted towards the under-fives. The groups are still shared across the anchor intervals by log width, now weighted 1.25 below 5 years and 0.5 from 21: the grid’s error comes from young children at high transmission, and groups above 21 years move no child’s incidence measurably. 118 is the smallest grid on which every falciparum claim infleetcheckpasses; the clinical age profile’s 3-5 year band at EIR 120 decides it. Severe incidence at EIR 120 is 2% below the IBM median, where it was 8%, and a 30-year falciparum run costs about 1.7 s, where it cost 0.65 s.default_age_lower(n_group = 53)has the new weights, so it is not the old grid.
The daily model
inst/odin/malaria_daily.Rreplacesinst/odin/malaria_ode.R. Each day reads the state at its start and lands every change at its end, as the IBM queues its updates: immunity decays, the day’s bites are drawn and deduplicated, infection and each state’s progression are one competing draw per person (so an infection pre-empts that day’s progression, as inCompetingHazard$resolve), the mosquito model is stepped across the day, and the dead are replaced by newborns. Only the day’s bitten carry an infection hazard in the draw, as in the IBM, rather than everyone carrying the bite-averaged one.The EIR, human-infectivity and incubation lags are the IBM’s own delay lines, not Erlang chains: the EIR
dedays ago and the infectivitydelay_gamdays ago, interpolated between two days asLaggedValuereadsdelay_gam, and the incubation flux that enteredceiling(dem) - 1days ago – the IBM’s queue holdsceiling(dem)days, so the default incubation is 9 days, not 10.A boost replaces the day’s immunity decay. The IBM queues each immunity’s decay first and a boost (start-of-day value + 1) later, and the later update overwrites the earlier, so a boosted person does not decay that day. At high transmission that raises equilibrium immunity by up to a sixth, and the continuous-time
dI/dt = boost - I/dmisses it.The larval carrying capacity is held at its start-of-day value across the day, as the IBM’s aquatic model holds it (it truncates the solver’s time to a whole day).
The mosquito model is stepped by an exponential midpoint rule, 32 sub-steps a day (
ode_tuning(n_sub =)), which is stable however stiff the larval equations get. There is no ODE solver and no tolerance to set.Settings the IBM reads on the day take effect on their own timestep. Treatment coverage, the drug mix, resistance and death rates change on the timestep scheduled, one day earlier than before; deployments (nets, spraying, vaccination, chemoprevention) act from the day after theirs, and their efficacy decays on the IBM’s clock, one day since deployment on that first day.
Prophylaxis chains have daily-clock stage counts. A stage left with a fixed probability a day lasts a geometric number of days, so matching the Weibull’s mean
Mand varianceVtakesM^2 / (V + M)stages: 10 for SP-AQ as chemoprevention (was 14), 9 for SP-AQ after treatment (was 16), and the chain means are counted in whole protected days.
Mechanisms brought into line with the IBM
Refractory boosting of clinical, detection and severe immunity. The IBM boosts ICA, ID and IVA on an infection only once the refractory window since the last boost has passed.
fleetapplied the renewal ratep / (p u + 1)in every state, as though someone in S or U were reinfected as often as someone in A, and so under-boosted. It now carries where the recently boosted have got to – D, A or U, from each day’s rates – and withholds boosts from that refractory stock only. Clinical incidence falls by 0.3% at EIR 1 to 3% at EIR 120, and severe by up to 4%; prevalence moves by under 0.1%.A chemoprevention round sends the detectable it treats through Tr.
update_mass_drug_admin()moves the clinical and the LM-detectable asymptomatic a round treats into Tr, where they stay detectable and infectious (at their infectivity xdrug_rel_c) fordt, ordt_slowunder resistance, before protection.fleetsent everyone straight to protection, and so cut prevalence in the dosed band overnight: at EIR 120 it read 0.12 the week after an SMC round where the IBM reads 0.42. The treated then join a chain carrying the protection left after Tr, sized as the post-treatment chain is.Mass PEV and TBV protect the cohort they vaccinated, as it ages. Protection was written into the targeted age groups for every later day, so the vaccinated lost it as they aged out of the band and children born after the campaign inherited it (an all-age campaign still cut infant infection by a fifth two years on). The band now moves up with the cohort, and repeated rounds combine as the most recent dose, as the IBM’s overwrite of the dose time does. The EPI group straddling the schedule’s completion age is vaccinated in proportion to the part of it that is old enough.
A treated run is seeded at raw treatment coverage, as the IBM hands it to
malariaEquilibriumandmalariaEquilibriumVivax, for both parasites. At coverage times efficacy, a treated run’s mosquitoes had been sized 2-5% below the IBM’s, and its transmission ran that much lower.Maternal immunity comes from mothers aged 20 to 21, as in the IBM (
trunc(age / 365) == 20), not from the 20-22.5 year group, which gave newborns about 7% too much.default_age_lower()pins an edge at 21 years, and a newborn inherits the population-weighted mean immunity of the groups making up [20, 21), as the IBM’s uniform draw of a mother gives.A treated run starts where the IBM starts it.
set_equilibrium()sizes the mosquitoes under the treatment coverage in force at timestep 1, but the IBM draws its initial humans under the coverage at timestep 0. With the usualset_clinical_treatment(timesteps = 1)the humans therefore start untreated among mosquitoes sized for a treated population.fleetseeded both treated, so its treated runs started apart from the IBM’s and met it only after the burn-in; the first month of such a run is now within a few per cent of the IBM’s on every state count.Bites and infectivity are shared by zeta x psi over the live population, as
human_pi()shares them, wherefleetassumed zeta averages 1. The quadrature’s mean is 0.99972 at five nodes (0.975 at three).
Output
-
The output table is
malariasimulation’s. A parameter list gives the columns the IBM would give it, under the same names and with the same meanings, andtests/testthat/test-output-parity.Rchecks this against the IBM across rendering configurations:- each column family is rendered over its own rendering list only, with no 2-10 and all-age bands added to a family whose list is empty, so a bare
get_parameters()gives the 2-10 prevalence columns and no incidence columns, as the IBM does; -
p_detect_lm_*is the expected number detected, as in the IBM, not a prevalence: prevalence isn_detect_lm_*/n_age_*, as postie computes it. The incidence families gain theirp_inc_*,p_inc_clinical_*andp_inc_severe_*partners (p_inc_*is 0, asmalariasimulation3.0.0 renders it), andn_infectionsis added; - a band
[lower, upper]holds the whole-day ageslowertoupper, both ends included, as the IBM’s does, where it was[lower, upper): bands that share an edge share its day, a single day[a, a]is one day’s cohort, and a band’s tag is formatted as the IBM formats it (182.5_1825.5,1e.05_2e.05); -
S_countcounts the drug-protected, who are uninfected, as the IBM counts them;Ph_countsays how many of them there are; -
ftis rendered only when clinical treatment is deployed, as in the IBM; -
EIRis the EIR biting humans today, the IBM’s lagged value, where it was the valuededays before humans felt it.
- each column family is rendered over its own rendering list only, with no 2-10 and all-age bands added to a family whose list is empty, so a bare
-
A run returns one row per day,
1..timesteps, asmalariasimulationdoes, where it returned0..timesteps. Rowtholds the state at the start of daytand the incidence during it, so row 1 is the seed and the tables line up with an IBM run row for row. Code that dropped the first row to align with the IBM should stop.
API
ode_tuning()holdsage_lower,n_ph,n_phc,n_subandodin_file. The ODE solver’satol,rtolandstep_size_maxand the Erlang lag countsn_eir,n_foimandn_eipare accepted with a warning and ignored, so scripts written for them keep running. An unnamed or non-finite field is an error, a stored tuning object is validated again, and a list of tuning fields passed as the third argument,correlations, is an error even when it holds a retired field.The positivity guards count the day’s ageing and deaths. An age group loses its ageing and death fraction out of the same stock as its infection, progression or prophylaxis exit, so the two together must fit: an age grid whose narrowest group cannot hold both is an error, and the prophylaxis chains’ default stage counts are held to what fits (8 for AL chemoprevention on a 209-group grid, where 9 went negative).
A refractory window of zero days is no window, not a negative one.
Agreement with the IBM
Re-measured in fleetcheck with the IBM rows unchanged, on the default 118-group age grid. Falciparum tier 2 is inside the IBM replicate band throughout the transmission grid: prevalence, under-5 and all-age clinical and all-age severe incidence at 6 of 6 EIRs, within 2.2% of the IBM median, and the clinical and severe age profiles in 25 of 25 and 16 of 16 bands, the clinical profile’s 3-5 year band at EIR 120 by 0.04 replicate SD. Intervention impacts are outside the band in 2 of 72 cells, both bed nets, where the best of the IBM’s own replicates is outside in 9.7%. fleet runs 44 times the IBM’s speed per simulated year. On 53 groups, at under half the cost, the grid’s discretisation error puts clinical incidence 3 to 4% and severe incidence up to 7% low at EIR 50 and 120, outside the band at 2 of 6 EIRs. Tier 1: an undisturbed run moves at most 0.35% off the seed. Tier 3: across the 63-country site files fleet tracks the IBM inside the claim, r 0.983 and 0.958 on clinical and severe incidence with slopes 0.983 and 0.927, and runs 7% above it, the excess concentrated below EIR 1 and not explained.
For P. vivax, tier 2 is inside the IBM replicate band throughout the transmission grid, EIR 0.3 to 30: prevalence, under-5 and all-age clinical incidence, relapses and hypnozoite carriage at 5 of 5 EIRs. The clinical age profile is inside in 22 of 23 bands: 3-5 years at EIR 10 sits 0.02 replicate SD above the band, the mean field’s school-age offset plus 0.3% from the default grid, which the falciparum claims set. Radical cure by either drug, treatment scale-up and bed nets above EIR 1 move every outcome as the IBM does; indoor residual spraying and bed nets at EIR 1 do not, largely the IBM’s relapse defect. A vivax run costs about what an IBM run does per simulated year, 1.07 times its speed. Tier 1: a run settles, 3 to 15% above the seed on prevalence as the IBM’s does.
Testing
tests/testthat/test-output-parity.Rrunsmalariasimulationbesidefleetand checks the output table column for column across seven rendering configurations,fleet’s own identities (thep_*partners, the whole-day band convention, the population counts), and the values against a 50,000- person IBM run.tests/testthat/test-daily-mechanics.Rpins each daily-clock mechanism on its own: the day settings and deployments take effect, the three delay lines, the skipped decay, the competing draw, the seed and the two treatment clocks, the chemoprevention treated phase, the refractory closure against an independent implementation, the vaccinated cohort ageing, and the positivity guards.tests/testthat/test-vivax.Rpins the vivax block’s set-up: the parameter translation, the seed onfleet’s own age grid, the heterogeneity nodes the IBM shares, conservation, radical cure, the column set, and the refusals.tests/testthat/test-vivax-mechanics.Rpins its daily mechanics one at a time, astest-daily-mechanics.Rdoes for falciparum: the infection and relapse draw, PEV on relapses, the hypnozoite ladder, immunity decay, the refractory windows and the boost that skips a day’s decay, the liver-stage clock and the delay lines.test-output-parity.Rchecks the vivax table againstmalariasimulation’s.tests/testthat/reference-values.csvandreference-interventions.csvare regenerated on the 209-group grid, and the intervention reference is sampled on the days each effect first reaches its quantity.reference-vivax.csvandreference-vivax-interventions.csvpin the vivax block the same way: its output levels untreated and under radical cure by primaquine and by tafenoquine, and the same three intervention probes on vivax’s own delay lines, with a check that none acts before its deployment day.
fleet 0.0.0.9002
Two changes to how age is discretised, both of which move model output. The ageing rate is now exponentially fitted, and the default age grid is log-spaced rather than made of fixed monthly / quarterly / yearly / 5-yearly sections. Together they halve the discretisation error at essentially the same cost.
The default age grid
-
default_age_lower()is log-spaced between pinned anchor ages. Group edges are pinned at the conventional reporting boundaries — 0, 1, 2, 3, 5, 7, 10, 15, 20, 30, 40, 60 years — so the usual output bands still fall on group boundaries exactly and every band weight is 0 or 1. Between two anchors the groups are of equal width, and the budget of groups is shared across the anchors in proportion to log width.Log width, because what the grid has to resolve is the rise of immunity with age, and that is much closer to a function of log age than of age. The old grid spent 12 of its 52 groups in the first year of life and 4 on the whole of 40–60; this one spends 7 and 5.
Measured against
fleetrun to grid convergence, at the same number of groups: the largest departure across age bands falls from 10.7% to 4.5% and the rms from 5.4% to 3.2%, for a few per cent of extra runtime. An equal-width grid at the same cost is 13.0% rms — 2.4× the old grid and 4× this one — so the grading matters more than the count.validations/age-grid/run.Rinfleetcheckreproduces all of it.This does not close the gap to
malariasimulation’s age profile of clinical incidence, and refining the grid further will not either. The two errors behave differently, which is the whole point of measuring them apart:fleet’s departure from its own converged profile falls 4.5% → 2.3% → 1.1% → 0.6% as the group count doubles (a ratio of 2.0, clean first order), while its departure from the IBM falls 7.7% → 5.3% → 4.6% → 4.3% and levels off. Discretisation is removable; what is left is the mean-field approximation — one immunity value per stratum against a spread of individual infection histories at the same age. That spread is between people, not across an age group, so no grid touches it. default_age_lower()gainedn_group(default 53). Refining the whole grid while keeping its shape is nowdefault_age_lower(n_group = 105)rather than a bespoke script, which is what a grid-convergence check wants.Age boundaries that do not land on a group edge — an SMC campaign targeting 3 to 59 months, say — are apportioned by exact fractional overlap, both for output bands and for intervention targeting. Nothing is snapped to the grid, so a boundary the old grid happened to have an edge on has not become less accurate, only differently discretised.
Numerics
-
The ageing rate is exponentially fitted rather than
1/width. A linear chain empties an age band at rater, so the stationary ratio between consecutive bands isr / (r + mu). With the obviousr = 1/hthat is1 / (1 + mu*h), where the continuous solution of the McKendrick equation isexp(-mu*h)— and since1/(1 + x) > exp(-x)for everyx > 0, the obvious rate always decays too slowly and always leaves too many people alive at old ages. It is a first-order donor-cell discretisation of an advection.Setting
r = mu / expm1(mu*h)makes that ratio exact, so the stationary age structure is the analytic band-integrated survival curve rather than an approximation to it. This is exponential fitting, the standard cure for a first-order advection scheme.It tends to
1/hasmu*h -> 0, so it is nearly a no-op where bands are narrow — 0.3% in the narrowest bands — and does its work in the wide ones, 11.4% in the 5-year bands above 60. Against 20malariasimulationreplicates the worst age-band error fell from 7.4% to 1.3%, and the population age structure went from 9 of 11 bands inside the IBM’s replicate band to 11 of 11.rnow depends on the death rate, which reads oddly: ageing should not depend on dying. It is not a biological rate. It is the coefficient that makes the discrete scheme reproduce the continuous solution, and that solution involvesmu.It is fitted at the baseline (
t = 0) mortality. Underset_demography()with time-varying ratesmumoves and this coefficient does not follow it, so the fit is exact at the seed and an improvement, not an identity, afterwards. Makingr_agetime-varying in the odin model would be the next step if a transient ever needs it.The seed is unaffected.
malariaEquilibriumassumesr = 1/width, so the constant-hazard reference the equilibrium arrives on keeps that convention and is computed separately.
Testing
tests/testthat/reference-values.csvandreference-interventions.csvare regenerated. Every pinned age-resolved output moved.The time-varying-mortality test in
test-site-features.Rasserts a smaller transient, 3% rather than 5%: a more accurate age structure shrank the movement it is testing for from 6% to 4%. What the test asserts is unchanged.test-package.Rno longer hardcodesr_age = 1/5yfor the 5-yearly section. It asserts the exponential-fitting identity against the rate actually in use.
fleet 0.0.0.9001
The package was renamed from blink to fleet. Otherwise this release is the defects a full package review found, fixed. Three of them move model output: the age-band rendering weights, five intervention series that took effect before their scheduled date, and TBV / mass-PEV age targeting. The rest turn inputs that used to be accepted silently into errors or warnings, switch the comparison CI on, and give the test suite something to fail on. Output on bands that line up with the model age grid, in a run with no interventions, is unchanged.
Package rename
-
blinkis nowfleet, and the repository moved to https://github.com/pwinskill/fleet. The old name collided with an existing CRAN package (Steorts, Record Linkage for Empirically Motivated Priors), and the collision was not cosmetic: pkgdown builds its “View on CRAN” link from the package name alone, so the site stood to send readers to someone else’s release. Install withremotes::install_github("pwinskill/fleet")and replacelibrary(blink)withlibrary(fleet).No function signatures changed. The three exported functions (
run_simulation_ode(),ode_tuning(),default_age_lower()) keep their arguments, and nothing moved numerically: 372 tests passing, both reference CSVs byte-identical, and the drift check reproducing every committed row to 1e-6. Three things do follow the name: the environment variables (BLINK_LIB,BLINK_ALLOW_SKIP,BLINK_REGENERATE_REFERENCE,BLINK_VALIDATE,BLINK_DIAGRAM_OUTandCMP_BLINK_ONLYare nowFLEET_*/CMP_FLEET_ONLY), theblink.pev_gqoption, nowfleet.pev_gq, and themodelcolumn ofcomparison/data/rep_*.csv.blink2_validateis deliberately untouched: it names a separate repository.
Bug fixes
-
Two coefficients in the model were frozen at the seed’s age structure and are now taken from the live population. Immunity ageing used
re[i] = r_age[i] + mu_age[i]as the per-capita inflow into an age cell, and the FOIM normalisation used the seed’s meanpsi. Both are exact only while the age structure is stationary. Underset_demography()with time-varying death rates – which every malariaverse site file supplies, yearly from 2000 – the structure drifts for decades, andre[i]was built from the new death rate while the population still had the old shape. The inflow is nowr_age[i-1] * N[i-1, j] / N[i, j](newborns ati = 1), which the intensive-variable derivation gives directly, and the FOIM denominator is the currentsum(psi * N) / sum(N), which is what malariasimulation’shuman_picomputes on the live population. Both equal the old forms to machine precision at the seed, so nothing changes while death rates are constant: every committed comparison row reproduces to 1e-6, and a synthetic run is identical to 6e-15 until the day its death rates change.Where they do change it is material, and measured against the IBM rather than argued from direction. Afghanistan’s site-file mortality series, 2000-2029, no interventions, against 20
malariasimulationreplicates: over the last ten years the bias against the IBM median goes from +3.6% to -0.3% on EIR, +0.9% to -0.3% on PfPR(2-10), +2.8% to +1.4% on all-age clinical incidence, +7.6% to +5.3% on under-5 clinical incidence, and +1.7% to +0.9% on under-5 severe. Years inside the IBM’s 10-90% replicate band go from 21 to 27 of 30 on EIR, 28 to 30 on PfPR and 17 to 24 on under-5 clinical incidence; nothing moves out of the band. All-age severe is the one outcome that goes slightly the other way, -1.3% to -1.9%, inside a replicate band 30% wide and 29 of 30 years inside it either way. A synthetic mortality step gives the same picture with every outcome closer. The human age structure is untouched, as it should be: the population total is conserved to 1e-15 and its age split moves 2e-6, which is solver tolerance over 30 years.mean_psiis no longer a model parameter. Output age bands are rendered by exact overlap, not by age-group midpoint. A model age group used to contribute its whole population to a rendering band if its midpoint fell inside the band, and nothing at all otherwise. A band narrower than the group it lands in captures no midpoint, so the whole column family came back as hard zeros: 52 of the 65 single-year bands from 15 to 80 years on the default grid, where
p_detect_lm_7300_7665 = 0reads as “no malaria in 20-21 year olds” rather than “no data”. And a partial overlap counted the whole group or none of it, so the standard 6-59-month band came back 2.2% light on a default parameter list, flowing into every PfPR denominator and every postie person-day. A group now contributes the fraction of its own width lying inside[min_age, max_age), so a group straddling a boundary is split between the two bands and the weights sum to exactly 1 per group over any set of bands that partition a span of the age axis.fleetalso warns now when a rendering band overlaps no model age group at all. This moves published numbers for any band that does not align with the model age grid, in every scenario; bands that do align are unaffected, and regenerating the unit-test reference outputs produced a byte-identical file.Bed nets, IRS, carrying capacity, PEV and TBV no longer take effect before their scheduled date. All five series are interpolated linearly, which is right for the within-round decay each carries, but a value that changes on a scheduled day ramps in from whatever knot precedes it, and the background grids are coarse: 10 days for vector control, 30 for TBV and PEV, 5 for carrying capacity without seasonality. So an intervention arrived early, in some cases almost entirely. Nets scheduled for day 100 had reached 66% of their effect on EIR by day 99, before a single net existed; a TBV round on day 400 had delivered 88% of its transmission blocking by day 399; a mass PEV campaign whose efficacy onset was day 490 had delivered 34% of its FOI reduction by day 485; a carrying-capacity halving at day 100 started moving EIR on day 99. Each grid now carries a knot at
onset - 1as well as at every scheduled day, confining the ramp to the single step onto the scheduled day: the resolution the chemoprevention pulses already had. Every scenario using one of these five interventions moves.-
TBV and mass-PEV age targeting weights by the fraction of each group covered. Both used to select the model age groups whose midpoint fell in the target, so a target that straddled no midpoint selected nothing and the whole campaign was a silent no-op. Above age 14 the default grid is 5-yearly and only the years 17, 22, 27 and so on are midpoints, so
set_tbv(ages = 18:20)vaccinated nobody, as did a mass PEV band ofmin_ages = 20 * 365,max_ages = 21 * 365, which falls between the midpoints at 17 and 22 years. Each group now carries the fraction of its own width inside the target; a target aligned with group edges gives weight 1 and reproduces the old result exactly, and one overlapping no group at all now warns.Fractional weights then exposed a second defect in mass PEV. The several
min_ages/max_agesbands of a single campaign were folded in one at a time with the independent-protection rule1 - (1-a)(1-b), which is the rule for separate campaigns, not for two bands of the same one; the midpoint rule could not expose it, because a group belonged to at most one band. Splitting one band into two at 1195 days, which should change nothing, moved a straddling group’s covered fraction from 0.7295 to 0.6834, a 6.3% shortfall. The bands of one campaign now add into a single covered fraction (capped at 1) before coverage is applied; independent combination is kept across campaigns. -
Inputs that could not mean what they said are rejected rather than run. Each of these used to be accepted, or to fail somewhere downstream with a message that never named the parameter at fault:
-
parameters$n_heterogeneity_groupsof 1 or 2. Fewer than three Gauss-Hermite nodes do not integrate the log-normal biting distribution, they just move every human off it: atn = 1the single node sits atzeta = exp(-s2/2) = 0.434, so every human in the model experienced 43% of the EIR the output column reported. For a genuine no-heterogeneity run setparameters$enable_heterogeneity = FALSE, which collapses to one stratum atzeta = 1exactly. -
parameters$bite_dedupoutside 0/1 (TRUE/FALSEstill accepted). It is a switch between two hazard forms, not a dial, and 0.5 was silently a third thing. -
parameters$acquired_immunity_offsetoutside[0, 1]. -
default_age_lower(max_age =)below 20 years, which made the grid’s 5-yearly sectionseq(15, max_age - 5, by = 5)run backwards and died with base R’s “wrong sign in ‘by’ argument”. -
timestepsthat is not a single finite whole number>= 1. This also catches the swapped-argument callrun_simulation_ode(parameters, timesteps), which used to die onparameters$init_EIRwith “$ operator is invalid for atomic vectors”.
-
Editing the parameter list after
set_equilibrium()now warns.set_equilibrium()stores its back-translation asparameters$eq_params, andbuild_inputs()merges that over the live translation, so the stored copy wins for every shared biological constant and an edit made afterwards (p$du <- 10) is a silent no-op. That contract stands —set_equilibrium()is meant to be the last call on the parameter list — but a violation of it must not be silent.fleetnow compares the two and warns, naming themalariasimulationfields whose live translation no longer agrees with the frozen copy, so the message points at the field you typed rather than at its equilibrium alias. A value that took a different arithmetic route to the same number cannot trigger it, and a deliberate customeq_paramsis called out as expected.
Numerics and performance
One
exp()per cell where there were two.p_infnow reusesq_b, which is the same1 - exp(-EPS); odin2 does no common-subexpression elimination, so the two spellings cost two evaluations per cell per derivative call. The right-hand side is bound by its ~2,600 transcendentals per evaluation and this was one in ten of them by count – butexpis cheap next to the five non-integerpowcalls per cell, so the measured saving is 3-5% of the derivative cost (22.5 to 21.2 microseconds per evaluation on an aseasonal run) and 2-6% of wall time. Output is bit-identical (the same operation, evaluated once): the solver takes exactly the same steps, and every committed comparison row reproduces to 1e-6.Where the solver’s time goes, measured rather than asserted. On an equilibrium run every accepted step is 0.62 days, and that does not move with
atol,rtol,step_size_max, the output grid or the delay-chain lengths below 4 per day. It is an explicit-stepper stability limit: the stiffest eigenvalue is the late-larval density-dependent mortalityml * gamma * (E + 2L) / K, 5.3 per day at the seed, and 3.3 / 5.31 = 0.621 days. That is intrinsic to malariasimulation’s larval model, so the ~1.6 steps per day it forces is the floor for this solver class, and the daily output grid adds ~2,700 rejected trial steps over 30 years on top. On seasonal runs the tolerance does bind:rtol = 1e-6is 1.44x faster with a maximum deviation of 4e-7 in daily clinical incidence. Theode_tuning()documentation now says this; it previously said the cap and the daily grid set the aseasonal step count, which was a reasonable guess that measurement did not support.The default age grid is not converged for severe disease or for adult clinical incidence. Against 20 IBM replicates at EIR 20, all-age severe incidence goes from -2.8% below the IBM median on the default grid to +0.2% on a grid with quarterly bands to 15 y and yearly bands to 40 y (110 groups, 2.1x the run time), and adult clinical incidence in the 30-60 y bands from +14-16% to +6%; prevalence and under-5 clinical incidence do not move. Both are Jensen error from averaging steep age dependence over wide bands. The default is unchanged, because every published comparison is stated on it, but the
age_lowerdocumentation now says which results need a finer grid, and the two claims in the fleetcheck register that rest on severe incidence by age (severe-allage-bias,age-structure) carry a discretisation component of the same size as the discrepancy they report.
Continuous integration
All four workflows are live, so something now checks the model without being asked. The triggers that
0.0.0.9000left staged and commented are switched on, as are thepaths-ignoreandconcurrencyblocks staged alongside them:comparison.yamlon every push and pull request that can touch the model plus a Monday 06:00 UTC schedule, andfigures.yamlon anything touching the comparison data, the renderers, the theme or the shared constants. The Monday run is the one with no substitute: it is the only thing that catchesmalariasimulationmoving underneath the comparison..github/workflows/CI.mdis now a description of what runs, not a go-live checklist, and carries the detail.R-CMD-check.yamlgains a job that fails when the compiled model is stale.inst/odin/malaria_ode.Ris the model’s source of truth, but what gets compiled is the committed, generatedsrc/malaria_ode.cpp(withR/dust.R,R/cpp11.R,src/cpp11.cppandinst/dust/), which no ordinary build regenerates. An edit to the odin model without a followingodin2::odin_package(".")therefore left every runner compiling the old model, the tests passing, andcheck_drift.Rreporting “nothing moved” — which reads as numerically inert when it means never compiled. The new job regenerates frominst/odinand fails if the working tree comes out dirty.
Testing and tooling
The suite pins absolute output, not just relationships between outputs. It could not previously detect a wrong answer: the only severe-incidence assertion was a ratio, invariant to any scale factor, so doubling
sev_inc_apassed every test.tests/testthat/reference-values.csvandreference-interventions.csvnow pin recorded output to 1e-6 across the EIR grid, and for each intervention at the day before deployment, at onset and after — the shape that catches an effect arriving early.A missing
malariasimulationfails the suite instead of quietly emptying it. Nearly everytest_that()block opens withskip_if_not_installed("malariasimulation"), so a machine where the package was missing or unloadable ran the handful of dependency-free blocks, skipped the rest, and reported success having tested almost nothing.tests/testthat/setup.Rnow errors, withFLEET_ALLOW_SKIPas the deliberate escape hatch.NAMESPACEis roxygen-generated. It was hand-written and held the only copy ofuseDynLib(fleet, .registration = TRUE), so the firstdevtools::document()would have dropped it and left the package unable to load.
Documentation
-
The docs are split into articles and the README is a front page again. It was 250 lines, roughly 170 of which restated the articles at summary level, which is how its claims went stale. Each topic now has one home:
vignette("fleet"), the newvignette("using"),vignette("model")(the specification alone), the newvignette("parameters")andvignette("comparison")..github/CI.mdmoved to.github/workflows/CI.md, where pkgdown no longer renders it as the site’s front page.
fleet 0.0.0.9000
First development release: a deterministic mean-field (ODE) twin of the malariasimulation individual-based model of P. falciparum malaria, built on odin2/dust2.
API changes
-
run_simulation_ode()’s signature now matchesmalariasimulation::run_simulation(). It was(timesteps, parameters, correlations, init_EIR, age_lower, n_eir, n_foim, n_eip, n_ph, n_phc, atol, rtol, step_size_max, odin_file): fourteen arguments, eleven of which the IBM does not have. It is now(timesteps, parameters, correlations, tuning). Two changes, both breaking.init_EIRis gone as an argument. The target EIR is a model input, so it belongs on the parameter list, wheremalariasimulation::set_equilibrium()already puts it asparameters$init_EIRand where the IBM reads it from:p <- malariasimulation::set_equilibrium(p, init_EIR = 20) out <- run_simulation_ode(3650, p)Setting
parameters$init_EIRby hand still works, but skips theeq_paramsthatset_equilibrium()stores andfleethonours. This is numerically inert for anyone already callingset_equilibrium(): the whole comparison harness reproduces bit-identically through the new API.Everything numerical moved into
tuning, a newode_tuning()object (also accepted as a plain named list of just the fields you want to change). That isage_lower, the five Erlang stage countsn_eir/n_foim/n_eip/n_ph/n_phc, the solver controlsatol/rtol/step_size_max, andodin_file:run_simulation_ode(3650, p, tuning = list(rtol = 1e-6, step_size_max = 10))Nothing epidemiological sits outside the parameter list any more: every field of
tuningis a numerical-approximation knob.ode_tuning()validates its fields, and an unrecognised name in atuninglist is an error rather than a silently ignored field. Passing any of the removed arguments gives a targeted error naming the call that replaces it, rather than R’s bare “unused argument”. get_epi_outputs()is removed (#3). It was a thin wrapper aroundpostie::get_rates()andpostie::get_prevalence(), plus argument-splitting machinery (rates_args,prevalence_args,...) that was more to learn than the two calls it replaced. malariasimulation has no such wrapper andfleet’s output table is malariasimulation-shaped, so IBM post-processing pipelines already work on afleetrun unchanged. Replaceepi <- get_epi_outputs(out)withpostie::get_prevalence(out, diagnostic = "lm")andpostie::get_rates(out).postieremains in Suggests for the vignette and tests, but nothingfleetexports depends on it.
Behaviour changes
Custom demography: mosquito sizing now replicates
set_equilibrium(). malariasimulation sizes the adult-mosquito population from the human equilibrium under its default exponential age structure (set_equilibrium()→equilibrium_total_M(); custom mortality never enters), so underset_demography()the IBM drifts to whatever transmission that density supports.fleetused to re-solve the equilibrium under the custom age structure and holdinit_EIRexactly, so the same parameter list realised different transmission in the two models (EIR 20 in fleet against 13.8 in the IBM in the comparison scenario, PfPR(2–10) 0.55 against 0.49). It now reproduces the IBM’stotal_Mexactly (ibm_total_M()), root-finds the EIR at which its own equilibrium under the custom demography has that density, and seeds there — still a fixed point, so there is no burn-in (the scenario now seeds at EIR 13.86, PfPR 0.489). Under the default demography nothing changes: fleet keeps its own sizing, which lands ~1.5% above the IBM’stotal_M, so the same exponential mortality expressed throughset_demography()seeds at EIR 19.7 rather than 20, within the IBM’s replicate noise.parameters$hold_init_EIR = TRUErestores the previous behaviour. Every malariaverse site file usesset_demography(), so this affects all country work: across the 63-country validation set it moves fleet’s mean clinical-incidence excess over the IBM from +10.1% to +8.7% and the regression slope from 1.02 to 1.00 (r 0.980 → 0.982); severe from +9.4% to +8.8%.Prophylaxis is an Erlang chain, not one exponential compartment. The IBM applies the Weibull survival
W(t - t_drug)to each treated person’s infection probability, so a treated cohort’s mean protection at lagtis exactlyW(t).fleetrepresented both prophylaxis compartments (Phpost-treatment,Ph_cchemoprevention) as a single exponential at the Weibull mean, which leaks protection early — for SP-AQ (shape 4.3, scale 38.1) only 42% are still protected at day 30 against the Weibull’s 70% — and under-estimated seasonal SMC (under-5 clinical reduction 40% vs the IBM’s 52% in the comparison article; now 54% against 52%, within the IBM’s replicate range). Both are now Erlang chains moment-matched to the Weibull. The chemoprevention chain hask = 1/CV²stages (14 for SP-AQ, 15 for DHA-PQP; SP-AQ chain at day 30: 0.67). The post-treatment chain follows the exponential treated stageTrwhile the IBM’s clock runs from the dose, so its mean is the integrated protection left afterTr(mean_W - ∫exp(-r_T t) W(t) dt) and its length matches the variance of the wholeTr + Phsojourn: 16 for SP-AQ, 20 for DHA-PQP, and 1 for AL, whose 10-day protection is already less variable thanTritself (measured output-identical to a 20-stage chain at half the run time). Counts are capped at 20;ode_tuning(n_ph =, n_phc =)overrides them and 1 recovers the old behaviour. The equilibrium seed distributes the prophylaxis mass across the stages exactly (solve_disease_block()generalised), so runs still hold flat, and runs now record only the output variables rather than the full state, keeping memory flat despite the larger state.
Bug fixes
-
Severe and all-infection incidence are counted with the deduplicated probability, not the raw hazard. This is the change in this release that moves the no-intervention numbers; the review-pass fixes in
0.0.0.9001move intervention scenarios and misaligned rendering bands, and nothing else.clin_inc_awas already counted withh_c;sev_inc_aandinc_ausedFOI. These outputs are rates that R integrates over a day, and the right rate depends on how fast the compartment drains.SandUdrain at the fullFOI, soint FOI*X exp(-FOI t) dt = X*(1 - exp(-FOI)) = X*p_inf— the IBM’s count, exactly.Adoes not: a sub-clinical re-infection of anAleaves them inA, soAdrains only ath_cand stays roughly flat over the day, makingint FOI*A dt ~ FOI*Aand over-counting byFOI/p_inf = -log(1-p_inf)/p_inf.Anow takesp_infdirectly. The IBM draws severe from the deduplicatedinfectionsbitset, so this is a replication defect, not a modelling choice.Against 3 IBM replicates of 10,000 people on the same parameter list, all-age
n_inc_*ran +2.6% (EIR 20) and +4.2% (EIR 50) above the IBM median, both outside its replicate spread; after the fix, +0.6% and +0.4%. The bias was concentrated in adults, whereAis a large share of the at-risk pool (under-5n_inc_*was only +0.3% / +1.7%), the signature of anA-only defect. Clinical incidence and prevalence are unchanged.It also makes one comparison worse, and that is worth stating plainly. The same correction lowers severe incidence slightly, and fleet already ran below the IBM there: all-age severe goes from −5.9% to −6.2% at EIR 20 and from −3.4% to −4.0% at EIR 50, dropping just outside the IBM’s 10–90% band at those two EIRs (by 0.2% and 0.3% of the lower edge) where it had been marginally inside. The upward bias had been masking part of a larger, separate severe deficit. That compensation was accidental, so the corrected numbers are the honest ones and the severe gap is now the largest open discrepancy between the two models.
vignette("comparison")and its tables are re-rendered accordingly. test-prophylaxis-chain.R’s first test no longer errors whenmalariasimulationis absent. It readAL_params/SP_AQ_paramswithout askip_if_not_installed()guard, the only one of the suite’s 57 test blocks to do so.malariasimulationis a GitHub Remote in Suggests, so on any check machine that could not install it the other 56 blocks skipped and this one errored, turning a missing optional dependency into anR CMD checkfailure. The block is now split rather than simply guarded: its closed-formerlang_stages()checks keep running unguarded, and only the Weibull parameters read out of the IBM’s tables sit behind the guard.build_inputs()no longer recomputes grid-invariant PEV protection. Two fixes inpev_series(), both verified bit-identical on the full[n_age, n_time]multiplier matrix across the EPI single-timestep path, the EPI multi-timestep path and mass campaigns..pev_eff_fun()built its interpolator withstats::approx(), which re-runsregularize.values()on every scalar call; it now builds onestats::approxfun()closure. And the EPI loop calledpev_protection()once per grid point when.booster_cov_vec()’s admin-time argument is ignored (which the single-rowbooster_coveragethatset_pev_epi()builds by default always is), so that call is now hoisted to once per age band. Together:pev_series()on a 20-year two-booster EPI schedule drops from 2.92 s to 1.09 s (2.7x).Chemoprevention pulses renew existing protection and clear
Tr_slow. The IBM’supdate_mass_drug_admin()resetsdrug_timefor everyone successfully treated whatever their state;fleet’s pulse left people already inPh/Ph_cdecaying from their earlier dose and skipped the slow-clearance treated compartment. Both now move to the first stage ofPh_cwith the rest of the covered fraction — material for monthly SMC rounds, where most of the previous round’s recipients are still protected.rT_slowuses the whole-day exit probability. The slow-parasite-clearance rate under antimalarial resistance was1/dt_slow; it is now1 - exp(-1/dt_slow), the same conversionbuild_inputs()applies torA/rD/rU/rT(the IBM leaves states with per-day probabilityrate_to_prob(1/d)).Custom demography: top age group’s death rate. The open-ended oldest model age group is represented by its lower bound, which the right-closed
set_demography()bins assigned to the band below it whenever that bound coincided with a bin edge: 80 y on the default grid, a very common edge. The over-80s therefore died at the 60–80 rate (0.05 instead of 0.12 per year in the comparison scenario) and the group held ~2.4× too many people (60–85 share 18.2% against an analytic 13.1%). The rate is now looked up one day above the group’s lower bound. Found by the new comparison article.
Documentation
- New article
vignette("comparison")— Comparison with malariasimulation — covering EIR, age, seasonality, demography, 63 country site files and five interventions, with the IBM as the median of 10 replicates and a 10–90% band.comparison/was rewritten around one runner, one renderer and a shared theme, with the per-replicate summaries committed so figures redraw without re-running the models; the pre-replication-pass figures (A_–F_,inc_*) are gone. - The comparison surfaced three fleet-side items, all fixed above: the top age group’s death rate under
set_demography(), exponential prophylaxis under-estimating seasonal SMC, and theset_equilibrium()convention under custom demography. - A run-time benchmark (#1), reproduced by
comparison/benchmark.R, now the Cost model section ofvignette("using"). - New figure
cmp_programme_tsand a Programmes over fifteen years section, from five newts_*scenarios running 15 years past deployment at EIR 20. - The intervention-impact and equilibrium-vs-EIR figures each gain all-age clinical and all-age severe panels, laid out 2×2;
summary_tables.Rreports both new outcomes. -
The 63-country site comparison is now an explicit snapshot,
comparison/data/site_snapshot.json, read bysummary_tables.Rin place of a live run against a checkout most people do not have;CMP_REFRESH_SITES=1re-takes it. - New workflows
comparison.yaml(the drift check) andfigures.yaml(do the committed figures and tables still match the committed data), plusdependabot.yml. All landed inert and were switched on in0.0.0.9001; see.github/workflows/CI.md. -
New
comparison/check_drift.R: check the match without re-running the IBM. It separates did anything move from is the match still good, failing only on the second unlessCMP_STRICT=1, andcomparison/data/ibm_reference.jsonrecords what the committed IBM rows were made from. The scenarios, summariser and run loop moved intocomparison/scenarios.R. -
The model flow diagram is simplified. Its layering now means only the human panel’s age × heterogeneity grid, and the stage-count outlines, at-risk rings and immunity box are gone. The FIDELITY invariant cites
deriv(A)andderiv(U)rather than odin line numbers that had rotted. - Every line figure draws the IBM’s dashed median over fleet’s solid line rather than under it, where agreement had been hiding the IBM entirely. The rule is in
comparison/theme.R; the unusedgeom_ibm_envelope()is removed. - The country-site figure reads its 1:1 correlation properly (#2): an orange dashed reference line rather than a series colour, a near-white low end to the log10 hex ramp, and legend breaks at 1 / 10 / 100 / 1,000 / 10,000.
- Three stale comment blocks are removed, each left above the correction that replaced it:
drug_mix()’s weights andrPinR/interventions.R,beta_effininst/odin/malaria_ode.R, andrT_slowintests/testthat/test-package.R.
Exact-replication pass (malariasimulation v3.0.0)
A systematic audit replaced every place fleet approximated a mechanism whose malariasimulation implementation is known. No tuned constants were introduced.
-
Output bands — bug fix, affects every user.
render_output()emittedn_inc_clinical_*/n_inc_severe_*/n_inc_*over the union of all rendering bands, producing overlapping strata thatpostiedouble-counted, inflating all-age clinical by ~1.21x and severe by ~1.10x. Each family is now emitted over its own*_rendering_ages, withn_age_*over the union. -
Maternal immunity source band. An exact tie in
which.min(abs(age_mid - 20*365))drewICM/IVMfrom 15-20 year-olds instead of the 20-21y mothers the IBM uses; they ran 9-15% low. It now selects the band containing 20y. -
EIP survival. The Erlang chain carried mortality in every stage (
(reip/(reip+mum))^n_eip, +4% at baseline); it is now a loss-free delay with the IBM’sexp(-mum*dem)applied at the exit. -
Slow parasite clearance.
Tris split into parallelTr/Tr_slow, replicating the IBM’s Bernoulli assignment of each treated individual todtordt_slowrather than one exponential at the blended mean. -
Clinical event counting. The clinical hazard is now
-log(1 - phi*p), which integrates to exactly the IBM’sphi*p*N;phi*FOIover-counted. -
State sojourns. Rates now use the IBM’s per-day exit probability
1-exp(-1/d), so the realised mean dwell matches (5.52 d fordd = 5, not 5). -
Immunity boosting. The boost rate is now
q/(q*u_eff + 1)withq = 1-exp(-rate)andu_eff = ceil(u) - 1, andICA/ID/IVAare boosted only for individuals eligible to be infected (S/A/U),IBfor everyone bitten. -
PEV antibodies. Efficacy is integrated over the antibody distribution the IBM samples (4-D Gauss-Hermite over
cs/rho/ds/dl) rather than evaluated at the profile median, which overstated R21 efficacy by up to ~4.7 pp. Validated against the IBM’s own sampler to <5e-4. -
PEV gating follows
pev_epi_coverages/pev_epi_timesteps/mass_pev_timesteps, notparameters$pev, which the IBM never reads. -
Seeding consequence. Because
malariaEquilibriumencodes the simplified forms, the analytic seed is now a close approximation rather than an exact fixed point: an undisturbed run relaxes by up to ~0.5% over the first years, as malariasimulation itself does.
Validation after this pass
The pass was validated monthly, P. falciparum only on both sides, over 63 countries / 1,391 sub-sites / 450,684 sub-site-months against pre-run malariasimulation output, and moved every headline statistic the right way. Those numbers, the committed snapshot they were re-taken from (comparison/data/site_snapshot.json, 2026-09-09) and the discrepancies still open are in vignette("comparison") and in the warning at the top of the README, characterised there rather than tuned away.
Parameter-ingestion completeness
A systematic audit of every user-facing malariasimulation config function (get_parameters + all set_*) found gaps where the model was not using the full parameter flexibility they expose, and closed them: the PEV booster sequence beyond the first booster, every target age band of a mass PEV campaign, fractional-overlap age targeting for chemoprevention, antimalarial resistance applied to chemoprevention drugs and not only to clinical treatment, IRS accumulating across spray rounds, TBV’s exact ages set, and set_equilibrium’s custom eq_params. All-infection incidence (n_inc_*; incidence_rendering_*) was added as a new output. The two remaining mean-field approximations — the time-varying multi-drug first-line switch and seasonal PEV boosters — are warned about rather than silently applied.
The argument-by-argument record of what is supported is vignette("parameters").
What the package is, rather than what changed
Earlier revisions of this file carried a full feature inventory here: the model structure, the intervention modules, the interface, the validation summary and the scope limits. That is a description of the package, not a changelog, and keeping a second copy of it here is how it went stale. It lives where it is maintained:
-
What is modelled, argument by argument –
vignette("model"). -
Where the mean field departs, and what to do about it –
vignette("using"). -
How well it agrees with the IBM –
vignette("comparison"). - Scope, and the open discrepancies – the warning at the top of the README.
