Part 3: Calibrate Electricity Demand¶
Note
This tutorial assumes you have completed Part 1 and Part 2. You should still have config.KZ.yaml in the project root and a baseline solved network for comparison.
Introduction¶
In Part 2 we compared the baseline model against 2020 national statistics. Total electricity demand looked roughly plausible (~107 TWh), but we had not chosen that number on purpose, it just came from PyPSA-Earth defaults. In this tutorial we take control of demand looking where the hourly profiles come from, how to adjust them with a multiplier, and how to align the annual total with a reference year before we calibrate generation and capacities in later parts.
This series builds an electricity-only (power system) model. We do not switch on sector coupling (heat, transport, industry, hydrogen, and so on). Everything in this part lives under load_options in the config and the build_demand_profiles rule, not under sector.
Where demand enters the workflow¶
Electricity demand is not read directly from national statistics inside the optimiser. Two rules, build_demand_profiles and add_electricity , handle it, both before the network is simplified or solved:
build_demand_profiles → resources/KZ/demand_profiles.csv
↓
add_electricity → networks/KZ/elec.nc
↓
… simplify, cluster, prepare, solve …
| Rule | What it does |
|---|---|
build_demand_profiles |
Loads GEGIS or DemandCast, applies scale, and writes an hourly profile per bus → demand_profiles.csv |
add_electricity |
Attaches demand profiles as PyPSA Load components on the network |
All demand settings live under load_options in the config. They affect build_demand_profiles only.
load_options:
source: "gegis" # "gegis" (scenario-based: 2011, 2013, 2018 available) or "demcast" (hourly 2000–2024 available): view [issue #1724](https://github.com/pypsa-meets-earth/pypsa-earth/issues/1724) for more details
weather_year: 2013 # Weather year of the load profile
prediction_year: 2030 # Valid only if "gegis" used as a source
scale: 1 # scales all load time-series, i.e. 2 = doubles load
For the full parameter list see load_options in the Configuration reference.
Step 1: Choose a demand source¶
PyPSA-Earth builds hourly load from a synthetic global demand dataset which is using pre-calculated country-level electricity consumption, not from national statistics directly. The demand profile is obtained using a machine learning approach which disaggregates the demand profile using seasonal and hourly demand patterns Two datasets are available via load_options.source:
| Source | Config value | Weather years | Data location |
|---|---|---|---|
| GEGIS | gegis |
2011, 2013, 2018 | data/ssp2-2.6/{prediction_year}/era5_{weather_year}/ (regional .nc / .csv, e.g. Asia.nc for KZ) |
| DemandCast | demcast |
2000–2024 (must match snapshots year) |
data/demand/forecasts_on_historical_period.parquet (via databundle) |
GEGIS is the default. Profiles are pre-calculated from population and GDP using the synde workflow; prediction_year selects the SSP pathway folder (e.g. 2030 under ssp2-2.6).
DemandCast (Steijn et al., 2025) adds wider time coverage (25 years vs three for GEGIS) and better spatial coverage for countries where GEGIS had gaps or zero demand. For most countries with good reference data, hourly patterns correlate strongly with GEGIS. Annual totals are still adjusted with scale either way. For the details, see the comparison and Ember validation in #1724.
For Kazakhstan we stay with the default gegis. The country is part of the Asia regional file, which Snakemake selects automatically from your countries list.
Add the source to config.KZ.yaml:
Step 2: Set weather and prediction year (GEGIS)¶
These two settings apply when source: gegis. They pick which pre-calculated file Snakemake loads from:
| Key | Meaning |
|---|---|
weather_year |
Weather year is a calendar year whose temperature patterns shape the hourly load curve. GEGIS provides 2011, 2013, and 2018. This must match the year your model simulates. With the default config that is 2013 (start: "2013-01-01", end: "2014-01-01" near the top of config.default.yaml), so weather_year: 2013 is the right choice. |
prediction_year |
Pathway year is a year which GEGIS future-demand scenario must use. prediction_year: 2030 loads demand built for 2030 under SSP2-2.6 (population and GDP assumptions for that year). It sets the socioeconomic level in the profile; it is not the calendar year you validate against in Part 2. |
In short: weather_year drives when the hours look like (hot summers, cold winters); prediction_year drives how much demand GEGIS expects in that SSP future (e.g. 2030).
Why 2030, not 2020?
GEGIS does not ship a 2020 pathway folder. Under data/ssp2-2.6/ the available prediction_year values are 2030, 2040, 2050, and 2100 which are future SSP scenario years, not historical calendar years.
We keep prediction_year: 2030 because it is the PyPSA-Earth default, matches planning_horizons: [2030] in config.default.yaml, and is the folder included in the standard databundle. That is why the Part 1 baseline was already near 107 TWh without any tuning.
We still validate against 2020 statistics via scale (Step 3), not by changing these years. DemandCast can use weather_year: 2020, but only if you also move snapshots start / end to 2020 — see below.
Tip
Validating against 2020 statistics does not mean setting weather_year: 2020 or prediction_year: 2020 under GEGIS as it offers neither of those. Keep weather_year: 2013 and prediction_year: 2030, then adjust the annual total with scale (next step). If you change GEGIS weather_year to 2011 or 2018, update snapshots start / end to that same calendar year.
If you experiment with DemandCast (source: demcast), set weather_year to the calendar year you simulate which is the same year as snapshots start / end. The parquet holds 2000–2024, but build_demand_profiles keeps only rows inside the snapshot window; a mismatch (e.g. weather_year: 2020 with default 2013 snapshots) produces an empty demand_profiles.csv.
Step 3: Calibrate annual demand with scale¶
Steps 1–2 fixed the hourly shape of demand. Step 3 sets the annual total to match KEGOC 2020 (107.3 TWh).
load_options.scale multiplies every hour after the profile loads. The curve shape is unchanged; only the annual total moves. The same scale logic applies whether you use GEGIS or DemandCast from Step 1.
You can pass scale in two forms:
| Form | When to use | Example |
|---|---|---|
| Single float | One country in countries (this tutorial) |
scale: 1.005 |
| Per-country dictionary | Multi-country run, different multipliers per country | scale: { DEFAULT: 1.0, KZ: 1.005 } |
With the dictionary form, each key is an ISO country code. DEFAULT is the fallback multiplier for any country in countries that does not have its own entry; if you omit it, PyPSA-Earth uses 1.0.
1. Pick target and baseline as explained in Part 2:
| Role | Source | TWh |
|---|---|---|
| Target (numerator) | KEGOC 2020 | 107.3 |
| Baseline (denominator) | Model (Part 2) | 106.8 |
Other targets: EIA 103.4 TWh, Ember 107.9 TWh.
Where does the baseline come from?
The GEGIS input file (data/ssp2-2.6/2030/era5_2013/Asia.csv) contains about 108.0 TWh for Kazakhstan. After simplification, load on electrically isolated sub-networks is dropped from the main grid, leaving about 106.8 TWh in the solved network from Part 2 with a gap of roughly 1.2 TWh. The scale factor in this step absorbs that loss together with any GEGIS-vs-actual discrepancy in one multiplier. Later in this series, when you improve network topology to reconnect isolated regions, that topology gap largely disappears and scale mainly corrects the dataset-vs-statistics difference.
2. Compute the multiplier:
3. Add scale to your load_options block
For Kazakhstan alone, a single float is enough:
You can download the file and merge it with your existing config.KZ.yaml, or add the load_options block by hand.
Step 4: Re-run the workflow¶
You updated load_options in Steps 1–3. Run the same target as Part 1:
Snakemake compares your config with the last run and rebuilds only what changed. Because load_options feeds build_demand_profiles, expect that rule to run again, followed by add_electricity and everything downstream through solve_network. Cutouts, OSM data, and the base network stay cached from Part 1.
Optional: skip data downloads on re-runs
If Part 1 completed successfully, add to config.KZ.yaml:
Demand changes do not require a new databundle or ERA5 cutout. This tells Snakemake to use cached files under data/ and cutouts/KZ/ and avoids another download attempt. See the FAQ if cutout retrieval caused trouble in Part 1.
When the run finishes, the updated solved network overwrites the same file you analysed in Part 2:
Expected runtime: much faster than Part 1, on the order of 7-10 minutes with HiGHS, because most upstream rules are skipped.
Step 5: Verify in your notebook¶
Reopen the notebook from Part 2 and reload the network. Total demand should match your KEGOC target:
import pypsa
n = pypsa.Network("results/KZ/networks/elec_s_10_ec_lcopt_6h.nc")
weights = n.snapshot_weightings.generators
total_TWh = n.loads_t.p_set.multiply(weights, axis=0).sum().sum() / 1e6
print(f"Total annual demand: {total_TWh:.1f} TWh")
Expected output:
The hourly profile shape is unchanged from Part 2, only the annual total moved.
Recap¶
| Step | Config key | Value | Role |
|---|---|---|---|
| 1 | load_options.source |
gegis |
Demand dataset |
| 2 | load_options.weather_year |
2013 |
Hourly shape (match simulation year) |
| 2 | load_options.prediction_year |
2030 |
GEGIS SSP level (no 2020 folder) |
| 3 | load_options.scale |
1.005 or { DEFAULT: 1.0, KZ: 1.005 } |
Match 2020 KEGOC total (float for single country; dict for multi-country) |
Demand is now anchored to 2020 consumption statistics. Generation and installed capacity are calibrated in Part 4 locking the 2020 fleet, filter powerplantmatching, and compare installed capacities to the same validation tables.