Skip to content

Province forecasts ​

This page gives the one-week-ahead forecast for each province from the joint model, as defined in the province forecast Methods section. The national forecast is on the forecasts page. How these forecasts have scored against what each province went on to report is in the forecast by province evaluation. How well the model reproduces each province's share is on the in-sample checks 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 fits this page reads, loaded from the cache here.
chn_joint = load_fit("joint");

Summary ​

The table gives each province's forecast for the week to   as a 90% predictive interval, and the bullets compare the provinces draw by draw.

Project each province a week ahead
julia
# Read from the same draws as the national forecast page, so the provinces
# add up to the national forecast shown there.
province_draws = fit_forecast("joint");
province_projection = forecast_provinces(
    province_draws; horizon = 7, n_patches = N_PATCHES
);
province_forecast = province_forecast_table(
    province_draws, province_projection;
    n_patches = N_PATCHES
);
province_forecast_fig = plot_province_forecast(
    province_draws, province_projection;
    n_patches = N_PATCHES
);
province_week_end = obs.cutoff + Day(7);
province_forecast_md = province_forecast_headline(
    province_projection; n_patches = N_PATCHES
);
ProvinceNew confirmed casesNew confirmed deathsPatients in isolationIsolation bedsNew infectionsR at T+7P(R > 1)
Ituri207–53472–201394–946570–1893282–12730.69–1.658%
Nord-Kivu135–39560–175220–636163–541219–10930.7–1.6663%
Haut-Uele10–812–3222–15778–26123–1640.58–1.7752%
Sud-Kivu0–20–16–6315–520–20.48–1.6444%
Tshopo4–430–155–4917–5813–930.74–1.9675%
Bas-Uele0–70–32–332–111–120.63–1.7860%
Sud-Ubangi0–20–10–44–210–30.61–1.7761%
Nord-Ubangi0–60–20–60–100–210.8–2.8584%
  • Most new confirmed cases: Ituri, with probability 80%.

  • Most new confirmed deaths: Ituri, with probability 63%.

  • Growing at T+7: 7 of 8 provinces are more likely than not to be growing (Ituri, Nord-Kivu, Haut-Uele, Tshopo, Bas-Uele, Sud-Ubangi, Nord-Ubangi).

  • Beds full at T+7: Nord-Kivu, Bas-Uele are more likely than not to be at or above its beds.

One-week-ahead forecast by province ​

Each panel is one forecast target for the week after the cut-off, with the provinces side by side.
Build the forecast maps
julia
province_forecast_draws(col) = [
    float.(province_projection[province_projection.patch .== p, col])
        for p in 1:N_PATCHES
]
province_forecast_map = plot_province_map(
    filter(
        !isnothing,
        [
            (;
                province_map_summary(province_forecast_draws(:rt_forecast))...,
                title = "Reproduction number at T+7", diverging_at = 1.0,
                colorbar_label = "R (median)",
            ),
            (;
                values = province_map_summary(
                    province_forecast_draws(:confirmed_new)
                ).values,
                title = "New confirmed cases by T+7",
                scale = CairoMakie.Makie.pseudolog10,
                colorbar_label = "Confirmed cases (median)",
            ),
            :isolation_level in propertynames(province_projection) ? (;
                    values = province_map_summary(
                        province_forecast_draws(:isolation_level)
                    ).values,
                    title = "Patients in isolation at T+7",
                    scale = CairoMakie.Makie.pseudolog10,
                    colorbar_label = "Patients (median)",
                ) : nothing,
        ]
    )
);

