Data

2026 DRC Bundibugyo virus outbreak — sources, extraction, and what reconciles

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

What’s here

The data on this page come from the situation reports (“sitreps”) that INSP, the DRC’s national public health institute, publishes on the outbreak. The reports count cases by health zone, the district-sized unit the country’s health system is organised into, and health zones roll up into provinces.

Every number and every figure on this page is read from a file the pipeline builds, at the moment the page renders. If a figure has no build rule behind it, or a file hasn’t been built yet, the render stops with an error, so nothing here can be typed in by hand or left over from an old build. To rebuild it all yourself, run make build, make streams, make figures and make compare.

The archive holds 155 situation reports (through 2026-10-23). 145 are read at health-zone level — every report that prints a per-zone table — for 6,998 zone-day rows; the provincial summary covers 140 reports, and three early reports are read from their own prose. Every held report contributes case data at the finest level its pages print.

'data/build/figures/latest/availability.png'

What each report states, at what geographic level (country, province or health zone), and which table layout I read it through.

A few dates are worth keeping in your head as you read that figure. The suspected-case and contact-tracing series stop at the start of June, which is also when counts of confirmed deaths begin. On 13 July the reports start splitting each day’s deaths by how they were found: community deaths, confirmed by a post-mortem swab, and deaths in treatment centres. And sitreps 084 to 086 use a layout that prints no per-zone table at all, so for those three reports I only have province totals.

The reports annotate their own early numbers, and I treat those annotations as data. Nearly every report from 004 through 033 carries a footnote saying its headline cumulative count is still being harmonised, that is, reconciled across the levels of the health system. From sitrep 026 (9 June) the footnote also says which way the error runs. The count leaves out confirmed cases who are still in the community and patients who escaped from care, so it is too low. Sitreps 011 and 012 have a footnote saying the death count itself is underestimated. Later, from sitrep 060 to 083, the reports warn that the last few days’ changes should be read with caution, because notifications arrive late and the data are still being consolidated. Each of these statements is a row in provisional_caveat.csv, whose kind column says which sort of caveat it is: harmonisation_pending (still being harmonised), harmonisation_undercount (being harmonised, and stated to be too low), deaths_underestimated, or recent_data_provisional (the warning about recent days, flagged “caveat” in the index). The index below flags them report by report. This matters if you fit the first four weeks of the outbreak, because you’d be fitting numbers the source itself called provisional, and from 9 June called too low.

Index of situation reports

One row per report. Route is the table layout it was read through (— when no zone table exists); identity asks whether the page’s own arithmetic closes; flags mark the source’s own provisionality caveat, a hand-verified exception, or a rejected route.

