Merge pull request #148 from PyPSA/co2-network

Add CO2 network
This commit is contained in:
Fabian Neumann 2021-09-28 11:29:20 +02:00 committed by GitHub
commit ec04d7909a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 158 additions and 31 deletions

View File

@ -223,7 +223,8 @@ sector:
co2_vent: true co2_vent: true
SMR: true SMR: true
co2_sequestration_potential: 200 #MtCO2/a sequestration potential for Europe 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 cc_fraction: 0.9 # default fraction of CO2 captured with post-combustion capture
hydrogen_underground_storage: true hydrogen_underground_storage: true
use_fischer_tropsch_waste_heat: true use_fischer_tropsch_waste_heat: true
@ -467,6 +468,7 @@ plotting:
hot water storage: '#BBBBBB' hot water storage: '#BBBBBB'
hot water charging: '#BBBBBB' hot water charging: '#BBBBBB'
hot water discharging: '#999999' hot water discharging: '#999999'
CO2 pipeline: '#999999'
CHP: r CHP: r
CHP heat: r CHP heat: r
CHP electric: r CHP electric: r

View File

@ -62,6 +62,14 @@ Future release
These are included in the environment specifications of PyPSA-Eur. These are included in the environment specifications of PyPSA-Eur.
* Consistent use of ``__main__`` block and further unspecific code cleaning. * Consistent use of ``__main__`` block and further unspecific code cleaning.
* Distinguish costs for home battery storage and inverter from utility-scale battery costs. * 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. * Added option for hydrogen liquefaction costs for hydrogen demand in shipping.
This introduces a new ``H2 liquid`` bus at each location. This introduces a new ``H2 liquid`` bus at each location.
It is activated via ``sector: shipping_hydrogen_liquefaction: true``. It is activated via ``sector: shipping_hydrogen_liquefaction: true``.

View File

@ -48,7 +48,7 @@ Solid biomass: single node for Europe, until transport costs can be
incorporated. incorporated.
CO2: single node for Europe, but a transport and storage cost is added for 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 Liquid hydrocarbons: single node for Europe, since transport costs for
liquids are low. liquids are low.

View File

