Skip to content

Health-zone forecasts ​

This page splits the one-week-ahead confirmed-case forecast across the health zones. The split is defined in the health-zone model Methods section. The national forecast is on the forecasts page and the split by province on the province forecasts page. How these forecasts have scored against what each zone went on to report is in the forecast by health zone evaluation. The zone estimates behind the split are on the health-zone estimates page.

Load packages, data and fitted chains
julia
# Shared setup: packages, observations and the fit registry. See
# `docs/pages/_setup.jl`.
using BVDOutbreakSize
include(joinpath(pkgdir(BVDOutbreakSize), "docs", "pages", "_setup.jl"))
validation_forecast_from (generic function with 1 method)
julia
# The zone fit this page reads, loaded from the cache here.
chn_local = load_fit("local");

Summary ​

The overall bullets rank the zones. The table and figure below give each zone's own projection.

Split the one-week-ahead forecast across the zones
julia
# `zone_stage_inputs` is defined in the shared setup, so the zone
# estimates page draws the same forecast from the same fixed inputs.
zone_inputs = zone_stage_inputs(; forecast = true);
zone_patch = zone_inputs.patch_of_zone;
zone_week_end = obs.cutoff + Day(7);
ZONE_THRESHOLDS = (1, 5, 10, 20)
zone_fc = zone_forecast(chn_local, zone_inputs);
zone_fc_draws = zone_forecast_draws(zone_fc, zone_inputs);
zone_fc_probs = zone_forecast_probabilities(
    zone_fc, zone_inputs; thresholds = ZONE_THRESHOLDS, draws = zone_fc_draws
);
zone_forecast_summary = zone_forecast_table(
    zone_fc, zone_inputs; thresholds = ZONE_THRESHOLDS
);
# Cases allocated to each zone over the past one, two and four weeks, and
# the last vintage on which each zone's count rose.
zone_recent = Dict(
    w => zone_recent_cases(zone_inputs; window = w) for w in (7, 14, 28)
);
zone_last_case = zone_last_case_dates(zone_inputs);
zone_forecast_bullets = let n_zones = length(zone_inputs.zone_labels)
    pct(x) = string(round(Int, 100 * x), "%")
    lead = first(
        sortperm([median(v) for v in zone_fc_draws.zones]; rev = true), 3
    )
    ranked = join(
        [
            "$(zone_inputs.zone_labels[z]) " *
                "$(median_interval_text(zone_fc_draws.zones[z]))"
                for z in lead
        ], "; "
    )
    quiet = findall(iszero, zone_recent[14])
    quiet_text = isempty(quiet) ?
        "every zone has had a case allocated over the past two weeks." :
        let z = quiet[argmax(zone_fc_probs[quiet, 1])]
            "$(length(quiet)) zones have had no case allocated over the " *
            "past two weeks. $(zone_inputs.zone_labels[z]) is the " *
            "most likely of them to report one, at " *
            "$(pct(zone_fc_probs[z, 1]))."
    end
    join(
        [
            "Projected counts are for the week to $(zone_week_end).", "",
            "- **Most new confirmed cases:** $(ranked).",
            "- **Chance of a case:** " *
                "$(count(>=(0.5), zone_fc_probs[:, 1])) of $(n_zones) " *
                "zones have at least an even chance of reporting a " *
                "confirmed case, and " *
                "$(count(>=(0.5), zone_fc_probs[:, 3])) of reporting at " *
                "least ten.",
            "- **Quiet zones:** $(quiet_text)",
        ], "\n"
    )
end;

Projected counts are for the week to 2026-10-03.

  • Most new confirmed cases: Beni about 82 (90% credible interval 37 to 155); Mandima about 77 (90% credible interval 29 to 164); Bunia about 43 (90% credible interval 14 to 94).

  • Chance of a case: 29 of 63 zones have at least an even chance of reporting a confirmed case, and 11 of reporting at least ten.

  • Quiet zones: 21 zones have had no case allocated over the past two weeks. Bafwasende is the most likely of them to report one, at 51%.

One-week-ahead forecast by health zone ​

The figure and table below give the fifteen zones with the largest forecasts. The patch totals in the table are the projections on the province forecasts page. Each zone's count is drawn from the zone model run a week past the cut-off, splitting a draw of its province's forecast total over the province's zones. The last four columns are the probability that the zone reports at least 1, 5, 10 and 20 confirmed cases over the week (Equation (71)).

One-week-ahead zone forecast
julia
zone_forecast_fig = plot_zone_forecast(
    zone_fc_draws.zones,
    zone_inputs.zone_labels, zone_patch;
    patch_labels = zone_inputs.patch_labels, top = 15
);
# The fifteen largest zone forecasts, then every patch total.
zone_forecast_display = let t = zone_forecast_summary
    zones = sort(t[t.zone .!= "Patch total", :], :median; rev = true)
    vcat(first(zones, 15), t[t.zone .== "Patch total", :])
end;

