Skip to content

Tutorial: 2025 day-ahead prices across Europe

This second walkthrough fans out across most of Europe and shows the monthly mean day-ahead clearing price for every bidding zone in our EIC table over calendar year 2025 — twelve small maps in one grid, then a single full-size annual mean.

The data was pulled once with the live API (one year-long day_ahead_prices call per zone) and pre-aggregated into a small JSON fixture committed to the repo at docs/src/assets/eu_monthly_prices_2025.json. The script that produced it is scripts/record_eu_prices_2025.jl; re-run it with a valid token when 2026 closes out and the fixture refreshes itself.

Pre-aggregation is a deliberate scope choice — a year of quarter-hour prices is ~2 MB raw per zone, and we have 25 zones; baking the 12-number monthly summary keeps the docs build offline, fast, and small (~12 KB).

Loading the fixture

julia
using ENTSOE
using JSON

const FIXTURE = joinpath(pkgdir(ENTSOE), "docs", "src", "assets",
                         "eu_monthly_prices_2025.json")
data = JSON.parsefile(FIXTURE)
year = data["year"]
zones = data["zones"]
(year = year, zone_count = length(zones))
(year = 2025, zone_count = 25)

A peek at one entry — the Netherlands' twelve monthly means, January through December (EUR/MWh):

julia
nl = zones[findfirst(z -> z["name"] == "Netherlands", zones)]
round.(Float64.(nl["monthly_eur_mwh"]); digits = 1)
12-element Vector{Float64}:
 116.8
 125.9
  92.6
  74.7
  64.6
  67.9
  87.9
  75.3
  78.4
  82.6
  93.8
  87.7

Setting up the map

We use GeoMakie on top of CairoMakie to render a static PNG: country polygons (Natural Earth Admin-0 dataset) projected with Lambert Conformal Conic, coloured by their monthly mean price. Zones that overlap one country (DE_LU spans both Germany and Luxembourg, DK1 only Western Denmark, NO2/SE3 only southern halves) are coloured on the country polygon — close enough for a tutorial.

julia
using GeoMakie, CairoMakie
using GeoMakie: NaturalEarth
using GeoInterface
using Polylabel
using Proj                       # enables GeometryOps' Proj-backed extension
import GeometryOps as GO

CairoMakie.activate!(type = "png")

# Natural Earth medium-detail country polygons. Cached after first
# fetch — subsequent runs are fast.
countries_fc = NaturalEarth.naturalearth("admin_0_countries", 50)
length(countries_fc)
242

Now build a lookup iso2 -> price[12] from the fixture:

julia
price_by_iso = Dict{String, Vector{Float64}}()
for z in zones
    price_by_iso[z["iso2"]] = Float64.(z["monthly_eur_mwh"])
end
sort(collect(keys(price_by_iso)))
25-element Vector{String}:
 "AT"
 "BE"
 "CH"
 "CZ"
 "DE"
 "DK"
 "EE"
 "ES"
 "FI"
 "FR"

 "LV"
 "NL"
 "NO"
 "PL"
 "PT"
 "RO"
 "SE"
 "SI"
 "SK"

For each country polygon, decide whether it's one of our zones — we match on Natural Earth's two-letter ISO code (:ISO_A2_EH, falling back to :ISO_A2; the GeoJSON properties are Symbol-keyed). For label positions we pick the country's largest sub-polygon by geodesic area (GeometryOps.area(GeometryOps.Geodesic(), …), so mainlands beat far-flung islands on true ellipsoidal area, not degrees²), reproject it into LCC with GeometryOps.reproject, and run Polylabel.jl on the result; that lands the price label in the visual centre of each country's mainland.

julia
const PROJ_STR = "+proj=lcc +lat_1=35 +lat_2=65 +lat_0=50 +lon_0=10"
const WGS84    = "+proj=longlat +datum=WGS84"

function label_lonlat(geom)
    sub_polys = GeoInterface.geomtrait(geom) isa GeoInterface.MultiPolygonTrait ?
        collect(GeoInterface.getgeom(geom)) : [geom]
    main = argmax(sub -> GO.area(GO.Geodesic(), sub), sub_polys)
    pole = polylabel(GO.reproject(main, WGS84, PROJ_STR); rtol = 0.005)
    pt = GO.reproject(GeoInterface.Wrappers.Point(pole...), PROJ_STR, WGS84)
    return (GeoInterface.x(pt), GeoInterface.y(pt))
end

function _country_iso2(feature)
    p = feature.properties
    for key in (:ISO_A2_EH, :ISO_A2)
        v = get(p, key, nothing)
        v === nothing && continue
        v isa AbstractString && v != "-99" && return String(v)
    end
    return ""