Table 1: Index of situation reports
report date route rows identity residual flags
001 2026-05-14 — 0 —
002 2026-05-17 — 0 —
004 2026-05-18 zone_early 8 ok 0 harmonisation
005 2026-05-19 zone_early 8 ok 0 harmonisation
006 2026-05-20 zone_early 10 ok 0 harmonisation
007 2026-05-21 zone_early 13 ok 0 harmonisation
008 2026-05-22 zone_early 13 ok 0 harmonisation
009 2026-05-23 zone_early 14 ok 0 harmonisation
010 2026-05-24 zone_early 18 ok 0 harmonisation
011 2026-05-25 zone_early 18 ok 0 harmonisation, deaths understated
012 2026-05-26 — 0 — deaths understated
013 2026-05-27 zone_early 21 ok 0 harmonisation
014 2026-05-28 — 0 —
015 2026-05-29 zone_mixed 31 ok 0
016 2026-05-30 — 0 — harmonisation
017 2026-05-31 — 0 — harmonisation
018 2026-06-01 zone_cumulative 29 ok 0 harmonisation
019 2026-06-02 zone_cumulative 30 ok 0 harmonisation
020 2026-06-03 zone_cumulative 30 ok 0 harmonisation
021 2026-06-04 zone_cumulative 30 ok 0 harmonisation
022 2026-06-05 zone_cumulative 30 ok 0 harmonisation
023 2026-06-06 zone_cumulative 30 ok 0 harmonisation
024 2026-06-07 zone_cumulative 30 ok 0 harmonisation
025 2026-06-08 zone_cumulative 30 ok 0 harmonisation
026 2026-06-09 zone_cumulative 31 ok 0 stated undercount
027 2026-06-10 zone_cumulative 34 ok 0 stated undercount
028 2026-06-11 zone_cumulative 34 ok 0 stated undercount
030 2026-06-13 zone_cumulative 36 ok 0 stated undercount
031 2026-06-14 zone_cumulative 36 ok 0 stated undercount
032 2026-06-15 zone_cumulative 36 ok 0 stated undercount
033 2026-06-16 — 0 — stated undercount, route rejected
034 2026-06-17 zone_cumulative 39 ok 0 exception
035 2026-06-18 zone_cumulative 39 ok 0
036 2026-06-19 zone_cumulative 39 ok 0
037 2026-06-20 zone_cumulative 39 ok 0
038 2026-06-21 zone_cumulative 39 ok 0
039 2026-06-22 zone_cumulative 39 ok 0
040 2026-06-23 zone_cumulative 39 ok 0
041 2026-06-24 zone_cumulative 39 ok 0
042 2026-06-25 zone_cumulative 39 ok 0
044 2026-06-27 zone_cumulative 40 ok 0
046 2026-06-29 zone_cumulative 40 ok 0
047 2026-06-30 zone_cumulative 41 ok 0
048 2026-07-01 zone_cumulative 41 ok 0
049 2026-07-02 zone_cumulative 41 ok 0
050 2026-07-03 zone_cumulative 41 ok 0
051 2026-07-04 zone_cumulative 41 ok 0
052 2026-07-05 zone_cumulative 41 ok 0
053 2026-07-06 zone_cumulative 42 fails -5 exception
054 2026-07-07 zone_cumulative 42 ok 0
055 2026-07-08 zone_cumulative 42 ok 0 exception
056 2026-07-09 zone_cumulative 42 ok 0
057 2026-07-10 zone_cumulative 48 fails 5
058 2026-07-11 zone_cumulative 49 ok 0
059 2026-07-12 zone_cumulative 49 ok 0 exception
060 2026-07-13 zone_cumulative 52 ok 0 caveat
061 2026-07-14 zone_cumulative 52 ok 0 caveat
062 2026-07-15 zone_cumulative 53 ok 0 caveat
063 2026-07-16 zone_cumulative 53 ok 0 caveat, exception
064 2026-07-17 zone_cumulative 53 ok 0 caveat
065 2026-07-18 zone_cumulative 53 ok 0 caveat
066 2026-07-19 zone_cumulative 54 ok 0 caveat
067 2026-07-20 zone_cumulative 54 ok 0 caveat
068 2026-07-21 zone_cumulative 54 ok 0 caveat
069 2026-07-22 zone_cumulative 53 ok 0 caveat
070 2026-07-23 zone_cumulative 54 ok 0 caveat
071 2026-07-24 zone_cumulative 54 ok 0 caveat
072 2026-07-25 zone_cumulative 54 ok 0 caveat
073 2026-07-26 zone_cumulative 54 ok 0 caveat
074 2026-07-27 zone_cumulative 54 ok 0 caveat
077 2026-07-30 zone_cumulative 55 ok 0 caveat
078 2026-07-31 zone_cumulative 55 ok 0 caveat
079 2026-08-01 zone_cumulative 56 ok 0 caveat
080 2026-08-02 zone_cumulative 57 ok 0 caveat, route rejected
081 2026-08-03 zone_cumulative 57 ok 0 caveat, exception
082 2026-08-04 zone_cumulative 57 ok 0 caveat
083 2026-08-05 zone_cumulative 59 ok 0 caveat, exception
084 2026-08-06 — 0 —
085 2026-08-07 — 0 —
086 2026-08-08 — 0 —
087 2026-08-09 zone_daily 34 ok 0
088 2026-08-10 zone_cumulative 60 ok 0
089 2026-08-11 zone_cumulative 60 fails 1 exception
090 2026-08-12 zone_cumulative 62 ok 0
091 2026-08-13 zone_cumulative 63 ok 0
092 2026-08-14 zone_cumulative 63 ok 0
093 2026-08-15 zone_cumulative 63 ok 0
094 2026-08-16 zone_cumulative 63 ok 0
095 2026-08-17 zone_cumulative 63 ok 0
096 2026-08-18 zone_cumulative 64 ok 0
097 2026-08-19 zone_cumulative 64 ok 0
098 2026-08-20 zone_cumulative 64 ok 0
099 2026-08-21 zone_cumulative 65 ok 0
100 2026-08-22 zone_cumulative 65 ok 0
101 2026-08-23 zone_cumulative 65 ok 0
102 2026-08-24 zone_cumulative 66 ok 0
103 2026-08-25 zone_cumulative 66 ok 0
104 2026-08-26 zone_cumulative 68 ok 0
105 2026-08-27 zone_cumulative 68 ok 0
106 2026-08-28 zone_cumulative 68 ok 0
107 2026-08-29 zone_cumulative 68 ok 0
108 2026-08-30 zone_cumulative 68 ok 0
109 2026-08-31 zone_cumulative 68 ok 0
110 2026-09-01 zone_cumulative 68 ok 0
111 2026-09-02 zone_cumulative 68 ok 0
112 2026-09-03 zone_cumulative 68 ok 0
113 2026-09-04 zone_cumulative 69 ok 0
114 2026-09-05 zone_cumulative 69 ok 0
115 2026-09-06 zone_cumulative 69 ok 0
116 2026-09-07 zone_cumulative 69 ok 0
117 2026-09-08 zone_cumulative 69 ok 0
118 2026-09-09 zone_cumulative 69 ok 0
119 2026-09-10 zone_cumulative 71 ok 0
120 2026-09-11 zone_cumulative 71 ok 0
121 2026-09-12 zone_cumulative 71 ok 0
122 2026-09-13 zone_cumulative 71 ok 0
123 2026-09-14 zone_cumulative 71 ok 0
124 2026-09-15 zone_cumulative 71 ok 0
125 2026-09-16 zone_cumulative 71 ok 0
126 2026-09-17 zone_cumulative 71 ok 0
127 2026-09-18 zone_cumulative 72 ok 0
128 2026-09-19 zone_cumulative 72 ok 0
129 2026-09-20 zone_cumulative 72 ok 0
130 2026-09-21 zone_cumulative 72 ok 0
131 2026-09-22 zone_cumulative 72 ok 0
132 2026-09-23 zone_cumulative 72 ok 0
133 2026-09-24 zone_cumulative 72 ok 0
134 2026-09-25 zone_cumulative 72 ok 0
135 2026-09-26 zone_cumulative 72 ok 0
136 2026-09-27 zone_cumulative 72 ok 0
137 2026-09-28 zone_cumulative 72 ok 0
138 2026-09-29 zone_cumulative 72 ok 0
139 2026-09-30 zone_cumulative 72 ok 0
140 2026-10-01 zone_cumulative 72 ok 0
141 2026-10-02 zone_cumulative 73 ok 0
143 2026-10-04 zone_cumulative 73 ok 0
144 2026-10-05 zone_cumulative 73 ok 0
145 2026-10-06 zone_cumulative 73 ok 0
146 2026-10-07 zone_cumulative 73 ok 0
147 2026-10-08 zone_cumulative 74 ok 0
148 2026-10-09 zone_cumulative 74 ok 0
149 2026-10-10 zone_cumulative 74 ok 0
150 2026-10-11 zone_cumulative 74 ok 0
151 2026-10-12 zone_cumulative 74 ok 0
152 2026-10-13 zone_cumulative 74 ok 0
153 2026-10-14 zone_cumulative 74 ok 0
154 2026-10-15 zone_cumulative 74 ok 0
155 2026-10-16 zone_cumulative 76 ok 0
156 2026-10-17 zone_cumulative 76 ok 0
157 2026-10-18 zone_cumulative 76 ok 0
158 2026-10-19 zone_cumulative 76 ok 0
159 2026-10-20 zone_cumulative 76 ok 0
160 2026-10-21 zone_cumulative 76 ok 0
161 2026-10-22 zone_cumulative 76 ok 0
163 2026-10-23 zone_cumulative 76 ok 0

