-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnmf.html
More file actions
450 lines (402 loc) · 35.6 KB
/
Copy pathnmf.html
File metadata and controls
450 lines (402 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Non-malaria fevers and inflated malaria case counts</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="styles.css">
<!-- GoatCounter: privacy-friendly, cookieless visitor counts -->
<script data-goatcounter="https://pwinskill.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</head>
<body>
<nav class="crumb"><a href="index.html">← All explainers</a></nav>
<header class="hero">
<p class="kicker">An interactive explainer</p>
<h1>Non-malaria fevers and inflated malaria case counts</h1>
<p class="lede">Routine surveillance records a malaria case when a person with a fever tests positive. But where malaria is endemic, many infections are asymptomatic, and a fever from another cause can coincide with an asymptomatic malaria infection. Those fevers test positive and are recorded as malaria, even though malaria did not cause them. Because non-malaria fevers occur year-round while true malaria can be seasonal, this over-counting is not uniform through the year.<sup class="cite"><a href="#ref1">1</a></sup></p>
</header>
<div class="wrapwide">
<!-- CONCEPT 1 -->
<section class="card">
<h2><span class="num">1</span> Routine data counts test-positive fevers, not clinical malaria</h2>
<h3>Evidence</h3>
<p>Following the shift to test-based case management, a person presenting at a health facility with a fever is given a diagnostic test, usually a rapid diagnostic test (RDT), before antimalarials are prescribed.<sup class="cite"><a href="#ref1">1</a></sup> Routine surveillance then records a confirmed malaria case for each febrile individual who tests positive. The recorded count is therefore a count of test-positive fevers presenting for care, not a direct count of illness caused by malaria.</p>
<h3>Mechanism</h3>
<p>A positive test establishes that the person carries a detectable infection. It does not establish that the infection is the cause of their fever. Two conditions have to hold for a recorded case to be a true clinical malaria case: the person must be carrying parasites, and those parasites must be the reason they are febrile. Where the two are not causally linked, the case is recorded all the same. The routine count is thus the sum of true clinical malaria and a second, coincidental component.</p>
</section>
<!-- CONCEPT 2 -->
<section class="card">
<h2><span class="num">2</span> Where malaria is endemic, much infection is asymptomatic</h2>
<h3>Evidence</h3>
<p>In endemic settings a large share of the population carries a bloodstream infection at any given time without being ill from it.<sup class="cite"><a href="#ref2">2</a></sup> Acquired immunity allows people to tolerate parasites, and untreated infections can persist for weeks to months, so this asymptomatic reservoir is both substantial and slow to turn over. As transmission intensity rises, clinical illness begins to level off while the prevalence of detectable infection keeps climbing, so the infection reservoir grows relative to disease: immunity increasingly limits illness without clearing infection.</p>
<h3>Mechanism</h3>
<p>Because carriage builds up over the preceding weeks of transmission and clears only slowly, the prevalence of detectable infection is a broadened, lagged and floored version of the transmission signal: it rises after the season is under way, falls gradually once transmission recedes, and does not collapse in the low season the way new clinical illness does. Anyone tested during the low season still has an appreciable chance of a positive result, whether or not malaria is making them ill.</p>
</section>
<!-- CONCEPT 3 -->
<section class="card">
<h2><span class="num">3</span> Year-round non-malaria fevers coincide with a seasonal reservoir</h2>
<h3>Evidence</h3>
<p>Fevers have many causes other than malaria (respiratory and other infections chief among them) and across a year these are far more common than malaria-caused fevers.<sup class="cite"><a href="#ref1">1</a></sup> Their rate is higher in young children. Some of these causes carry their own seasonality, for example respiratory and diarrhoeal illnesses. But that seasonality generally differs in timing and duration from the malaria transmission season. We assume here that the non-malaria fever rate is constant through the year.</p>
<h3>Mechanism</h3>
<p>A non-malaria fever that is tested returns a positive result whenever the person also carries a detectable infection. The coincidental positives are therefore the non-malaria fever rate times the share tested times the prevalence of infection. With the fever rate steady, that count follows the asymptomatic reservoir rather than the seasonal course of disease.</p>
</section>
</div>
<!-- INTERACTIVE TOY MODEL -->
<div class="wrapwide">
<section class="card toy">
<span class="toy-tag" title="This is an illustrative teaching model, not calibrated to any setting. Not for real-world decisions.">Illustrative, not for decision making</span>
<h2><span class="num">▶</span> Watch the recorded count separate from the truth</h2>
<p class="sub">A <strong style="color:var(--warm)">toy</strong> model of true versus routine-recorded malaria cases through a season</p>
<p>Set the transmission intensity (which fixes both the level of clinical malaria and the prevalence of infection) and how seasonal transmission is, then set the non-malaria fever rate and the share of fevers that seek care and are tested. The chart shows true malaria cases recorded from those who seek care (purple) and the total routine-recorded count (dark line); the shaded band is the falsely attributed cases: non-malaria fevers that coincided with a detectable infection.</p>
<p class="muted" style="font-size:14px;margin-top:-4px">Two things to try: raise the non-malaria fever rate and watch the recorded curve lift and its seasonality flatten; and move treatment seeking and notice the vertical scale shifts while the peak- and low-season figures do not. Better access records more cases but does not fix the misattribution.</p>
<div class="controls">
<div class="slider-block season">
<div class="slider-top"><span class="label">Transmission intensity</span><span class="val" id="vTrans">high, 47%</span></div>
<input type="range" id="transmission" min="0" max="100" value="55" step="1" aria-label="Transmission intensity, setting the level of clinical malaria and the prevalence of infection">
<p class="slider-sub muted">The percentage is the illustrative peak-season prevalence of detectable infection in the focal child group. It is not the parasite prevalence in 2 to 10 year-olds reported by the equilibrium explainers, and the two should not be compared.</p>
<div class="subslider">
<div class="slider-top"><span class="label">Seasonality</span><span class="val val-word" id="vSeason">highly seasonal</span></div>
<input type="range" id="seasonality" min="0" max="100" value="75" step="1" aria-label="Seasonality, from perennial to highly seasonal">
</div>
</div>
<div class="slider-block cover">
<div class="slider-top"><span class="label">Non-malaria fever rate</span><span class="val" id="vNmf">6 / yr</span></div>
<input type="range" id="nmf" min="0" max="12" value="6" step="1" aria-label="Non-malaria fever episodes per child per year">
<p class="slider-sub muted">Episodes per child per year, held constant through the year; the rate is higher in young children than across the wider population.</p>
<div class="subslider">
<div class="slider-top"><span class="label">Treatment seeking</span><span class="val" id="vTreat">60%</span></div>
<input type="range" id="treat" min="0" max="100" value="60" step="1" aria-label="Share of fevers that seek care and are tested, percent">
</div>
</div>
</div>
<div class="readouts ro-sentences">
<div class="readout">
<div class="l">Routine-recorded total is</div>
<div class="v" id="rPeak" style="color:#c45a25">–</div>
<div class="l">the true malaria cases, in the peak season</div>
</div>
<div class="readout">
<div class="l">Routine-recorded total is</div>
<div class="v" id="rLow" style="color:#c45a25">–</div>
<div class="l">the true malaria cases, in the low season</div>
</div>
<div class="readout">
<div class="l">Over the year,</div>
<div class="v" id="rShare" style="color:#c45a25">–</div>
<div class="l">of recorded cases are not caused by malaria</div>
</div>
</div>
<div class="sr-only" id="srLive" aria-live="polite" aria-atomic="true"></div>
<p class="chart-label">True versus routine-recorded malaria cases through the year</p>
<canvas id="nmfChart" height="300" aria-label="True malaria cases and routine-recorded malaria cases through the year, with the falsely attributed cases shaded"></canvas>
<div class="rb-legend">
<span><i style="background:var(--clin)"></i> True malaria cases</span>
<span><i style="background:var(--ink)"></i> Routine-recorded total</span>
<span><i style="background:var(--warm);opacity:.55"></i> Falsely attributed (coincidental)</span>
</div>
<p class="pcap" style="margin-top:14px">The recorded line sits above the truth all year; the gap is the coincidental positives. In the peak the gap is small relative to a large true signal, but in the low season the true signal has collapsed while the reservoir persists, so the same non-malaria fevers make up most of what is recorded.</p>
</section>
</div>
<div class="wrapwide">
<section class="card">
<h2>What to notice</h2>
<p><strong>The recorded count is always at or above the truth.</strong> Every non-malaria fever that coincides with a detectable infection adds a case that malaria did not cause.</p>
<p><strong>The impact of non-malaria fevers is differential across the season.</strong> Compare the first two readouts. Because the recorded count is set against true malaria, the over-count tends to be smaller in the peak season, when the true signal is large, and larger in the low season, when true clinical malaria has fallen towards its floor while the asymptomatic reservoir, and the steady non-malaria fever rate that samples it, has not. In a seasonal setting this can make the recorded season look flatter and longer than the real one.</p>
<p><strong>The over-count leans to the months after the peak.</strong> The falsely attributed band is not symmetric around the season; it is heavier on the falling side. Infections take weeks to clear, so the pool of detectable infection lags the transmission that produced it and wanes only slowly: it is still large in the months after cases have peaked, when it keeps turning non-malaria fevers into recorded positives. Prevalence therefore peaks later than clinical incidence and trails a long tail into the following dry season, and the coincidental count follows it.</p>
<p><strong>The more concentrated the season, the larger the low-season over-count.</strong> Raise the seasonality slider so the true peak is narrow and its off-season floor is low. The low-season over-count climbs, because the true signal it is measured against has almost vanished while the coincidental component holds up.</p>
<p><strong>Higher transmission enlarges the reservoir faster than the disease.</strong> Raise the transmission intensity: clinical illness levels off while the prevalence of infection keeps climbing, as immunity limits disease without clearing infection. So a fixed non-malaria fever rate yields more coincidental positives per true case, and the over-count grows. Where infection is common, a positive test carries less information about the cause of a fever.<sup class="cite"><a href="#ref2">2</a></sup></p>
<p><strong>Better access records more cases but does not fix the bias.</strong> Move treatment seeking: it multiplies both the true malaria cases and the coincidental ones, so the recorded volume rises or falls (the vertical scale moves with it), but the over-count ratios and the share of recorded cases not caused by malaria do not move. In this model the misattribution is a property of the case definition and the reservoir, not of how many people present for care.<sup class="cite"><a href="#ref3">3</a></sup></p>
<p><strong>Reading routine data as clinical burden may overstate malaria.</strong> The share of recorded cases not caused by malaria is substantial at plausible fever rates: largest in the low season, and, as a share of positive tests, greater the higher the transmission intensity, because a common infection reservoir turns more non-malaria fevers into coincidental positives.<sup class="cite"><a href="#ref3">3</a></sup> Trends and seasonal profiles built from confirmed-case counts inherit this bias, most heavily off-peak.</p>
</section>
<div class="note">
<p><strong>Methods.</strong> A deliberately simple, illustrative toy model showing the <em>shape</em> of how non-malaria fevers inflate routine malaria counts, not a specific setting; not to be used for decision making. The vertical axis is a relative case scale, not a count in real units. It rescales to fill the frame at every slider setting, so as transmission, the fever rate and treatment-seeking move it is the scale that shifts, not the height of the curve. Its absolute level is not calibrated to any setting, so only the shape and the relative over-counts are meaningful; the readouts, which are ratios and shares, are unaffected by the scale. It models a single focal group (young children), with the case peak fixed at mid-year and a symmetric rise and fall.</p>
<ul>
<li><strong>Seasonal shape.</strong> A periodic curve <span class="var">g</span>(<span class="var">t</span>) = <span class="var">f</span> + (1 − <span class="var">f</span>)·((1 + cos(2π(<span class="var">t</span> − <span class="var">t</span><sub>peak</sub>)/365))/2)<sup><span class="var">p</span></sup> gives a single yearly peak fixed at 1. The seasonality slider raises the peakiness <span class="var">p</span> = 1 + 8<span class="var">S</span> + 16<span class="var">S</span><sup>4</sup> and lowers the off-season floor <span class="var">f</span> = (1 − <span class="var">S</span>)<sup>1.5</sup> together, from a near-flat perennial year to a sharp two-to-three-month peak. This is the same seasonal curve used in the <a href="seasonality.html">seasonality explainer</a>, here with the peak fixed at mid-year.</li>
<li><strong>Transmission sets the levels.</strong> The transmission-intensity slider (<span class="var">u</span> = <span class="var">T</span>/100) fixes the peak level of clinical incidence, <span class="var">c</span>(<span class="var">u</span>) = <span class="var">u</span>/(<span class="var">u</span> + 0.15), and of prevalence, <span class="var">v</span>(<span class="var">u</span>) = 0.9·<span class="var">u</span>/(<span class="var">u</span> + 0.5). Both rise with transmission, but clinical incidence saturates sooner: the ratio <span class="var">v</span>/<span class="var">c</span> grows with <span class="var">T</span>, a stand-in for immunity limiting disease while infection persists.<sup class="cite"><a href="#ref3">3</a></sup> Clinical incidence through the year is <span class="var">C</span>(<span class="var">t</span>) = <span class="var">c</span>(<span class="var">u</span>)·<span class="var">g</span>(<span class="var">t</span>).</li>
<li><strong>Prevalence.</strong> The prevalence of detectable infection is <span class="var">P</span>(<span class="var">t</span>) = <span class="var">v</span>(<span class="var">u</span>)·<span class="var">ĝ</span>(<span class="var">t</span>), where <span class="var">ĝ</span> is a broadened, lagged, floored version of the seasonal shape, obtained by relaxing towards <span class="var">g</span> with a timescale <span class="var">D</span> = 80 days (d<span class="var">ĝ</span>/d<span class="var">t</span> = (<span class="var">g</span> − <span class="var">ĝ</span>)/<span class="var">D</span>, integrated around the year and normalised to a peak of 1). The finite duration sustains prevalence in the low season after clinical incidence has collapsed.</li>
<li><strong>Recorded cases.</strong> Writing the treatment-seeking share <span class="var">τ</span> and the non-malaria fever rate as <span class="var">N</span> episodes per child per year with a normalised value <span class="var">n</span> = <span class="var">N</span>/6, the true malaria cases recorded are <span class="var">M</span>(<span class="var">t</span>) = <span class="var">τ</span>·<span class="var">C</span>(<span class="var">t</span>) and the falsely attributed cases are <span class="var">X</span>(<span class="var">t</span>) = <span class="var">τ</span>·<span class="var">κ</span>·<span class="var">n</span>·<span class="var">P</span>(<span class="var">t</span>) with <span class="var">κ</span> = 0.9 a fixed relative scale. The routine-recorded count is <span class="var">R</span>(<span class="var">t</span>) = <span class="var">M</span>(<span class="var">t</span>) + <span class="var">X</span>(<span class="var">t</span>); the shaded band is <span class="var">X</span>. Because <span class="var">τ</span> multiplies both terms, it scales the recorded volume but cancels from every over-count.</li>
<li><strong>Readouts.</strong> The year is split at half the peak clinical incidence: peak-season days have <span class="var">C</span> ≥ ½·max <span class="var">C</span>, low-season days below it. The peak- and low-season over-counts are the recorded total divided by the true malaria total over those days, ∫<span class="var">R</span> / ∫<span class="var">M</span> (the low-season figure is shown as "–" when the year has no distinct low season, that is a flat or only weakly seasonal setting). The share of recorded cases not caused by malaria is ∫<span class="var">X</span> / ∫<span class="var">R</span> over the whole year. All three are independent of the treatment-seeking share.</li>
<li><strong>What is left out.</strong> The toy omits imperfect and age-varying treatment-seeking (here a single share applied equally to malaria and non-malaria fevers, so differential care-seeking by cause would shift the over-count, not merely scale it), test sensitivity and specificity, the coincidence pool's use of total infection prevalence (which includes the carriage of those already ill with malaria, slightly overstating the peak-season overlap), the under-counting of true cases that never present, and any seasonality or age-dependence in the non-malaria fever rate. A further simplification works in the same direction as the effect shown: it ignores the persistence of RDT antigen (HRP2) for weeks after parasites clear, which would enlarge and further lag the test-positive reservoir beyond true infection prevalence. It captures only the coincidence mechanism that inflates confirmed-case counts.</li>
<li><strong>Relation to published estimates.</strong> The scale <span class="var">κ</span> and the level mappings are chosen so that at plausible settings the share of recorded cases not caused by malaria falls in the broad range reported by Dalrymple <em>et al</em>, who estimate that in 2014 only about 28% of malaria-positive fevers in under-fives were causally attributable to malaria (about 10% of all fevers), implying routine positive counts can overstate clinical malaria by a large margin that grows as transmission rises.<sup class="cite"><a href="#ref3">3</a></sup> That 28% is a continent-wide 2014 average and the attributable fraction varies widely between settings, so the toy is meant to sit in the same broad range rather than to reproduce that figure. The non-malaria fever process mirrors the structure being added to <a href="https://github.com/mrc-ide/malariasimulation/pull/372" target="_blank" rel="noopener">malariasimulation</a>,<sup class="cite"><a href="#ref4">4</a></sup> where an age-specific non-malaria fever rate drives treatment-seeking and a positive test in someone with a detectable infection is recorded as a case. The cross-check in <a href="validation/validate_nmf.R" target="_blank" rel="noopener"><span class="var">validation/validate_nmf.R</span></a> reproduces these curves and readouts in R.</li>
</ul>
<p><strong>References.</strong></p>
<ol class="refs">
<li id="ref1">World Health Organization, 2023. WHO guidelines for malaria. <a href="https://www.who.int/publications/i/item/guidelines-for-malaria" target="_blank" rel="noopener">who.int/publications/i/item/guidelines-for-malaria</a></li>
<li id="ref2">Bousema <em>et al</em>, 2014. Asymptomatic malaria infections: detectability, transmissibility and public health relevance. <em>Nature Reviews Microbiology</em> 12:833–840. <a href="https://doi.org/10.1038/nrmicro3364" target="_blank" rel="noopener">doi.org/10.1038/nrmicro3364</a></li>
<li id="ref3">Dalrymple <em>et al</em>, 2017. Quantifying the contribution of <em>Plasmodium falciparum</em> malaria to febrile illness amongst African children. <em>eLife</em> 6:e29198. <a href="https://doi.org/10.7554/eLife.29198" target="_blank" rel="noopener">doi.org/10.7554/eLife.29198</a></li>
<li id="ref4">mrc-ide. malariasimulation: an individual-based model of <em>Plasmodium falciparum</em> transmission (Griffin model). Non-malaria fever process, <a href="https://github.com/mrc-ide/malariasimulation/pull/372" target="_blank" rel="noopener">pull request #372</a>.</li>
</ol>
</div>
<footer>
<p>Numbers here are illustrative: they show the shape of how non-malaria fevers inflate recorded malaria counts, not real-world impact, which depends on local transmission, fever epidemiology, testing and care-seeking. Not to be used for decision making.</p>
</footer>
</div>
<script>
// ---------- toy non-malaria-fever over-counting model (illustrative) ----------
// g(t): seasonal shape (peak fixed mid-year at 1), from the seasonality explainer.
// C(t): true clinical malaria = c(u)*g(t); its level c(u) is set by transmission intensity.
// P(t): prevalence of detectable infection = v(u)*ghat(t); ghat is a broadened, lagged,
// floored (low-pass) version of g, and the level v(u) is set by transmission intensity.
// M(t): true malaria cases recorded = treat * C(t)
// X(t): falsely attributed cases = treat * kappa * nNorm * P(t) (coincident positives)
// R(t): routine-recorded total = M(t) + X(t)
const YEAR = 365, DT = 1;
const PEAK_DAY = 182; // case peak fixed at mid-year
const D_INF = 80; // mean duration of detectable infection (days): sets the reservoir lag/floor
const NMF_REF = 6; // reference non-malaria fever rate (episodes/child/yr); slider normalised to it
const KAPPA = 0.9; // relative scale converting (fever rate x prevalence) into recorded-case units
const CASE_SCALE = 100; // display only: scales the model's relative rate onto the illustrative relative-case axis
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
// seasonal shape; symmetric single peak fixed at mid-year, peakiness/floor from seasonality S
function seasonalShape(t, S){
const p = 1 + 8 * S + 16 * Math.pow(S, 4);
const floor = Math.pow(1 - S, 1.5);
const phi = 2 * Math.PI * (t - PEAK_DAY) / YEAR;
const s = Math.pow((1 + Math.cos(phi)) / 2, p);
return floor + (1 - floor) * s;
}
// transmission intensity (u = T/100) -> peak levels of clinical incidence and prevalence.
// clinical saturates sooner than prevalence, so the reservoir grows relative to disease with T.
function clinLevel(u){ return u / (u + 0.15); }
function prevLevel(u){ return 0.9 * u / (u + 0.5); }
// seasonal shape sampled over the year, cached on S. Used by both the clinical curve and the
// prevalence low-pass, so it is built once per seasonality setting (not per slider frame).
let _shKey = null, _shVal = null;
function shapeArr(S){
if(S === _shKey) return _shVal;
const n = Math.round(YEAR / DT);
const g = new Array(n);
for(let i = 0; i < n; i++) g[i] = seasonalShape(i * DT, S);
_shKey = S; _shVal = g;
return g;
}
// broadened/lagged/floored seasonal shape for prevalence: relax towards g with timescale D_INF,
// iterate around the year to a periodic steady state, normalise to a peak of 1. Depends only on S.
let _psKey = null, _psVal = null;
function prevShape(S){
if(S === _psKey) return _psVal;
const g = shapeArr(S), n = g.length;
let A = 0.3;
for(let rep = 0; rep < 8; rep++) for(let i = 0; i < n; i++) A += (g[i] - A) / D_INF * DT;
const out = new Array(n);
for(let i = 0; i < n; i++){ A += (g[i] - A) / D_INF * DT; out[i] = A; }
let mx = 0; for(const v of out) if(v > mx) mx = v;
for(let i = 0; i < n; i++) out[i] /= mx;
_psKey = S; _psVal = out;
return out;
}
// ---------- simulate over the year ----------
function simulate(T, S, nmfPerYr, treatPct){
const u = T / 100, nNorm = nmfPerYr / NMF_REF, tau = treatPct / 100;
const cL = clinLevel(u), vL = prevLevel(u);
const g = shapeArr(S), ghat = prevShape(S), n = g.length;
const pts = [];
let xTot = 0, rTot = 0;
let peakM = 0, peakR = 0, lowM = 0, lowR = 0, recMax = 0;
for(let i = 0; i < n; i++){
const C = cL * g[i]; // true clinical incidence
const P = vL * ghat[i]; // prevalence of detectable infection
const M = tau * C; // true malaria cases recorded
const X = tau * KAPPA * nNorm * P; // falsely attributed (coincident) cases
const R = M + X;
pts.push({t: i * DT, M, R});
xTot += X; rTot += R;
if(R > recMax) recMax = R; // tallest recorded point, sets the y-axis scale
if(g[i] >= 0.5){ peakM += M; peakR += R; } else { lowM += M; lowR += R; } // peak season: C >= half its max
}
return {
pts, recMax,
peakRatio: peakM > 0 ? peakR / peakM : null,
lowRatio: lowM > 0 ? lowR / lowM : null, // null when there is no low season (perennial)
falseShare: rTot > 0 ? xTot / rTot : 0
};
}
// ---------- canvas helpers (match seasonality.html / rebounds.html) ----------
function css(v){ return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }
function setup(canvas){
if(!canvas) return null;
const dpr = window.devicePixelRatio || 1;
if(!canvas.dataset.h) canvas.dataset.h = canvas.getAttribute('height') || 280;
const H = +canvas.dataset.h;
const W = canvas.getBoundingClientRect().width;
if(!W) return null;
canvas.style.height = H + 'px';
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
return {ctx, W, H};
}
// choose a nice y-axis: ~3-4 round ticks with a little headroom above the peak
function axisFor(peak){
const target = peak * 1.1;
const rough = target / 4;
const p = Math.pow(10, Math.floor(Math.log10(rough)));
const nn = rough / p;
const step = (nn <= 1 ? 1 : nn <= 2 ? 2 : nn <= 5 ? 5 : 10) * p;
return {step, max: Math.ceil(target / step) * step};
}
// ---------- main chart ----------
// renderChart draws one frame at a given axis max + tick step; drawChart (below) eases the axis
// max toward its target so it glides between "nice" values instead of snapping as sliders move.
function renderChart(sim, YMAX, step){
const c = document.getElementById('nmfChart');
const s = setup(c); if(!s) return;
const {ctx, W, H} = s;
const padL = 48, padR = 14, padT = 16, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const cases = v => v * CASE_SCALE; // model relative rate -> illustrative relative-case axis
const {pts} = sim;
const X = t => padL + t / YEAR * plotW;
const Y = v => padT + (1 - v / YMAX) * plotH;
// gridlines + y-axis labels at the target's round ticks, drawn up to the current animated max
for(let gv = step; gv <= YMAX + 1e-6; gv += step){
ctx.strokeStyle = css('--line'); ctx.lineWidth = 1; ctx.globalAlpha = 0.55;
ctx.beginPath(); ctx.moveTo(padL, Y(gv)); ctx.lineTo(padL + plotW, Y(gv)); ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillStyle = css('--muted'); ctx.font = '10px -apple-system,Segoe UI,sans-serif'; ctx.textAlign = 'right';
ctx.fillText(step < 1 ? gv.toFixed(1) : String(Math.round(gv)), padL - 6, Y(gv) + 3);
}
// axes
ctx.strokeStyle = css('--line'); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL, padT); ctx.lineTo(padL, padT + plotH); ctx.lineTo(padL + plotW, padT + plotH); ctx.stroke();
// y-axis title
ctx.save();
ctx.translate(13, padT + plotH / 2); ctx.rotate(-Math.PI / 2);
ctx.fillStyle = css('--muted'); ctx.textAlign = 'center';
ctx.fillText('relative cases', 0, 0);
ctx.restore();
// true malaria cases: filled area + line
ctx.fillStyle = css('--clin'); ctx.globalAlpha = 0.13;
ctx.beginPath(); ctx.moveTo(X(0), Y(0));
for(const p of pts) ctx.lineTo(X(p.t), Y(cases(p.M)));
ctx.lineTo(X(pts[pts.length - 1].t), Y(0)); ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
// falsely-attributed band: between the true line and the recorded line
ctx.fillStyle = css('--warm'); ctx.globalAlpha = 0.55;
ctx.beginPath();
for(const p of pts) ctx.lineTo(X(p.t), Y(cases(p.R)));
for(let i = pts.length - 1; i >= 0; i--) ctx.lineTo(X(pts[i].t), Y(cases(pts[i].M)));
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
// true malaria line
ctx.strokeStyle = css('--clin'); ctx.lineWidth = 2.4; ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach((p, i) => { const xx = X(p.t), yy = Y(cases(p.M)); i ? ctx.lineTo(xx, yy) : ctx.moveTo(xx, yy); });
ctx.stroke();
// recorded line (near-black, distinct from the coloured true line)
ctx.strokeStyle = css('--ink'); ctx.lineWidth = 2.4; ctx.lineJoin = 'round';
ctx.beginPath();
pts.forEach((p, i) => { const xx = X(p.t), yy = Y(cases(p.R)); i ? ctx.lineTo(xx, yy) : ctx.moveTo(xx, yy); });
ctx.stroke();
// direct labels at the widest separation between the two lines
let si = 0, sv = 0;
for(let i = 0; i < pts.length; i++){ const d = pts[i].R - pts[i].M; if(d > sv){ sv = d; si = i; } }
if(sv > 0.04){
const q = pts[si], lx = Math.max(padL + 46, Math.min(padL + plotW - 46, X(q.t)));
ctx.font = '600 11px -apple-system,Segoe UI,sans-serif'; ctx.textAlign = 'center';
const tag = (text, v, color, dy) => {
const ty = Math.max(padT + 10, Math.min(padT + plotH - 4, Y(v) + dy));
ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(255,255,255,.85)'; ctx.strokeText(text, lx, ty);
ctx.fillStyle = color; ctx.fillText(text, lx, ty);
};
tag('recorded', cases(q.R), css('--ink'), -7);
tag('true', cases(q.M), css('--clin'), 14);
}
// month labels
ctx.fillStyle = css('--muted'); ctx.font = '10px -apple-system,Segoe UI,sans-serif'; ctx.textAlign = 'center';
for(let m = 0; m < 12; m++){ const day = (m + 0.5) * YEAR / 12; ctx.fillText(MONTHS[m][0], X(day), padT + plotH + 20); }
}
// ease the displayed axis max toward its target so the rescaling glides rather than snaps between
// "nice" values as the sliders move; labels stay on the target's round ticks throughout.
const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let _axMax = null, _axTarget = null, _lastSim = null, _axRAF = 0;
function drawChart(sim){
_lastSim = sim;
_axTarget = axisFor(Math.max(sim.recMax * CASE_SCALE, 1)); // fill the frame at the current peak
if(_axMax === null || REDUCED_MOTION){ // first paint, or reduced motion: snap to target
_axMax = _axTarget.max;
renderChart(_lastSim, _axMax, _axTarget.step);
return;
}
if(!_axRAF) _axRAF = requestAnimationFrame(stepAxis);
}
function stepAxis(){
const diff = _axTarget.max - _axMax;
if(Math.abs(diff) < Math.max(_axTarget.max * 0.004, 0.5)){ // close enough: settle on the target
_axMax = _axTarget.max; _axRAF = 0;
} else {
_axMax += diff * 0.18; // exponential ease toward the target
_axRAF = requestAnimationFrame(stepAxis);
}
renderChart(_lastSim, _axMax, _axTarget.step); // labels on the target's round ticks
}
// ---------- wiring ----------
const transmission = document.getElementById('transmission');
const seasonality = document.getElementById('seasonality');
const nmf = document.getElementById('nmf');
const treat = document.getElementById('treat');
const allSliders = [transmission, seasonality, nmf, treat];
function fillPct(el){ el.style.setProperty('--fill', ((el.value - el.min) / (el.max - el.min) * 100) + '%'); }
// thresholds align with when a low season exists: g dips below half-peak only once the floor
// (1-S)^1.5 < 0.5, i.e. S > ~0.37, so "seasonal" starts there and the low-season readout is
// non-empty whenever the label says seasonal or highly seasonal.
function seasonLabel(S){ return S < 0.15 ? 'perennial' : S < 0.4 ? 'weakly seasonal' : S < 0.7 ? 'seasonal' : 'highly seasonal'; }
// Verbal bands take the two lower cut-points used by the other explainers (10 and 35 per cent),
// applied to this page's own peak infection prevalence prevLevel(). There is no 'intense' band:
// prevLevel saturates at exactly 0.60, which is where the equilibrium pages' top band begins, so
// an 'intense' tier here would cover the last slider step alone.
// The word is derived from the SAME rounded integer that is displayed, so the two can never
// disagree at a band edge. Note this prevalence is an illustrative, child-focal, peak-season
// figure, not the Griffin PfPR 2-10 the equilibrium pages report.
function transPct(T){ return Math.round(prevLevel(T / 100) * 100); }
function transWord(T){
const pc = transPct(T);
return pc < 10 ? 'low' : pc < 35 ? 'moderate' : 'high';
}
function transLabel(T){ return transWord(T) + ', ' + transPct(T) + '%'; }
function update(){
allSliders.forEach(fillPct);
const T = +transmission.value;
const S = +seasonality.value / 100;
const nmfPerYr = +nmf.value;
const treatPct = +treat.value;
document.getElementById('vTrans').textContent = transLabel(T);
document.getElementById('vSeason').textContent = seasonLabel(S);
document.getElementById('vNmf').textContent = nmfPerYr + ' / yr';
document.getElementById('vTreat').textContent = treatPct + '%';
// human-readable values for assistive tech (sliders carry raw numbers)
transmission.setAttribute('aria-valuetext', transWord(T) + ' transmission, ' + transPct(T) + ' percent peak infection prevalence');
seasonality.setAttribute('aria-valuetext', seasonLabel(S));
nmf.setAttribute('aria-valuetext', nmfPerYr + ' non-malaria fevers per child per year');
treat.setAttribute('aria-valuetext', treatPct + ' percent seeking care');
const sim = simulate(T, S, nmfPerYr, treatPct);
const fmt = r => r == null ? '–' : r.toFixed(1) + '×';
document.getElementById('rPeak').textContent = fmt(sim.peakRatio);
document.getElementById('rLow').textContent = fmt(sim.lowRatio);
document.getElementById('rShare').textContent = sim.peakRatio == null ? '–' : Math.round(sim.falseShare * 100) + '%';
announce(sim);
drawChart(sim);
}
// debounce the screen-reader announcement so dragging a slider doesn't flood it
let announceTimer = null;
function announce(sim){
clearTimeout(announceTimer);
announceTimer = setTimeout(() => {
const low = sim.lowRatio == null ? 'no distinct low season' : sim.lowRatio.toFixed(1) + ' times in the low season';
const peak = sim.peakRatio == null ? '–' : sim.peakRatio.toFixed(1);
document.getElementById('srLive').textContent =
'Recorded cases ' + peak + ' times the truth in the peak season, ' + low + '; ' +
Math.round(sim.falseShare * 100) + '% of recorded cases not caused by malaria';
}, 450);
}
allSliders.forEach(el => el.addEventListener('input', update));
window.addEventListener('resize', update);
window.addEventListener('load', update);
update();
</script>
</body>
</html>