Skip to content

Province forecasts ​

This page gives the one-week-ahead forecast for each province from the joint model. The projection is 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 under it compare the provinces draw by draw. The provinces add up to the national forecast.

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)
Ituri159–39466–196259–653959–1388169–6840.49–1.1213%
Nord-Kivu68–24147–163104–362242–35390–4940.44–1.2318%
Haut-Uele9–712–357–71131–19416–1400.46–1.5735%
Other provinces2–290–144–5720–317–660.57–1.7552%
  • Most new confirmed cases: Ituri, with probability 92%.

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

  • Growing at T+7: 1 of 4 provinces are more likely than not to be growing (Other provinces).

  • Beds full at T+7: no province is more likely than not to be at or above its beds.

One-week-ahead forecast by province ​

Each panel is one forecast target, the provinces side by side, for the week to province_week_end.

The maps shade each province by its patch's forecast, so the pooled patch shades all its provinces alike. The reproduction number is centred on one, and a province whose 90% predictive interval spans one is washed out.

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,
        ]
    )
);

Forecast summary table by province
ProvinceQuantityLower 90%Lower 60%Lower 30%Upper 30%Upper 60%Upper 90%
IturiNew confirmed cases by T+7159205232289324394
IturiNew confirmed deaths by T+76693110140160196
IturiPatients in isolation at T+7259332380469525653
IturiIsolation beds at T+795910501102120312691388
IturiNew infections by T+7169246303416497684
IturiReproduction number at T+70.490.630.710.860.941.12
Nord-KivuNew confirmed cases by T+76899118156183241
Nord-KivuNew confirmed deaths by T+7476781107124163
Nord-KivuPatients in isolation at T+7104151178237279362
Nord-KivuIsolation beds at T+7242265278305322353
Nord-KivuNew infections by T+790135171253325494
Nord-KivuReproduction number at T+70.440.570.660.850.981.23
Haut-UeleNew confirmed cases by T+791824384971
Haut-UeleNew confirmed deaths by T+7258152135
Haut-UelePatients in isolation at T+771420344471
Haut-UeleIsolation beds at T+7131143151166175194
Haut-UeleNew infections by T+71628376383140
Haut-UeleReproduction number at T+70.460.630.7511.191.57
Other provincesNew confirmed cases by T+7257141829
Other provincesNew confirmed deaths by T+70125714
Other provincesPatients in isolation at T+741015273657
Other provincesIsolation beds at T+7202223262831
Other provincesNew infections by T+771216273666
Other provincesReproduction number at T+70.570.760.91.141.321.75

Forecast for each province ​

Each province has its own forecast figure, 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 in the spatial tables.

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 == 4 && PROVINCE_LABELS[1:4] ==
    ["Ituri", "Nord-Kivu", "Haut-Uele", "Other provinces"]
# 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-09-20 to 2026-09-27. The observed week of confirmed deaths runs from 2026-09-20 to 2026-09-27. Each ends at its last spatial vintage, which can be earlier than the cut-off.

Ituri ​

Nord-Kivu ​

Haut-Uele ​

Other provinces ​

The other provinces are pooled into one patch, so their forecast is for the pool.

Past forecasts against what was observed ​

Only province forecast projections are shown, so the figure fills in as releases accumulate. Each panel is one province stream at one horizon. 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 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.

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."
);

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 the
# provinces' summed forecast.
province_map_dir = joinpath(
    pkgdir(BVDOutbreakSize), "docs", "src", "summary_assets"
)
mkpath(province_map_dir)
CSV.write(
    joinpath(province_map_dir, "province_estimates.csv"),
    province_map_estimates(
        chn_joint, province_forecast_draws(:confirmed_new);
        n_patches = N_PATCHES,
        confirmed_history = obs.province_confirmed_history,
        death_history = obs.province_death_history, cutoff = obs.cutoff
    )
);
national_forecast_draws = let d = province_projection
    [float(sum(d.confirmed_new[d.draw .== i])) for i in sort(unique(d.draw))]
end
CSV.write(
    joinpath(province_map_dir, "national_estimates.csv"),
    province_map_estimates(
        [vec(Array(chn_joint[:R_T]))], [national_forecast_draws];
        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"])
    )
);
# The daily reproduction number and the weekly confirmed cases of each
# patch and of the country, keyed by the patch label.
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
)
national_ts_inc = let c = obs.confirmed_history.counts
    isempty(c) ? zeros(Int, 1, 0) : reshape([c[1]; max.(diff(c), 0)], 1, :)
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(
            vcat(
                weekly_count_table(
                    province_ts_inc.days, province_ts_inc.increments,
                    PROVINCE_LABELS[1:N_PATCHES];
                    cutoff = obs.cutoff, n = obs.n
                ),
                weekly_count_table(
                    obs.confirmed_history.days, national_ts_inc,
                    ["National"]; cutoff = obs.cutoff, n = obs.n
                )
            ), "cases"
        );
        cols = :union
    )
);

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