Skip to contents

r·en·euro provides a European energy system model built on energyRt. It was developed as teaching material for a modelling course, and as a standalone working model.

It currently reproduces PyPSA-Eur, an established open model of the European power system, at several spatial resolutions. A published model provides a reference solution for verifying the R implementation.

Current functionality includes:
- Import of pre-built PyPSA-Eur models from netCDF, including component attributes that PyPSA omits when they hold default values. - Translation into energyRt model objects: regions, commodities, demand, generation technologies, storage, hydro, weather and transmission. - Sampling in time (subsets of snapshots) and in space (bus aggregation or sub-territory selection). - Solution through any energyRt back end — GLPK, Julia/JuMP, Python/Pyomo, GAMS — and the solvers each supports, locally or remotely via NEOS. - Comparison of converted results against the source model, by quantity.

Transmission is represented along a configurable ladder, from an unconstrained copperplate through capacity-limited transport with piecewise-linear losses to Kirchhoff’s voltage law, since energyRt uses a transport formulation while PyPSA combines both.

Models

Four spatial models ship, covering the same European system at resolutions from five nodes to a thousand. Each models a single planning year — 2050 costs, the 2025 fleet as existing stock — resolved hour by hour within it. All are lazy-loaded and need neither Python nor a PyPSA-Eur clone. (Seven more — the pypsa_eur_41_2025 … _2050 horizon series and the vintaged multi-year pypsa_eur_41v — take the 41-node system through 2050; see ?pypsa_eur_models.)

model nodes snapshots how the regions were formed
pypsa_eur_5 5 one week, 168 h PyPSA-Eur k-means, Belgium only
pypsa_eur_5cp 5 one week, 168 h as above, copperplated
pypsa_eur_41 41 full year, 8,760 h PyPSA-Eur k-means
pypsa_eur_nuts3 1,035 full year, 8,760 h NUTS3 regions, weather ships separately

Built by PyPSA-Eur. pypsa_eur_5, _5cp and _41 are clustered by PyPSA-Eur’s cluster_network rule and read into energyRt without further reduction, so each is comparable to a PyPSA-Eur run at the same size.

pypsa_eur_nuts3 takes the same route but on a busmap generated by this package (pypsa_busmap(), written out by the converter workspace’s make_busmap.R and applied through PyPSA-Eur’s clustering.mode = custom_busmap) rather than by k-means, so its regions are administrative. Everything downstream — renewable profiles, line aggregation, costs — is PyPSA-Eur’s.

NUTS — Nomenclature of Territorial Units for Statistics — is Eurostat’s hierarchy of European regions, against which national statistics are published. It has four nested levels, each a complete partition of the territory:

level Eurostat’s definition count one nesting chain
NUTS0 country 36 DE
NUTS1 major socio-economic regions 109 DE9
NUTS2 basic regions for regional policy 296 DE93
NUTS3 small regions for specific diagnoses 1,477 DE933

Each code contains its ancestry: DE933 is inside DE93, inside DE9, inside DE, so the hierarchy is read rather than inferred.

The 2021 edition is used, as in PyPSA-Eur. An official classification lets a model be joined to published statistics — population, GDP, industrial output — without a separate crosswalk.

Four countries in the model territory are outside NUTS: Bosnia and Herzegovina, Moldova, Ukraine and Kosovo. They are filled from OpenStreetMap first-level administrative boundaries, so “NUTS1” and “NUTS2” for those four are stand-ins rather than real levels.

The NUTS models nest over the same system, so a result can be tested for sensitivity to spatial resolution with nothing else changed. Node counts fall short of region counts because a region without a substation merges into a neighbour, keeping its demand and generation: 296 NUTS2 regions give 289 nodes, and 1,477 NUTS3 give 1,035. Every country has at least one substation, so NUTS0 would be 36 for 36.

Coarser levels are not shipped. NUTS0 and NUTS1 are a region aggregation of the NUTS3 model, so shipping them would store the same system twice more. They are built on demand instead, in a few seconds:

m <- energyRt::aggregate_model_regions(pypsa_eur_nuts3, level = "nuts1")

pypsa_eur_nuts3 carries nuts_gs, so no geoscale argument is needed. Extensive quantities — capacities, demand, supply availability — are summed over the regions of each parent. Intensive ones — efficiencies, availability factors, costs — take a mean weighted by the object’s own capacity in each region, so an aggregated value stays inside the range of the values it came from. Corridors whose endpoints land in one parent become internal and are dropped; the rest merge, their capacities adding and their loss factors taking the capacity-weighted mean.