Each province is shaded by its forecast. The reproduction number is centred on one, and a province whose 90% predictive interval spans one is washed out.
ProvinceQuantityLower 90%Lower 60%Lower 30%Upper 30%Upper 60%Upper 90%
IturiNew confirmed cases by T+7207272317389433534
IturiNew confirmed deaths by T+77299116146164201
IturiPatients in isolation at T+7394511582705783946
IturiIsolation beds at T+7570794947124214471893
IturiNew infections by T+72824014937048631273
IturiReproduction number at T+70.690.860.971.151.291.6
Nord-KivuNew confirmed cases by T+7135183213275316395
Nord-KivuNew confirmed deaths by T+7608196123140175
Nord-KivuPatients in isolation at T+7220300352449515636
Nord-KivuIsolation beds at T+7163227271357415541
Nord-KivuNew infections by T+72193294035777051093
Nord-KivuReproduction number at T+70.70.890.991.171.321.66
Haut-UeleNew confirmed cases by T+7101927415481
Haut-UeleNew confirmed deaths by T+7269152032
Haut-UelePatients in isolation at T+722405382103157
Haut-UeleIsolation beds at T+778109131171200261
Haut-UeleNew infections by T+72336477599164
Haut-UeleReproduction number at T+70.580.760.891.151.341.77
Sud-KivuNew confirmed cases by T+7000002
Sud-KivuNew confirmed deaths by T+7000001
Sud-KivuPatients in isolation at T+761217304063
Sud-KivuIsolation beds at T+7152125333952
Sud-KivuNew infections by T+7000112
Sud-KivuReproduction number at T+70.480.690.831.081.261.64
TshopoNew confirmed cases by T+74812202643
TshopoNew confirmed deaths by T+70236815
TshopoPatients in isolation at T+751015243349
TshopoIsolation beds at T+7172428374458
TshopoNew infections by T+7132127425693
TshopoReproduction number at T+70.740.961.11.321.521.96
Bas-UeleNew confirmed cases by T+7001237
Bas-UeleNew confirmed deaths by T+7000113
Bas-UelePatients in isolation at T+7268152033
Bas-UeleIsolation beds at T+72346811
Bas-UeleNew infections by T+71224612
Bas-UeleReproduction number at T+70.630.830.961.21.381.78
Sud-UbangiNew confirmed cases by T+7000012
Sud-UbangiNew confirmed deaths by T+7000001
Sud-UbangiPatients in isolation at T+7000124
Sud-UbangiIsolation beds at T+7468111421
Sud-UbangiNew infections by T+7000113
Sud-UbangiReproduction number at T+70.610.830.961.211.381.77
Nord-UbangiNew confirmed cases by T+7000136
Nord-UbangiNew confirmed deaths by T+7000012
Nord-UbangiPatients in isolation at T+7000126
Nord-UbangiIsolation beds at T+70012310
Nord-UbangiNew infections by T+70124721
Nord-UbangiReproduction number at T+70.81.041.221.621.972.85

Forecast for each province ​

Each province's figure has one histogram per target with its 90% predictive interval shaded. The dashed rule on the case and death panels is the count the province reported over its most recent week.

Build the per-province figures
julia
# The spatial tables' most recent week in each province, clamped as the
# compositions are, so a downward revision reads as no new cases.
recent_cases = province_recent_counts(
    obs.province_confirmed_history, PROVINCE_NAMES, N_PATCHES
)
recent_deaths = province_recent_counts(
    obs.province_death_history, PROVINCE_NAMES, N_PATCHES
)
function forecast_province_observed(p)
    o = (;)
    recent_cases === nothing ||
        (o = merge(o, (; confirmed_new = recent_cases.counts[p])))
    recent_deaths === nothing ||
        (o = merge(o, (; confirmed_deaths_new = recent_deaths.counts[p])))
    return o
end
forecast_province_fig(p) = plot_province_forecast_detail(
    province_draws, province_projection;
    province = p, n_patches = N_PATCHES,
    observed = forecast_province_observed(p)
)
# The province blocks below are written out one per patch.
@assert N_PATCHES == 8 && PROVINCE_LABELS[1:8] == [
    "Ituri", "Nord-Kivu", "Haut-Uele",
    "Sud-Kivu", "Tshopo", "Bas-Uele", "Sud-Ubangi", "Nord-Ubangi",
]
# Cases and deaths come from separate tables, so each stream's observed
# week is stated on its own.
function forecast_province_recent_week(r, stream)
    r === nothing &&
        return "The spatial tables carry no recent week of $(stream)."
    return string(
        "The observed week of $(stream) runs from ",
        grid_date(r.start_day), " to ", grid_date(r.last_day), "."
    )
end
recent_note = join(
    [
        forecast_province_recent_week(recent_cases, "confirmed cases"),
        forecast_province_recent_week(recent_deaths, "confirmed deaths"),
        "Each ends at its last spatial vintage, which can be earlier than " *
            "the cut-off.",
    ], " "
);