zonepatchmedianlower_90lower_60lower_30upper_30upper_60upper_90p_ge_1p_ge_5p_ge_10p_ge_20
BeniNord-Kivu82375570961161551111
MandimaIturi77295064951161641110.98
BuniaIturi43142534536594110.980.88
Nia-NiaIturi22310163041670.990.930.820.56
RwamparaIturi2039152737590.990.930.80.52
MongbwaluIturi1727122232540.990.880.710.42
KatwaNord-Kivu17481322294710.920.760.41
KomandaIturi1627112330530.980.870.710.42
KalungutaNord-Kivu111471521360.970.790.540.22
PawaHaut-Uele112681520310.990.860.580.21
NiziIturi90361320380.950.720.50.22
ButemboNord-Kivu91361319340.970.760.50.19
WambaHaut-Uele92461216260.980.770.450.13
LitaIturi80351318360.930.670.450.19
MangalaIturi80251220360.920.670.450.19
Patch totalIturi280162214249314351425
Patch totalNord-Kivu16778121146188216284
Patch totalHaut-Uele2881521354669
Patch totalOther provinces11258141932

Zones quiet for two weeks ​

The table lists the zones with no allocated case over the past two weeks, ranked by the probability that they report at least one. It also gives the cases over the past four weeks and the date of the last case.

Quiet zones ranked by the chance of a case
julia
zone_quiet_display = let
    quiet = findall(iszero, zone_recent[14])
    order = quiet[sortperm(zone_fc_probs[quiet, 1]; rev = true)]
    DataFrame(
        zone = zone_inputs.zone_labels[order],
        patch = zone_inputs.patch_labels[zone_patch[order]],
        cases = zone_inputs.cumulative[order],
        cases_past_4_weeks = zone_recent[28][order],
        last_case = [
            ismissing(d) ? "" : string(d) for d in zone_last_case[order]
        ],
        p_ge_1 = round.(zone_fc_probs[order, 1]; digits = 2),
        p_ge_5 = round.(zone_fc_probs[order, 2]; digits = 2),
    )
end;
zonepatchcasescases_past_4_weekslast_casep_ge_1p_ge_5
BafwasendeOther provinces752026-09-120.510.04
GangaOther provinces322026-09-110.350.02
LubungaOther provinces102026-07-110.30.01
DrodroIturi1522026-09-040.270.08
Miti-MurhesaOther provinces302026-06-010.260.01
ButaOther provinces102026-08-120.230
TshopoOther provinces102026-08-130.230
Wanie-RukulaOther provinces102026-08-010.220
KaynaNord-Kivu112026-09-040.180.04
GetyIturi712026-09-060.180.05
LogoIturi1322026-09-040.170.05
RunguHaut-Uele212026-09-020.170.01
AruIturi802026-08-280.150.04
AungbaIturi1202026-08-200.140.04
AriwaraIturi802026-08-170.140.04
MahagiIturi402026-07-270.130.04
GombariHaut-Uele302026-08-220.120
GomaNord-Kivu102026-07-020.110.02
BogaIturi202026-08-240.10.03
KambalaIturi202026-06-300.10.03
AdjaIturi1102026-07-250.070.02

Saving zone forecast assets ​

The summary dashboard shows the zone forecast figure and the summary bullets.

Write the zone forecast asset
julia
dashboard_dir = joinpath(
    pkgdir(BVDOutbreakSize), "docs", "src", "summary_assets"
)
mkpath(dashboard_dir)
CairoMakie.save(
    joinpath(dashboard_dir, "zone_forecast.png"),
    zone_forecast_fig
)
open(joinpath(dashboard_dir, "zone_forecast.md"), "w") do io
    print(io, zone_forecast_bullets)
end

Past zone forecasts against what was observed ​

Each release archives its zone forecast, and later releases score it against what each zone went on to report. The figure shows the ten zones with the largest forecasts in this build, one panel per zone. The x-axis is the cut-off each forecast was made from. Each forecast shows its median and 90% predictive interval, beside the persistence baseline and the count the zone went on to report. A week is left out when the zone tables carry no vintage on its last day, when it holds a harmonisation-break day, or when its province moved unallocated cases into named zones during it. The scores are in the forecast by health zone across releases evaluation.

Load the archived zone forecasts and their outcomes
julia
# Written by `scripts/score_releases.jl` from each release's
# `zone_forecast.csv`, in the national overlay's schema. A missing file
# reads as an empty table, which the figure reports as nothing scored.
zone_overlay_df = _release_data(
    joinpath("zone", "forecast_overlay.csv"),
    (;
        release = String, made_date = Date, stream = String, horizon = Int,
        target_date = Date, fit = String, observed = Float64,
        median = Float64, lo30 = Float64, hi30 = Float64, lo60 = Float64,
        hi60 = Float64, lo90 = Float64, hi90 = Float64,
    )
)
zone_past_keys = zone_inputs.zone_keys[
    first(sortperm([median(v) for v in zone_fc_draws.zones]; rev = true), 10),
]
zone_past_fig = plot_forecast_overlay(
    zone_score_rows(
        scored_overlay(zone_overlay_df), zone_past_keys,
        zone_inputs.zone_labels[indexin(zone_past_keys, zone_inputs.zone_keys)]
    );
    empty_message = "No zone forecast has been scored yet. Scores appear " *
        "from the first release that archives the zone forecast onwards."
);

Archiving the zone forecast ​

Each release carries the zone forecast draws in zone_forecast.csv, for scoring once the week has been reported.

Write the zone forecast archive
julia
output_dir = get(
    ENV, "BVD_OUTPUT_DIR",
    joinpath(pkgdir(BVDOutbreakSize), "output")
)
mkpath(output_dir)
CSV.write(
    joinpath(output_dir, "zone_forecast.csv"),
    zone_forecast_archive(
        zone_fc, zone_inputs; made_date = zone_inputs.cutoff, thin = 5
    )
);

The full analysis code, data and model definitions are in the epiforecasts/BVDOutbreakSize repository.