@ -19,6 +19,37 @@ from helper import override_component_attrs
import logging import logging
logger = logging.getLogger(__name__) 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): def emission_sectors_from_opts(opts):
@ -54,6 +85,40 @@ def get(item, investment_year=None):
return item 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): def co2_emissions_year(countries, opts, year):
""" """
Calculate CO2 emissions in one specific year (e.g. 1990 or 2018). 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 # this tracks CO2 stored, e.g. underground
n.add("Bus", n.madd("Bus",
"co2 stored", spatial.co2.nodes,
location="EU", location=spatial.co2.locations,
carrier="co2 stored" carrier="co2 stored"
) )
n.add("Store", n.madd("Store",
"co2 stored", spatial.co2.nodes,
e_nom_extendable=True, e_nom_extendable=True,
e_nom_max=options['co2_sequestration_potential'] * 1e6, e_nom_max=np.inf,
capital_cost=options['co2_sequestration_cost'], capital_cost=options['co2_sequestration_cost'],
carrier="co2 stored", carrier="co2 stored",
bus="co2 stored" bus=spatial.co2.nodes
) )
if options['co2_vent']: if options['co2_vent']:
n.add("Link", n.madd("Link",
"co2 vent", spatial.co2.vents,
bus0="co2 stored", bus0=spatial.co2.nodes,
bus1="co2 atmosphere", bus1="co2 atmosphere",
carrier="co2 vent", carrier="co2 vent",
efficiency=1., 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): def add_dac(n, costs):
heat_carriers = ["urban central heat", "services urban decentral heat"] heat_carriers = ["urban central heat", "services urban decentral heat"]
@ -339,7 +426,7 @@ def add_dac(n, costs):
locations, locations,
suffix=" DAC", suffix=" DAC",
bus0="co2 atmosphere", bus0="co2 atmosphere",
bus1="co2 stored", bus1=spatial.co2.df.loc[locations, "nodes"].values,
bus2=locations.values, bus2=locations.values,
bus3=heat_buses, bus3=heat_buses,
carrier="DAC", carrier="DAC",
@ -990,10 +1077,11 @@ def add_storage(n, costs):
if options['methanation']: if options['methanation']:
n.madd("Link", n.madd("Link",
nodes + " Sabatier", spatial.nodes,
suffix=" Sabatier",
bus0=nodes + " H2", bus0=nodes + " H2",
bus1="EU gas", bus1="EU gas",
bus2="co2 stored", bus2=spatial.co2.nodes,
p_nom_extendable=True, p_nom_extendable=True,
carrier="Sabatier", carrier="Sabatier",
efficiency=costs.at["methanation", "efficiency"], efficiency=costs.at["methanation", "efficiency"],
@ -1005,10 +1093,11 @@ def add_storage(n, costs):
if options['helmeth']: if options['helmeth']:
n.madd("Link", n.madd("Link",
nodes + " helmeth", spatial.nodes,
suffix=" helmeth",
bus0=nodes, bus0=nodes,
bus1="EU gas", bus1="EU gas",
bus2="co2 stored", bus2=spatial.co2.nodes,
carrier="helmeth", carrier="helmeth",
p_nom_extendable=True, p_nom_extendable=True,
efficiency=costs.at["helmeth", "efficiency"], efficiency=costs.at["helmeth", "efficiency"],
@ -1021,11 +1110,12 @@ def add_storage(n, costs):
if options['SMR']: if options['SMR']:
n.madd("Link", n.madd("Link",
nodes + " SMR CC", spatial.nodes,
suffix=" SMR CC",
bus0="EU gas", bus0="EU gas",
bus1=nodes + " H2", bus1=nodes + " H2",
bus2="co2 atmosphere", bus2="co2 atmosphere",
bus3="co2 stored", bus3=spatial.co2.nodes,
p_nom_extendable=True, p_nom_extendable=True,
carrier="SMR CC", carrier="SMR CC",
efficiency=costs.at["SMR CC", "efficiency"], efficiency=costs.at["SMR CC", "efficiency"],
@ -1374,7 +1464,7 @@ def add_heat(n, costs):
bus1=nodes[name], bus1=nodes[name],
bus2=nodes[name] + " urban central heat", bus2=nodes[name] + " urban central heat",
bus3="co2 atmosphere", bus3="co2 atmosphere",
bus4="co2 stored", bus4=spatial.co2.df.loc[nodes[name], "nodes"].values,
carrier="urban central gas CHP CC", carrier="urban central gas CHP CC",
p_nom_extendable=True, 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'], 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, bus1=urban_central,
bus2=urban_central + " urban central heat", bus2=urban_central + " urban central heat",
bus3="co2 atmosphere", bus3="co2 atmosphere",
bus4="co2 stored", bus4=spatial.co2.df.loc[urban_central, "nodes"].values,
carrier="urban central solid biomass CHP CC", carrier="urban central solid biomass CHP CC",
p_nom_extendable=True, 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'], 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. efficiency=1.
) )
n.add("Link", n.madd("Link",
"solid biomass for industry CC", spatial.co2.locations,
suffix=" solid biomass for industry CC",
bus0="EU solid biomass", bus0="EU solid biomass",
bus1="solid biomass for industry", bus1="solid biomass for industry",
bus2="co2 atmosphere", bus2="co2 atmosphere",
bus3="co2 stored", bus3=spatial.co2.nodes,
carrier="solid biomass for industry CC", carrier="solid biomass for industry CC",
p_nom_extendable=True, p_nom_extendable=True,
capital_cost=costs.at["cement capture", "fixed"] * costs.at['solid biomass', 'CO2 intensity'], 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'] efficiency2=costs.at['gas', 'CO2 intensity']
) )
n.add("Link", n.madd("Link",
"gas for industry CC", spatial.co2.locations,
suffix=" gas for industry CC",
bus0="EU gas", bus0="EU gas",
bus1="gas for industry", bus1="gas for industry",
bus2="co2 atmosphere", bus2="co2 atmosphere",
bus3="co2 stored", bus3=spatial.co2.nodes,
carrier="gas for industry CC", carrier="gas for industry CC",
p_nom_extendable=True, p_nom_extendable=True,
capital_cost=costs.at["cement capture", "fixed"] * costs.at['gas', 'CO2 intensity'], capital_cost=costs.at["cement capture", "fixed"] * costs.at['gas', 'CO2 intensity'],
@ -1827,7 +1919,7 @@ def add_industry(n, costs):
nodes + " Fischer-Tropsch", nodes + " Fischer-Tropsch",
bus0=nodes + " H2", bus0=nodes + " H2",
bus1="EU oil", bus1="EU oil",
bus2="co2 stored", bus2=spatial.co2.nodes,
carrier="Fischer-Tropsch", carrier="Fischer-Tropsch",
efficiency=costs.at["Fischer-Tropsch", 'efficiency'], efficiency=costs.at["Fischer-Tropsch", 'efficiency'],
capital_cost=costs.at["Fischer-Tropsch", 'fixed'], capital_cost=costs.at["Fischer-Tropsch", 'fixed'],
@ -1916,11 +2008,12 @@ def add_industry(n, costs):
) )
#assume enough local waste heat for CC #assume enough local waste heat for CC
n.add("Link", n.madd("Link",
"process emissions CC", spatial.co2.locations,
suffix=" process emissions CC",
bus0="process emissions", bus0="process emissions",
bus1="co2 atmosphere", bus1="co2 atmosphere",
bus2="co2 stored", bus2=spatial.co2.nodes,
carrier="process emissions CC", carrier="process emissions CC",
p_nom_extendable=True, p_nom_extendable=True,
capital_cost=costs.at["cement capture", "fixed"], capital_cost=costs.at["cement capture", "fixed"],
@ -2039,6 +2132,8 @@ if __name__ == "__main__":
patch_electricity_network(n) patch_electricity_network(n)
define_spatial(pop_layout.index)
if snakemake.config["foresight"] == 'myopic': if snakemake.config["foresight"] == 'myopic':
add_lifetime_wind_solar(n, costs) add_lifetime_wind_solar(n, costs)
@ -2091,6 +2186,9 @@ if __name__ == "__main__":
if "noH2network" in opts: if "noH2network" in opts:
remove_h2_network(n) remove_h2_network(n)
if options["co2_network"]:
add_co2_network(n, costs)
for o in opts: for o in opts:
m = re.match(r'^\d+h$', o, re.IGNORECASE) m = re.match(r'^\d+h$', o, re.IGNORECASE)
if m is not None: if m is not None:

View File

@ -3,6 +3,7 @@
import pypsa import pypsa
import numpy as np import numpy as np
import pandas as pd
from pypsa.linopt import get_var, linexpr, define_constraints 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') 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): def extra_functionality(n, snapshots):
add_battery_constraints(n) add_battery_constraints(n)
add_co2_sequestration_limit(n, snapshots)
def solve_network(n, config, opts='', **kwargs): def solve_network(n, config, opts='', **kwargs):