end

plotted_geoms   = []
plotted_iso     = String[]
plotted_centers = Tuple{Float64, Float64}[]
for feat in countries_fc
    iso = _country_iso2(feat)
    haskey(price_by_iso, iso) || continue
    push!(plotted_geoms, feat.geometry)
    push!(plotted_iso, iso)
    push!(plotted_centers, label_lonlat(feat.geometry))
end
length(plotted_iso)
25

A 12-month grid

Twelve mini-maps, one per month, in a 4×3 layout. A single shared Colorbar to the right of the grid keeps the colour scale honest across panels, so you can read absolute price differences just by eye.

julia
const COLORMAP    = :magma
const PRICE_RANGE = (40.0, 160.0)
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
                     "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

fig = Figure(size = (1100, 1100))

for m in 1:12
    row = (m - 1) ÷ 3 + 1
    col = (m - 1) %  3 + 1
    ax = GeoAxis(fig[row, col];
        dest = PROJ_STR,
        limits = ((-15, 35), (34, 72)),
        title = "$(MONTH_NAMES[m]) $(year)",
        titlesize = 14,
        xgridvisible = false, ygridvisible = false,
    )
    hidedecorations!(ax)
    # backdrop: every European country light grey
    for feat in countries_fc
        poly!(ax, feat.geometry;
            color = :grey85, strokecolor = :white, strokewidth = 0.3)
    end
    # foreground: priced zones
    for (i, iso) in enumerate(plotted_iso)
        poly!(ax, plotted_geoms[i];
            color = price_by_iso[iso][m],
            colormap = COLORMAP, colorrange = PRICE_RANGE,
            strokecolor = :white, strokewidth = 0.5)
    end
end

Colorbar(fig[1:4, 4];
    colormap = COLORMAP, colorrange = PRICE_RANGE,
    label = "EUR / MWh", height = Relative(0.85))
fig

A few patterns jump out month-to-month:

  • Winter peak (Jan–Feb). Continental Europe sits at €110–€150 through the deep cold; Iberia is markedly cheaper as solar already contributes meaningfully.

  • Spring trough (Apr–Jun). Hydro, wind and solar overlap; prices drop into the €60–€80 band almost everywhere south of the Alps, while the Nordics slip into single-digit territory.

  • Summer rebound (Jul–Aug). South-east Europe (RO, GR, BG) pushes back up as cooling demand kicks in and hydro reservoirs draw down.

  • Autumn climb (Sep–Dec). Heating returns; CWE and the Iberian peninsula re-converge in the €80–€120 range by year-end.

A single annual-mean map

For the headline view, average each zone's twelve months and draw one big map with on-country price labels. Same pipeline, just one layer:

julia
annual_by_iso = Dict(iso => sum(v) / length(v) for (iso, v) in price_by_iso)

fig2 = Figure(size = (820, 720))
ax = GeoAxis(fig2[1, 1];
    dest = PROJ_STR,
    limits = ((-15, 35), (34, 72)),
    title = "$(year) annual-mean day-ahead price",
    xgridvisible = false, ygridvisible = false,
)

for feat in countries_fc
    poly!(ax, feat.geometry;
        color = :grey85, strokecolor = :white, strokewidth = 0.4)
end
for (i, iso) in enumerate(plotted_iso)
    poly!(ax, plotted_geoms[i];
        color = annual_by_iso[iso],
        colormap = COLORMAP, colorrange = PRICE_RANGE,
        strokecolor = :white, strokewidth = 0.6)
end
for (i, iso) in enumerate(plotted_iso)
    text!(ax, plotted_centers[i]...;
        text = string(round(Int, annual_by_iso[iso])),
        align = (:center, :center),
        fontsize = 14, color = :black,
        glowwidth = 4, glowcolor = (:white, 0.9))
end
Colorbar(fig2[1, 2];
    colormap = COLORMAP, colorrange = PRICE_RANGE,
    label = "EUR / MWh")
fig2

Where to next

  • The "first-tutorial" walkthrough — tutorial.md — drills into the day-ahead query for one zone (NL) and parses the full quarter-hour timeseries.

  • For the VRE-share view of the same map (solar + wind as a % of daily generation across six zones), see the renewables share map.

  • For more zones than the 25 we cover here, edit the ZONES table in scripts/record_eu_prices_2025.jl and re-run; the JSON fixture picks up the additions automatically.

  • See day_ahead_prices and the REST API reference for every endpoint we wrap.