The observed week of confirmed cases runs from 2026-10-13 to 2026-10-20. The observed week of confirmed deaths runs from 2026-10-13 to 2026-10-20. Each ends at its last spatial vintage, which can be earlier than the cut-off.

Ituri ​

Nord-Kivu ​

Haut-Uele ​

Sud-Kivu ​

Tshopo ​

Bas-Uele ​

Sud-Ubangi ​

Nord-Ubangi ​

Past forecasts against what was observed ​

Load the archived province forecasts and their outcomes
julia
# Written by `scripts/score_releases.jl` from each release's
# `province_forecast.csv`, in the national overlay's schema. A missing
# file reads as an empty table, which the figure reports as nothing scored.
province_overlay_df = _release_data(
    joinpath("province", "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,
    )
)
province_overlay_fig = plot_forecast_overlay(
    scored_overlay(province_overlay_df);
    empty_message = "No projection forecast has been scored yet. " *
        "This fills in as releases accumulate."
);

Past province forecasts by the cut-off each was made from, one panel per stream and horizon, filling in as releases accumulate. Each shows its median and 90% predictive interval beside the persistence baseline and the count the province went on to report. A window holding a harmonisation-break day is left out, because that day's backfill is published for the country and not by province.

Saving province map assets ​

The dashboard map's province layer and its detail column read the per-province and national estimates and time series written here.

Write the province map estimates and time series
julia
# The national row reads the joint's national reproduction number and
# case-fatality ratio and the provinces' summed forecasts, patients in
# isolation and beds.
province_map_dir = joinpath(
    pkgdir(BVDOutbreakSize), "docs", "src", "summary_assets"
)
mkpath(province_map_dir)
has_death_forecast = :confirmed_deaths_new in propertynames(province_projection)
has_care_forecast = :isolation_level in propertynames(province_projection) &&
    :bed_capacity in propertynames(province_projection)
has_care = BVDOutbreakSize._has_key(chn_joint, :province_bed_capacity)
# Patients in isolation over beds, draw by draw.
care_ratio(iso, beds) = [i ./ b for (i, b) in zip(iso, beds)]
province_care_forecast = has_care_forecast ? (;
        isolation_forecast = province_forecast_draws(:isolation_level),
        beds_forecast = province_forecast_draws(:bed_capacity),
        bed_use_forecast = care_ratio(
            province_forecast_draws(:isolation_level),
            province_forecast_draws(:bed_capacity)
        ),
    ) : (;)
CSV.write(
    joinpath(province_map_dir, "province_estimates.csv"),
    province_map_estimates(
        chn_joint, province_forecast_draws(:confirmed_new);
        n_patches = N_PATCHES,
        deaths_forecast = has_death_forecast ?
            province_forecast_draws(:confirmed_deaths_new) : nothing,
        confirmed_history = obs.province_confirmed_history,
        death_history = obs.province_death_history, cutoff = obs.cutoff,
        isolation_history = obs.province_isolation_history,
        bed_history = obs.province_bed_capacity_history, n = obs.n,
        province_care_forecast...
    )
);
national_forecast_draws(col) = let d = province_projection
    [float(sum(d[d.draw .== i, col])) for i in sort(unique(d.draw))]
end
# The provinces' cut-off draws summed into one national draw vector.
national_care_draws(key) = let v = BVDOutbreakSize._per_patch(
        chn_joint, key, N_PATCHES
    )
    [sum(x[i] for x in v) for i in eachindex(first(v))]
end
national_care = has_care ? (;
        isolation = [national_care_draws(:province_expected_isolation)],
        beds = [national_care_draws(:province_bed_capacity)],
        bed_use = care_ratio(
            [national_care_draws(:province_expected_isolation)],
            [national_care_draws(:province_bed_capacity)]
        ),
    ) : (;)
national_care_forecast = has_care_forecast ? (;
        isolation_forecast = [national_forecast_draws(:isolation_level)],
        beds_forecast = [national_forecast_draws(:bed_capacity)],
        bed_use_forecast = care_ratio(
            [national_forecast_draws(:isolation_level)],
            [national_forecast_draws(:bed_capacity)]
        ),
    ) : (;)
