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..5ffeb4ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # 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. - 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 f841e7e8b..68eb57bbd 100644 --- a/Snakefile +++ b/Snakefile @@ -654,6 +654,13 @@ 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" + ), input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_brownfield.nc" @@ -675,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" ), @@ -682,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" @@ -695,7 +709,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: @@ -740,7 +754,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: @@ -755,9 +769,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/config/config.de.yaml b/config/config.de.yaml index f872c2c35..f06ad6e69 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,20 @@ 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 + 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 @@ -261,7 +274,12 @@ 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 + 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") @@ -336,44 +354,30 @@ 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: 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 @@ -394,22 +398,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 @@ -468,7 +456,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 +466,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 @@ -502,6 +490,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: @@ -530,21 +521,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: @@ -601,8 +591,8 @@ solving: # boundary condition lower? DE: 2020: 0 - 2025: 0 - 2030: 10 + 2025: 6 + 2030: 20 2035: 105 2040: 200 2045: 300 @@ -720,7 +710,7 @@ onshore_nep_force: offshore_nep_force: cutin_year: 2025 cutout_year: 2030 - delay_years: 0 + delay_years: 2 scale_capacity: 2020: 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/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 diff --git a/scripts/process_cost_data.py b/scripts/process_cost_data.py index 7f56b3027..ed727a36a 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,9 +125,12 @@ def prepare_costs( index_col=["technology", "parameter"], ).query("planning_horizon in [@planning_horizon, 'all']") + 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") @@ -128,12 +141,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) 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 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/build_scenarios.py b/scripts/pypsa-de/build_scenarios.py index c891fd773..77d4fd6bf 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( @@ -85,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"] @@ -117,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) 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="", diff --git a/scripts/pypsa-de/modify_prenetwork.py b/scripts/pypsa-de/modify_prenetwork.py index fe147ae5d..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): @@ -917,6 +918,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. @@ -1277,6 +1392,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 +1477,23 @@ def scale_capacity(n, scaling): 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] + ) + n.export_to_netcdf(snakemake.output.network)