One primary source, and two readings I only check against

The primary source is the set of situation-report PDFs that INSP (Institut National de Santé Publique) posts at insp.cd. I download them and pin each one with a committed index and a manifest of SHA-256 hashes, so I can tell if a file ever changes. Then I read the numbers from the table geometry drawn in the PDF itself.

Two other groups have read the same documents. INRB-UMIE, the national laboratory, keeps its own transcription, and epiforecasts has a scan. I use both only as checks; nothing on this page is built from them. Since all three of us read the same pages, agreement between us tells you the transcription is right. It tells you nothing about whether the counts themselves are right. A disagreement, on the other hand, always means something: either one of the readings has an error, or two versions of a revised report were read.

Against INRB-UMIE, per zone-day: 5,592 of 5,609 case values agree exactly and 5,490 of 5,499 death values; 23 rows disagree on either, each recorded with both values. The denominator is the inner join of the two lineages — 1,384 of our 6,998 zone-day rows have no INRB counterpart and sit outside every agreement figure, and they are not random: the INRB series currently ends 2026-10-18, ours 2026-10-23, so the most recent reports are uncompared by construction. Against the epiforecasts scan, the national cumulative agrees on 133 of 134 reports exactly. The largest disagreement is not ours: sitrep 032 prints Rwampara at 159 twice over — in its table and its narrative — where the INRB file carries 192.