CSV.write(
    joinpath(province_map_dir, "national_estimates.csv"),
    province_map_estimates(
        [vec(Array(chn_joint[:R_T]))],
        [national_forecast_draws(:confirmed_new)];
        deaths_forecast = has_death_forecast ?
            [national_forecast_draws(:confirmed_deaths_new)] : nothing,
        cfr = [vec(Array(chn_joint[:CFR]))],
        confirmed_history = Dict("national" => obs.confirmed_history),
        death_history = Dict("national" => obs.confirmed_deaths_history),
        cutoff = obs.cutoff, patch_names = ["national"],
        patch_labels = ["National"],
        members = Dict("national" => ["national"]),
        isolation_history = Dict("national" => obs.isolation_history),
        bed_history = Dict("national" => obs.bed_capacity_history),
        n = obs.n, national_care..., national_care_forecast...
    )
);
# The daily reproduction number and the weekly confirmed cases and deaths
# of each patch and of the country, keyed by the patch label, and the
# reported patients in isolation and beds over the same eight weeks, keyed
# by source province.
province_ts_rt_args = (;
    n = obs.n, breakpoint = _BREAKPOINT, rt_start = _rt_start_plot,
    rt_walk_start = clamp(_BREAKPOINT - RT_WALK_LEAD, _rt_start_plot, obs.n),
    ramp = RT_INTERVENTION_RAMP,
)
province_ts_inc = province_increment_matrix(
    obs.province_confirmed_history, PROVINCE_NAMES, N_PATCHES
)
province_ts_deaths = province_increment_matrix(
    obs.province_death_history, PROVINCE_NAMES, N_PATCHES
)
national_increments(c) = isempty(c) ? zeros(Int, 1, 0) :
    reshape([c[1]; max.(diff(c), 0)], 1, :)
# Each patch's and the country's weekly counts from their increments.
function province_weekly(inc, national)
    return vcat(
        weekly_count_table(
            inc.days, inc.increments, PROVINCE_LABELS[1:N_PATCHES];
            cutoff = obs.cutoff, n = obs.n
        ),
        weekly_count_table(
            national.days, national_increments(national.counts),
            ["National"]; cutoff = obs.cutoff, n = obs.n
        )
    )
end
# Tag a long table with the series it holds.
_with_series(t, s) = (t[!, :series] .= s; t)
CSV.write(
    joinpath(province_map_dir, "province_timeseries.csv"),
    vcat(
        _with_series(
            rt_quantile_table(
                [
                    reconstruct_patch_rt(
                        chn_joint; n_patches = N_PATCHES,
                        province_ts_rt_args...
                    );
                    [reconstruct_rt(chn_joint; province_ts_rt_args...)]
                ],
                [PROVINCE_LABELS[1:N_PATCHES]; "National"];
                cutoff = obs.cutoff, n = obs.n, from = _rt_start_plot
            ), "rt"
        ),
        _with_series(
            province_weekly(province_ts_inc, obs.confirmed_history), "cases"
        ),
        _with_series(
            province_weekly(province_ts_deaths, obs.confirmed_deaths_history),
            "deaths"
        ),
        _with_series(
            reported_level_table(
                merge(
                    obs.province_isolation_history,
                    Dict("National" => obs.isolation_history)
                ); cutoff = obs.cutoff, n = obs.n, from = obs.n - 55
            ), "isolation"
        ),
        _with_series(
            reported_level_table(
                merge(
                    obs.province_bed_capacity_history,
                    Dict("National" => obs.bed_capacity_history)
                ); cutoff = obs.cutoff, n = obs.n, from = obs.n - 55
            ), "beds"
        );
        cols = :union
    )
);
# The same three tables into the release outputs, so each results release
# carries the dashboard map's province and national inputs at its cut-off.
output_dir = get(
    ENV, "BVD_OUTPUT_DIR",
    joinpath(pkgdir(BVDOutbreakSize), "output")
)
mkpath(output_dir)
for f in (
        "province_estimates.csv", "national_estimates.csv",
        "province_timeseries.csv",
    )
    cp(joinpath(province_map_dir, f), joinpath(output_dir, f); force = true)
end

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