The counts are 36 regions at NUTS0, 106 at NUTS1 and 289 at NUTS2. The NUTS2 result has been validated against an independent build: a NUTS2 model aggregated through PyPSA’s own workflow reached the same 289 regions and agreed on every technology’s capacity except a 1.5% hydro gap, which came from the order of operations — aggregating the network first merges some zero-duration storage units that conversion would otherwise exclude.

One caveat on reading a coarser run. Aggregation does two opposing things: it removes transport constraints, which can only lower cost, and it averages efficiencies, which loses the ability to dispatch the best unit and can raise it. Neither direction is guaranteed in general.

Regions

nuts_gs is the region hierarchy the four NUTS models are built from: a geoscales object with 1,477 NUTS3 atoms nesting through NUTS2, NUTS1 and country up to a single European root. Aggregating a model to any level, or splitting a national figure down to regions, is a call against this object.

It carries per-region data as well as geometry, in two families. Geographic — area, population, GDP per capita and GDP total — is defined for every region. Model — annual demand, coincident peak, substation count, existing generation and hydro capacity, and the wind and solar potentials by carrier — comes from the NUTS3 network and is located at substations, so it is zero in the 442 regions that contain none.

Seven columns are declared weights. The choice among them is a modelling decision: national demand divides by load_twh or pop, a wind target by pot_onwind; area is rarely the right weight for either.

library(reneuro)
library(dplyr)

geoscales::geoscale_leaftable(nuts_gs) |>
  as.data.frame() |>
  group_by(nuts0) |>
  summarise(twh = sum(load_twh), wind_gw = sum(pot_onwind) / 1e3) |>
  arrange(desc(twh)) |>
  head()
#> # A tibble: 6 × 3
#>   nuts0   twh wind_gw
#>   <chr> <dbl>   <dbl>
#> 1 DE     509.    490.
#> 2 FR     492.    969.
#> 3 IT     316.    504.
#> 4 GB     316.    439.
#> 5 ES     246.    938.
#> 6 UA     149.    851.

The solar and offshore potentials overlap. Fixed-tilt and tracking photovoltaics occupy the same land, and 313 of the 314 offshore regions carry more than one offshore type on the same sea area, so these columns are kept separate and never summed.

A source model

pypsa_eur_nuts3 is not intended to be solved at full resolution. A full-year continental model at 1,035 nodes is beyond the open solvers this package targets: interpolated on a 96-hour seasonal sample it is already 1.3 million LP rows and about 18 minutes, and at four weeks it is 9.4 million rows, where the interior-point method returns no solution. At continental scale, aggregate it to a coarser NUTS level first.

Its use is subsetting. It holds the finest granularity the underlying data supports, so a country or study area can be taken from it at full detail and solved on its own. The coarser models cannot supply that detail, having already averaged it away.

Show the code
library(dplyr)

pt <- geoscales::geoscale_leaftable(nuts_gs) |>
  as.data.frame() |>
  filter(nuts0 == "PT") |>
  pull(region) |>
  intersect(get_region(pypsa_eur_nuts3))

m <- attach_weather(pypsa_eur_nuts3, c("onwind", "solar", "ror")) |>
  energyRt::subset_model_regions(region = pt)

The result is Portugal at 22 nodes: every NUTS3 region containing a substation, with the network between them intact. region accepts any subset of the model’s regions, so a study area need not follow a national border.

subset_model_regions() drops the corridors crossing the boundary and reports each. Without replacement the model is islanded and must meet demand from its own resources, so its cost is an upper bound; boundary_prices replaces the dropped routes with priced import/export stubs.

The weather series are the bulk of the model: seven full-year hourly profiles over 1,035 regions are most of its size. Whole, pypsa_eur_nuts3 saves to 181 MB, past GitHub’s 100 MB limit — so its profiles ship as separate objects, none over 48 MB, attached on demand:

m <- attach_weather(pypsa_eur_nuts3)                    # all seven
m <- attach_weather(pypsa_eur_nuts3, c("onwind", "solar"))

A model must have the profiles its technologies reference before it is interpolated. Attaching only what a study uses keeps it small: solar and onshore wind load 75 MB rather than 182 MB.

Models are lazy-loaded and require neither Python nor a PyPSA-Eur clone, and each carries its provenance as an attribute. data-raw/ holds the scripts that rebuild every object from a clone, recording the upstream commit and configuration used.

Solver benchmarks

Measured on one workstation. The same interpolated scenario was given to each back end, so the linear programme is identical and the objectives agree to solver tolerance in every case. Times are end to end, including model generation and reading the solution back.

