Skip to content

Tutorial: forecast vs. realised load

How well did the day-ahead load forecast track reality for Belgium during the week of 2024-01-15 → 2024-01-22? This tutorial pulls two parallel series:

…lines them up, plots both on the same axes, and computes the mean absolute percentage error (MAP).

Setup

julia
using ENTSOE
using CairoMakie
using Statistics: mean
using Dates

CairoMakie.activate!(type = "png")

include(joinpath(pkgdir(ENTSOE), "test", "_brokenrecord_helpers.jl"))
const BR = _load_brokenrecord()
client = ENTSOEClient("PLAYBACK")

Two parallel series

Both wrappers have the same shape — same area code, same period arguments, same (time, value) StructVector return — so we can hit them back-to-back. Realised load first:

julia
actual = BR.playback("tut_load_actual_BE_week.yml") do
    actual_total_load(
        client, EIC.BE,
        DateTime("2024-01-14T23:00"),
        DateTime("2024-01-21T23:00"))
end
length(actual), actual[1], actual[end]
(672, (time = Dates.DateTime("2024-01-14T23:00:00"), value = 9728.1), (time = Dates.DateTime("2024-01-21T22:45:00"), value = 9128.59))

…and the day-ahead forecast for the same window:

julia
forecast = BR.playback("tut_load_forecast_BE_week.yml") do
    day_ahead_load_forecast(
        client, EIC.BE,
        DateTime("2024-01-14T23:00"),
        DateTime("2024-01-21T23:00"))
end
length(forecast), forecast[1]
(672, (time = Dates.DateTime("2024-01-14T23:00:00"), value = 9317.99))

Both series come back at the same quarter-hour resolution, so we can compute the error directly with column-view subtraction:

julia
@assert length(actual.value) == length(forecast.value) == length(actual.time)

err  = forecast.value .- actual.value           # MW, signed
ape  = abs.(err) ./ actual.value .* 100         # absolute % error per slot
map = sum(ape) / length(ape)                   # mean absolute % error
bias = sum(err) / sum(actual.value) * 100       # signed bias as a %

(map_pct = round(map; digits = 2),
 bias_pct = round(bias; digits = 2),
 worst_slot_idx = argmax(ape),
 worst_pct = round(maximum(ape); digits = 2))
(map_pct = 2.33, bias_pct = -1.12, worst_slot_idx = 531, worst_pct = 9.92)

A MAP around 2 % is solid for a system-wide load forecast; the signed bias tells us whether the forecast over- or under-shoots on average.

Plotting

julia
fig = Figure(size = (900, 520))

ax1 = Axis(fig[1, 1];
    ylabel = "GW",
    title  = "BE load — actual vs. day-ahead forecast (week 2024-01-15)",
)
xs = 1:length(actual.value)
lines!(ax1, xs, actual.value ./ 1_000;
    color = :black, linewidth = 1.4, label = "Realised")
lines!(ax1, xs, forecast.value ./ 1_000;
    color = :firebrick, linewidth = 1.4, linestyle = :dash,
    label = "Day-ahead forecast")
axislegend(ax1; position = :rb, framevisible = false)

ax2 = Axis(fig[2, 1];
    ylabel = "Forecast − actual (MW)",
    xlabel = "UTC time",
)
hlines!(ax2, [0]; color = :gray70, linewidth = 0.5)
# `band!` accepts a single fill colour, so we draw the over- and under-
# forecast halves as two bands. Clamping to zero keeps each half on its
# correct side of the axis.
band!(ax2, xs, zeros(length(err)), max.(err, 0);
    color = (:firebrick, 0.35), label = "Over-forecast")
band!(ax2, xs, min.(err, 0), zeros(length(err));
    color = (:seagreen,  0.35), label = "Under-forecast")
lines!(ax2, xs, err; color = :black, linewidth = 0.6)

linkxaxes!(ax1, ax2)
ax2.xticks = (
    1:96:length(err),    # one tick per day (96 quarter-hours)
    [Dates.format(actual.time[i], "E dd") for i in 1:96:length(err)],
)
hidexdecorations!(ax1; grid = false)
rowgap!(fig.layout, 1, 6)
fig

A second plot — the error histogram — gives a quick read on the forecast's shape (centred? skewed? heavy-tailed?):

julia
fig2 = Figure(size = (720, 320))
ax = Axis(fig2[1, 1];
    xlabel = "Forecast − actual (MW)",
    ylabel = "count",
    title  = "Error distribution — week of 2024-01-15",
)
hist!(ax, err; bins = 40, color = (:steelblue, 0.7), strokewidth = 0.5,
    strokecolor = :white)
vlines!(ax, [0.0]; color = :black, linestyle = :dash, linewidth = 0.8)
fig2

What you read from this:

  • Daily diurnal pattern is captured well by the forecast — the morning ramp and evening shoulder track within a few hundred MW.

  • Errors cluster at the peaks, where small percentage errors translate to bigger MW gaps.

  • The signed-error band in the bottom panel shows where the forecast leans too high (red) versus too low (green).

Where to next