diff --git a/config.default.yaml b/config.default.yaml index ec7dc399..747e8023 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -223,7 +223,8 @@ sector: co2_vent: true SMR: true co2_sequestration_potential: 200 #MtCO2/a sequestration potential for Europe - co2_sequestration_cost: 20 #EUR/tCO2 for transport and sequestration of CO2 + co2_sequestration_cost: 10 #EUR/tCO2 for sequestration of CO2 + co2_network: false cc_fraction: 0.9 # default fraction of CO2 captured with post-combustion capture hydrogen_underground_storage: true use_fischer_tropsch_waste_heat: true @@ -467,6 +468,7 @@ plotting: hot water storage: '#BBBBBB' hot water charging: '#BBBBBB' hot water discharging: '#999999' + CO2 pipeline: '#999999' CHP: r CHP heat: r CHP electric: r diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 4de40f06..f838544c 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -62,6 +62,14 @@ Future release These are included in the environment specifications of PyPSA-Eur. * Consistent use of ``__main__`` block and further unspecific code cleaning. * Distinguish costs for home battery storage and inverter from utility-scale battery costs. +* Add option to regionally resolve CO2 storage and add CO2 pipeline transport because geological storage potential, + CO2 utilisation sites and CO2 capture sites may be separated. + The CO2 network is built from zero based on the topology of the electricity grid (greenfield). + Pipelines are assumed to be bidirectional and lossless. + Furthermore, neither retrofitting of natural gas pipelines (required pressures are too high, 80-160 bar vs <80 bar) + nor other modes of CO2 transport (by ship, road or rail) are considered. + The regional representation of CO2 is activated with the config setting ``sector: co2_network: true`` but is deactivated by default. + The global limit for CO2 sequestration now applies to the sum of all CO2 stores via an ``extra_functionality`` constraint. * Added option for hydrogen liquefaction costs for hydrogen demand in shipping. This introduces a new ``H2 liquid`` bus at each location. It is activated via ``sector: shipping_hydrogen_liquefaction: true``. diff --git a/doc/spatial_resolution.rst b/doc/spatial_resolution.rst index 1be9f3ad..13e94543 100644 --- a/doc/spatial_resolution.rst +++ b/doc/spatial_resolution.rst @@ -48,7 +48,7 @@ Solid biomass: single node for Europe, until transport costs can be incorporated. CO2: single node for Europe, but a transport and storage cost is added for -sequestered CO2. +sequestered CO2. Optionally: nodal, with CO2 transport via pipelines. Liquid hydrocarbons: single node for Europe, since transport costs for liquids are low. diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index ef6845fc..36d9aec7 100644 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -19,6 +19,37 @@ from helper import override_component_attrs import logging logger = logging.getLogger(__name__) +from types import SimpleNamespace +spatial = SimpleNamespace() + + +def define_spatial(nodes): + """ + Namespace for spatial + + Parameters + ---------- + nodes : list-like + """ + + global spatial + global options + + spatial.nodes = nodes + + spatial.co2 = SimpleNamespace() + + if options["co2_network"]: + spatial.co2.nodes = nodes + " co2 stored" + spatial.co2.locations = nodes + spatial.co2.vents = nodes + " co2 vent" + else: + spatial.co2.nodes = ["co2 stored"] + spatial.co2.locations = ["EU"] + spatial.co2.vents = ["co2 vent"] + + spatial.co2.df = pd.DataFrame(vars(spatial.co2), index=nodes) + def emission_sectors_from_opts(opts): @@ -54,6 +85,40 @@ def get(item, investment_year=None): return item +def create_network_topology(n, prefix, connector=" -> "): + """ + Create a network topology like the power transmission network. + + Parameters + ---------- + n : pypsa.Network + prefix : str + connector : str + + Returns + ------- + pd.DataFrame with columns bus0, bus1 and length + """ + + ln_attrs = ["bus0", "bus1", "length"] + lk_attrs = ["bus0", "bus1", "length", "underwater_fraction"] + + candidates = pd.concat([ + n.lines[ln_attrs], + n.links.loc[n.links.carrier == "DC", lk_attrs] + ]).fillna(0) + + positive_order = candidates.bus0 < candidates.bus1 + candidates_p = candidates[positive_order] + swap_buses = {"bus0": "bus1", "bus1": "bus0"} + candidates_n = candidates[~positive_order].rename(columns=swap_buses) + candidates = pd.concat([candidates_p, candidates_n]) + + topo = candidates.groupby(["bus0", "bus1"], as_index=False).mean() + topo.index = topo.apply(lambda c: prefix + c.bus0 + connector + c.bus1, axis=1) + return topo + + def co2_emissions_year(countries, opts, year): """ Calculate CO2 emissions in one specific year (e.g. 1990 or 2018). @@ -299,26 +364,26 @@ def add_co2_tracking(n, options): ) # this tracks CO2 stored, e.g. underground - n.add("Bus", - "co2 stored", - location="EU", + n.madd("Bus", + spatial.co2.nodes, + location=spatial.co2.locations, carrier="co2 stored" ) - n.add("Store", - "co2 stored", + n.madd("Store", + spatial.co2.nodes, e_nom_extendable=True, - e_nom_max=options['co2_sequestration_potential'] * 1e6, + e_nom_max=np.inf, capital_cost=options['co2_sequestration_cost'], carrier="co2 stored", - bus="co2 stored" + bus=spatial.co2.nodes ) if options['co2_vent']: - n.add("Link", - "co2 vent", - bus0="co2 stored", + n.madd("Link", + spatial.co2.vents, + bus0=spatial.co2.nodes, bus1="co2 atmosphere", carrier="co2 vent", efficiency=1., @@ -326,6 +391,28 @@ def add_co2_tracking(n, options): ) +def add_co2_network(n, costs): + + logger.info("Adding CO2 network.") + co2_links = create_network_topology(n, "CO2 pipeline ") + + cost_onshore = (1 - co2_links.underwater_fraction) * costs.at['CO2 pipeline', 'fixed'] * co2_links.length + cost_submarine = co2_links.underwater_fraction * costs.at['CO2 submarine pipeline', 'fixed'] * co2_links.length + capital_cost = cost_onshore + cost_submarine + + n.madd("Link", + co2_links.index, + bus0=co2_links.bus0.values + " co2 stored", + bus1=co2_links.bus1.values + " co2 stored", + p_min_pu=-1, + p_nom_extendable=True, + length=co2_links.length.values, + capital_cost=capital_cost.values, + carrier="CO2 pipeline", + lifetime=costs.at['CO2 pipeline', 'lifetime'] + ) + + def add_dac(n, costs): heat_carriers = ["urban central heat", "services urban decentral heat"] @@ -339,7 +426,7 @@ def add_dac(n, costs): locations, suffix=" DAC", bus0="co2 atmosphere", - bus1="co2 stored", + bus1=spatial.co2.df.loc[locations, "nodes"].values, bus2=locations.values, bus3=heat_buses, carrier="DAC", @@ -990,10 +1077,11 @@ def add_storage(n, costs): if options['methanation']: n.madd("Link", - nodes + " Sabatier", + spatial.nodes, + suffix=" Sabatier", bus0=nodes + " H2", bus1="EU gas", - bus2="co2 stored", + bus2=spatial.co2.nodes, p_nom_extendable=True, carrier="Sabatier", efficiency=costs.at["methanation", "efficiency"], @@ -1005,10 +1093,11 @@ def add_storage(n, costs): if options['helmeth']: n.madd("Link", - nodes + " helmeth", + spatial.nodes, + suffix=" helmeth", bus0=nodes, bus1="EU gas", - bus2="co2 stored", + bus2=spatial.co2.nodes, carrier="helmeth", p_nom_extendable=True, efficiency=costs.at["helmeth", "efficiency"], @@ -1021,11 +1110,12 @@ def add_storage(n, costs): if options['SMR']: n.madd("Link", - nodes + " SMR CC", + spatial.nodes, + suffix=" SMR CC", bus0="EU gas", bus1=nodes + " H2", bus2="co2 atmosphere", - bus3="co2 stored", + bus3=spatial.co2.nodes, p_nom_extendable=True, carrier="SMR CC", efficiency=costs.at["SMR CC", "efficiency"], @@ -1374,7 +1464,7 @@ def add_heat(n, costs): bus1=nodes[name], bus2=nodes[name] + " urban central heat", bus3="co2 atmosphere", - bus4="co2 stored", + bus4=spatial.co2.df.loc[nodes[name], "nodes"].values, carrier="urban central gas CHP CC", p_nom_extendable=True, capital_cost=costs.at['central gas CHP', 'fixed']*costs.at['central gas CHP', 'efficiency'] + costs.at['biomass CHP capture', 'fixed']*costs.at['gas', 'CO2 intensity'], @@ -1607,7 +1697,7 @@ def add_biomass(n, costs): bus1=urban_central, bus2=urban_central + " urban central heat", bus3="co2 atmosphere", - bus4="co2 stored", + bus4=spatial.co2.df.loc[urban_central, "nodes"].values, carrier="urban central solid biomass CHP CC", p_nom_extendable=True, capital_cost=costs.at[key, 'fixed'] * costs.at[key, 'efficiency'] + costs.at['biomass CHP capture', 'fixed'] * costs.at['solid biomass', 'CO2 intensity'], @@ -1653,12 +1743,13 @@ def add_industry(n, costs): efficiency=1. ) - n.add("Link", - "solid biomass for industry CC", + n.madd("Link", + spatial.co2.locations, + suffix=" solid biomass for industry CC", bus0="EU solid biomass", bus1="solid biomass for industry", bus2="co2 atmosphere", - bus3="co2 stored", + bus3=spatial.co2.nodes, carrier="solid biomass for industry CC", p_nom_extendable=True, capital_cost=costs.at["cement capture", "fixed"] * costs.at['solid biomass', 'CO2 intensity'], @@ -1691,12 +1782,13 @@ def add_industry(n, costs): efficiency2=costs.at['gas', 'CO2 intensity'] ) - n.add("Link", - "gas for industry CC", + n.madd("Link", + spatial.co2.locations, + suffix=" gas for industry CC", bus0="EU gas", bus1="gas for industry", bus2="co2 atmosphere", - bus3="co2 stored", + bus3=spatial.co2.nodes, carrier="gas for industry CC", p_nom_extendable=True, capital_cost=costs.at["cement capture", "fixed"] * costs.at['gas', 'CO2 intensity'], @@ -1827,7 +1919,7 @@ def add_industry(n, costs): nodes + " Fischer-Tropsch", bus0=nodes + " H2", bus1="EU oil", - bus2="co2 stored", + bus2=spatial.co2.nodes, carrier="Fischer-Tropsch", efficiency=costs.at["Fischer-Tropsch", 'efficiency'], capital_cost=costs.at["Fischer-Tropsch", 'fixed'], @@ -1916,11 +2008,12 @@ def add_industry(n, costs): ) #assume enough local waste heat for CC - n.add("Link", - "process emissions CC", + n.madd("Link", + spatial.co2.locations, + suffix=" process emissions CC", bus0="process emissions", bus1="co2 atmosphere", - bus2="co2 stored", + bus2=spatial.co2.nodes, carrier="process emissions CC", p_nom_extendable=True, capital_cost=costs.at["cement capture", "fixed"], @@ -2039,6 +2132,8 @@ if __name__ == "__main__": patch_electricity_network(n) + define_spatial(pop_layout.index) + if snakemake.config["foresight"] == 'myopic': add_lifetime_wind_solar(n, costs) @@ -2091,6 +2186,9 @@ if __name__ == "__main__": if "noH2network" in opts: remove_h2_network(n) + if options["co2_network"]: + add_co2_network(n, costs) + for o in opts: m = re.match(r'^\d+h$', o, re.IGNORECASE) if m is not None: diff --git a/scripts/solve_network.py b/scripts/solve_network.py index a46acc30..9534a123 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -3,6 +3,7 @@ import pypsa import numpy as np +import pandas as pd from pypsa.linopt import get_var, linexpr, define_constraints @@ -150,8 +151,26 @@ def add_chp_constraints(n): define_constraints(n, lhs, "<=", 0, 'chplink', 'backpressure') +def add_co2_sequestration_limit(n, sns): + + co2_stores = n.stores.loc[n.stores.carrier=='co2 stored'].index + + if co2_stores.empty or ('Store', 'e') not in n.variables.index: + return + + vars_final_co2_stored = get_var(n, 'Store', 'e').loc[sns[-1], co2_stores] + + lhs = linexpr((1, vars_final_co2_stored)).sum() + rhs = n.config["sector"].get("co2_sequestration_potential", 200) * 1e6 + + name = 'co2_sequestration_limit' + define_constraints(n, lhs, "<=", rhs, 'GlobalConstraint', + 'mu', axes=pd.Index([name]), spec=name) + + def extra_functionality(n, snapshots): add_battery_constraints(n) + add_co2_sequestration_limit(n, snapshots) def solve_network(n, config, opts='', **kwargs):