Our values plotted against INRB-UMIE’s, with the line where the two would be equal, and each disagreement over time with its sign.
'data/build/figures/latest/comparison_series.png'

The three readings as time series: our health-zone rows summed up, INSP’s own printed totals (rings), and INRB-UMIE’s transcription summed through the same mapping from zones to provinces. The bottom row shows where they differ. Ours minus INRB mixes two things, values we disagree on and rows missing from their file. Ours minus printed is plain arithmetic against the page, and it is zero except where the source disagrees with itself, each case of which is recorded in a register (see below). INRB’s national file agrees with ours exactly wherever both have a value, and it starts a few days before our zone-level series does.

Every table is checked against the page’s own arithmetic

Every table I extract has to add up the way the page says it does. A province’s health-zone rows, plus any rows the source counts but hasn’t assigned to a zone yet, must equal the province total printed on the page, and the provinces must add up to the printed national total. When they don’t, I assume the mistake is mine until reading the page shows otherwise.

Sometimes the page really doesn’t add up. When that happens the judgement goes into a register, a hand-kept file with one row per judgement. Each row is tied to the SHA-256 hash of that exact PDF, and it excuses that one gap on that one document and nothing else.

Province-reports: 719 reconcile exactly, 10 carry a hand-verified exception (the page disagrees with itself, read and recorded), 0 disagree and remain open, 30 print no total to check against.

This check has a blind spot. It only catches errors that break the arithmetic. If I read the neighbouring column of a table by mistake, say suspected cases instead of confirmed, that column adds up just as well, and the check passes. Mistakes like that get caught another way: by tests that pin the values of specific named reports, and by comparing the assembled series against the printed totals, further down.

The series

'data/build/figures/latest/province_series.png'

Cumulative confirmed cases and deaths by province. The line is the sum of the health-zone rows the reports print. Rings are the values in the printed provincial summary, shown as a check. Squares are values read from sentences in the report text, which carry their own internal check.
'data/build/figures/latest/streams_detail.png'

Every daily flow I extract, by province. The reports that carried the source’s warning that recent data were still being consolidated aren’t marked here, but every daily stream file has a consolidating column that says which they are.
'data/build/figures/latest/national_detail.png'

