Part 5: Adapt Fuel and Generation Costs¶
Note
This tutorial assumes you have completed Part 1 through Part 4. Demand should be calibrated (load_options.scale: 1.005), the 2020 fleet locked with custom_powerplants: replace, and config.KZ.yaml should include those settings. The model will still shed ~7.7 TWh of load on isolated buses at this point; that is expected, and it gets fixed in Part 6 and Part 7.
Introduction¶
By the end of Part 4 the model has the right demand and fleet size: installed coal and gas capacity sit near KEGOC 2020. Dispatch does not: the model runs almost entirely on coal. This tutorial checks the piece that is independent of network topology: is the coal-over-gas dispatch actually explained by fuel and O&M costs? Rather than assume the answer, we replace the generic technology-data defaults (European reference assumptions) with Kazakhstan-specific fuel and O&M data and let the numbers speak.
After the custom fleet, annual Supply still looks roughly like:
| Carrier | Model (TWh) | KEGOC 2020 (TWh) |
|---|---|---|
| Coal | ~89 | ~75 |
| Gas (CCGT + OCGT) | ~0.6 | ~22 |
| Hydro | ~7 | ~9.5 |
| Solar / wind | ~1 / ~1.3 | ~1.3 / ~1.1 |
~5.2 GW of gas capacity sits in the network, but the optimiser barely uses it. With a fixed fleet, the underlying linear program (LP) is a merit-order problem: generators with lower marginal cost (EUR/MWh) are dispatched first. Default technology-data fuel prices make coal cheaper than gas, so coal fills most hours. That holds regardless of network topology, because cost ranking rather than connectivity decides dispatch order between connected buses, so it is worth checking against Kazakhstan-specific cost data now, before Parts 6–7 rebuild the grid.
In this tutorial we:
- Inspect the default marginal costs on the generators in the solved network.
- Compute country-specific marginal costs for Kazakhstan from sourced fuel and O&M data.
- Override
costs.marginal_costwith computed marginal costs from step 2. - Re-solve and check whether country-specific costs actually explain the coal/gas split.
Everything lives under costs in the config. Changing it re-runs process_cost_data and everything that consumes resources/KZ/costs_*_elec.csv, including add_electricity and the solve.
Where costs enter the workflow¶
retrieve_cost_data → resources/KZ/costs_{year}.csv (technology-data)
↓
process_cost_data → resources/KZ/costs_{year}_elec.csv
capital_cost, marginal_cost, co2_emissions
← costs.fuel / costs.VOM / costs.marginal_cost overrides
↓
add_electricity → attaches generators with those marginal costs
↓
… simplify, cluster, prepare, solve …
| Step | What happens |
|---|---|
retrieve_cost_data |
Downloads PyPSA/technology-data for costs.year (default 2030) |
process_cost_data |
Builds capital_cost and marginal_cost; applies config overrides |
add_electricity |
Copies those values onto generators / storage units |
Formula used for thermal plants:
- Fuel cost (
fuelin the cost tables) is in EUR/MWhth. - Efficiency converts that thermal fuel cost to electrical MWh.
- CCGT and OCGT inherit the
gasfuel price. - Coal uses the
coalfuel price.
See the costs user guide and configuration reference.
Step 1: Diagnose the default merit order¶
Reload the Part 4 solved network and print average variable costs by carrier:
import pypsa
n = pypsa.Network("results/KZ/networks/elec_s_10_ec_lcopt_6h.nc")
print(n.generators.groupby("carrier")["marginal_cost"].mean())
With European defaults you should see something like (EUR/MWh):
carrier
CCGT 46.81
OCGT 64.69
coal 30.11
load shedding 100000.01
offwind-ac 0.03
offwind-dc 0.02
onwind 0.02
solar 0.02
Generators also carry a capital_cost (annuitized investment), but with the Part 4 locked fleet it does not enter the objective; see the note in Step 4 for when it would matter.
Coal (~30) is cheaper than CCGT (~47) and much cheaper than OCGT (~65). Renewables are near zero. load shedding sits at the default penalty price (100,000 EUR/MWh): it only dispatches when nothing else can meet demand, which is why it stays out of the normal merit order entirely. With no CO₂ price and no heat demand for CHPs, the optimiser runs coal first and leaves gas idle, exactly the Part 4 generation gap.
You can also inspect the processed cost file:
import pandas as pd
costs = pd.read_csv("resources/KZ/costs_2030_elec.csv", index_col=0)
print(
costs.loc[["coal", "CCGT", "OCGT"], ["fuel", "VOM", "efficiency", "marginal_cost"]]
)
You should see something like:
technology fuel VOM efficiency marginal_cost
coal 9.55 3.26 0.356 30.10
CCGT 24.57 4.44 0.580 46.80
OCGT 24.57 4.76 0.410 64.68
Gas fuel cost (~24.6 EUR/MWhth) is about 2.6 times coal fuel cost (~9.6 EUR/MWhth): that gap is the root cause. CCGT and OCGT both pay the gas price, but OCGT's lower efficiency than CCGT's (OCGT - 0.41 ; CCGT - 0.58) stretches the same fuel cost over less electrical output, pushing its marginal cost well above CCGT's.
Step 2: Choose the cost year¶
Defaults use costs.year: 2030 (forward-looking technology-data). For a 2020 validation study, pin the same year as your fleet and IRENA settings:
This switches which costs_{year}.csv is retrieved and processed. The fuel prices in the 2020 file may differ a little from the 2030 defaults, but coal usually stays cheaper than gas relative to each other. You still need Step 3 to bring in country-specific cost data.
Step 3: Compute country-specific marginal costs for Kazakhstan¶
Generic technology-data defaults are a reasonable starting point, but country-specific data is better when you can find it. As an example, Gasilov (2025), "Cost-optimal energy system development pathways for Kazakhstan," QazaqGreen gives, for existing plants:
| Carrier | Fuel price | O&M (OPEX) |
|---|---|---|
| Coal | 2.8 KZT/kWh | 6.0 KZT/kWh |
| CCGT | 24.5 KZT/kWh | 3.0 KZT/kWh |
| OCGT | 29.9 KZT/kWh | 4.0 KZT/kWh |
with 1 USD ≈ 500 KZT (the article's own exchange rate).
Watch the units. This fuel price already accounts for efficiency, unlike the fuel-cost term in the formula above (VOM + fuel cost / η), which is EUR/MWhth before efficiency. So rather than overriding costs.fuel, we compute marginal_cost ourselves and override that directly.
Since Fuel and O&M are already on an electricity basis, no efficiency division is needed: just add them together to get the marginal cost:
| Carrier | Marginal cost | → USD/MWh | → EUR/MWh |
|---|---|---|---|
| Coal | 2.8 + 6.0 = 8.8 KZT/kWh | 17.6 | ≈ 16 |
| CCGT | 24.5 + 3.0 = 27.5 KZT/kWh | 55.0 | ≈ 51 |
| OCGT | 29.9 + 4.0 = 33.9 KZT/kWh | 67.8 | ≈ 62 |
Unit conversion
Multiply KZT/kWh by 2 to get USD/MWh: 1 KZT/kWh = 1,000 KZT/MWh, and 1,000 ÷ 500 KZT/USD = 2 USD/MWh. The EUR column is an exemplary conversion (output_currency default is EUR); use your own current USD→EUR rate for your study.
This does not flip the merit order. Coal (~16 EUR/MWh) is still roughly 3× cheaper than CCGT (~51). That gap is even wider than with technology-data defaults (~30 vs ~47 in Step 1), because the sourced Kazakhstan coal price is lower than the European default, while gas is not much higher. So Kazakhstan-specific fuel and O&M data alone do not explain why KEGOC dispatches ~22 TWh/yr of gas.
Step 4: Apply the country-specific costs with costs.marginal_cost¶
Because the sourced numbers are already electricity-basis EUR/MWhel values (not a thermal fuel price), write them under costs.marginal_cost rather than costs.fuel. This runs after the fuel formula and replaces the computed marginal cost for that technology. costs.capital_cost works the same way: a direct override of the annuitized investment cost, if you have a sourced CAPEX/annuity figure instead of raw investment / lifetime / FOM:
costs:
year: 2020
marginal_cost: # EUR/MWh_el, sourced Kazakhstan fuel + O&M data
coal: 16.0
CCGT: 51.0
OCGT: 62.0
Use costs.marginal_cost whenever your source already reports cost per unit of electricity generated, as here. Use costs.fuel instead when your source gives a thermal-basis fuel price (EUR/MWhth, before efficiency); that way efficiency and VOM stay consistent with technology-data.
The same per-technology override pattern works for costs.VOM, costs.FOM, costs.efficiency, costs.investment, and costs.lifetime. That is useful if you find sourced values for those too.
Why capital_cost would not change this solve
PyPSA only puts capital_cost into the objective for extendable capacity; fixed p_nom is excluded from that term entirely. Since Part 4 locked the fleet (electricity.extendable_carriers all empty), nothing here is extendable, so a costs.capital_cost override would not move this solution at all; it only matters once you allow new capacity, e.g. in a capacity-expansion study.
Do not mix blindly
If you set both fuel and marginal_cost for the same technology, the marginal_cost overwrite wins. Keep one approach per carrier.
Step 5: Complete your config¶
Merge the costs changes above with your Parts 3–4 settings (load_options, electricity, and so on). The combined config looks like:
# SPDX-FileCopyrightText: PyPSA-Earth and PyPSA-Eur Authors
#
# SPDX-License-Identifier: CC0-1.0
countries: ["KZ"]
run:
name: "KZ"
shared_cutouts: false
enable:
retrieve_databundle: false
retrieve_cutout: false
scenario:
opts: [6h]
load_options:
source: gegis
weather_year: 2013
prediction_year: 2030
scale: 1.005
electricity:
powerplants_filter: (DateOut >= 2020 or DateOut != DateOut) and (DateIn <= 2020 or DateIn != DateIn)
extendable_carriers:
Generator: []
StorageUnit: []
Store: []
Link: []
estimate_renewable_capacities:
stats: irena
year: 2020
custom_powerplants: replace
costs:
year: 2020
marginal_cost: # EUR/MWh_el, sourced Kazakhstan fuel + O&M data
coal: 16.0
CCGT: 51.0
OCGT: 62.0
solving:
solver:
name: highs
The new block is only costs. Everything else should already be in your working config.KZ.yaml.
Step 6: Re-run the workflow¶
We now re-run the electricity workflow with the updated config.KZ.yaml. Because the costs options changed, Snakemake will re-execute process_cost_data and every downstream rule that reads the elec cost table (including add_electricity and solve_network). Cutouts and OSM stay cached:
Expect a runtime similar to Part 4 (~7–10 minutes with cached data), not a full Part 1 rebuild.
While the full solve runs, do a quick sanity check that the override actually landed: process_cost_data writes the post-override table to resources/KZ/costs_2020_elec.csv, so it should already show your Step 4 values (16.0 / 51.0 / 62.0):
costs = pd.read_csv("resources/KZ/costs_2020_elec.csv", index_col=0)
print(costs.loc[["coal", "CCGT", "OCGT"], ["marginal_cost"]])
If the numbers don't match, check for a typo in the carrier name or config indentation before waiting on the full solve.
Step 7: Verify the generation mix¶
To check whether the country-specific costs changed the coal/gas dispatch, and to compare the resulting energy balance with KEGOC 2020, reload the solved network and inspect marginal costs and Supply (TWh):
import pypsa
n = pypsa.Network("results/KZ/networks/elec_s_10_ec_lcopt_6h.nc")
print(n.generators.groupby("carrier")["marginal_cost"].mean().sort_values())
supply = n.statistics()["Supply"].dropna() / 1e6 # TWh
supply = supply.drop(["Line", "Load"], errors="ignore") # dropping line and load values
print(supply.sort_values(ascending=False).to_string())
You should see something like:
carrier
solar 0.02
offwind-dc 0.02
onwind 0.02
offwind-ac 0.03
coal 16.01
CCGT 51.01
OCGT 62.01
load shedding 100000.01
Name: marginal_cost, dtype: float64
Generator Coal 89.89
Load shedding 7.72
StorageUnit Reservoir & Dam 7.15
Generator Onshore Wind 1.34
Solar 0.99
Combined-Cycle Gas 0.20
Open-Cycle Gas 0.00
What to look for
| Check | Expectation |
|---|---|
| Merit order | coal.marginal_cost (~16 EUR/MWh) still below CCGT/OCGT (~51 / ~62 EUR/MWh); confirmed, country-specific data does not flip it |
| Gas supply | Stays low (~0.2 TWh above), even lower than Part 4's ~0.6 TWh; country-specific costs do not push gas up |
| Coal supply | Stays close to ~89 TWh |
| Load shedding | Roughly unchanged from Part 4 (~7.7 TWh), a network issue (isolated buses), not a costs issue; fixed in Part 6 |
| Hydro / VRE | Largely unchanged (near-zero marginal cost; constrained by profiles) |
That is the honest result: sourced Kazakhstan fuel and O&M data still make coal the cheaper option on the margin. This model does not reproduce KEGOC's real ~22 TWh/yr of gas generation through costs alone; if anything, gas supply drops slightly further. In practice much of Kazakhstan's gas fleet is combined-heat-and-power (CHP) serving district heat demand, or otherwise committed under contracts rather than pure economic merit order: a behaviour that marginal-cost override cannot capture. Closing this gap credibly would mean modelling that constraint directly (for example a must-run floor or heat-linked dispatch for gas CHP), not further fuel-price tuning.
A second confusion that is not cost-related sits in the network. By default, PyPSA-Earth lets the solver expand every transmission line at capital cost (scenario.ll: ["copt"]). Therefore, coal can reach any bus without a local gas plant ever needing to dispatch. This is further discussed in Part 7 where this setting is turned off.
Recap¶
| Step | Config key | Role |
|---|---|---|
| 2 | costs.year |
Use 2020 technology-data for the validation year |
| 3 | (sourced Kazakhstan fuel + O&M data) | Compute the country-specific marginal cost per technology (already EUR/MWhel) |
| 4 | costs.marginal_cost.* |
Apply the computed values, replacing technology-data's generic figures |
Marginal costs now reflect sourced Kazakhstan fuel and O&M data instead of generic technology-data defaults, but the coal/gas dispatch split barely moves, because country-specific costs still favor coal. The gap to KEGOC's observed gas generation is therefore not primarily an economics problem in this model. The model still sheds ~7.7 TWh of load on electrically isolated buses; that is a separate network problem. Part 6 diagnoses and fixes those isolated sub-networks, and Part 7 then rebuilds the transmission network itself with Kazakhstan-specific voltage levels and line ratings.