diff --git a/Snakefile b/Snakefile index 1536a0cc83..cf5fb74ce4 100644 --- a/Snakefile +++ b/Snakefile @@ -22,7 +22,6 @@ from scripts._helpers import ( configfile: "config/config.default.yaml" configfile: "config/plotting.default.yaml" configfile: "config/config.private.yaml" -configfile: "config/config.yaml" run = config["run"] @@ -78,9 +77,52 @@ if config["foresight"] == "perfect": include: "rules/solve_perfect.smk" +def input_all_tyndp(w): + files = [] + if config_provider("sector", "H2_network")(w): + files.extend( + expand( + ( + resources( + "maps/base_h2_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}.pdf" + ) + ), + run=config["run"]["name"], + **config["scenario"], + ) + ) + if config_provider("sector", "offshore_hubs_tyndp", "enable")(w): + files.extend( + expand( + ( + resources( + "maps/base_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}.pdf" + ) + ), + run=config["run"]["name"], + **config["scenario"], + carrier=config_provider("plotting", "offshore_maps", "bus_carriers")(w), + ) + ) + files.extend( + expand( + ( + RESULTS + + "maps/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-offshore_network_{carrier}.pdf" + ), + run=config["run"]["name"], + **config["scenario"], + carrier=config_provider("plotting", "offshore_maps", "bus_carriers")(w), + ) + ) + return files + + rule all: input: + input_all_tyndp, expand(RESULTS + "graphs/costs.svg", run=config["run"]["name"]), + expand(resources("maps/power-network.pdf"), run=config["run"]["name"]), expand( resources("maps/power-network-s-{clusters}.pdf"), run=config["run"]["name"], diff --git a/config/config.default.yaml b/config/config.default.yaml index 79667abe83..c5c8e92ca6 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -817,6 +817,11 @@ sector: methanol: 121 gas: 122 oil: 125 + offshore_hubs_tyndp: + enable: false + max_capacity: + DC_OH: 10 + H2 pipeline OH: 30 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#industry industry: diff --git a/config/config.tyndp.yaml b/config/config.tyndp.yaml index 45c07636fd..0dc36952a9 100644 --- a/config/config.tyndp.yaml +++ b/config/config.tyndp.yaml @@ -148,6 +148,21 @@ sector: enable: true carriers: - H2 + offshore_hubs_tyndp: + enable: true + max_capacity: + DC_OH: 10 + H2 pipeline OH: 30 + +costs: + overwrites: + lifetime: + electrolysis: 25 + HVDC submarine: 25 + H2 (g) submarine pipeline: 25 + offwind: 25 + efficiency: + electrolysis: 0.68 # ToDo Account for time-varying efficiencies, currently using fixed 2020 value from TYNDP 2024 clustering: mode: administrative diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index 1dfda4a009..98de8d3df8 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -241,6 +241,10 @@ plotting: - 200 - 100 branch_sizes: + offshore_maps: + bus_carriers: + - DC_OH + - H2 pipeline OH nice_names: OCGT: "Open-Cycle Gas" @@ -269,12 +273,20 @@ plotting: offwind-ac: "#6895dd" offshore wind (AC): "#6895dd" offshore wind ac: "#6895dd" + offwind-ac-fb-r: "#6895dd" + offwind-ac-fl-r: "#6da5e8" + offwind-dc-fb-r: "#71b5ed" offwind-dc: "#74c6f2" offshore wind (DC): "#74c6f2" offshore wind dc: "#74c6f2" + offwind-dc-fb-oh: "#74c6f2" + offwind-dc-fl-r: "#94d4f6" offwind-float: "#b5e2fa" offshore wind (Float): "#b5e2fa" offshore wind float: "#b5e2fa" + offwind-dc-fl-oh: "#b5e2fa" + offwind-h2-fb-oh: "#c47dbd" + offwind-h2-fl-oh: "#9d4d96" # water hydro: '#298c81' hydro reservoir: '#298c81' @@ -482,6 +494,7 @@ plotting: H2 for industry: "#f073da" H2 for shipping: "#ebaee0" H2: '#bf13a0' + H2_OH: '#bf13a0' hydrogen: '#bf13a0' retrofitted H2 boiler: '#e5a0d9' SMR: '#870c71' @@ -493,6 +506,7 @@ plotting: H2 storage: '#bf13a0' land transport fuel cell: '#6b3161' H2 pipeline: '#f081dc' + H2 pipeline OH: '#f081dc' H2 pipeline retrofitted: '#ba99b5' H2 import LH2: "#F28C28" H2 import Pipeline: "#FFA500" @@ -565,11 +579,14 @@ plotting: geothermal district heat: '#d19D00' geothermal organic rankine cycle: '#ffbf00' AC: "#70af1d" + AC_OH: "#70af1d" + AC_DRES: "#70af1d" AC-AC: "#70af1d" AC line: "#70af1d" links: "#8a1caf" HVDC links: "#8a1caf" DC: "#8a1caf" + DC_OH: "#8a1caf" DC-DC: "#8a1caf" DC link: "#8a1caf" load: "#dd2e23" diff --git a/config/test/config.tyndp.yaml b/config/test/config.tyndp.yaml index 9e781ec6e5..0d664cd2cd 100644 --- a/config/test/config.tyndp.yaml +++ b/config/test/config.tyndp.yaml @@ -33,7 +33,14 @@ enable: co2_budget: # TODO define carbon budget compliant with EU 2030 and 2050 targets - 2040: 0.25 # Temporary increase to allow CI to pass while load configurations are incomplete. + 2020: 0.720 # average emissions of 2019 to 2021 relative to 1990, CO2 excl LULUCF, EEA data, European Environment Agency. (2023a). Annual European Union greenhouse gas inventory 1990–2021 and inventory report 2023 - CRF Table. https://unfccc.int/documents/627830 + 2025: 0.648 # With additional measures (WAM) projection, CO2 excl LULUCF, European Environment Agency. (2023e). Member States’ greenhouse gas (GHG) emission projections 2023. https://www.eea.europa.eu/en/datahub/datahubitem-view/4b8d94a4-aed7-4e67-a54c-0623a50f48e8 + 2030: 0.450 # 55% reduction by 2030 (Ff55) + 2035: 0.250 + 2040: 0.100 # 90% by 2040 + 2045: 0.050 + 2050: 0.000 # climate-neutral by 2050 + electricity: base_network: tyndp-raw @@ -100,6 +107,7 @@ load: enable: false manual_adjustments: false supplement_synthetic: false + pypsa_eur: Bus: - AC @@ -166,6 +174,21 @@ sector: enable: true carriers: - H2 + offshore_hubs_tyndp: + enable: true + max_capacity: + DC_OH: 10 + H2 pipeline OH: 30 + +costs: + overwrites: + lifetime: + electrolysis: 25 + HVDC submarine: 25 + H2 (g) submarine pipeline: 25 + offwind: 25 + efficiency: + electrolysis: 0.68 # ToDo Account for time-varying efficiencies, currently using fixed 2020 value from TYNDP 2024 clustering: mode: administrative diff --git a/doc/configtables/sector.csv b/doc/configtables/sector.csv index db4f99b172..956965219f 100644 --- a/doc/configtables/sector.csv +++ b/doc/configtables/sector.csv @@ -229,4 +229,8 @@ imports,,, -- limit,TWh,float,Maximum allowed renewable energy imports -- limit_sense,--,"{==, <=, >=}",Sense of the limit -- price,,"{H2, NH3, methanol, gas, oil}", --- -- {carrier},currency/MWh,float,Price for importing renewable energy of carrier \ No newline at end of file +-- -- {carrier},currency/MWh,float,Price for importing renewable energy of carrier +offshore_hubs_tyndp,--,"{true, false}",Add options for TYNDP offshore hubs +-- enable,--,"{true, false}",Add option to include TYNDP offshore hubs +-- max_capacity,,, +-- -- {carrier},GW,float,Maximum transmission capacity between two offshore hubs of a carrier \ No newline at end of file diff --git a/doc/release_notes.rst b/doc/release_notes.rst index e02e6590a2..e6d8f6d3de 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -13,6 +13,8 @@ Release Notes **Changes** +* Introduce TYNDP offshore wind hubs via `sector:offshore_hubs_tyndp` configuration (https://github.com/open-energy-transition/open-tyndp/pull/54). This feature implements an offshore grid topology with both electric and hydrogen infrastructure, offshore electrolysers, and detailed wind farm characteristics. Three wind farm types are supported: AC-radial (ac-r), DC-radial (dc-r) and DC-hubs (dc-oh). Each is compatible with fixed-bottom (fb) or floating (fl) foundations. Wind farms connected to hubs can produce hydrogen directly through a dedicated P2G unit, while electricity can be supplied to the network or converted to hydrogen via offshore electrolysers connected to the hubs. The network includes existing capacities, with capacity expansion constrained by technological potential and evolving zone potential. + * Add processing and preparation of TYNDP 2024 PECD v3.1 renewable profiles, replacing default ERA5-based profiles processed with Atlite (https://github.com/open-energy-transition/open-tyndp/pull/53). Initial implementation first addresses profiles for offshore technologies. * Add TYNDP hydrogen import potentials and corridors from outside of the modelled countries (https://github.com/open-energy-transition/open-tyndp/pull/36). Notably this includes pipelines and shipping imports from North Africa, Ukraine and Norway. Different import potentials are available for each of the planning years which are differentiated by wildcards. diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 1a0a49872d..d2851ee557 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -1354,6 +1354,50 @@ if config["sector"]["h2_topology_tyndp"]: "../scripts/build_tyndp_h2_imports.py" +if config["sector"]["offshore_hubs_tyndp"]["enable"]: + + rule build_tyndp_offshore_hubs: + params: + planning_horizons=config_provider("scenario", "planning_horizons"), + scenario=config_provider("tyndp_scenario"), + countries=config_provider("countries"), + offshore_hubs_tyndp=config_provider("sector", "offshore_hubs_tyndp"), + input: + nodes="data/tyndp_2024_bundle/Offshore hubs/NODE.xlsx", + grid="data/tyndp_2024_bundle/Offshore hubs/GRID.xlsx", + electrolysers="data/tyndp_2024_bundle/Offshore hubs/ELECTROLYSER.xlsx", + generators="data/tyndp_2024_bundle/Offshore hubs/GENERATOR.xlsx", + output: + offshore_buses=resources("offshore_buses.csv"), + offshore_grid=resources("offshore_grid.csv"), + offshore_electrolysers=resources("offshore_electrolysers.csv"), + offshore_generators=resources("offshore_generators.csv"), + offshore_zone_trajectories=resources("offshore_zone_trajectories.csv"), + log: + logs("build_tyndp_offshore_hubs.log"), + benchmark: + benchmarks("build_tyndp_offshore_hubs") + threads: 1 + resources: + mem_mb=4000, + conda: + "../envs/environment.yaml" + script: + "../scripts/build_tyndp_offshore_hubs.py" + + +def input_offshore_hubs(w): + offshore_files = [ + "offshore_buses", + "offshore_grid", + "offshore_electrolysers", + "offshore_generators", + ] + if config_provider("sector", "offshore_hubs_tyndp", "enable")(w): + return {f: resources(f"{f}.csv") for f in offshore_files} + return {} + + rule prepare_sector_network: params: time_resolution=config_provider("clustering", "temporal", "resolution_sector"), @@ -1390,10 +1434,12 @@ rule prepare_sector_network: ), load_source=config_provider("load", "source"), scaling_factor=config_provider("load", "scaling_factor"), + offshore_hubs_tyndp=config_provider("sector", "offshore_hubs_tyndp", "enable"), input: unpack(input_profile_offwind), unpack(input_profile_pecd), unpack(input_heat_source_power), + unpack(input_offshore_hubs), **rules.cluster_gas_network.output, **rules.build_gas_input_locations.output, snapshot_weightings=resources( diff --git a/rules/postprocess.smk b/rules/postprocess.smk index 3d0680623f..9cd1193b0a 100644 --- a/rules/postprocess.smk +++ b/rules/postprocess.smk @@ -68,6 +68,33 @@ if config["foresight"] != "perfect": script: "../scripts/plot_power_network.py" + rule plot_base_hydrogen_network: + params: + plotting=config_provider("plotting"), + input: + network=resources( + "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc" + ), + regions_onshore=resources("regions_onshore.geojson"), + output: + map=resources( + "maps/base_h2_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}.pdf" + ), + threads: 1 + resources: + mem_mb=4000, + benchmark: + benchmarks( + "plot_base_hydrogen_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}" + ) + log: + RESULTS + + "logs/plot_base_hydrogen_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}.log", + conda: + "../envs/environment.yaml" + script: + "../scripts/plot_base_hydrogen_network.py" + rule plot_hydrogen_network: params: plotting=config_provider("plotting"), @@ -100,32 +127,50 @@ if config["foresight"] != "perfect": script: "../scripts/plot_hydrogen_network.py" - rule plot_base_hydrogen_network: + rule plot_base_offshore_network: params: plotting=config_provider("plotting"), + expanded=False, input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc" ), - regions_onshore=resources("regions_onshore.geojson"), + regions_offshore=resources("regions_offshore.geojson"), output: map=resources( - "maps/base_h2_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}.pdf" + "maps/base_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}.pdf" ), threads: 1 resources: mem_mb=4000, benchmark: benchmarks( - "plot_base_hydrogen_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}" + "plot_base_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}" ) log: RESULTS - + "logs/plot_base_hydrogen_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}.log", + + "logs/plot_base_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}.log", conda: "../envs/environment.yaml" script: - "../scripts/plot_base_hydrogen_network.py" + "../scripts/plot_offshore_network.py" + + use rule plot_base_offshore_network as plot_offshore_network with: + params: + expanded=True, + input: + network=RESULTS + + "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc", + output: + map=RESULTS + + "maps/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}-offshore_network_{carrier}.pdf", + benchmark: + benchmarks( + "plot_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}" + ) + log: + RESULTS + + "logs/plot_offshore_network_{clusters}_{opts}_{sector_opts}_{planning_horizons}_{carrier}.log", rule plot_gas_network: params: diff --git a/rules/retrieve.smk b/rules/retrieve.smk index 514551171c..9f35a6b069 100755 --- a/rules/retrieve.smk +++ b/rules/retrieve.smk @@ -179,7 +179,10 @@ if config["enable"]["retrieve"] and config["enable"].get("retrieve_tyndp_bundle" h2_reference_grid="data/tyndp_2024_bundle/Line data/ReferenceGrid_Hydrogen.xlsx", electricity_demand=directory("data/tyndp_2024_bundle/Demand Profiles"), h2_imports="data/tyndp_2024_bundle/Hydrogen/H2 IMPORTS GENERATORS PROPERTIES.xlsx", - offshore_buses="data/tyndp_2024_bundle/Offshore hubs/NODE.xlsx", + offshore_nodes="data/tyndp_2024_bundle/Offshore hubs/NODE.xlsx", + offshore_grid="data/tyndp_2024_bundle/Offshore hubs/GRID.xlsx", + offshore_electrolysers="data/tyndp_2024_bundle/Offshore hubs/ELECTROLYSER.xlsx", + offshore_generators="data/tyndp_2024_bundle/Offshore hubs/GENERATOR.xlsx", log: "logs/retrieve_tyndp_bundle.log", retries: 2 diff --git a/rules/solve_electricity.smk b/rules/solve_electricity.smk index 9d08eed043..fd89175f77 100644 --- a/rules/solve_electricity.smk +++ b/rules/solve_electricity.smk @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# SPDX-FileCopyrightText: Open Energy Transition gGmbH and contributors to PyPSA-Eur # # SPDX-License-Identifier: MIT @@ -11,8 +11,13 @@ rule solve_network: "sector", "co2_sequestration_potential", default=200 ), custom_extra_functionality=input_custom_extra_functionality, + carriers_tyndp=config_provider("electricity", "tyndp_renewable_carriers"), input: network=resources("networks/base_s_{clusters}_elec_{opts}.nc"), + offshore_zone_trajectories=branch( + config_provider("sector", "offshore_hubs_tyndp", "enable"), + resources("offshore_zone_trajectories.csv"), + ), output: network=RESULTS + "networks/base_s_{clusters}_elec_{opts}.nc", config=RESULTS + "configs/config.base_s_{clusters}_elec_{opts}.yaml", diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 473e274520..7c27cfdf0c 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -7,12 +7,6 @@ rule add_existing_baseyear: params: baseyear=config_provider("scenario", "planning_horizons", 0), sector=config_provider("sector"), - pecd_renewable_profiles=config_provider( - "electricity", "pecd_renewable_profiles" - ), - tyndp_renewable_carriers=config_provider( - "electricity", "tyndp_renewable_carriers" - ), existing_capacities=config_provider("existing_capacities"), carriers=config_provider("electricity", "renewable_carriers"), costs=config_provider("costs"), @@ -86,12 +80,6 @@ rule add_brownfield: ), threshold_capacity=config_provider("existing_capacities", "threshold_capacity"), snapshots=config_provider("snapshots"), - pecd_renewable_profiles=config_provider( - "electricity", "pecd_renewable_profiles" - ), - tyndp_renewable_carriers=config_provider( - "electricity", "tyndp_renewable_carriers" - ), drop_leap_day=config_provider("enable", "drop_leap_day"), carriers=config_provider("electricity", "renewable_carriers"), heat_pump_sources=config_provider("sector", "heat_pump_sources"), @@ -99,6 +87,7 @@ rule add_brownfield: dynamic_ptes_capacity=config_provider( "sector", "district_heating", "ptes", "dynamic_capacity" ), + offshore_hubs_tyndp=config_provider("sector", "offshore_hubs_tyndp", "enable"), input: unpack(input_profile_tech_brownfield), unpack(input_profile_tech_brownfield_pecd), @@ -142,11 +131,16 @@ rule solve_sector_network_myopic: "sector", "co2_sequestration_potential", default=200 ), custom_extra_functionality=input_custom_extra_functionality, + carriers_tyndp=config_provider("electricity", "tyndp_renewable_carriers"), input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}_brownfield.nc" ), costs=resources("costs_{planning_horizons}.csv"), + offshore_zone_trajectories=branch( + config_provider("sector", "offshore_hubs_tyndp", "enable"), + resources("offshore_zone_trajectories.csv"), + ), output: network=RESULTS + "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc", diff --git a/rules/solve_overnight.smk b/rules/solve_overnight.smk index 5b78bc7254..c521c20774 100644 --- a/rules/solve_overnight.smk +++ b/rules/solve_overnight.smk @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# SPDX-FileCopyrightText: Open Energy Transition gGmbH and contributors to PyPSA-Eur # # SPDX-License-Identifier: MIT @@ -11,10 +11,15 @@ rule solve_sector_network: "sector", "co2_sequestration_potential", default=200 ), custom_extra_functionality=input_custom_extra_functionality, + carriers_tyndp=config_provider("electricity", "tyndp_renewable_carriers"), input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc" ), + offshore_zone_trajectories=branch( + config_provider("sector", "offshore_hubs_tyndp", "enable"), + resources("offshore_zone_trajectories.csv"), + ), output: network=RESULTS + "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc", diff --git a/rules/solve_perfect.smk b/rules/solve_perfect.smk index f8792dc88c..09001ce592 100644 --- a/rules/solve_perfect.smk +++ b/rules/solve_perfect.smk @@ -5,12 +5,6 @@ rule add_existing_baseyear: params: baseyear=config_provider("scenario", "planning_horizons", 0), sector=config_provider("sector"), - pecd_renewable_profiles=config_provider( - "electricity", "pecd_renewable_profiles" - ), - tyndp_renewable_carriers=config_provider( - "electricity", "tyndp_renewable_carriers" - ), existing_capacities=config_provider("existing_capacities"), carriers=config_provider("electricity", "renewable_carriers"), costs=config_provider("costs"), @@ -107,11 +101,16 @@ rule solve_sector_network_perfect: "sector", "co2_sequestration_potential", default=200 ), custom_extra_functionality=input_custom_extra_functionality, + carriers_tyndp=config_provider("electricity", "tyndp_renewable_carriers"), input: network=resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_brownfield_all_years.nc" ), costs=resources("costs_2030.csv"), + offshore_zone_trajectories=branch( + config_provider("sector", "offshore_hubs_tyndp", "enable"), + resources("offshore_zone_trajectories.csv"), + ), output: network=RESULTS + "networks/base_s_{clusters}_{opts}_{sector_opts}_brownfield_all_years.nc", diff --git a/scripts/_helpers.py b/scripts/_helpers.py index 9543a25106..4a0829006e 100644 --- a/scripts/_helpers.py +++ b/scripts/_helpers.py @@ -1077,8 +1077,10 @@ def load_cutout( return cutout -def make_index(c, carrier): - return carrier + " " + c.bus0 + " -> " + c.bus1 +def make_index(c, cname0="bus0", cname1="bus1", prefix="", connector="->", suffix=""): + idx = [prefix, c[cname0], connector, c[cname1], suffix] + idx = [i for i in idx if i] + return " ".join(idx) def extract_grid_data_tyndp( @@ -1124,6 +1126,6 @@ def extract_grid_data_tyndp( # Combine into unidirectional links and return h2_grid = pd.concat([forward_links, reverse_links]) - h2_grid.index = h2_grid.apply(make_index, axis=1, args=(carrier,)) + h2_grid.index = h2_grid.apply(make_index, axis=1, prefix=carrier) return h2_grid diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 03771db9aa..46535cec78 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -33,6 +33,7 @@ def add_brownfield( h2_retrofit=False, h2_retrofit_capacity_per_ch4=None, capacity_threshold=None, + offshore_hubs_tyndp=False, ): """ Add brownfield capacity from previous network. @@ -51,6 +52,8 @@ def add_brownfield( Ratio of hydrogen to methane capacity for pipeline retrofitting capacity_threshold : float Threshold for removing assets with low capacity + offshore_hubs_tyndp : bool + Whether to enable offshore hubs """ logger.info(f"Preparing brownfield for the year {year}") @@ -110,6 +113,81 @@ def add_brownfield( for tattr in n.component_attrs[c.name].index[selection]: n.import_series_from_dataframe(c.pnl[tattr], c.name, tattr) + # adjust TYNDP offshore expansion by subtracting existing capacity from previous years + # from current year total capacity and potential + # hydrogen- and electricity-generating wind farms share the same potential; values are adjusted accordingly + if offshore_hubs_tyndp: + filter = {"Link": "Offshore", "Generator": "offwind"} + eff_map = {"Link": "efficiency", "Generator": "efficiency_dc_to_h2"} + for c in n.iterate_components(["Link", "Generator"]): + off_fixed_i = c.df[ + (c.df.index.str.contains(filter[c.name])) & (c.df.build_year != year) + ].index + off_i = c.df[ + (c.df.index.str.contains(filter[c.name])) & (c.df.build_year == year) + ].index + + off_capacity = c.df.loc[off_i, "p_nom"] + off_potential = c.df.loc[off_i, "p_nom_max"] + + # Determine existing capacities in MW_e and MW_h2 + already_existing = ( + c.df.loc[off_fixed_i] + .assign( + p_nom_opt_e=lambda df: np.where( + df.carrier.str.contains("h2"), + df.p_nom_opt.div(df[eff_map[c.name]]), + df.p_nom_opt, + ), + p_nom_opt_h2=lambda df: np.where( + ~df.carrier.str.contains("h2"), + df.p_nom_opt.mul(df[eff_map[c.name]]), + df.p_nom_opt, + ), + ) + .rename(lambda x: x.split("-2")[0] + f"-{year}")[ + ["p_nom_opt", "p_nom_opt_e", "p_nom_opt_h2"] + ] + .groupby(level=0) + .sum() + ) + + # account for the shared potential of hydrogen- and electricity-generating wind farms + if c.name == "Generator": + h2_gens = already_existing.loc[ + already_existing.index.str.contains("h2") + ] + dc_gens = already_existing.loc[ + already_existing.index.str.contains("dc.*oh") + ] + + h2_to_dc = h2_gens.p_nom_opt_e.rename( + index=lambda x: x.replace("h2", "dc") + ).rename("p_nom_opt") + dc_to_h2 = dc_gens.p_nom_opt_h2.rename( + index=lambda x: x.replace("dc", "h2") + ).rename("p_nom_opt") + + already_existing_l = ( + pd.concat([already_existing.p_nom_opt, h2_to_dc, dc_to_h2]) + .groupby(level=0) + .sum() + ) + else: + already_existing_l = already_existing.p_nom_opt + + # values should be non-negative; clipping applied to handle rounding errors + remaining_capacity = ( + off_capacity + - already_existing.p_nom_opt.reindex(index=off_capacity.index).fillna(0) + ).clip(lower=0) + remaining_potential = ( + off_potential + - already_existing_l.reindex(index=off_capacity.index).fillna(0) + ).clip(lower=0) + c.df.loc[off_i, ["p_nom_min", "p_nom"]] = remaining_capacity + c.df.loc[off_i, "p_nom_max"] = remaining_potential + # deal with gas network if h2_retrofit: # subtract the already retrofitted from the maximum capacity @@ -206,9 +284,7 @@ def disable_grid_expansion_if_limit_hit(n): n.global_constraints.drop(name, inplace=True) -def adjust_renewable_profiles( - n, input_profiles, params, year, tyndp_renewable_carriers -): +def adjust_renewable_profiles(n, input_profiles, params, year): """ Adjusts renewable profiles according to the renewable technology specified, using the latest year below or equal to the selected year. @@ -220,12 +296,7 @@ def adjust_renewable_profiles( pd.Series(dr, index=dr).where(lambda x: x.isin(n.snapshots), pd.NA).ffill() ) - # TODO: hotfix remove filter for tyndp_renewable_carriers after tyndp generators are added - if len(tyndp_renewable_carriers) > 0: - logger.info( - f"Hotfix until TYNDP renewable carriers are added. Skipping renewable carriers '{', '.join(tyndp_renewable_carriers)}'." - ) - for carrier in set(params["carriers"]) - set(tyndp_renewable_carriers): + for carrier in set(params["carriers"]): if carrier == "hydro": continue @@ -246,7 +317,8 @@ def adjust_renewable_profiles( p_max_pu = p_max_pu.groupby(snapshotmaps).mean() # replace renewable time series - n.generators_t.p_max_pu.loc[:, p_max_pu.columns] = p_max_pu + idx = n.generators[n.generators.carrier == carrier].index + n.generators_t.p_max_pu.loc[:, p_max_pu[idx].columns] = p_max_pu[idx] def update_heat_pump_efficiency(n: pypsa.Network, n_p: pypsa.Network, year: int): @@ -356,11 +428,7 @@ def update_dynamic_ptes_capacity( n = pypsa.Network(snakemake.input.network) - tyndp_renewable_carriers = snakemake.params.tyndp_renewable_carriers - - adjust_renewable_profiles( - n, snakemake.input, snakemake.params, year, tyndp_renewable_carriers - ) + adjust_renewable_profiles(n, snakemake.input, snakemake.params, year) add_build_year_to_new_assets(n, year) @@ -378,6 +446,7 @@ def update_dynamic_ptes_capacity( h2_retrofit=snakemake.params.H2_retrofit, h2_retrofit_capacity_per_ch4=snakemake.params.H2_retrofit_capacity_per_CH4, capacity_threshold=snakemake.params.threshold_capacity, + offshore_hubs_tyndp=snakemake.params.offshore_hubs_tyndp, ) disable_grid_expansion_if_limit_hit(n) diff --git a/scripts/add_existing_baseyear.py b/scripts/add_existing_baseyear.py index 1eae4a215d..5c08ba485e 100644 --- a/scripts/add_existing_baseyear.py +++ b/scripts/add_existing_baseyear.py @@ -72,7 +72,6 @@ def add_existing_renewables( df_agg: pd.DataFrame, countries: list[str], renewable_carriers: list[str], - tyndp_renewable_carriers: list[str], ) -> None: """ Add existing renewable capacities to conventional power plant data. @@ -89,8 +88,6 @@ def add_existing_renewables( List of country codes to consider renewable_carriers: list List of renewable carriers in the network - tyndp_renewable_carriers: list - List of renewable technologies from TYNDP Returns ------- @@ -98,12 +95,6 @@ def add_existing_renewables( Modifies df_agg in-place """ tech_map = {"solar": "PV", "onwind": "Onshore", "offwind-ac": "Offshore"} - # TODO: remove when TYNDP renewable generators are added - if len(tyndp_renewable_carriers) > 0: - logger.info( - f"Hotfix until TYNDP renewable carriers are added. Skipping renewable carriers '{', '.join(tyndp_renewable_carriers)}'." - ) - renewable_carriers = set(renewable_carriers) - set(tyndp_renewable_carriers) irena = pm.data.IRENASTAT().powerplant.convert_country_to_alpha2() irena = irena.query("Country in @countries") @@ -167,7 +158,6 @@ def add_power_capacities_installed_before_baseyear( capacity_threshold: float, lifetime_values: dict[str, float], renewable_carriers: list[str], - tyndp_renewable_carriers: list[str], ) -> None: """ Add power generation capacities installed before base year. @@ -192,8 +182,6 @@ def add_power_capacities_installed_before_baseyear( Default values for missing data renewable_carriers: list List of renewable carriers in the network - tyndp_renewable_carriers: list - List of renewable technologies from TYNDP """ logger.debug(f"Adding power capacities installed before {baseyear}") @@ -247,7 +235,6 @@ def add_power_capacities_installed_before_baseyear( n=n, countries=countries, renewable_carriers=renewable_carriers, - tyndp_renewable_carriers=tyndp_renewable_carriers, ) # drop assets which are already phased out / decommissioned phased_out = df_agg[df_agg["DateOut"] < baseyear].index @@ -760,7 +747,6 @@ def add_heating_capacities_installed_before_baseyear( options = snakemake.params.sector renewable_carriers = snakemake.params.carriers - tyndp_renewable_carriers = snakemake.params.tyndp_renewable_carriers baseyear = snakemake.params.baseyear @@ -789,7 +775,6 @@ def add_heating_capacities_installed_before_baseyear( capacity_threshold=snakemake.params.existing_capacities["threshold_capacity"], lifetime_values=snakemake.params.costs["fill_values"], renewable_carriers=renewable_carriers, - tyndp_renewable_carriers=tyndp_renewable_carriers, ) if options["heating"]: diff --git a/scripts/build_tyndp_network.py b/scripts/build_tyndp_network.py index ea6db8bcfd..db13786131 100644 --- a/scripts/build_tyndp_network.py +++ b/scripts/build_tyndp_network.py @@ -149,7 +149,7 @@ def build_shapes(bz_fn, geo_crs: str = GEO_CRS, distance_crs: str = DISTANCE_CRS y=lambda df: df["node"].y, ) - # Correct DK, IT, GR and SE coordinates + # Correct DK, IT, GR, SE and GB coordinates country_shapes.loc["DK", ["node", "x", "y"]] = bidding_shapes.loc[ "DKE1", ["node", "x", "y"] ] @@ -162,6 +162,9 @@ def build_shapes(bz_fn, geo_crs: str = GEO_CRS, distance_crs: str = DISTANCE_CRS country_shapes.loc["SE", ["node", "x", "y"]] = bidding_shapes.loc[ "SE01", ["node", "x", "y"] ] + country_shapes.loc["GB", ["node", "x", "y"]] = bidding_shapes.loc[ + "GB00", ["node", "x", "y"] + ] return bidding_shapes, country_shapes @@ -190,6 +193,7 @@ def build_buses( """ buses = ( pd.read_excel(buses_fn) + .replace("UK", "GB", regex=True) .merge( bidding_shapes[["country", "node", "x", "y"]], how="outer", diff --git a/scripts/build_tyndp_offshore_hubs.py b/scripts/build_tyndp_offshore_hubs.py new file mode 100644 index 0000000000..9fdf4a0e32 --- /dev/null +++ b/scripts/build_tyndp_offshore_hubs.py @@ -0,0 +1,583 @@ +# SPDX-FileCopyrightText: Open Energy Transition gGmbH +# +# SPDX-License-Identifier: MIT +""" +This script is used to clean TYNDP Scenario Building offshore hubs data to be used in the PyPSA-Eur workflow. Depending on the scenario, different planning years (`pyear`) are available. DE and GA are defined for 2030, 2040 and 2050. NT scenario is only defined for 2030 and 2040. All the planning years are read at once. +""" + +import logging + +import geopandas as gpd +import numpy as np +import pandas as pd + +from scripts._helpers import configure_logging, set_scenario_config + +logger = logging.getLogger(__name__) + +GEO_CRS = "EPSG:4326" +SCENARIO_DICT = { + "Distributed Energy": "DE", + "Global Ambition": "GA", + "National Trends": "NT", +} + + +def load_offshore_hubs(fn: str): + """ + Load and process offshore hub coordinates from Excel file. + + Parameters + ---------- + fn : str + Path to the Excel file containing offshore hub data. + + Returns + ------- + gpd.GeoDataFrame + GeoDataFrame containing the offshore hub data. + + The GeoDataFrame uses the coordinate reference system defined by `GEO_CRS`. + """ + column_names = { + "OFFSHORE_NODE": "Bus", + "OFFSHORE_NODE_TYPE": "type", + "LAT": "y", + "LON": "x", + } + + nodes = ( + pd.read_excel(fn, sheet_name="NODE") + .rename(columns=column_names) + .assign( + location=lambda x: x.Bus, + country=lambda x: x.location.str[:2], + ) + ) + + nodes = gpd.GeoDataFrame( + nodes, geometry=gpd.points_from_xy(nodes.x, nodes.y), crs=GEO_CRS + ) + + return nodes + + +def expand_all_scenario(df: pd.DataFrame, scenarios: list): + all_mask = df["scenario"] == "All" + all_rows = ( + df[all_mask] + .drop(columns="scenario") + .merge(pd.DataFrame({"scenario": scenarios}), how="cross") + ) + return pd.concat([df[~all_mask], all_rows], ignore_index=True) + + +def load_offshore_grid( + fn: str, + scenario: str, + planning_horizons: list[int], + countries: list[str], + max_capacity: dict[str, int], +): + """ + Load offshore grid (electricity and hydrogen) and format data. + + Parameters + ---------- + fn : str + Path to the Excel file containing offshore grid data. + scenario : str + Scenario identifier to filter the grid data. Must be one of the scenario + codes: "DE" (Distributed Energy), "GA" (Global Ambition), or + "NT" (National Trends). + planning_horizons : list[int] + List of planning years to include in the cost data filtering. + countries : list[str] + List of country codes used to clean data. + max_capacity : dict[str, int] + Maximum transmission capacity between two offshore hubs per carrier + + Returns + ------- + pd.DataFrame + DataFrame containing the merged offshore grid data. + """ + column_names = { + "FROM": "bus0", + "TO": "bus1", + "YEAR": "pyear", + "SCENARIO": "scenario", + "MARKET": "carrier", + "CAPACITY": "p_nom_min", + "CAPEX": "capex", + "OPEX": "opex", + } + + # Load reference grid + grid = ( + pd.read_excel(fn, sheet_name="Reference grid") + .rename(columns=column_names) + .replace( + { + "carrier": {"E": "DC_OH", "H2": "H2 pipeline OH"}, + "scenario": SCENARIO_DICT, + } + ) + ) + grid = expand_all_scenario(grid, SCENARIO_DICT.values()).query( + "scenario == @scenario" + ) + + # Load costs data + grid_costs = ( + pd.read_excel( + fn, + sheet_name="COST", + ) + .rename(columns=column_names) + .replace({"scenario": SCENARIO_DICT}) + .query("pyear in @planning_horizons and scenario == @scenario") + ) + grid_costs[["capex", "opex"]] = grid_costs[["capex", "opex"]].mul( + 1e3 + ) # kEUR/MW to EUR/MW + grid_costs["carrier"] = grid_costs["carrier"].replace( + {"E": "DC_OH", "H2": "H2 pipeline OH"} + ) + + # Merge information + grid = grid.merge( + grid_costs, how="outer", on=["bus0", "bus1", "pyear", "scenario", "carrier"] + ).assign( + p_min_pu=0, + p_max_pu=1, + ) + + # Filter out radial nodes and Convert to explicit hydrogen buses + grid = grid.query("~bus0.str.contains('OR') and ~bus1.str.contains('OR')").assign( + bus0=lambda df: np.where( + df.carrier == "H2 pipeline OH", + np.where( + df.bus0.str.contains("OH"), df.bus0 + " H2", df.bus0.str[:2] + " H2 Z2" + ), + df.bus0, + ), + bus1=lambda df: np.where( + df.carrier == "H2 pipeline OH", + np.where( + df.bus1.str.contains("OH"), df.bus1 + " H2", df.bus1.str[:2] + " H2 Z2" + ), + df.bus1, + ), + ) + + # Handle missing data + # TODO Validate assumption + grid["p_nom_extendable"] = ~grid[["capex", "opex"]].isna().any(axis=1) + grid[["capex", "opex"]] = grid[["capex", "opex"]].fillna(0) + grid["p_nom_min"] = grid["p_nom_min"].fillna(0) + + # Add maximum transmission capacities + grid["p_nom_max"] = np.where( + grid.bus0.str.contains("OH") & grid.bus1.str.contains("OH"), + np.where( + (grid.carrier == "DC_OH"), + max_capacity["DC_OH"], + max_capacity["H2 pipeline OH"], + ) + * 1e3, + grid.get("p_nom_max", np.inf), + ) # GW > MW + + # Rename UK in GB + grid[["bus0", "bus1"]] = grid[["bus0", "bus1"]].replace("UK", "GB", regex=True) + + # Filter selected countries and nodes + grid = grid.assign( + country0=lambda x: x.bus0.str[:2], + country1=lambda x: x.bus1.str[:2], + ).query("country0 in @countries and country1 in @countries") + + return grid + + +def load_offshore_electrolysers( + fn: str, scenario: str, planning_horizons: list[int], countries: list[str] +): + """ + Load offshore electrolysers data and format data. + + Parameters + ---------- + fn : str + Path to the Excel file containing offshore electrolyser data. + scenario : str + Scenario identifier to filter the grid data. Must be one of the scenario + codes: "DE" (Distributed Energy), "GA" (Global Ambition), or + "NT" (National Trends). + planning_horizons : list[int] + List of planning years to include in the cost data filtering. + countries : list[str] + List of country codes used to clean data. + + Returns + ------- + pd.DataFrame + DataFrame containing the formatted offshore electrolyser data. + """ + column_names = { + "NODE": "bus0", + "OFFSHORE_NODE_TYPE": "type", + "YEAR": "pyear", + "SCENARIO": "scenario", + "CAPEX": "capex", + "OPEX": "opex", + } + + # Load electrolysers data + electrolysers = ( + pd.read_excel( + fn, + sheet_name="COST", + ) + .rename(columns=column_names) + .query("pyear in @planning_horizons") + .replace({"scenario": SCENARIO_DICT}) + .query("scenario == @scenario") + .assign(country=lambda x: x.bus0.str[:2], bus1=lambda x: x.bus0 + " H2") + .drop(columns="OFFSHORE_NODE") + ) + + mask = electrolysers["type"] == "Radial" + electrolysers.loc[mask, "bus1"] = electrolysers.loc[mask, "country"] + " H2 Z2" + + electrolysers[["capex", "opex"]] = electrolysers[["capex", "opex"]].mul( + 1e3 + ) # kEUR/MW to EUR/MW + + # rename UK in GB + electrolysers[["bus0", "bus1", "country"]] = electrolysers[ + ["bus0", "bus1", "country"] + ].replace("UK", "GB", regex=True) + + # filter selected countries + electrolysers = electrolysers.query("country in @countries") + + return electrolysers + + +def collect_from_layer(generators_e, generators_l, nodes): + """ + Combine existing capacities with potentials and resolve bus allocations. + + This function merges generator data from two sources: existing capacities (EXISTING sheet) + and potential capacities (LAYER sheet). It handles reallocation of radial wind farms by + correcting inconsistencies between EXISTING and LAYER data, particularly for offshore + radial connections that need to be mapped to hub connections. + + Parameters + ---------- + generators_e : pd.DataFrame + Existing generator capacities. + generators_l : pd.DataFrame + Layer potential capacities. Contains candidate generators without explicit bus assignments. + nodes : pd.DataFrame + Node definitions. Used to deduce bus assignments for candidates lacking explicit bus information. + + Returns + ------- + pd.DataFrame + Combined generator dataframe with corrected bus allocations and merged existing + and potential capacities. + """ + # Identify reallocations of radial wind farms using LAYER_POTENTIAL + idx = ["location", "bus", "type", "pyear", "scenario", "carrier"] + generators_el = generators_e.merge( + generators_l, + how="outer", + on=["location", "type", "pyear", "scenario", "carrier"], + suffixes=("", "_l"), + ) + + radial_inconsistent = generators_el.query( + "carrier.str.contains('-r') " # only radial connection + "and p_nom_min != p_nom_min_l " # when EXISTING and LAYER_POTENTIAL values are inconsistent + "and ~(p_nom_min.isna() and p_nom_min_l == 0)" # treat missing values and zeros as equivalent + )[idx + ["p_nom_min"]] + + # Fix EXISTING technologies by reallocating radial to hubs + corrections_radial = radial_inconsistent.assign( + bus=lambda x: x.location, carrier=lambda x: x.carrier.str.replace("-r", "-oh") + ) + generators_e_fixed = ( + pd.concat( + [ + generators_e.set_index(idx).drop( + radial_inconsistent.set_index(idx).index + ), + corrections_radial.set_index(idx), + ] + ) + .groupby(level=list(range(len(idx)))) + .sum() + .reset_index() + .assign(carrier_mapped=lambda x: x.carrier.str.replace("h2", "dc", regex=True)) + ) + generators_l_fixed = ( + generators_l.drop(columns="p_nom_min") + .query("p_nom_max != 0") + .rename(columns={"carrier": "carrier_mapped"}) + ) + + # Combine existing capacities with potentials + # Set identical potentials for both hydrogen- and electricity-generating farms + generators = ( + generators_e_fixed.merge( + generators_l_fixed, + how="outer", + on=["location", "type", "pyear", "scenario", "carrier_mapped"], + ) + .assign( + p_nom_min=lambda df: df.p_nom_min.fillna(0), + carrier=lambda df: df.carrier.fillna(df.carrier_mapped), + ) + .drop(columns="carrier_mapped") + ) + + # Fill missing buses + generators = ( + generators.merge(nodes[["location", "HOME_NODE"]], how="left", on="location") + .assign( + bus=lambda df: df.bus.fillna( + df.HOME_NODE.where(df.carrier.str.contains("-r"), df.location) + ) + ) + .drop(columns="HOME_NODE") + ) + + # Remove duplicates introduced by LAYER + generators = generators.sort_values( + by="p_nom_min", ascending=False + ).drop_duplicates(subset=idx) + + return generators + + +def load_offshore_generators( + fn: str, + nodes: pd.DataFrame, + scenario: str, + planning_horizons: list[int], + countries: list[str], +): + """ + Load offshore generators data and format data. + + The `EXISTING` sheet is assumed to contain the existing capacities collected prior to any reallocations intended to align with the PEMMDB. This sheet appears to be excluded from the modelling exercise, except for hydrogen-generating capacities. + + The `LAYER_POTENTIAL` sheet is viewed as containing the reallocated existing capacities (excluding hydrogen-generating specific information) and the theoretical potentials per technology. Existing capacities are specified for both electricity- and hydrogen-generating offshore wind farms. Technology shares from `EXISTING` will be used to supplement the data. + + The `ZONE_POTENTIAL` sheet is considered as the source for achievable potentials for each node across all planning horizons. It establishes a nodal constraint on top of the theoretical potentials outlined by `LAYER_POTENTIAL`. + + **Existing capacities** will be read from the `LAYER_POTENTIAL` sheet, utilizing technology shares specified in `EXISTING` for hydrogen-generating capacities. A discrepancy of 526 MW for `DEOH002` in 2045 (across all scenarios) is noted when comparing existing capacities with `ZONE_POTENTIAL`. It remains uncertain which of the two values is correct: 5828.55 MW from `LAYER_POTENTIAL` or 6354.55 MW from `ZONE_POTENTIAL`. Currently, the value of 5828.55 MW is used. Additionally, the existing capacity of 3768.25 MW in 2040 exceeds the potential of 3242.25MW shown in the `LAYER_POTENTIAL` sheet. This value has been adjusted to match the maximum potential value across all scenarios. + + **Potentials** will be obtained from both the `LAYER_POTENTIAL` and the `ZONE_POTENTIAL` sheets. `LAYER_POTENTIAL` will establish a technology level constraint, while `ZONE_POTENTIAL` will restrict expansion across all technologies at each node. The same 526 MW discrepancy for `DEOH002` in 2045 and 2050 (across all planning horizons and scenarios) has been noted and needs to be addressed to ensure that existing capacities do not exceed their potential. Currently, the value `ZONE_POTENTIAL` value is corrected at 5828.55 MW. + + Parameters + ---------- + fn : str + Path to the Excel file containing offshore generators data. + nodes : pd.DataFrame + DataFrame containing node information. + scenario : str + Scenario identifier to filter the grid data. Must be one of the scenario + codes: "DE" (Distributed Energy), "GA" (Global Ambition), or + "NT" (National Trends). + planning_horizons : list[int] + List of planning years to include in the cost data filtering. + countries : list[str] + List of country codes used to clean data. + + Returns + ------- + generators : pd.DataFrame + DataFrame containing the formatted offshore generators data + + zone_trajectories : pd.DataFrame + DataFrame containing the zone potentials trajectories + """ + column_names = { + "NODE": "bus", + "OFFSHORE_NODE": "location", + "OFFSHORE_NODE_TYPE": "type", + "YEAR": "pyear", + "SCENARIO": "scenario", + "TECHNOLOGY": "carrier", + "TECH1": "carrier", + "CAPEX": "capex", + "OPEX": "opex", + "MW": "p_nom_min", + "EXISTING_MW": "p_nom_min", + "MAX_MW": "p_nom_max", + } + + column_del = [ + "TECH2", + "TECH3", + "TECH4", + "TECH5", + "TECH6", + "MARGIN_MW", + "LAYER", + ] + + # Load data + def load_generators(sheet_name, tech_switch=None): + generators = pd.read_excel( + fn, + sheet_name=sheet_name, + ) + if tech_switch: + generators = generators.dropna(subset=tech_switch).assign( + TECH1=lambda df: df[tech_switch] + ) + generators = ( + generators.rename(columns=column_names) + .replace({"scenario": SCENARIO_DICT}) + .query("pyear in @planning_horizons and scenario == @scenario") + .assign( + carrier=lambda x: "offwind-" + + x.carrier.str.lower().replace("_", "-", regex=True) + ) + .drop(columns=column_del, errors="ignore") + ) + return generators + + generators_e = load_generators("EXISTING") + generators_l_e = load_generators("LAYER_POTENTIAL") + generators_l_h2 = load_generators("LAYER_POTENTIAL", tech_switch="TECH2").drop( + columns="p_nom_min" + ) + generators_l = pd.concat([generators_l_e, generators_l_h2]) + generators_z = load_generators("ZONE_POTENTIAL").drop( + columns=["carrier", "p_nom_min"] + ) + generators_c = load_generators("COST") + generators_c[["capex", "opex"]] = generators_c[["capex", "opex"]].mul( + 1e3 + ) # kEUR/MW to EUR/MW + + # Collect existing capacities and potentials in LAYER_POTENTIAL using H2 tech shares from EXISTING + generators = collect_from_layer(generators_e, generators_l, nodes) + + # Collect potentials trajectories in ZONE_POTENTIAL + zone_trajectories = generators_z + + # Resolve known DEOH002 data discrepancy + # This is a temporary fix for a 526 MW discrepancy between LAYER_POTENTIAL + # and ZONE_POTENTIAL data sources. + # TODO: Remove this once upstream TYNDP data is corrected + DEOH002_DISCREPANCY_MW = 526 + idx_l = generators.query( + "location=='DEOH002' and pyear == 2040 and carrier=='offwind-ac-fb-r'" + ).index + generators.loc[idx_l, "p_nom_min"] = ( + generators.loc[idx_l, "p_nom_min"] - DEOH002_DISCREPANCY_MW + ) + idx_z = zone_trajectories.query( + "location=='DEOH002' and pyear in [2045, 2050]" + ).index + zone_trajectories.loc[idx_z, "p_nom_max"] = ( + zone_trajectories.loc[idx_z, "p_nom_max"] - DEOH002_DISCREPANCY_MW + ) + + # Collect cost assumptions + generators = generators.merge( + generators_c, + how="left", + on=["bus", "location", "pyear", "scenario", "type", "carrier"], + ) + + # Convert to explicit hydrogen buses + mask = generators["carrier"].str.contains("h2") + generators.loc[mask, "bus"] = generators.loc[mask, "bus"] + " H2" + + # Validate that all required cost assumptions are defined + if generators[["capex", "opex"]].isna().any().any(): + raise ValueError("Missing generator cost data in input dataset.") + generators.loc[:, "p_nom_extendable"] = True + + # Rename UK in GB + generators[["bus", "location"]] = generators[["bus", "location"]].replace( + "UK", "GB", regex=True + ) + zone_trajectories["location"] = zone_trajectories["location"].replace( + "UK", "GB", regex=True + ) + + # Filter selected countries + generators = generators.assign(country=lambda x: x.location.str[:2]).query( + "country in @countries" + ) + zone_trajectories = zone_trajectories.assign( + country=lambda x: x.location.str[:2] + ).query("country in @countries") + + return generators, zone_trajectories + + +if __name__ == "__main__": + if "snakemake" not in globals(): + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_tyndp_offshore_hubs", configfiles="config/test/config.tyndp.yaml" + ) + + configure_logging(snakemake) + set_scenario_config(snakemake) + + # Parameters + scenario = snakemake.params["scenario"] + planning_horizons = snakemake.params["planning_horizons"] + countries = snakemake.params["countries"] + + nodes = load_offshore_hubs(snakemake.input.nodes) + + grid = load_offshore_grid( + snakemake.input.grid, + snakemake.params["scenario"], + planning_horizons, + countries, + snakemake.params["offshore_hubs_tyndp"]["max_capacity"], + ) + + electrolysers = load_offshore_electrolysers( + snakemake.input.electrolysers, + snakemake.params["scenario"], + planning_horizons, + countries, + ) + + generators, zone_trajectories = load_offshore_generators( + snakemake.input.generators, + nodes, + snakemake.params["scenario"], + planning_horizons, + countries, + ) + + # Convert country codes and retain only specified countries and offshore wind hubs + nodes[["Bus", "location", "country"]] = nodes[ + ["Bus", "location", "country"] + ].replace("UK", "GB", regex=True) + nodes = nodes.query("type != 'Radial' and country in @countries").drop( + columns="HOME_NODE" + ) + + # Save data + nodes.to_csv(snakemake.output.offshore_buses, index=False) + grid.to_csv(snakemake.output.offshore_grid, index=False) + electrolysers.to_csv(snakemake.output.offshore_electrolysers, index=False) + generators.to_csv(snakemake.output.offshore_generators, index=False) + zone_trajectories.to_csv(snakemake.output.offshore_zone_trajectories, index=False) diff --git a/scripts/clean_tyndp_h2_imports.py b/scripts/clean_tyndp_h2_imports.py index da745225fc..ffe1604044 100644 --- a/scripts/clean_tyndp_h2_imports.py +++ b/scripts/clean_tyndp_h2_imports.py @@ -117,7 +117,7 @@ def load_import_data(fn, countries_centroids): # Match countries centroids for defining coordinates of import nodes imports = match_centroids(imports, countries_centroids) imports.index = ( - imports.apply(make_index, axis=1, args=("H2 import",)) + " - " + imports.Band + imports.apply(make_index, axis=1, prefix="H2 import") + " - " + imports.Band ) return imports diff --git a/scripts/plot_base_hydrogen_network.py b/scripts/plot_base_hydrogen_network.py index 6e8184ba07..d42101b1a6 100644 --- a/scripts/plot_base_hydrogen_network.py +++ b/scripts/plot_base_hydrogen_network.py @@ -75,6 +75,10 @@ def plot_h2_map_base( ], inplace=True, ) + n.links.drop( + n.links.index[n.links.carrier.str.contains("OH")], + inplace=True, + ) p_nom = "p_nom_opt" if expanded else "p_nom" # capacity of pipes and imports @@ -100,7 +104,12 @@ def plot_h2_map_base( link_widths_imports = link_widths_imports.reindex(n.links.index).fillna(0.0) # drop non H2 buses - n.buses.drop(n.buses.index[~n.buses.carrier.str.contains("H2")], inplace=True) + n.buses.drop( + n.buses.index[ + (~n.buses.carrier.str.contains("H2")) | (n.buses.carrier.str.contains("OH")) + ], + inplace=True, + ) # optionally add hydrogen storage capacities onto the map if regions_for_storage is not None: @@ -164,7 +173,7 @@ def plot_h2_map_base( sizes = [30, 10] labels = [f"{s} GW" for s in sizes] - scale = 1e3 / 4e3 + scale = 1e3 / linewidth_factor sizes = [s * scale for s in sizes] legend_kw = dict( @@ -228,7 +237,7 @@ def plot_h2_map_base( snakemake = mock_snakemake( "plot_base_hydrogen_network", opts="", - clusters="100", + clusters="all", sector_opts="", planning_horizons=2030, ) diff --git a/scripts/plot_base_network.py b/scripts/plot_base_network.py index 5cb1cde5f0..02720f62e8 100644 --- a/scripts/plot_base_network.py +++ b/scripts/plot_base_network.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT """ -Plot base network transmission network. +Plot base transmission network. """ import geopandas as gpd @@ -16,7 +16,7 @@ if "snakemake" not in globals(): from _helpers import mock_snakemake - snakemake = mock_snakemake("plot_base_network", run="tyndp-raw") + snakemake = mock_snakemake("plot_base_network") set_scenario_config(snakemake) n = pypsa.Network(snakemake.input.network) diff --git a/scripts/plot_offshore_network.py b/scripts/plot_offshore_network.py new file mode 100644 index 0000000000..3743f94cea --- /dev/null +++ b/scripts/plot_offshore_network.py @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: Open Energy Transition gGmbH +# +# SPDX-License-Identifier: MIT +""" +Plot offshore transmission network with existing capacities. If `expanded` is enabled, the optimal capacities are plotted instead. +""" + +import logging +import re + +import geopandas as gpd +import matplotlib.pyplot as plt +import pypsa +from _helpers import configure_logging, set_scenario_config +from plot_power_network import load_projection +from pypsa.plot import add_legend_circles, add_legend_lines, add_legend_patches + +plt.style.use(["ggplot"]) + + +logger = logging.getLogger(__name__) + + +def plot_offshore_map( + network, + map_opts, + map_fn, + planning_horizons, + carrier="DC_OH", + p_nom="p_nom", + legend=True, + hubs_only=False, +): + """ + Plots the offshore network hydrogen or electricity capacities and offshore-hubs buses. + If `p_nom` parameter is set as `p_nom_opt`, optimal capacities are plotted instead. + + Parameters + ---------- + network : pypsa.Network + PyPSA network for plotting the offshore grid. Can be either presolving or post solving. + map_opts : dict + Map options for plotting. + map_fn : str + Path to save the final map plot to. + planning_horizons : int + The planning horizon year + carrier : str, optional + Carrier to plot + p_nom : str | float, optional + Nominal power parameter for determining link thickness. If str, must be "p_nom" or "p_nom_opt". + If float, uses fixed value for all links. Defaults to plotting only base network (p_nom). + legend : bool, optional + Whether to display a legend on the plot. Defaults to display the legend. + hubs_only : bool, optional + Whether to only plot the offshore hubs. Defaults to plot both home market nodes and offshore hubs. + + Returns + ------- + None + Saves the map plot as figure to the provided map_fn path. + """ + n = network.copy() + + lw_factor = 1e4 if carrier == "DC_OH" else 5e3 + link_lower_threshold = 1e2 # MW below which not drawn + + n.links.drop( + n.links.index[n.links.carrier != carrier], + inplace=True, + ) + + # transmission capacities + if isinstance(p_nom, str): + links = ( + n.links[n.links.carrier == carrier][p_nom] + .rename(index=lambda x: re.sub(r"-\d{4}$", f"-{planning_horizons}", x)) + .groupby(level=0) + .sum() + ) + # set link widths + links[links < link_lower_threshold] = 0.0 + link_widths = links / lw_factor + if link_widths.notnull().empty: + logger.info(f"No offshore capacities for {carrier}, skipping plot.") + return + link_widths = link_widths.reindex(n.links.index).fillna(0.0) + elif isinstance(p_nom, float) or isinstance(p_nom, int): + link_widths = p_nom + else: + raise ValueError("Parameter 'p_nom' must be either str or float.") + + # keep relevant buses + bus_carriers = [carrier.replace("DC", "AC")] + ( + ["AC"] if carrier == "DC_OH" else ["H2", "H2_OH"] + ) + n.buses.drop( + n.buses.index[~n.buses.carrier.isin(bus_carriers)], + inplace=True, + ) + n_oh = n.copy() + n_oh.buses.drop( + n_oh.buses.index[~n_oh.buses.carrier.str.contains("OH")], inplace=True + ) + + if hubs_only: + n.buses = n_oh.buses + + # plot transmission network + logger.info("Plotting offshore transmission network.") + proj = load_projection(dict(name="EqualEarth")) + fig, ax = plt.subplots(figsize=(7, 6), subplot_kw={"projection": proj}) + color_h2 = "#f081dc" + color_dc = "darkseagreen" + color = color_dc if carrier == "DC_OH" else color_h2 + color_oh_nodes = "#ff29d9" + color_hm_nodes = "darkgray" + + n.plot( + geomap=True, + bus_sizes=0.05, + bus_colors=color_hm_nodes, + link_colors=color, + link_widths=link_widths, + branch_components=["Link"], + ax=ax, + **map_opts, + ) + + n_oh.plot( + geomap=True, + bus_sizes=0.05, + bus_colors=color_oh_nodes, + branch_components=[], + ax=ax, + **map_opts, + ) + + if legend: + sizes = [30, 10] + labels = [f"{s} GW" for s in sizes] + scale = 1e3 / lw_factor + sizes = [s * scale for s in sizes] + + legend_kw = dict( + loc="upper left", + bbox_to_anchor=(0.32, 1.13), + frameon=False, + ncol=1, + labelspacing=0.8, + handletextpad=1, + ) + + add_legend_lines( + ax, + sizes, + labels, + patch_kw=dict(color="lightgrey"), + legend_kw=legend_kw, + ) + + legend_kw = dict( + loc="upper left", + bbox_to_anchor=(0.55, 1.13), + labelspacing=0.8, + handletextpad=0, + frameon=False, + ) + + add_legend_circles( + ax, + sizes=[0.1], + labels=["Home market"], + srid=n.srid, + patch_kw=dict(facecolor=color_hm_nodes), + legend_kw=legend_kw, + ) + + legend_kw["bbox_to_anchor"] = (0.55, 1.08) + + add_legend_circles( + ax, + sizes=[0.1], + labels=["Offshore hubs"], + srid=n.srid, + patch_kw=dict(facecolor=color_oh_nodes), + legend_kw=legend_kw, + ) + + label = "DC link" if carrier == "DC_OH" else "H2 pipeline" + + legend_kw = dict( + loc="upper left", + bbox_to_anchor=(0, 1.13), + ncol=1, + frameon=False, + ) + + add_legend_patches(ax, color, label, legend_kw=legend_kw) + + ax.set_facecolor("white") + + plt.savefig(map_fn, bbox_inches="tight") + plt.close() + + +if __name__ == "__main__": + if "snakemake" not in globals(): + from _helpers import mock_snakemake + + snakemake = mock_snakemake( + "plot_offshore_network", + opts="", + clusters="all", + sector_opts="", + planning_horizons=2050, + carrier="DC_OH", + ) + configure_logging(snakemake) + set_scenario_config(snakemake) + + n = pypsa.Network(snakemake.input.network) + + map_opts = snakemake.params.plotting["map"] + + if map_opts["boundaries"] is None: + regions = gpd.read_file(snakemake.input.regions_onshore).set_index("name") + map_opts["boundaries"] = regions.total_bounds[[0, 2, 1, 3]] + [-1, 1, -1, 1] + + proj = load_projection(snakemake.params.plotting) + map_fn = snakemake.output.map + + p_nom = "p_nom_opt" if snakemake.params.expanded else "p_nom" + + plot_offshore_map( + n, + map_opts, + map_fn, + snakemake.wildcards.planning_horizons, + carrier=snakemake.wildcards.carrier, + p_nom=p_nom, + ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 6a39c6d55d..d57b40505f 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -25,6 +25,7 @@ from scripts._helpers import ( configure_logging, get, + make_index, set_scenario_config, update_config_from_wildcards, ) @@ -51,7 +52,7 @@ logger = logging.getLogger(__name__) -def define_spatial(nodes, options, buses_h2_file=None): +def define_spatial(nodes, options, offshore_buses_fn=None, buses_h2_file=None): """ Namespace for spatial. @@ -70,11 +71,36 @@ def define_spatial(nodes, options, buses_h2_file=None): - regional_oil_demand : bool - regional_coal_demand : bool buses_h2_file : str - Path to CSV file containing TYNDP H2 buses information + Path to the file containing TYNDP H2 buses information. + offshore_buses_fn : str + Path to the file containing offshore bus data. """ spatial.nodes = nodes + # offshore hubs + + if options["offshore_hubs_tyndp"]["enable"] and offshore_buses_fn: + spatial.offshore_hubs = SimpleNamespace() + offshore_buses = pd.read_csv(offshore_buses_fn, index_col=0) + offshore_buses_h2 = offshore_buses.set_index(offshore_buses.index + " H2") + spatial.offshore_hubs.nodes = offshore_buses.index + spatial.offshore_hubs.nodes_h2 = offshore_buses_h2.index + spatial.offshore_hubs.carrier = pd.Series("AC_OH", index=offshore_buses.index) + spatial.offshore_hubs.carrier_h2 = pd.Series( + "H2_OH", index=offshore_buses_h2.index + ) + spatial.offshore_hubs.x = offshore_buses.x + spatial.offshore_hubs.y = offshore_buses.y + spatial.offshore_hubs.x_h2 = offshore_buses_h2.x + spatial.offshore_hubs.y_h2 = offshore_buses_h2.y + spatial.offshore_hubs.locations = offshore_buses.location + spatial.offshore_hubs.locations_h2 = offshore_buses_h2.location + spatial.offshore_hubs.country = offshore_buses.location.str[:2] + spatial.offshore_hubs.country_h2 = offshore_buses_h2.location.str[:2] + spatial.offshore_hubs.type = offshore_buses.type + spatial.offshore_hubs.type_h2 = offshore_buses_h2.type + # biomass spatial.biomass = SimpleNamespace() @@ -436,11 +462,8 @@ def create_network_topology( candidates_n = candidates[~positive_order].rename(columns=swap_buses) candidates = pd.concat([candidates_p, candidates_n]) - def make_index(c): - return prefix + c.bus0 + connector + c.bus1 - topo = candidates.groupby(["bus0", "bus1"], as_index=False).mean() - topo.index = topo.apply(make_index, axis=1) + topo.index = topo.apply(make_index, axis=1, prefix=prefix, connector=connector) if not bidirectional: topo_reverse = topo.copy() @@ -1630,7 +1653,7 @@ def insert_electricity_distribution_grid( - Micro-CHP units """ - nodes = n.buses.query("carrier == 'AC' and not index.str.contains('DRES')").index + nodes = n.buses.query("carrier == 'AC'").index n.add( "Bus", @@ -1964,7 +1987,7 @@ def add_h2_dres_tyndp(n, spatial, buses_h2_z2, costs): location=buses_h2_z2, country=spatial.h2_tyndp.df.loc[buses_h2_z2].country.values, v_nom=380.0, - carrier="AC", + carrier="AC_DRES", unit="MWh_el", substation_off=1.0, substation_lv=1.0, @@ -2338,7 +2361,7 @@ def add_h2_topology_tyndp( ) -def add_h2_production(n, nodes, options, spatial, costs, logger): +def add_h2_production(n, nodes, options, spatial, costs): """ Adds base H2 production technologies. @@ -2358,8 +2381,6 @@ def add_h2_production(n, nodes, options, spatial, costs, logger): Object containing spatial information about nodes and their locations costs : pd.DataFrame Technology cost assumptions - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2416,7 +2437,7 @@ def add_h2_production(n, nodes, options, spatial, costs, logger): ) -def add_h2_reconversion(n, nodes, options, spatial, costs, logger): +def add_h2_reconversion(n, nodes, options, spatial, costs): """ Adds base H2 reconversion technologies (optional). @@ -2436,8 +2457,6 @@ def add_h2_reconversion(n, nodes, options, spatial, costs, logger): Object containing spatial information about nodes and their locations costs : pd.DataFrame Technology cost assumptions - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2500,7 +2519,7 @@ def add_h2_reconversion(n, nodes, options, spatial, costs, logger): ) -def add_h2_storage(n, nodes, options, cavern_types, h2_cavern_file, costs, logger): +def add_h2_storage(n, nodes, options, cavern_types, h2_cavern_file, costs): """ Adds H2 storage as underground cavern storage (optional) and H2 steel tanks. @@ -2520,8 +2539,6 @@ def add_h2_storage(n, nodes, options, cavern_types, h2_cavern_file, costs, logge Path to CSV file containing hydrogen cavern storage potentials costs : pd.DataFrame Technology cost assumptions - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2581,7 +2598,7 @@ def add_h2_storage(n, nodes, options, cavern_types, h2_cavern_file, costs, logge ) -def add_gas_network(n, gas_pipes, options, costs, gas_input_nodes, logger): +def add_gas_network(n, gas_pipes, options, costs, gas_input_nodes): """ Adds natural gas infrastructure, incl. LNG terminals, production, storage and entry-points. @@ -2600,8 +2617,6 @@ def add_gas_network(n, gas_pipes, options, costs, gas_input_nodes, logger): Technology cost assumptions gas_input_nodes : pd.DataFrame, optional DataFrame containing gas input node information (LNG, pipeline, etc.) - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2715,7 +2730,7 @@ def add_gas_network(n, gas_pipes, options, costs, gas_input_nodes, logger): ) -def add_h2_pipeline_retrofit(n, gas_pipes, options, costs, logger): +def add_h2_pipeline_retrofit(n, gas_pipes, options, costs): """ Adds retrofitting options of existing CH4 pipes to H2 pipes. @@ -2731,8 +2746,6 @@ def add_h2_pipeline_retrofit(n, gas_pipes, options, costs, logger): - H2_retrofit_capacity_per_CH4 : float costs : pd.DataFrame Technology cost assumptions - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2763,7 +2776,7 @@ def add_h2_pipeline_retrofit(n, gas_pipes, options, costs, logger): ) -def add_h2_pipeline_new(n, costs, logger): +def add_h2_pipeline_new(n, costs): """ Adds options for new H2 pipelines. @@ -2773,8 +2786,6 @@ def add_h2_pipeline_new(n, costs, logger): The PyPSA network container object costs : pd.DataFrame Technology cost assumptions - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2874,7 +2885,6 @@ def add_gas_and_h2_infrastructure( gas_input_nodes, spatial, options, - logger, ): """ Add storage and grid infrastructure to the network for gas and hydrogen. @@ -2914,8 +2924,6 @@ def add_gas_and_h2_infrastructure( - SMR : bool - cc_fraction : float - methanation : bool - logger : logging.Logger, optional - Logger for output messages. If None, no logging is performed. Returns ------- @@ -2964,7 +2972,6 @@ def add_gas_and_h2_infrastructure( options=options, spatial=spatial, costs=costs, - logger=logger, ) add_h2_reconversion( n=n, @@ -2972,7 +2979,6 @@ def add_gas_and_h2_infrastructure( options=options, spatial=spatial, costs=costs, - logger=logger, ) add_h2_storage( n=n, @@ -2981,7 +2987,6 @@ def add_gas_and_h2_infrastructure( cavern_types=cavern_types, h2_cavern_file=h2_cavern_file, costs=costs, - logger=logger, ) # add gas network, along with new and retrofitted H2 pipelines @@ -2995,7 +3000,6 @@ def add_gas_and_h2_infrastructure( options=options, costs=costs, gas_input_nodes=gas_input_nodes, - logger=logger, ) if options["H2_retrofit"] and not options["h2_topology_tyndp"]: @@ -3004,11 +3008,371 @@ def add_gas_and_h2_infrastructure( gas_pipes=gas_pipes, options=options, costs=costs, - logger=logger, ) if options["H2_network"] and not options["h2_topology_tyndp"]: - add_h2_pipeline_new(n=n, costs=costs, logger=logger) + add_h2_pipeline_new(n=n, costs=costs) + + +def add_offshore_generators_tyndp( + n: pypsa.Network, + pyear: int, + offshore_generators_fn: str, + profiles: dict[str, str], + pecd_mapping: dict[str, str], + costs: pd.DataFrame, + nyears: float = 1, +): + """ + Add offshore generators to the network model. + + This function adds offshore generation capacity, various offshore wind + turbines (AC and H2), to the offshore hub buses in the network. + + Existing capacities and potentials are adjusted for hydrogen-generating wind farms + to account for efficiency. + + Parameters + ---------- + n : pypsa.Network + The network object to add offshore generators to. + pyear : int + Planning horizon used to filter which reference generator data to include. + offshore_generators_fn : str + Path to the file containing offshore generators configuration data. + profiles : dict[str, str] + Dictionary mapping technology names to profile file paths + e.g. {'offwind-dc': 'path/to/profile.nc'} + pecd_mapping : dict[str, str] + Dictionary mapping technology names to PECD profile names + e.g. {'offwind-dc-fb-oh': 'Offshore_Wind'} + costs : pd.DataFrame + Technology costs assumptions. + nyears : float, default 1 + Number of years for which to scale the investment costs. + + Returns + ------- + None + Modifies the network object in-place by adding offshore generators. + """ + logger.info("Adding offshore generators") + + offshore_generators = pd.read_csv(offshore_generators_fn).query("pyear==@pyear") + + # Assign locations and index + offshore_generators.index = ( + offshore_generators.location + " 0 " + offshore_generators.carrier + ) + + # Adjust capacities and costs to account for efficiency + h2_idx = offshore_generators.filter(like="h2", axis=0).index + offshore_generators["efficiency"] = np.where( + offshore_generators["carrier"].str.contains("h2"), + costs.at["electrolysis", "efficiency"], + 1.0, + ) + offshore_generators.loc[h2_idx, ["p_nom_min", "p_nom_max"]] *= costs.at[ + "electrolysis", "efficiency" + ] + offshore_generators.loc[h2_idx, ["capex", "opex"]] /= costs.at[ + "electrolysis", "efficiency" + ] + + # Determine capital_cost + annuity_factor = calculate_annuity(costs["lifetime"], costs["discount rate"]) + offshore_generators.loc[:, "capital_cost"] = ( + annuity_factor.get("electrolysis") * offshore_generators["capex"] + + offshore_generators["opex"] + ) * nyears + + # Mapping from TYNDP offshore generators to PECD profiles + offshore_generators["pecd_profile_name"] = offshore_generators["carrier"].map( + pecd_mapping + ) + + # Load PECD profiles + p_max_pu = [] + for key, fn in profiles.items(): + tech = key[len("profile_pecd_") :] + techs = offshore_generators[ + offshore_generators.pecd_profile_name == tech + ].carrier.unique() + + with xr.open_dataset(fn) as ds: + ds = ds.stack(bus_bin=["bus", "bin"]) + p_max_pu_i = ds["profile"].sel(year=pyear, time=n.snapshots).to_pandas() + + for tech_i in techs: + p_max_pu_t = p_max_pu_i.copy() + p_max_pu_t.columns = p_max_pu_t.columns.map(flatten) + f" {tech_i}" + p_max_pu.append(p_max_pu_t) + + p_max_pu = pd.concat(p_max_pu, axis=1).reindex(offshore_generators.index, axis=1) + + # Add generators to the network + n.add( + "Generator", + offshore_generators.index, + bus=offshore_generators.bus, + carrier=offshore_generators.carrier, + p_nom=offshore_generators.p_nom_min, + p_nom_min=offshore_generators.p_nom_min, + p_nom_max=offshore_generators.p_nom_max, + p_nom_extendable=offshore_generators.p_nom_extendable, + capital_cost=offshore_generators.capital_cost, + marginal_cost=costs.at["offwind", "marginal_cost"], + efficiency_dc_to_b0=offshore_generators.efficiency, + efficiency_dc_to_h2=costs.at["electrolysis", "efficiency"], + p_max_pu=p_max_pu, + lifetime=costs.at["offwind", "lifetime"], + ) + + +def add_offshore_electrolysers_tyndp( + n: pypsa.Network, + pyear: int, + offshore_electrolysers_fn: str, + costs: pd.DataFrame, + nyears: float = 1, +): + """ + Add offshore electrolysers to the network model. + + This function adds offshore electrolysis capacity to the offshore hub buses in the network. + + Parameters + ---------- + n : pypsa.Network + The network object to add offshore generators to. + pyear : int + Planning horizon used to filter which reference generator data to include. + offshore_electrolysers_fn : str + Path to the file containing offshore electrolysers configuration data. + costs : pd.DataFrame + Technology costs assumptions. + nyears : float, default 1 + Number of years for which to scale the investment costs. + + Returns + ------- + None + Modifies the network object in-place by adding offshore generators. + """ + logger.info("Adding offshore electrolysers") + + offshore_electrolysers = pd.read_csv(offshore_electrolysers_fn).query( + "pyear==@pyear" + ) + annuity_factor = calculate_annuity(costs["lifetime"], costs["discount rate"]) + offshore_electrolysers.index = ( + offshore_electrolysers.bus0 + " H2 Offshore Electrolysis" + ) + + offshore_electrolysers.loc[:, "capital_cost"] = ( + annuity_factor.get("electrolysis") * offshore_electrolysers["capex"] + + offshore_electrolysers["opex"] + ) * nyears + + n.add( + "Link", + offshore_electrolysers.index, + bus0=offshore_electrolysers.bus0, + bus1=offshore_electrolysers.bus1, + p_nom_extendable=True, + carrier="H2 Electrolysis", + efficiency=costs.at["electrolysis", "efficiency"], + capital_cost=offshore_electrolysers.capital_cost, + lifetime=costs.at["electrolysis", "lifetime"], + ) + + +def add_offshore_grid_tyndp( + n: pypsa.Network, + pyear: int, + offshore_grid_fn: str, + costs: pd.DataFrame, + nyears: float = 1, +): + """ + Add offshore grid connections to the network model. + + This function reads offshore grid configuration data and adds both DC and H2 pipeline links to the network. + + Parameters + ---------- + n : pypsa.Network + The network object to add offshore grid connections to. + pyear : int + Planning horizon used to filter which reference grid data to include. + offshore_grid_fn : str + Path to the file containing offshore grid configuration data. + costs : pd.DataFrame + Technology costs assumptions. + nyears : float, default 1 + Number of years for which to scale the investment costs. + + Returns + ------- + None + Modifies the network object in-place by adding offshore grid links. + + + Notes + ----- + The capital costs are calculated as: + (annuity_factor * capex + opex) * nyears + + """ + logger.info("Adding offshore grid connections") + + offshore_grid = pd.read_csv(offshore_grid_fn).query("pyear==@pyear") + annuity_factor = calculate_annuity(costs["lifetime"], costs["discount rate"]) + + # Add DC grid connections + offshore_grid_dc = offshore_grid.query("carrier=='DC_OH'").copy() + offshore_grid_dc.index = offshore_grid_dc.apply( + lambda x: f"{x.bus0}-{x.bus1}-Offshore DC", axis=1 + ) + offshore_grid_dc.loc[:, "capital_cost"] = ( + annuity_factor.get("HVDC submarine") * offshore_grid_dc["capex"] + + offshore_grid_dc["opex"] + ) * nyears + + n.add( + "Link", + offshore_grid_dc.index, + bus0=offshore_grid_dc.bus0, + bus1=offshore_grid_dc.bus1, + p_nom_extendable=offshore_grid_dc.p_nom_extendable, + p_nom=offshore_grid_dc.p_nom_min, + p_nom_min=offshore_grid_dc.p_nom_min, + p_nom_max=offshore_grid_dc.p_nom_max, + p_min_pu=offshore_grid_dc.p_min_pu, + p_max_pu=offshore_grid_dc.p_max_pu, + capital_cost=offshore_grid_dc.capital_cost, + carrier=offshore_grid_dc.carrier, + lifetime=costs.at["HVDC submarine", "lifetime"], + ) + + # Add H2 pipeline connections + offshore_grid_h2 = offshore_grid.query("carrier=='H2 pipeline OH'").copy() + offshore_grid_h2.index = offshore_grid_h2.apply( + make_index, axis=1, prefix="Offshore H2 pipeline" + ) + offshore_grid_h2.loc[:, "capital_cost"] = ( + annuity_factor.get("H2 (g) submarine pipeline") * offshore_grid_h2["capex"] + + offshore_grid_h2["opex"] + ) * nyears + + n.add( + "Link", + offshore_grid_h2.index, + bus0=offshore_grid_h2.bus0, + bus1=offshore_grid_h2.bus1, + p_nom_extendable=offshore_grid_h2.p_nom_extendable, + p_nom=offshore_grid_h2.p_nom_min, + p_nom_min=offshore_grid_h2.p_nom_min, + p_nom_max=offshore_grid_h2.p_nom_max, + p_min_pu=offshore_grid_h2.p_min_pu, + p_max_pu=offshore_grid_h2.p_max_pu, + capital_cost=offshore_grid_h2.capital_cost, + carrier=offshore_grid_h2.carrier, + lifetime=costs.at["H2 (g) submarine pipeline", "lifetime"], + ) + + +def add_offshore_hubs_tyndp( + n: pypsa.Network, + pyear: int, + offshore_generators_fn: str, + offshore_electrolysers_fn: str, + offshore_grid_fn: str, + profiles: dict[str, str], + pecd_mapping: dict[str, str], + costs: pd.DataFrame, + spatial: SimpleNamespace, + nyears: float = 1, +): + """ + Add offshore hubs and grid connections to the network model. + + This function creates offshore hub infrastructure by adding both the physical + hubs (buses) and their interconnecting grid (DC and H2 pipeline links). + + Parameters + ---------- + n : pypsa.Network + The network object to add offshore hubs and grid to. + pyear: int + Planning horizon used to filter which reference grid data to include. + offshore_generators_fn : str + Path to the file containing offshore generators configuration data. + offshore_electrolysers_fn : str + Path to the file containing offshore electrolysers configuration data. + offshore_grid_fn : str + Path to the file containing offshore grid configuration data. + profiles : dict[str, str] + Dictionary mapping technology names to profile file paths + e.g. {'offwind-dc': 'path/to/profile.nc'} + pecd_mapping : dict[str, str] + Dictionary mapping technology names to PECD profile names + e.g. {'offwind-dc-fb-oh': 'Offshore_Wind'} + costs : pd.DataFrame + Technology costs assumptions. + spatial : object, optional + Object containing spatial information about nodes and their locations. + nyears : float + Number of years for which to scale the investment costs. + + Returns + ------- + None + Modifies the network object in-place by adding offshore hubs + + Notes + ----- + Components added to the network: + - Offshore DC and H2 buses + - Offshore DC and H2 grid + """ + logger.info("Adding offshore hubs") + + n.add( + "Bus", + spatial.offshore_hubs.nodes, + x=spatial.offshore_hubs.x, + y=spatial.offshore_hubs.y, + location=spatial.offshore_hubs.locations, + country=spatial.offshore_hubs.country, + type=spatial.offshore_hubs.type, + carrier="AC_OH", + unit="MWh_el", + v_nom=380, + ) + + n.add( + "Bus", + spatial.offshore_hubs.nodes_h2, + x=spatial.offshore_hubs.x_h2, + y=spatial.offshore_hubs.y_h2, + location=spatial.offshore_hubs.locations_h2, + country=spatial.offshore_hubs.country_h2, + type=spatial.offshore_hubs.type_h2, + carrier="H2_OH", + unit="MWh_LHV", + ) + + # Add power production units + add_offshore_generators_tyndp( + n, pyear, offshore_generators_fn, profiles, pecd_mapping, costs, nyears + ) + + # Add H2 production units + add_offshore_electrolysers_tyndp(n, pyear, offshore_electrolysers_fn, costs, nyears) + + # Add offshore DC and H2 grid connections + add_offshore_grid_tyndp(n, pyear, offshore_grid_fn, costs, nyears) def check_land_transport_shares(shares): @@ -7144,6 +7508,13 @@ def add_import_options( for key in snakemake.input.keys() if key.startswith("profile") } + pecd_renewable_profiles_techs = snakemake.params.electricity[ + "pecd_renewable_profiles" + ]["technologies"] + pecd_mapping = { + v: k for k, v_list in pecd_renewable_profiles_techs.items() for v in v_list + } + landfall_lengths = { tech: settings["landfall_length"] for tech, settings in snakemake.params.renewable.items() @@ -7156,7 +7527,17 @@ def add_import_options( heating_efficiencies = pd.read_csv(fn, index_col=[1, 0]).loc[year] buses_h2_file = snakemake.input.buses_h2 if options["h2_topology_tyndp"] else None - spatial = define_spatial(pop_layout.index, options, buses_h2_file=buses_h2_file) + buses_oh_file = ( + snakemake.input.offshore_buses + if options["offshore_hubs_tyndp"]["enable"] + else None + ) + spatial = define_spatial( + pop_layout.index, + options, + offshore_buses_fn=buses_oh_file, + buses_h2_file=buses_h2_file, + ) if snakemake.params.foresight in ["overnight", "myopic", "perfect"]: add_lifetime_wind_solar(n, costs) @@ -7206,9 +7587,22 @@ def add_import_options( gas_input_nodes=gas_input_nodes, spatial=spatial, options=options, - logger=logger, ) + if snakemake.params.offshore_hubs_tyndp: + add_offshore_hubs_tyndp( + n=n, + pyear=int(snakemake.wildcards.planning_horizons), + offshore_generators_fn=snakemake.input.offshore_generators, + offshore_electrolysers_fn=snakemake.input.offshore_electrolysers, + offshore_grid_fn=snakemake.input.offshore_grid, + profiles=profiles, + pecd_mapping=pecd_mapping, + costs=costs, + spatial=spatial, + nyears=nyears, + ) + add_battery_stores(n=n, nodes=pop_layout.index, costs=costs) if options["transport"]: diff --git a/scripts/solve_network.py b/scripts/solve_network.py index f9945579f1..f6bb098061 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# SPDX-FileCopyrightText: Open Energy Transition gGmbH and contributors to PyPSA-Eur # # SPDX-License-Identifier: MIT """ @@ -1134,6 +1134,89 @@ def add_import_limit_constraint(n: pypsa.Network, sns: pd.DatetimeIndex): n.model.add_constraints(lhs, limit_sense, rhs, name="import_limit") +def add_offshore_hubs_constraint( + n, + planning_horizons: int, + offshore_zone_trajectories_fn, + carriers_tyndp: list[str], +): + """ + Add two constraints on offshore hubs. + + 1. Constraint expansion of DC and H2 sitting on the same location, as the sum of the two capacities cannot exceed the layer potential. + 2. Constraint the maximum potential per zone. + + Parameters + ---------- + n : pypsa.Network + The PyPSA network instance + planning_horizons : int, optional + The current planning horizon year or None in perfect foresight + offshore_zone_trajectories_fn: str + Path to the file containing the offshore zone potentials trajectories + carriers_tyndp : list[str], optional + List of TYNDP renewable carriers + """ + ext_i = n.generators.p_nom_extendable + gens = n.generators.assign( + layer=lambda df: df.index.str.replace( + r"-\d{4}$", f"-{planning_horizons}", regex=True + ), + zone=lambda df: df.index.str.split().str[0], + ).rename_axis("Generator-ext") + off_carriers = [i for i in carriers_tyndp if "offwind" in i] + off_h2_carriers = [i for i in off_carriers if "h2" in i] + + # Constraint DC / H2 expansion on the same layer + h2_i = gens.carrier.isin(off_h2_carriers) + h2_gens = gens.loc[(h2_i) & (ext_i)] + h2_gens_i = h2_gens.index + dc_gens_i = h2_gens_i.str.replace("h2", "dc").str.replace(" H2", "") + p_nom = n.model["Generator-p_nom"] + + lhs = ( + p_nom.loc[dc_gens_i] + + p_nom.loc[h2_gens_i] / h2_gens.loc[h2_gens_i, "efficiency_dc_to_b0"] + ) + rhs = gens.loc[dc_gens_i].p_nom_max + + if not lhs.empty: + n.model.add_constraints(lhs <= rhs, name="Generator-off_h2_dc_pot") + + # Constraint the maximum potential per zone + limit = ( + pd.read_csv(offshore_zone_trajectories_fn, index_col=0) + .query("pyear == @planning_horizons") + .p_nom_max + ) + + off_i = gens.carrier.isin(off_carriers) + + off_gens_i = gens.loc[(off_i) & (ext_i)].index + grouper_ext = gens.loc[off_gens_i].zone.rename("Generator-ext") + idx = pd.Index(set(limit.index).intersection(grouper_ext)) + eff_z = gens["efficiency_dc_to_b0"].reindex(off_gens_i) + lhs = (p_nom.loc[off_gens_i] / eff_z).groupby(grouper_ext).sum().loc[idx] + + existing_z = ( + gens.loc[(off_i) & ~(ext_i)] + .assign( + p_nom=lambda df: np.where( + df.carrier.str.contains("h2"), + df.p_nom.div(df.efficiency_dc_to_h2), + df.p_nom, + ) + ) + .rename(lambda x: x.split("-2")[0] + f"-{planning_horizons}")[["p_nom", "zone"]] + .groupby(by="zone") + .sum() + .reindex(idx, fill_value=0)["p_nom"] + ) + rhs = limit.loc[idx] - existing_z + + n.model.add_constraints(lhs <= rhs, name="Generator-off_zone_pot") + + def add_co2_atmosphere_constraint(n, snapshots): glcs = n.global_constraints[n.global_constraints.type == "co2_atmosphere"] @@ -1158,7 +1241,11 @@ def add_co2_atmosphere_constraint(n, snapshots): def extra_functionality( - n: pypsa.Network, snapshots: pd.DatetimeIndex, planning_horizons: str | None = None + n: pypsa.Network, + snapshots: pd.DatetimeIndex, + planning_horizons: str | None = None, + offshore_zone_trajectories_fn: str | None = None, + carriers_tyndp: list[str] = [], ) -> None: """ Add custom constraints and functionality. @@ -1171,6 +1258,10 @@ def extra_functionality( Simulation timesteps planning_horizons : str, optional The current planning horizon year or None in perfect foresight + offshore_zone_trajectories_fn: str, optional + Path to the file containing the offshore zone potentials trajectories + carriers_tyndp : list[str], optional + List of TYNDP renewable carriers Collects supplementary constraints which will be passed to ``pypsa.optimization.optimize``. @@ -1227,6 +1318,11 @@ def extra_functionality( if config["sector"]["imports"]["enable"]: add_import_limit_constraint(n, snapshots) + if config["sector"]["offshore_hubs_tyndp"]["enable"]: + add_offshore_hubs_constraint( + n, int(planning_horizons), offshore_zone_trajectories_fn, carriers_tyndp + ) + if n.params.custom_extra_functionality: source_path = n.params.custom_extra_functionality assert os.path.exists(source_path), f"{source_path} does not exist" @@ -1272,6 +1368,8 @@ def solve_network( solving: dict, rule_name: str | None = None, planning_horizons: str | None = None, + offshore_zone_trajectories_fn: str | None = None, + carriers_tyndp: list[str] = [], **kwargs, ) -> None: """ @@ -1290,7 +1388,11 @@ def solve_network( rule_name : str, optional Name of the snakemake rule being executed planning_horizons : str, optional - The current planning horizon year or None in perfect foresight + The current planning horizon year or None in perfect foresight + offshore_zone_trajectories_fn : str, optional + Path to DataFrame containing the offshore zone potentials trajectories + carriers_tyndp : list[str], optional + List of TYNDP renewable carriers **kwargs Additional keyword arguments passed to the solver @@ -1319,7 +1421,10 @@ def solve_network( ) kwargs["solver_name"] = solving["solver"]["name"] kwargs["extra_functionality"] = partial( - extra_functionality, planning_horizons=planning_horizons + extra_functionality, + planning_horizons=planning_horizons, + offshore_zone_trajectories_fn=offshore_zone_trajectories_fn, + carriers_tyndp=carriers_tyndp, ) kwargs["transmission_losses"] = cf_solving.get("transmission_losses", False) kwargs["linearized_unit_commitment"] = cf_solving.get( @@ -1425,6 +1530,8 @@ def solve_network( planning_horizons=planning_horizons, rule_name=snakemake.rule, log_fn=snakemake.log.solver, + offshore_zone_trajectories_fn=snakemake.input.offshore_zone_trajectories, + carriers_tyndp=snakemake.params.carriers_tyndp, ) logger.info(f"Maximum memory usage: {mem.mem_usage}")