model rows (LP) GLPK CBC HiGHS
5 nodes, 168 h ~24 k 10 s 19 s 24-47 s
69 nodes, 96 h ~300 k 2,158 s 809 s 79 s
1,035 nodes, 96 h 1.3 M – – 18.5 min

The ordering reverses with size. GLPK is fastest on the smallest model, where start-up cost dominates; at 69 regions HiGHS is 27 times faster, and the margin grows from there. Julia adds a fixed 20-30 s per invocation for just-in-time compilation, which is amortised across a session but not within a single call, so Pyomo is faster end to end for one small solve even where Julia’s solver call is faster.

The NUTS3 model on a four-week sample (672 h) produces 9.4 million rows after presolve, where the interior-point method returns no solution. First-order (pdlp) and dual simplex methods have not been tested at that size.

Reproduce with benchmark_solvers():

scen <- energyRt::interpolate_model(pypsa_eur_5, name = "be")
benchmark_solvers(scen)

Development

Planned work, in order of priority:

  1. Revision and extension of the input data, including alternative sources.
  2. Review of the processing algorithms that derive model parameters from raw data: aggregation, clustering, capacity factors and loss representation.
  3. Extension to sectors beyond electricity.
  4. Interfaces to other modelling frameworks for cross-model comparison.

Contributing

Contributions are welcome. Issues and pull requests may be opened at github.com/optimal2050/reneuro.

The package follows the optimal2050 conventions: tidyverse style with the native pipe, roxygen2 with Markdown, and the shared pkgdown theme.

References

This package reproduces PyPSA-Eur. If you use reneuro in research, please cite the upstream work as well as this package.

The software

  • PyPSA — the power system analysis framework PyPSA-Eur is built on.
  • PyPSA-Eur — the European model reproduced here, version v2026.02.0.
  • energyRt — the modelling framework reneuro is built on.

The papers

For the electricity-only model, which is what this package currently reproduces:

Jonas Hörsch, Fabian Hofmann, David Schlachtberger and Tom Brown (2018). PyPSA-Eur: An open optimisation model of the European transmission system. Energy Strategy Reviews 22, 207–215. doi:10.1016/j.esr.2018.08.012, arXiv:1806.01613

For the underlying framework:

Tom Brown, Jonas Hörsch and David Schlachtberger (2018). PyPSA: Python for Power System Analysis. Journal of Open Research Software 6(1), 4. doi:10.5334/jors.188, arXiv:1707.09913

On spatial resolution and what coarsening removes:

Jonas Hörsch and Tom Brown (2017). The role of spatial scale in joint optimisations of generation and transmission for European highly renewable scenarios. 14th International Conference on the European Energy Market (EEM). doi:10.1109/EEM.2017.7982024, arXiv:1705.07617

PyPSA-Eur is now a sector-coupled model, and its authors ask that sector studies cite instead:

Fabian Neumann, Elisabeth Zeyen, Marta Victoria and Tom Brown (2023). The potential role of a hydrogen network in Europe. Joule 7, 1–25. doi:10.1016/j.joule.2023.06.016

Marta Victoria, Elisabeth Zeyen and Tom Brown (2022). Speed of technological transformations required in Europe to achieve different climate goals. Joule 6(5), 1066–1086. doi:10.1016/j.joule.2022.04.016

Each release of PyPSA and PyPSA-Eur carries a version-specific DOI on Zenodo, 10.5281/zenodo.3946412 and 10.5281/zenodo.3520874 respectively. Cite the version used.

Citing reneuro

citation("reneuro")

Machine-readable metadata is in CITATION.cff.

Licence

reneuro sources Apache-2.0
reneuro with energyRt AGPL-3

energyRt is licensed under AGPL-3 and is imported by reneuro. R links imported namespaces into a single process, so distributing the two together constitutes a combined work conveyed under AGPL-3. Apache-2.0 is one-way compatible with AGPL-3. The conversion code alone, independent of energyRt, remains available under Apache-2.0.

Converted models inherit the licences of their inputs. OpenStreetMap supplies the transmission topology under ODbL-1.0, whose share-alike terms extend to derived databases; Copernicus land cover and EEA Natura 2000 data are CC-BY-4.0; Eurostat NUTS geometry is reusable under Commission Decision 2011/833/EU, with acknowledgement of the European Union and EuroGeographics. Two electricity demand series (OPSD and ENTSO-E) are recorded as licence unknown in PyPSA-Eur’s data inventory; this is unresolved upstream.

Shipped data carries a LICENSE.note recording provenance for each object, including inputs excluded as non-redistributable.