The four series the source prints only for the whole country, never by province. Two of them are stocks: each value is a reading taken at one moment, like the number of patients in isolation that day, rather than a count accumulated over a day. So they’re drawn as levels and never smoothed. The calendar matches the panel above.

The streams that downstream models fit are weekly incidence per province. To build them I take the cumulative count, carry the last known value forward over days with no report, difference it, and bin the differences into weeks counted from day 0 (2026-05-19). Two checks have to pass. The total a stream emits has to equal exactly how far the cumulative count moved, and the assembled grid is compared against every province row the reports print.

Table 2: Stream reconciliation. vs source Δ must be zero by construction; grid = printed counts report dates where the assembled cumulative equals the printed province row, and the final gap is the difference on the last report — zero for every stream.
stream obs emitted vs source Δ grid = printed final gap
cases_bas_uele 11 12 0 72/72 +0
cases_daily_bas_uele 71 12 0 — —
cases_daily_haut_uele 98 430 0 — —
cases_daily_ituri 142 7,105 0 — —
cases_daily_national 152 9,843 0 — —
cases_daily_nord_kivu 143 2,209 0 — —
cases_daily_nord_ubangi 7 1 0 — —
cases_daily_sud_kivu 140 2 0 — —
cases_daily_sud_ubangi 42 1 0 — —
cases_daily_tshopo 98 73 0 — —
cases_haut_uele 16 430 0 99/99 +0
cases_ituri 24 7,105 0 134/136 +0
cases_national 24 9,843 0 — —
cases_nord_kivu 24 2,209 0 133/136 +0
cases_nord_ubangi 2 1 0 8/8 +0
cases_sud_kivu 23 2 0 136/136 +0
cases_sud_ubangi 7 1 0 43/43 +0
cases_tshopo 16 73 0 99/99 +0
community_deaths_bas_uele 11 3 of 4 — 12/12 daily —
community_deaths_daily_bas_uele 72 3 of 4 — 0 flagged —
community_deaths_daily_haut_uele 87 75 of 165 — 13 flagged —
community_deaths_daily_ituri 96 1,434 of 2,380 — 21 flagged —
community_deaths_daily_national 96 2,313 of 3,636 — 21 flagged —
community_deaths_daily_nord_kivu 96 789 of 1,063 — 21 flagged —
community_deaths_daily_nord_ubangi 8 1 of 1 — 0 flagged —
community_deaths_daily_sud_kivu 80 8 of 22 — 6 flagged —
community_deaths_daily_sud_ubangi 43 1 of 1 — 0 flagged —
community_deaths_daily_tshopo 79 10 of 22 — 5 flagged —
community_deaths_haut_uele 16 75 of 165 — 72/84 daily —
community_deaths_ituri 16 1,434 of 2,380 — 36/95 daily —
community_deaths_national 16 2,313 of 3,636 — 93/96 daily —
community_deaths_nord_kivu 16 789 of 1,063 — 81/96 daily —
community_deaths_nord_ubangi 2 1 of 1 — 1/1 daily —
community_deaths_sud_kivu 14 8 of 22 — 6/6 daily —
community_deaths_sud_ubangi 7 1 of 1 — 2/2 daily —
community_deaths_tshopo 13 10 of 22 — 38/41 daily —
cumul_recovered 147 0 → 2,646 — stock, 2026-05-17 to 2026-10-23 —
deaths_bas_uele 11 3 0 72/72 +0
deaths_daily_bas_uele 71 3 0 — —
deaths_daily_haut_uele 98 188 0 — —
deaths_daily_ituri 133 3,303 0 — —
deaths_daily_national 153 4,799 0 — —
deaths_daily_nord_kivu 133 1,236 0 — —
deaths_daily_nord_ubangi 7 0 0 — —
deaths_daily_sud_kivu 134 0 0 — —
deaths_daily_sud_ubangi 42 0 0 — —
deaths_daily_tshopo 98 25 0 — —
deaths_haut_uele 16 188 0 99/99 +0
deaths_ituri 22 3,303 0 132/135 +0
deaths_national 24 4,799 0 — —
deaths_nord_kivu 22 1,236 0 129/135 +0
deaths_nord_ubangi 2 0 0 8/8 +0
deaths_sud_kivu 22 0 0 135/135 +0
deaths_sud_ubangi 7 0 0 43/43 +0
deaths_tshopo 16 25 0 98/99 +0
lab_daily_bas_uele 33 7 of 270 tests — — —
lab_daily_haut_uele 69 281 of 1,545 tests — — —
lab_daily_ituri 125 5,675 of 25,619 tests — — —
lab_daily_national 130 7,991 of 44,014 tests — 1–6 provinces/day —
lab_daily_nord_kivu 111 1,915 of 15,648 tests — — —
lab_daily_nord_ubangi 1 1 of 4 tests — — —
lab_daily_sud_kivu 26 0 of 137 tests — — —
lab_daily_tshopo 42 112 of 791 tests — — —
lab_national 21 7,991 of 44,014 tests — 317/333 vs new cases —
patients_isolated 139 173 → 1,018 — stock, 2026-06-01 to 2026-10-23 —
patients_isolated_confirmed 49 47 → 335 — stock, 2026-06-02 to 2026-08-02 —

