From 23bacad73a0dddf5991682926f4deaac4b06c645 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 12 Jan 2026 15:01:25 +0100 Subject: [PATCH 01/20] improve gas price 2025 --- data/pypsa-de/custom_costs_nep_2021.csv | 2 +- data/pypsa-de/custom_costs_nep_2023.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/pypsa-de/custom_costs_nep_2021.csv b/data/pypsa-de/custom_costs_nep_2021.csv index 27dec7f41..22440a35d 100644 --- a/data/pypsa-de/custom_costs_nep_2021.csv +++ b/data/pypsa-de/custom_costs_nep_2021.csv @@ -41,7 +41,7 @@ all,hydrogen storage underground,investment,0.55,EUR/kWh,Langfristszenarien Szen 2020,coal,fuel,5.7048,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1t = 8.06 MWh" 2020,decentral air-sourced heat pump,investment,1685,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf 2020,decentral ground-sourced heat pump,investment,2774,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf -2025,gas,fuel,40,EUR/MWh_th,Ariadne, +2025,gas,fuel,30,EUR2020/MWh_th,Ariadne,Näherungsweise Durchschnittspreis 2025 (Annahme 36€) korrigiert für Inflation (Annahme 20%) 2025,oil,fuel,32.9876,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1bbl = 1.6998MWh" 2025,coal,fuel,10.6694,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1t = 8.06 MWh" 2025,decentral air-sourced heat pump,investment,1604,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf and cost reduction from DEA diff --git a/data/pypsa-de/custom_costs_nep_2023.csv b/data/pypsa-de/custom_costs_nep_2023.csv index d139ccc9f..df1fc51f3 100644 --- a/data/pypsa-de/custom_costs_nep_2023.csv +++ b/data/pypsa-de/custom_costs_nep_2023.csv @@ -41,7 +41,7 @@ all,hydrogen storage underground,investment,0.55,EUR/kWh,Langfristszenarien Szen 2020,coal,fuel,5.7048,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1t = 8.06 MWh" 2020,decentral air-sourced heat pump,investment,1685,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf 2020,decentral ground-sourced heat pump,investment,2774,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf -2025,gas,fuel,40,EUR/MWh_th,Ariadne, +2025,gas,fuel,30,EUR2020/MWh_th,Ariadne,Näherungsweise Durchschnittspreis 2025 (Annahme 36€) korrigiert für Inflation (Annahme 20%) 2025,oil,fuel,32.9876,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1bbl = 1.6998MWh" 2025,coal,fuel,10.6694,EUR2020/MWh,Ariadne,"$2020 = 0.8775 EUR2020, 1t = 8.06 MWh" 2025,decentral air-sourced heat pump,investment,1604,EUR2020/kW_th,https://ariadneprojekt.de/media/2024/01/Ariadne-Analyse_HeizkostenEmissionenGebaeude_Januar2024.pdf https://www.enpal.de/waermepumpe/kosten/ https://www.bdew.de/media/documents/BDEW-HKV_Altbau.pdf and cost reduction from DEA From 0ec4aeb2264322d420e7ca29c7f7d7fb99c3fa32 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 12 Jan 2026 15:05:33 +0100 Subject: [PATCH 02/20] limit ttes capacity before 2025, and no ptes availability before 2035 --- config/config.de.yaml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/config.de.yaml b/config/config.de.yaml index 29f712a0e..415ffe414 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -261,7 +261,10 @@ first_technology_occurrence: H2 pipeline: 2025 H2 Electrolysis: 2025 H2 pipeline retrofitted: 2025 - + urban central water pits charger: 2035 + urban central water pits discharger: 2035 + Store: + urban central water pits: 2035 costs: custom_cost_fn: data/pypsa-de/custom_costs_nep_2023.csv transmission: "overhead" # either overhead line ("overhead") or underground cable ("underground") @@ -499,6 +502,9 @@ solving: 2040: 50000 2045: 80000 2050: 80000 + urban central water tanks: + DE: + 2025: 120 # GWh, https://www.hamburg-institut.com/wp-content/uploads/2023/12/Referenzblatt_SysGF-1.pdf Link: methanolisation: DE: From d1849ce12e3dd5f308a2de93f74e19fdff693964 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 12 Jan 2026 15:19:50 +0100 Subject: [PATCH 03/20] improve emission data for 2025 and 2030 --- scripts/pypsa-de/build_scenarios.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/pypsa-de/build_scenarios.py b/scripts/pypsa-de/build_scenarios.py index c891fd773..1abe76fb1 100644 --- a/scripts/pypsa-de/build_scenarios.py +++ b/scripts/pypsa-de/build_scenarios.py @@ -66,7 +66,13 @@ def get_co2_budget(df, source): ## GHG targets according to KSG initial_years_co2 = pd.Series( index=[2020, 2025, 2030], - data=[813, 643, 438], + data=[ + 813, # historisch + # 643, # Ziel laut KSG + 640, # geschätzt, https://www.agora-energiewende.de/fileadmin/Projekte/2025/2025-28_DE_JAW25/A-EW_391_Die_Energiewende_in_Deutschland_Stand_der_Dinge_2025_WEB.pdf, + # 438, # Ziel laut KSG + 456.8, # Durch UBA anhand der Emissionsdaten 2024 vom 15.3.2025 rechnerisch ermittelte Vorschläge für die Anpassung der Jahresemissionsgesamtmengen nach § 4 Abs. 2 KSG + ], ) later_years_co2 = pd.Series( From e0030aa021dce71af715dc17385f2cc54659fcd8 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 12 Jan 2026 15:50:28 +0100 Subject: [PATCH 04/20] improve RES capacity constraints for 2025 and 2030 --- config/config.de.yaml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/config/config.de.yaml b/config/config.de.yaml index 415ffe414..f881c966c 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -468,7 +468,7 @@ solving: onwind: DE: 2020: 54.5 - 2025: 69 + 2025: 68 # # Abb. 4_9 https://www.agora-energiewende.de/fileadmin/Projekte/2025/2025-28_DE_JAW25/A-EW_391_Die_Energiewende_in_Deutschland_Stand_der_Dinge_2025_WEB.pdf 2030: 115 # EEG2023 Ziel für 2030 2035: 160 # EEG2023 Ziel für 2040 2040: 250 @@ -478,15 +478,15 @@ solving: DE: 2020: 7.8 2025: 11.3 - 2030: 29.3 # uba Projektionsbericht and NEP without delayed BalWin 3 - 2035: 50 # Planned projects until 2035 (offshore_connection_points.csv) -1.3 GW for potential delays + 2030: 24 # very optimistic upper ceiling to "Mittelfristprognose zur deutschlandweiten Stromerzeugung" + 2035: 50 # Planned projects until 2035 (offshore_connection_points.csv) -1.3 GW for potential delays 2040: 65 # Planned projects until 2040 -1.5 GW for potential retirments 2045: 70 2050: 70 solar: DE: 2020: 53.7 - 2025: 110 # EEG2023; assumes for 2026: 128 GW, assuming a fair share reached by end of 2025 + 2025: 119 # Abb. 4_9 https://www.agora-energiewende.de/fileadmin/Projekte/2025/2025-28_DE_JAW25/A-EW_391_Die_Energiewende_in_Deutschland_Stand_der_Dinge_2025_WEB.pdf 2030: 235 # PV Ziel 2030 + 20 GW 2035: 400 2040: 800 @@ -533,21 +533,20 @@ solving: Generator: onwind: DE: + 2025: 67 # Abb. 4_9 https://www.agora-energiewende.de/fileadmin/Projekte/2025/2025-28_DE_JAW25/A-EW_391_Die_Energiewende_in_Deutschland_Stand_der_Dinge_2025_WEB.pdf 2030: 99 # Wind-an-Land Law 2028 2035: 115 # Wind-an-Land Law 2030 2040: 157 # target 2035 2045: 160 # target 2040 offwind: DE: - 2030: 22.5 # 75% Wind-auf-See Law + 2030: 18 # Mittelfristprognose zur deutschlandweiten Stromerzeugung 2035: 35 2040: 42 2045: 50 solar: DE: - # EEG2023; Ziel for 2024: 88 GW and for 2026: 128 GW, - # assuming at least 1/3 of difference reached in 2025 - 2025: 101 + 2025: 118 # Abb. 4_9 https://www.agora-energiewende.de/fileadmin/Projekte/2025/2025-28_DE_JAW25/A-EW_391_Die_Energiewende_in_Deutschland_Stand_der_Dinge_2025_WEB.pdf Link: H2 Electrolysis: DE: @@ -723,7 +722,7 @@ onshore_nep_force: offshore_nep_force: cutin_year: 2025 cutout_year: 2030 - delay_years: 0 + delay_years: 2 scale_capacity: 2020: From 3370d84b6e2365d28f87b634d0ec8792f6fef30b Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 1 Sep 2025 15:33:18 +0200 Subject: [PATCH 05/20] allow a small amount of derivatives to be imported (mostly MeOH) --- config/config.de.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/config.de.yaml b/config/config.de.yaml index f881c966c..26b1cf112 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -603,8 +603,8 @@ solving: # boundary condition lower? DE: 2020: 0 - 2025: 0 - 2030: 10 + 2025: 6 + 2030: 20 2035: 105 2040: 200 2045: 300 From 006524e123f7865023441001bffa85a77e63c190 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 12:51:22 +0100 Subject: [PATCH 06/20] decrease land_transport electric share towards pypsa-eur defaults --- config/config.de.yaml | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/config/config.de.yaml b/config/config.de.yaml index 26b1cf112..e5c177e89 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -336,30 +336,24 @@ sector: 2045: 0.36 2050: 0.43 # For Germany these settings get overwritten in build_exogenous_mobility_data + # Essentially all PyPSA-EUR settings but with a little bit of hydrogen to allow for the later overwrites land_transport_fuel_cell_share: - 2020: 0.01 - 2025: 0.01 - 2030: 0.02 - 2035: 0.03 - 2040: 0.03 - 2045: 0.03 - 2050: 0.03 + 2020: 0.001 + 2025: 0.001 + 2030: 0.001 + 2035: 0.001 + 2040: 0.001 + 2045: 0.001 + 2050: 0.001 land_transport_electric_share: - 2020: 0.05 - 2025: 0.15 - 2030: 0.3 - 2035: 0.45 - 2040: 0.72 - 2045: 0.87 - 2050: 0.97 + 2050: 0.999 land_transport_ice_share: - 2020: 0.94 - 2025: 0.84 - 2030: 0.68 - 2035: 0.52 - 2040: 0.25 - 2045: 0.1 - 2050: 0.0 + 2020: 0.999 + 2025: 0.949 + 2030: 0.799 + 2035: 0.549 + 2040: 0.299 + 2045: 0.149 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#industry industry: From dbbd64c76c369f09533e6e9be5d1130225cfdf0c Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 12:53:54 +0100 Subject: [PATCH 07/20] take more configs from config.default.yaml --- config/config.de.yaml | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/config/config.de.yaml b/config/config.de.yaml index e5c177e89..fa06ae327 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -360,14 +360,6 @@ industry: steam_biomass_fraction: 0.4 steam_hydrogen_fraction: 0.3 steam_electricity_fraction: 0.3 - St_primary_fraction: - 2020: 0.6 - 2025: 0.55 - 2030: 0.5 - 2035: 0.45 - 2040: 0.4 - 2045: 0.35 - 2050: 0.3 DRI_fraction: 2020: 0 2025: 0 @@ -388,22 +380,6 @@ industry: 2040: 0.6 2045: 0.5 2050: 0.4 - HVC_mechanical_recycling_fraction: - 2020: 0.12 - 2025: 0.15 - 2030: 0.18 - 2035: 0.21 - 2040: 0.24 - 2045: 0.27 - 2050: 0.30 - HVC_chemical_recycling_fraction: - 2020: 0.0 - 2025: 0.0 - 2030: 0.04 - 2035: 0.08 - 2040: 0.12 - 2045: 0.16 - 2050: 0.20 # 15 Mt HVC production (from IDEES) -> 6 Mt Plastikabfälle, # To substitute for other waste, assume all Plastikabfall is used energetically whereas in reality ~40% is recycled # see https://github.com/PyPSA/pypsa-ariadne/pull/292 From 8df4e51059d5ffe48e8e454cbd8484e9db00056d Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Mon, 12 Jan 2026 15:59:28 +0100 Subject: [PATCH 08/20] disable some config changes in pypsa-eur --- config/config.de.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/config.de.yaml b/config/config.de.yaml index fa06ae327..b0a7870d9 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -319,7 +319,10 @@ sector: regional_coal_demand: true #set to true if regional CO2 constraints needed gas_network: false regional_gas_demand: true + regional_co2_sequestration_potential: + enable: false H2_retrofit: true + hydrogen_turbine: false biogas_upgrading_cc: true biomass_to_liquid: true biomass_to_liquid_cc: true From 4a6444d85b0bdc6e411f768763e74bd1ab75d14b Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 15:40:58 +0100 Subject: [PATCH 09/20] limit cross-border AC flows --- Snakefile | 3 +++ config/config.de.yaml | 8 ++++++++ scripts/pypsa-de/modify_prenetwork.py | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/Snakefile b/Snakefile index f841e7e8b..528c7dab1 100644 --- a/Snakefile +++ b/Snakefile @@ -654,6 +654,9 @@ rule modify_prenetwork: bev_charge_rate=config_provider("sector", "bev_charge_rate"), bev_energy=config_provider("sector", "bev_energy"), bev_dsm_availability=config_provider("sector", "bev_dsm_availability"), + limit_cross_border_flows_ac=config_provider( + "pypsa-de", "limit_cross_border_flows_ac" + ), input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_brownfield.nc" diff --git a/config/config.de.yaml b/config/config.de.yaml index b0a7870d9..92eae45a7 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -37,6 +37,14 @@ pypsa-de: region: Deutschland ageb_for_mobility: true # In 2020 use AGEB data for final energy demand and KBA for vehicles uba_for_mobility: false # For 2025–2035 use MWMS scenario from UBA Projektionsbericht 2025 + limit_cross_border_flows_ac: # relevant if only one node per country is used + 2020: 0.4 + 2025: 0.4 + 2030: 0.45 + 2035: 0.49 # = 0.7 * 0.7 <=> security margin * CEP target + 2040: 0.49 + 2045: 0.49 + 2050: 0.49 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#foresight foresight: myopic diff --git a/scripts/pypsa-de/modify_prenetwork.py b/scripts/pypsa-de/modify_prenetwork.py index fe147ae5d..da64b9448 100644 --- a/scripts/pypsa-de/modify_prenetwork.py +++ b/scripts/pypsa-de/modify_prenetwork.py @@ -1277,6 +1277,14 @@ def scale_capacity(n, scaling): ] +def limit_cross_border_flows_ac(n, s_max_pu): + logger.info( + f"Limiting AC cross-border flows between all countries to {s_max_pu} of maximum capacity." + ) + cross_border_lines = n.lines.index[n.lines.bus0.str[:2] != n.lines.bus1.str[:2]] + n.lines.loc[cross_border_lines, "s_max_pu"] = s_max_pu + + if __name__ == "__main__": if "snakemake" not in globals(): snakemake = mock_snakemake( @@ -1354,4 +1362,9 @@ def scale_capacity(n, scaling): sanitize_custom_columns(n) + if current_year in snakemake.params.limit_cross_border_flows_ac: + limit_cross_border_flows_ac( + n, snakemake.params.limit_cross_border_flows_ac[current_year] + ) + n.export_to_netcdf(snakemake.output.network) From 557dd4c410aaed024a1f205f28f6f2e9100c109f Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 15:50:24 +0100 Subject: [PATCH 10/20] rename script --- Snakefile | 8 ++++---- ...y_industry_demand.py => modify_industry_production.py} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename scripts/pypsa-de/{modify_industry_demand.py => modify_industry_production.py} (99%) diff --git a/Snakefile b/Snakefile index 528c7dab1..48c48bcc8 100644 --- a/Snakefile +++ b/Snakefile @@ -698,7 +698,7 @@ rule modify_prenetwork: "scripts/pypsa-de/modify_prenetwork.py" -ruleorder: modify_industry_demand > build_industrial_production_per_country_tomorrow +ruleorder: modify_industry_production > build_industrial_production_per_country_tomorrow rule modify_existing_heating: @@ -743,7 +743,7 @@ rule build_existing_chp_de: "scripts/pypsa-de/build_existing_chp_de.py" -rule modify_industry_demand: +rule modify_industry_production: params: reference_scenario=config_provider("pypsa-de", "reference_scenario"), input: @@ -758,9 +758,9 @@ rule modify_industry_demand: resources: mem_mb=1000, log: - logs("modify_industry_demand_{planning_horizons}.log"), + logs("modify_industry_production_{planning_horizons}.log"), script: - "scripts/pypsa-de/modify_industry_demand.py" + "scripts/pypsa-de/modify_industry_production.py" rule build_wasserstoff_kernnetz: diff --git a/scripts/pypsa-de/modify_industry_demand.py b/scripts/pypsa-de/modify_industry_production.py similarity index 99% rename from scripts/pypsa-de/modify_industry_demand.py rename to scripts/pypsa-de/modify_industry_production.py index 1212ddda2..24a2822e1 100644 --- a/scripts/pypsa-de/modify_industry_demand.py +++ b/scripts/pypsa-de/modify_industry_production.py @@ -28,7 +28,7 @@ if __name__ == "__main__": if "snakemake" not in globals(): snakemake = mock_snakemake( - "modify_industry_demand", + "modify_industry_production", simpl="", clusters=22, opts="", From b6bea90cf3ea36cacfa865e0449aab046d71e40a Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 16:17:01 +0100 Subject: [PATCH 11/20] add UBA industry data and processing, activate it in 2025; activate UBA for mobility in 2025 --- .gitignore | 6 - CHANGELOG.md | 2 + Snakefile | 11 ++ config/config.de.yaml | 9 +- ...rojektionsbericht2025_Abbildung31_MWMS.csv | 6 + scripts/pypsa-de/modify_prenetwork.py | 128 ++++++++++++++++++ 6 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 data/pypsa-de/UBA_Projektionsbericht2025_Abbildung31_MWMS.csv diff --git a/.gitignore b/.gitignore index c82d60353..fbbf13ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -28,12 +28,6 @@ gurobi.log # Allowlist a small subset under data/pypsa-de. # Important: parent directories must be un-ignored for negations to take effect. !/data/pypsa-de/ -/data/pypsa-de/* -!/data/pypsa-de/custom_costs_nep_2021.csv -!/data/pypsa-de/custom_costs_nep_2023.csv -!/data/pypsa-de/offshore_connection_points.csv -!/data/pypsa-de/wasserstoff_kernnetz/ -!/data/pypsa-de/wasserstoff_kernnetz/** /cutouts /tmp doc/_build diff --git a/CHANGELOG.md b/CHANGELOG.md index cba950fda..11b3ffa41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Changelog +- Added an option to source industry energy demand from UBA MWMS (Projektionsbericht 2025) for the years 2025-2035 +- renamed some scripts - Upstream: PyPSA-Eur adopted a new functionality for overwriting costs. PyPSA-DE follows this convention now. As a consequence, the `costs:horizon:optimist/mean/pessimist` is no longer available. `Mean` will be provided exclusively from now on. Also, the costs assumptions for onwind turbines changed sligthly. Furthermore, "costs:NEP_year:2021/2023" is no longer available, instead one of the custom_cost files for these NEP years provided in the `data/pypsa-de` folder has to be specified. - The `ariadne-data` folder has been moved and renamed to `data/pypsa-de` to conform with the syntax of `scripts/pypsa-de` - Bugfix: Enforce stricter power import limit to avoid that import from one country compensate from exports to another diff --git a/Snakefile b/Snakefile index 48c48bcc8..68eb57bbd 100644 --- a/Snakefile +++ b/Snakefile @@ -654,6 +654,10 @@ rule modify_prenetwork: bev_charge_rate=config_provider("sector", "bev_charge_rate"), bev_energy=config_provider("sector", "bev_energy"), bev_dsm_availability=config_provider("sector", "bev_dsm_availability"), + uba_for_industry=config_provider("pypsa-de", "uba_for_industry", "enable"), + scale_industry_non_energy=config_provider( + "pypsa-de", "uba_for_industry", "scale_industry_non_energy" + ), limit_cross_border_flows_ac=config_provider( "pypsa-de", "limit_cross_border_flows_ac" ), @@ -678,6 +682,12 @@ rule modify_prenetwork: industrial_demand=resources( "industrial_energy_demand_base_s_{clusters}_{planning_horizons}.csv" ), + industrial_production_per_country_tomorrow=resources( + "industrial_production_per_country_tomorrow_{planning_horizons}-modified.csv" + ), + industry_sector_ratios=resources( + "industry_sector_ratios_{planning_horizons}.csv" + ), pop_weighted_energy_totals=resources( "pop_weighted_energy_totals_s_{clusters}.csv" ), @@ -685,6 +695,7 @@ rule modify_prenetwork: regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"), regions_offshore=resources("regions_offshore_base_s_{clusters}.geojson"), offshore_connection_points="data/pypsa-de/offshore_connection_points.csv", + new_industrial_energy_demand="data/pypsa-de/UBA_Projektionsbericht2025_Abbildung31_MWMS.csv", output: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_final.nc" diff --git a/config/config.de.yaml b/config/config.de.yaml index 92eae45a7..c6b0d3231 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -4,7 +4,7 @@ # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#run run: - prefix: 20251017_improve_power_limits + prefix: 20260114_limit_cross_border_flows name: # - ExPol - KN2045_Mix @@ -36,7 +36,12 @@ pypsa-de: reference_scenario: KN2045_Mix region: Deutschland ageb_for_mobility: true # In 2020 use AGEB data for final energy demand and KBA for vehicles - uba_for_mobility: false # For 2025–2035 use MWMS scenario from UBA Projektionsbericht 2025 + uba_for_mobility: # Available for 2025–2035; uses MWMS scenario from UBA Projektionsbericht 2025 + - 2025 + uba_for_industry: # Available for 2025–2035; uses MWMS scenario from UBA Projektionsbericht 2025 + scale_non_energy: false # Scale non-energy demand directly proportional to energy demand + enable: # Allowed values are "false" or a subset of [2025, 2030, 2035] + - 2025 limit_cross_border_flows_ac: # relevant if only one node per country is used 2020: 0.4 2025: 0.4 diff --git a/data/pypsa-de/UBA_Projektionsbericht2025_Abbildung31_MWMS.csv b/data/pypsa-de/UBA_Projektionsbericht2025_Abbildung31_MWMS.csv new file mode 100644 index 000000000..0fb68f5f1 --- /dev/null +++ b/data/pypsa-de/UBA_Projektionsbericht2025_Abbildung31_MWMS.csv @@ -0,0 +1,6 @@ +carrier,2025,2030,2035 +fossil,324,258,191 +industry electricity,211,234,249 +solid biomass for industry,31,35,31 +H2 for industry,0,6,42 +low-temperature heat for industry,48,59,63 \ No newline at end of file diff --git a/scripts/pypsa-de/modify_prenetwork.py b/scripts/pypsa-de/modify_prenetwork.py index da64b9448..fb9c58085 100644 --- a/scripts/pypsa-de/modify_prenetwork.py +++ b/scripts/pypsa-de/modify_prenetwork.py @@ -917,6 +917,120 @@ def modify_mobility_demand(n, mobility_data_file): n.stores.loc[dsm.index, "e_nom"] *= scale_factor +def modify_industry_demand( + n, + year, + industry_energy_demand_file, + industry_production_file, + sector_ratios_file, + scale_non_energy=False, +): + logger.info("Modifying industry demand in Germany.") + + industry_production = pd.read_csv( + industry_production_file, + index_col="kton/a", + ).rename_axis("country") + + sector_ratios = pd.read_csv( + sector_ratios_file, + header=[0, 1], + index_col=0, + ).rename_axis("carrier") + + new_demand = pd.read_csv( + industry_energy_demand_file, + index_col=0, + )[str(year)].mul(1e6) + + subcategories = ["HVC", "Methanol", "Chlorine", "Ammonia"] + carrier = ["hydrogen", "methane", "naphtha"] + + ip = industry_production.loc["DE", subcategories] # kt/a + sr = sector_ratios["DE"].loc[carrier, subcategories] # MWh/tMaterial + _non_energy = sr.multiply(ip).sum(axis=1) * 1e3 + + non_energy = pd.Series( + { + "industry electricity": 0.0, + "low-temperature heat for industry": 0.0, + "solid biomass for industry": 0.0, + "H2 for industry": _non_energy["hydrogen"], + "coal for industry": 0.0, + "gas for industry": _non_energy["methane"], + "naphtha for industry": _non_energy["naphtha"], + } + ) + + _industry_loads = [ + "solid biomass for industry", + "gas for industry", + "H2 for industry", + "industry methanol", + "naphtha for industry", + "low-temperature heat for industry", + "industry electricity", + "coal for industry", + ] + industry_loads = n.loads.query( + f"carrier in {_industry_loads} and bus.str.startswith('DE')" + ) + + if scale_non_energy: + new_demand_without_non_energy = new_demand.sum() + pypsa_industry_without_non_energy = ( + industry_loads.p_set.sum() * 8760 - non_energy.sum() + ) + non_energy_scaling_factor = ( + new_demand_without_non_energy / pypsa_industry_without_non_energy + ) + logger.info( + f"Scaling non-energy use by {non_energy_scaling_factor:.2f} to match UBA data." + ) + non_energy_corrected = non_energy * non_energy_scaling_factor + else: + non_energy_corrected = non_energy + + for carrier in [ + "industry electricity", + # "H2 for industry", # skip because UBA is too optimistic on H2 + "solid biomass for industry", + "low-temperature heat for industry", + ]: + loads_i = n.loads.query( + f"carrier == '{carrier}' and bus.str.startswith('DE')" + ).index + logger.info( + f"Total load of {carrier} in DE before scaling: {n.loads.loc[loads_i, 'p_set'].sum() * 8760:.2f} MWh/a" + ) + total_load = industry_loads.p_set.loc[loads_i].sum() * 8760 + scaling_factor = ( + new_demand[carrier] + non_energy_corrected[carrier] + ) / total_load + n.loads.loc[loads_i, "p_set"] *= scaling_factor + logger.info( + f"Total load of {carrier} in DE after scaling: {n.loads.loc[loads_i, 'p_set'].sum() * 8760:.2f} MWh/a" + ) + + # Fossil fuels are aggregated in UBA MWMS but have to be scaled separately + fossil_loads = industry_loads.query("carrier.str.contains('gas|coal|naphtha')") + fossil_totals = ( + fossil_loads[["p_set", "carrier"]].groupby("carrier").p_set.sum() * 8760 + ) + fossil_energy = fossil_totals - non_energy[fossil_totals.index] + fossil_energy_corrected = fossil_energy * new_demand["fossil"] / fossil_energy.sum() + fossil_totals_corrected = ( + fossil_energy_corrected + non_energy_corrected[fossil_totals.index] + ) + for carrier in fossil_totals.index: + loads_i = fossil_loads.query( + f"carrier == '{carrier}' and bus.str.startswith('DE')" + ).index + n.loads.loc[loads_i, "p_set"] *= ( + fossil_totals_corrected[carrier] / fossil_totals[carrier] + ) + + def add_hydrogen_turbines(n): """ This adds links that instead of a gas turbine use a hydrogen turbine. @@ -1362,6 +1476,20 @@ def limit_cross_border_flows_ac(n, s_max_pu): sanitize_custom_columns(n) + if current_year in snakemake.params.uba_for_industry: + if current_year not in [2025, 2030, 2035]: + logger.error( + "The UBA for industry data is only available for 2025, 2030 and 2035. Please check your config." + ) + modify_industry_demand( + n, + current_year, + snakemake.input.new_industrial_energy_demand, + snakemake.input.industrial_production_per_country_tomorrow, + snakemake.input.industry_sector_ratios, + scale_non_energy=snakemake.params.scale_industry_non_energy, + ) + if current_year in snakemake.params.limit_cross_border_flows_ac: limit_cross_border_flows_ac( n, snakemake.params.limit_cross_border_flows_ac[current_year] From a71ee0ae8d5d4eccda24e955311069921396d528 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Wed, 14 Jan 2026 17:45:14 +0100 Subject: [PATCH 12/20] improve uba mobility data pipeline and technology occurence definition --- scripts/pypsa-de/build_exogenous_mobility_data.py | 10 +++++----- scripts/pypsa-de/modify_prenetwork.py | 9 +++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/scripts/pypsa-de/build_exogenous_mobility_data.py b/scripts/pypsa-de/build_exogenous_mobility_data.py index fb2d3a44b..4d80a0ca4 100644 --- a/scripts/pypsa-de/build_exogenous_mobility_data.py +++ b/scripts/pypsa-de/build_exogenous_mobility_data.py @@ -17,7 +17,7 @@ def get_mobility_data( year, non_land_liquids, ageb_for_mobility=True, - uba_for_mobility=False, + uba_for_mobility="", ): """ Retrieve the German mobility demand from the transport model. @@ -67,7 +67,7 @@ def get_mobility_data( # FZ27_202101, table FZ 27.2, 1. January 2021: mobility_data["million_EVs"] = 0.358498 + 0.280149 - elif year == "2025" and uba_for_mobility: + elif year == "2025" and int(year) in uba_for_mobility: # https://www.umweltbundesamt.de/sites/default/files/medien/11850/publikationen/projektionsbericht_2025.pdf, Abbildung 64 & 59, mobility_data = pd.Series( { @@ -82,7 +82,7 @@ def get_mobility_data( mobility_data = mobility_data.mul(1e6) # convert TWh to MWh mobility_data["million_EVs"] = 2.7 + 1.2 # BEV + PHEV - elif year == "2030" and uba_for_mobility: + elif year == "2030" and int(year) in uba_for_mobility: mobility_data = pd.Series( { "Electricity": 57.0, @@ -94,7 +94,7 @@ def get_mobility_data( mobility_data = mobility_data.mul(1e6) mobility_data["million_EVs"] = 8.7 + 1.8 - elif year == "2035" and uba_for_mobility: + elif year == "2035" and int(year) in uba_for_mobility: mobility_data = pd.Series( { "Electricity": 117.0, @@ -107,7 +107,7 @@ def get_mobility_data( mobility_data["million_EVs"] = 18.9 + 1.8 else: - if uba_for_mobility: + if int(year) in uba_for_mobility: # here year > 2035 logger.error( f"Year {year} is not supported for UBA mobility projections. Please use only 2020, 2025, 2030, 2035." ) diff --git a/scripts/pypsa-de/modify_prenetwork.py b/scripts/pypsa-de/modify_prenetwork.py index fb9c58085..51e66c002 100644 --- a/scripts/pypsa-de/modify_prenetwork.py +++ b/scripts/pypsa-de/modify_prenetwork.py @@ -21,15 +21,16 @@ def first_technology_occurrence(n): """ - Sets p_nom_extendable to false for carriers with configured first - occurrence if investment year is before configured year. + Drop configured technologies before configured year. """ for c, carriers in snakemake.params.technology_occurrence.items(): for carrier, first_year in carriers.items(): if int(snakemake.wildcards.planning_horizons) < first_year: - logger.info(f"{carrier} not extendable before {first_year}.") - n.df(c).loc[n.df(c).carrier == carrier, "p_nom_extendable"] = False + to_drop = n.df(c).query(f"carrier == '{carrier}'").index + if to_drop.empty: + continue + n.remove(c, to_drop) def fix_new_boiler_profiles(n): From 6a0aeeeadd7d703b848e74dce07d60520028eaa0 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 15 Jan 2026 15:57:24 +0100 Subject: [PATCH 13/20] national co2 budget: apply domestic factor for shipping --- scripts/pypsa-de/additional_functionality.py | 67 ++++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/scripts/pypsa-de/additional_functionality.py b/scripts/pypsa-de/additional_functionality.py index 0f52e7729..1524294a7 100644 --- a/scripts/pypsa-de/additional_functionality.py +++ b/scripts/pypsa-de/additional_functionality.py @@ -416,6 +416,7 @@ def add_national_co2_budgets(n, snakemake, national_co2_budgets, investment_year nyears = nhours / 8760 sectors = determine_emission_sectors(n.config["sector"]) + energy_totals = pd.read_csv(snakemake.input.energy_totals, index_col=[0, 1]) # convert MtCO2 to tCO2 co2_totals = 1e6 * pd.read_csv(snakemake.input.co2_totals_name, index_col=0) @@ -427,9 +428,6 @@ def add_national_co2_budgets(n, snakemake, national_co2_budgets, investment_year logger.error( f"CO2 budget for countries other than `DE` is not yet supported. Found country {ct}. Please check the config file." ) - raise NotImplementedError( - f"CO2 budget for countries other than `DE` is not yet supported. Found country {ct}. Please check the config file." - ) limit = co2_total_totals[ct] * national_co2_budgets[ct][investment_year] logger.info( @@ -443,8 +441,8 @@ def add_national_co2_budgets(n, snakemake, national_co2_budgets, investment_year links = n.links.index[ (n.links.index.str[:2] == ct) & (n.links[f"bus{port}"] == "co2 atmosphere") - & ( - n.links.carrier != "kerosene for aviation" + & ~n.links.carrier.str.contains( + "shipping|aviation" ) # first exclude aviation to multiply it with a domestic factor later ] @@ -468,27 +466,68 @@ def add_national_co2_budgets(n, snakemake, national_co2_budgets, investment_year ) # Aviation demand - energy_totals = pd.read_csv(snakemake.input.energy_totals, index_col=[0, 1]) domestic_aviation = energy_totals.loc[ (ct, snakemake.params.energy_year), "total domestic aviation" ] international_aviation = energy_totals.loc[ (ct, snakemake.params.energy_year), "total international aviation" ] - domestic_factor = domestic_aviation / ( + domestic_aviation_factor = domestic_aviation / ( domestic_aviation + international_aviation ) aviation_links = n.links[ (n.links.index.str[:2] == ct) & (n.links.carrier == "kerosene for aviation") ] - lhs.append - ( - n.model["Link-p"].loc[:, aviation_links.index] - * aviation_links.efficiency2 - * n.snapshot_weightings.generators - ).sum() * domestic_factor + lhs.append( + ( + n.model["Link-p"].loc[:, aviation_links.index] + * aviation_links.efficiency2 + * n.snapshot_weightings.generators + ).sum() + * domestic_aviation_factor + ) + logger.info( + f"Adding domestic aviation emissions for {ct} with a factor of {domestic_aviation_factor}" + ) + + # Shipping oil + domestic_navigation = energy_totals.loc[ + (ct, snakemake.params.energy_year), "total domestic navigation" + ] + international_navigation = energy_totals.loc[ + (ct, snakemake.params.energy_year), "total international navigation" + ] + domestic_navigation_factor = domestic_navigation / ( + domestic_navigation + international_navigation + ) + shipping_links = n.links[ + (n.links.index.str[:2] == ct) & (n.links.carrier == "shipping oil") + ] + lhs.append( + ( + n.model["Link-p"].loc[:, shipping_links.index] + * shipping_links.efficiency2 + * n.snapshot_weightings.generators + ).sum() + * domestic_navigation_factor + ) + + # Shipping methanol + shipping_meoh_links = n.links[ + (n.links.index.str[:2] == ct) & (n.links.carrier == "shipping methanol") + ] + if not shipping_meoh_links.empty: # no shipping methanol in 2025 + lhs.append( + ( + n.model["Link-p"].loc[:, shipping_meoh_links.index] + * shipping_meoh_links.efficiency2 + * n.snapshot_weightings.generators + ).sum() + * domestic_navigation_factor + ) + logger.info( - f"Adding domestic aviation emissions for {ct} with a factor of {domestic_factor}" + f"Adding domestic shipping emissions for {ct} with a factor of {domestic_navigation_factor}" ) # Adding Efuel imports and exports to constraint From e27d602075a4d687020d626a981819ea206f07ab Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 15 Jan 2026 16:33:12 +0100 Subject: [PATCH 14/20] address pylint errors --- scripts/add_existing_baseyear.py | 64 +++++++++++++++++++++++-------- scripts/prepare_sector_network.py | 3 ++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index 08797a2e0..0c25fed5b 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -156,10 +156,13 @@ def add_power_capacities_installed_before_baseyear( grouping_years: list[int], baseyear: int, powerplants_file: str, + custom_powerplants_file: str, countries: list[str], capacity_threshold: float, lifetime_values: dict[str, float], + lifetime_gas_chp: int, renewable_carriers: list[str], + options: dict, ) -> None: """ Add power generation capacities installed before base year. @@ -176,23 +179,25 @@ def add_power_capacities_installed_before_baseyear( Base year for analysis powerplants_file : str Path to powerplants CSV file + custom_powerplants_file : str + Path to custom powerplants CSV file countries : list List of countries to consider capacity_threshold : float Minimum capacity threshold lifetime_values : dict Default values for missing data - renewable_carriers: list + lifetime_gas_chp: int, + Lifetime for gas CHPs if missing + renewable_carriers: list[str] List of renewable carriers in the network + options: dict, """ logger.debug(f"Adding power capacities installed before {baseyear}") df_agg = pd.read_csv(powerplants_file, index_col=0) - - if snakemake.input.get("custom_powerplants"): - df_agg = add_custom_powerplants( - df_agg, snakemake.input.custom_powerplants, True - ) + if custom_powerplants_file: + df_agg = add_custom_powerplants(df_agg, custom_powerplants_file, True) rename_fuel = { "Hard Coal": "coal", @@ -258,7 +263,18 @@ def add_power_capacities_installed_before_baseyear( ) # add chp plants - add_chp_plants(n, grouping_years, costs, baseyear) + add_chp_plants( + n, + grouping_years, + costs, + baseyear, + powerplants_file, + custom_powerplants_file, + lifetime_values, + lifetime_gas_chp, + capacity_threshold, + options, + ) # drop assets which are already phased out / decommissioned phased_out = df_agg[df_agg["DateOut"] < baseyear].index @@ -484,7 +500,18 @@ def add_power_capacities_installed_before_baseyear( ] -def add_chp_plants(n, grouping_years, costs, baseyear): +def add_chp_plants( + n, + grouping_years, + costs, + baseyear, + powerplants_file, + custom_powerplants_file, + lifetime_values, + lifetime_gas_chp, + capacity_threshold, + options, +): # rename fuel of CHPs - lignite not in DEA database rename_fuel = { "Hard Coal": "coal", @@ -495,13 +522,13 @@ def add_chp_plants(n, grouping_years, costs, baseyear): "Oil": "oil", } - ppl = pd.read_csv(snakemake.input.powerplants, index_col=0) + ppl = pd.read_csv(powerplants_file, index_col=0) - if snakemake.input.get("custom_powerplants"): - if snakemake.input.custom_powerplants.endswith("german_chp_{clusters}.csv"): + if custom_powerplants_file: + if custom_powerplants_file.endswith("german_chp_{clusters}.csv"): logger.info("Supersedeing default German CHPs with custom_powerplants.") ppl = ppl.query("~(Set == 'CHP' and Country == 'DE')") - ppl = add_custom_powerplants(ppl, snakemake.input.custom_powerplants, True) + ppl = add_custom_powerplants(ppl, custom_powerplants_file, True) # drop assets which are already phased out / decommissioned # drop hydro, waste and oil fueltypes for CHP @@ -519,11 +546,11 @@ def add_chp_plants(n, grouping_years, costs, baseyear): grouping_years, np.digitize(chp.DateIn, grouping_years, right=True) ) chp["lifetime"] = (chp.DateOut - chp["grouping_year"] + 1).fillna( - snakemake.params.costs["fill_values"]["lifetime"] + lifetime_values["lifetime"] ) chp.loc[chp.Fueltype == "gas", "lifetime"] = ( chp.DateOut - chp["grouping_year"] + 1 - ).fillna(snakemake.params.existing_capacities["fill_value_gas_chp_lifetime"]) + ).fillna(lifetime_gas_chp) chp = chp.loc[ chp.grouping_year + chp.lifetime > baseyear @@ -611,7 +638,7 @@ def add_chp_plants(n, grouping_years, costs, baseyear): for grouping_year, generator in mastr_chp_p_nom.index: # capacity is the capacity in MW at each node for this p_nom = mastr_chp_p_nom.loc[grouping_year, generator] - threshold = snakemake.params.existing_capacities["threshold_capacity"] + threshold = capacity_threshold p_nom = p_nom[p_nom > threshold] efficiency_power = mastr_chp_efficiency_power.loc[grouping_year, generator] @@ -701,7 +728,7 @@ def add_chp_plants(n, grouping_years, costs, baseyear): ) for grouping_year, generator in chp_nodal_p_nom.index: p_nom = chp_nodal_p_nom.loc[grouping_year, generator] - threshold = snakemake.params.existing_capacities["threshold_capacity"] + threshold = capacity_threshold p_nom = p_nom[p_nom > threshold] lifetime = chp_nodal_lifetime.loc[grouping_year, generator] @@ -1156,10 +1183,15 @@ def add_heating_capacities_installed_before_baseyear( grouping_years=grouping_years_power, baseyear=baseyear, powerplants_file=snakemake.input.powerplants, + custom_powerplants_file=snakemake.input.get("custom_powerplants", ""), countries=snakemake.config["countries"], capacity_threshold=snakemake.params.existing_capacities["threshold_capacity"], lifetime_values=snakemake.params.costs["fill_values"], + lifetime_gas_chp=snakemake.params.existing_capacities[ + "fill_value_gas_chp_lifetime" + ], renewable_carriers=renewable_carriers, + options=options, ) if options["heating"]: diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index d860549a5..3378e2db7 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2423,6 +2423,7 @@ def add_EVs( temperature: pd.DataFrame, spatial: SimpleNamespace, options: dict, + investment_year: int, ) -> None: """ Add electric vehicle (EV) infrastructure to the network. @@ -2463,6 +2464,7 @@ def add_EVs( - bev_energy: float - bev_dsm_availability: float - v2g: bool + investment_year: int Returns ------- @@ -2872,6 +2874,7 @@ def add_land_transport( temperature, spatial, options, + investment_year, ) if shares["fuel_cell"] > 0: From 917dcf67ab1ca5581c5ecddc55bc9321541729d9 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 15 Jan 2026 17:07:21 +0100 Subject: [PATCH 15/20] update assumptions for nonco2 in 2020 and 2025 --- scripts/pypsa-de/build_scenarios.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/scripts/pypsa-de/build_scenarios.py b/scripts/pypsa-de/build_scenarios.py index 1abe76fb1..77d4fd6bf 100644 --- a/scripts/pypsa-de/build_scenarios.py +++ b/scripts/pypsa-de/build_scenarios.py @@ -91,18 +91,8 @@ def get_co2_budget(df, source): ) else: raise ValueError("Invalid source for CO2 budget.") - ## Compute nonco2 from Ariadne-Leitmodell (REMIND) - # co2 = ( - # df.loc["Emissions|CO2 incl Bunkers","Mt CO2/yr"] - # - df.loc["Emissions|CO2|Land-Use Change","Mt CO2-equiv/yr"] - # - df.loc["Emissions|CO2|Energy|Demand|Bunkers","Mt CO2/yr"] - # ) - # ghg = ( - # df.loc["Emissions|Kyoto Gases","Mt CO2-equiv/yr"] - # - df.loc["Emissions|Kyoto Gases|Land-Use Change","Mt CO2-equiv/yr"] - # # No Kyoto Gas emissions for Bunkers recorded in Ariadne DB - # ) + ## Compute nonco2 from Ariadne-Leitmodell (REMIND) try: co2_land_use_change = df.loc["Emissions|CO2|Land-Use Change", "Mt CO2-equiv/yr"] @@ -123,14 +113,20 @@ def get_co2_budget(df, source): nonco2 = ghg - co2 + # Hard-code values for 2020 and 2025 from UBA reports / projections + # Table 1, https://reportnet.europa.eu/public/dataflow/1478, GHG - CO2 + nonco2[2020] = 733.7 - 647.9 + nonco2[2025] = 628.8 - 554.6 + ## PyPSA disregards nonco2 GHG emissions, but includes bunkers targets_pypsa = targets_co2 - nonco2 + logger.info("Non-CO2 GHG emissions assumed (in Mt CO2-equiv/yr):") + for year in nonco2.index: + logger.info(f"{year}: {nonco2.loc[year]:.1f}") + target_fractions_pypsa = targets_pypsa.loc[targets_co2.index] / baseline_pypsa - target_fractions_pypsa[2020] = ( - 0.671 # Hard-coded based on REMIND data from ariadne2-internal DB - ) return target_fractions_pypsa.round(3) From 6bc83715be4a6bfe6ea172d7345606e19f9f8e0c Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Fri, 16 Jan 2026 18:06:38 +0100 Subject: [PATCH 16/20] add an entry for Bus: to first_tech_occurence to avoid empty buses --- config/config.de.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.de.yaml b/config/config.de.yaml index c6b0d3231..f06ad6e69 100644 --- a/config/config.de.yaml +++ b/config/config.de.yaml @@ -278,6 +278,8 @@ first_technology_occurrence: urban central water pits discharger: 2035 Store: urban central water pits: 2035 + Bus: + urban central water pits: 2035 costs: custom_cost_fn: data/pypsa-de/custom_costs_nep_2023.csv transmission: "overhead" # either overhead line ("overhead") or underground cable ("underground") From 75e2dd3acbef8b92256fd4dbd89ad3f3e9e18c2b Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 29 Jan 2026 15:03:57 +0100 Subject: [PATCH 17/20] fix custom_costs scaling --- scripts/process_cost_data.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/process_cost_data.py b/scripts/process_cost_data.py index 7f56b3027..3c602c2d4 100644 --- a/scripts/process_cost_data.py +++ b/scripts/process_cost_data.py @@ -115,6 +115,13 @@ def prepare_costs( index_col=["technology", "parameter"], ).query("planning_horizon in [@planning_horizon, 'all']") + # correct units to MW and EUR + custom_costs.loc[custom_costs.unit.str.contains("/kW"), "value"] *= 1e3 + custom_costs.loc[custom_costs.unit.str.contains("/GW"), "value"] /= 1e3 + + custom_costs.unit = custom_costs.unit.str.replace("/kW", "/MW") + custom_costs.unit = custom_costs.unit.str.replace("/GW", "/MW") + custom_costs = custom_costs.drop("planning_horizon", axis=1).value.unstack( level=1 ) From 0db8d83497d372011970fccc778a9d54245ae5a8 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 29 Jan 2026 15:52:10 +0100 Subject: [PATCH 18/20] avoid code duplication --- scripts/process_cost_data.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/scripts/process_cost_data.py b/scripts/process_cost_data.py index 3c602c2d4..f11afccb8 100644 --- a/scripts/process_cost_data.py +++ b/scripts/process_cost_data.py @@ -105,6 +105,16 @@ def prepare_costs( DataFrame containing the prepared cost data """ + + def _convert_to_MW(cost_df: pd.DataFrame) -> pd.DataFrame: + # correct units to MW and EUR + cost_df.loc[cost_df.unit.str.contains("/kW"), "value"] *= 1e3 + cost_df.loc[cost_df.unit.str.contains("/GW"), "value"] /= 1e3 + + cost_df.unit = cost_df.unit.str.replace("/kW", "/MW") + cost_df.unit = cost_df.unit.str.replace("/GW", "/MW") + return cost_df + # Load custom costs and categorize into two sets: # - Raw attributes: overwritten before cost preparation # - Prepared attributes: overwritten after cost preparation @@ -115,16 +125,8 @@ def prepare_costs( index_col=["technology", "parameter"], ).query("planning_horizon in [@planning_horizon, 'all']") - # correct units to MW and EUR - custom_costs.loc[custom_costs.unit.str.contains("/kW"), "value"] *= 1e3 - custom_costs.loc[custom_costs.unit.str.contains("/GW"), "value"] /= 1e3 - - custom_costs.unit = custom_costs.unit.str.replace("/kW", "/MW") - custom_costs.unit = custom_costs.unit.str.replace("/GW", "/MW") + custom_costs = _convert_to_MW(custom_costs) - custom_costs = custom_costs.drop("planning_horizon", axis=1).value.unstack( - level=1 - ) prepared_attrs = ["marginal_cost", "capital_cost"] raw_attrs = list(set(custom_costs.columns) - set(prepared_attrs)) custom_raw = custom_costs[raw_attrs].dropna(axis=0, how="all") @@ -135,12 +137,7 @@ def prepare_costs( if key in config: config["overwrites"][key] = config[key] - # correct units to MW and EUR - costs.loc[costs.unit.str.contains("/kW"), "value"] *= 1e3 - costs.loc[costs.unit.str.contains("/GW"), "value"] /= 1e3 - - costs.unit = costs.unit.str.replace("/kW", "/MW") - costs.unit = costs.unit.str.replace("/GW", "/MW") + costs = _convert_to_MW(costs) # min_count=1 is important to generate NaNs which are then filled by fillna costs = costs.value.unstack(level=1).groupby("technology").sum(min_count=1) From 5471b7d10ba1196085aeb44b3fc83d0d38428dce Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Thu, 29 Jan 2026 16:08:53 +0100 Subject: [PATCH 19/20] minor fix --- scripts/process_cost_data.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/process_cost_data.py b/scripts/process_cost_data.py index f11afccb8..ed727a36a 100644 --- a/scripts/process_cost_data.py +++ b/scripts/process_cost_data.py @@ -127,6 +127,10 @@ def _convert_to_MW(cost_df: pd.DataFrame) -> pd.DataFrame: custom_costs = _convert_to_MW(custom_costs) + custom_costs = custom_costs.drop("planning_horizon", axis=1).value.unstack( + level=1 + ) + prepared_attrs = ["marginal_cost", "capital_cost"] raw_attrs = list(set(custom_costs.columns) - set(prepared_attrs)) custom_raw = custom_costs[raw_attrs].dropna(axis=0, how="all") From db2876caa94463ff11778655fed4aea733a791b6 Mon Sep 17 00:00:00 2001 From: Michael Lindner Date: Fri, 30 Jan 2026 14:21:55 +0100 Subject: [PATCH 20/20] add a little documentation of changes --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b3ffa41..5ffeb4ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # Changelog +- added 2 delay years to the offshore NEP, s.t. capacity in 2030 is ~20GW +- improved script name `modify_industry_demand` -> `modify_industry_production` +- excluded international shipping from DE-specific CO2 emisisons +- improved land transport shares, and some RES constraints +- disabled ptes before 2035 +- add function to limit cross border electricity flows, to comply with the 0.7 security margin and the 0.7 EU trade capacity goals - Added an option to source industry energy demand from UBA MWMS (Projektionsbericht 2025) for the years 2025-2035 - renamed some scripts - Upstream: PyPSA-Eur adopted a new functionality for overwriting costs. PyPSA-DE follows this convention now. As a consequence, the `costs:horizon:optimist/mean/pessimist` is no longer available. `Mean` will be provided exclusively from now on. Also, the costs assumptions for onwind turbines changed sligthly. Furthermore, "costs:NEP_year:2021/2023" is no longer available, instead one of the custom_cost files for these NEP years provided in the `data/pypsa-de` folder has to be specified.