2026 DRC Bundibugyo outbreak — interactive companion to the data document
NoteMostly written by AI
Most of the text on this site, and most of the code behind it, was written by Anthropic’s Claude Code agents working under my direction. The outbreak produces a new situation report every day, and I wanted this data out fast and kept current, which I couldn’t have done by hand. The numbers are computed by the pipeline and checked against the reports (here’s how), but the writing was done in a hurry and will have mistakes. If you find one, please open an issue or get in touch. — Vince Buffalo
This page is the interactive companion to the data document. Everything you see here is read from the pipeline’s built files at the moment the page is rendered, and every read goes through the same guard the data document uses: if a file isn’t one the pipeline declares and actually produces, the render stops rather than quietly showing you something stale.
The data come from INSP’s daily situation reports (sitreps), the numbered bulletins the national public health institute publishes during the outbreak. Some of those reports warn that their most recent figures are still being consolidated and may change. I mark each of those reports with a faint pink vertical line; the “show consolidation caveat” toggle above turns them off. Hover over any point to see which report it came from.
Cumulative confirmed cases by province
A health zone is the DRC’s basic unit of health administration, smaller than a province, and most reports print a table with one row per zone. The line here is what you get when I add up those zone rows for each province. That sum is the series everything else on this site is built from.
Many reports also print their own subtotal row for each province, and those are the open rings. Because the ring and the line come from the same page, they should land on top of each other. When a ring sits off its line, the report’s own arithmetic doesn’t add up, and that’s worth looking into.
Each point is one report, placed at its reporting date, and shows how much the cumulative count went up since the report before it. I haven’t smoothed or adjusted anything here. A zero means the report printed the same cumulative as the previous one, so those zeros are really in the data. A point below zero means the source revised its cumulative downward, and I’ve left those in too.
This is the interactive version of the grid of daily series in the data document. Each row is one series I extract, and Ituri and Nord-Kivu, the two main provinces of the outbreak, share that row’s vertical scale. The grey points are the numbers as each report published them. The coloured line is a seven-day rolling geometric mean, the same smoother as in the static figure, and it breaks wherever there’s a gap in reporting so it doesn’t draw a level across days the source said nothing about.
The reference project also reads series from report tables I haven’t fully extracted yet: the laboratory, alerts, contact-tracing and isolation tables. They’ll show up here as rows once I do.
flows = flowsJ.map(d => ({...d,day:parseD(d.date)}))FLOW_LABELS =newMap([ ["cases_diff", ["Confirmed cases / day","differenced cumulative — the fitted stream"]], ["new_cases", ["New confirmed cases / day","the source's own 24 h figure, printed from 13 July"]], ["deaths_diff", ["Confirmed deaths / day","differenced cumulative"]], ["deaths_community", ["Community deaths / day","post-mortem swab — the ascertainment signal"]], ["deaths_treatment_centre", ["Treatment-centre deaths / day","the other half of the same split"]], ["lab_tests", ["Samples analysed / day","laboratory prose — every point licensed by its own printed positivity"]],])
{const provs = ["Ituri","Nord-Kivu"].filter(p => provinces.includes(p));if (provs.length===0) returnhtml`<em>Select Ituri or Nord-Kivu above.</em>`;const panels = [...FLOW_LABELS].map(([key, [title, sub]]) => {const sel = flows.filter(d => d.stream=== key && provs.includes(d.province));if (sel.length===0) returnnull;const plot = Plot.plot({width:950,height:190,marginLeft:55,color: colorScale,fx: {domain: provs,label:null},y: {type: logy ?"symlog":"linear",grid:true,label:null},x: {label:null},marks: [ showCaveat ?caveatMarks() :null, Plot.ruleY([0], {stroke:"#ddd"}), Plot.dot(sel, {fx:"province",x:"day",y:"value",r:2,fill:"#a5a5a5",channels: {report:"sitrep",stream: () => title},tip:true}),// The nulls in `smooth` ARE the gap-breakers -- Plot leaves a gap at// an invalid y, so they must reach the mark, not be filtered out;// filtering them reconnects the line across the reporting gaps it// exists to break. Plot.lineY(sel, {fx:"province",x:"day",y:"smooth",stroke:"province",strokeWidth:1.7}), ] });returnhtml`<div style="margin-bottom:0.4rem"> <div style="font-size:0.86rem"><b>${title}</b> <span style="color:#777"> — ${sub}</span></div>${plot}</div>`; });returnhtml`${panels}`;}
How noisy is the reporting?
Here I take each report’s number and ask how far it sits from the smoothed line. Since the smoother averages on a log scale, I measure the distance on that scale too, as log1p(observed) − log1p(smoothed). Pay more attention to the shape of the histogram than to its mean. The smoothing window includes the point being measured and gets cut short at the start and end of the series, so the residuals won’t be centred exactly on zero.
What would a real problem look like? If reports were batched, with a run of reports that undercount followed by one that catches up all at once, you’d see a strong right skew. Still, be suspicious of any lopsidedness you see here. An earlier version of this page showed Ituri skewed at +0.62, and all of it came from two of the source’s downward revisions. The smoother was treating them as zeros, which pulled the line down and pushed every nearby residual up. Once those revisions are left out properly, both provinces come out close to symmetric. So negative increments are kept out of the histogram and out of every smoothing window, and I count them in the note above the plot instead.
viewof resStream = Inputs.select( ["cases_diff","deaths_diff","new_cases"], {label:"stream",format: k => FLOW_LABELS.get(k)[0]})
{const provs = ["Ituri","Nord-Kivu"].filter(p => provinces.includes(p));if (provs.length===0) returnhtml`<em>Select Ituri or Nord-Kivu above.</em>`;const base = flows.filter(d => d.stream=== resStream&& provs.includes(d.province)&& d.smooth!=null&& d.value!=null);const neg = base.filter(d => d.value<0);const res = base.filter(d => d.value>=0).map(d => ({...d,res:Math.log1p(d.value) -Math.log1p(d.smooth),weekday: d.day.toLocaleDateString("en", {weekday:"short",timeZone:"UTC"})}));const stats = provs.map(p => {const r = res.filter(d => d.province=== p).map(d => d.res).sort(d3.ascending);return {province: p,n: r.length,median: d3.median(r),mean: d3.mean(r),above: r.filter(v => v >0).length/ r.length}; });const hist = Plot.plot({width:950,height:300,marginLeft:55,color: colorScale,fx: {domain: provs,label:null},x: {label:"log1p(observed) − log1p(smoothed)"},y: {label:"reports",grid:true},marks: [ Plot.rectY(res, Plot.binX({y:"count"}, {x:"res",fx:"province",fill:"province",thresholds:25,tip:true})), Plot.ruleX([0], {stroke:"#555"}), Plot.ruleX(stats, {x:"median",fx:"province",stroke:"#555",strokeDasharray:"3,3"}), ] });// Pre-aggregated in plain JS rather than via Plot's group transform: the// medians are the statement, so they are computed once, inspectably, and// the mark receives seven numbers per province -- nothing left for a// transform to render weirdly.const DAYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];const wkMed = provs.flatMap(p => DAYS.map(day => {const vs = res.filter(d => d.province=== p && d.weekday=== day).map(d => d.res);return {province: p,weekday: day,n: vs.length,median: vs.length? d3.median(vs) :null}; })).filter(d => d.median!==null);const wk = Plot.plot({width:950,height:220,marginLeft:55,color: colorScale,fx: {domain: provs,label:null},x: {domain: DAYS,label:null},y: {label:"median residual",grid:true},marks: [ Plot.ruleY([0], {stroke:"#999"}), Plot.barY(wkMed, {fx:"province",x:"weekday",y:"median",fill:"province",channels: {"reports that weekday":"n"},tip:true}), ] });const line = stats.map(s =>`<b>${s.province}</b>: n=${s.n}, median ${s.median.toFixed(3)}, `+`mean ${s.mean.toFixed(3)}, ${(100* s.above).toFixed(0)}% above the line`).join(" · ");const negline = neg.length?`<br><span style="color:#a33">${neg.length} negative increment(s) — `+`the source's own downward revisions — excluded from the histogram.</span>`:"";returnhtml`<div> <div class="note">${line}${negline}</div>${hist} <div class="note" style="margin-top:0.8rem">Median residual by weekday — a consistent weekday dip or bump is a reporting-cycle artefact, not epidemiology.</div>${wk}</div>`;}
The weekly series downstream models fit
These are exactly the series that downstream models read, taken from data/build/streams/. Counts are binned by week, and by the last week each series has no gap left against the printed cumulative totals.
When the source revises its cumulative downward, the drop has to go somewhere. I don’t publish it as a negative weekly count, and I don’t throw it away either. Instead I carry the deficit forward and take it out of the weeks that follow. That way every bar is a whole number of zero or more, and each series still adds up to the full change in the printed cumulative. data/build/streams/revisions.tsv lists every row that this correction changed. The solid bars are cases and the paler bars drawn over them are deaths.
My extraction against INRB-UMIE’s, zone by zone and day by day
INRB-UMIE, at the national laboratory, publishes its own transcription of the same situation reports. I don’t build anything from it; I only compare against it. Each point is one health zone in one report, with their number on one axis and mine on the other. Keep in mind that both of us are reading the same pages. When we agree, that tells you we copied the numbers correctly. It doesn’t tell you anything about whether the counts themselves are right. A red point is a place where we disagree, and you can hover over it to see the full record.
You can search this table and sort it by any column. Route is the table layout I read that report’s zone counts from, since the reports changed format over the course of the outbreak. Identity records whether the page’s own arithmetic holds: ok if the zone rows add up to the totals the report prints, fails if they don’t. Residual is the size of the gap when they don’t. Under flags, caveat means the report warns its recent figures are still being consolidated, and exception means I’ve checked the mismatch by hand against the page and recorded it in registers/reconciliation_exceptions.tsv.