The registers hold the judgements I made by reading a page

Everything else in this repository is either downloaded or computed. The registers are different. Each row is a judgement I made after reading a page. Every row is keyed on the SHA-256 hash of the document it’s about, so if INSP revises a report, the hash stops matching and the row has to be judged again. And each row excuses exactly one value, so if the value we read changes, the excuse no longer covers it.

register rows each row asserts
reconciliation_exceptions.tsv 15 this page’s printed rows do not sum to its printed total, by exactly this much
rejected_routes.tsv 2 this route, on this document, reads the wrong table – or reads the right one with named columns displaced
zone_province_overrides.tsv 13 the page states in words which province this row belongs to
zone_aliases_local.csv 8 this printed spelling is that canonical health zone
sitrep_number_overrides.tsv 29 this upload’s filename misstates its report number or data date
external_sitreps.tsv 3 INSP does not serve this report; it is held from a secondary archive and its PDF is committed, because nothing can fetch it again

The care ledger

The reports also keep a ledger of the treatment centres, written out in words for each province in the section on patient care. It gives admissions; exits, and the four ways a patient can leave a centre; the census at the end of each day, split into confirmed and suspected patients; and the number of beds installed. extract_care_prose.py reads all of it into data/build/series/care_prose.csv, and the release publishes it as the care_* stream files, which the streams README lists. The confirmed census is worth a look if you’re fitting: it measures a state (how many confirmed patients are in care at one moment), while the case and death streams measure flows.

'data/build/figures/latest/care_ledger.png'

The care ledger, as the reports write it out in words. Panel A is why I trust it. The front-page tile and the care section’s sentence about the whole response are two readings of the same page, made by two different extractors in this repository, and wherever both print a census of confirmed patients in isolation, they agree exactly. Panel B is occupancy against capacity for each province. It’s a ratio of sums, so it can’t see one full centre inside a half-full province. Panel C is the ledger itself, split into panels by the passage that printed each value, and never summed across panels. Panel D is every count of absconders (patients who left a centre without being discharged) that the reports print, drawn as separate marks because that’s what the data are.

What isn’t here

I don’t extract the laboratory, alerts or contact-tracing tables yet. The reference pipeline reads them, and this one will. The care ledger comes from the report text only. The reports also print a patient-movement table with the same quantities per province, absconders included, and reading it would roughly triple the absconder coverage you see in panel D. I also don’t read the daily suspected inflow from the front-page dashboard after 12 July, so the national suspected series ends where the early layout stops printing it. The 23 disagreements with INRB are waiting to be settled: I’ll read each page, then decide case by case whether to report it upstream. Finally, no fit uses these streams yet. That work belongs to the modelling repository.