Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CO2 network #148

Merged
merged 16 commits into from
Sep 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -466,6 +467,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
Expand Down
8 changes: 8 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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``.
Expand Down
2 changes: 1 addition & 1 deletion doc/spatial_resolution.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
156 changes: 127 additions & 29 deletions scripts/prepare_sector_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,37 @@
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):

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -299,33 +364,55 @@ 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.,
p_nom_extendable=True
)


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"]
Expand All @@ -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",
Expand Down Expand Up @@ -989,10 +1076,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"],
Expand All @@ -1004,10 +1092,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"],
Expand All @@ -1020,11 +1109,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"],
Expand Down Expand Up @@ -1373,7 +1463,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'],
Expand Down Expand Up @@ -1606,7 +1696,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'],
Expand Down Expand Up @@ -1652,12 +1742,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'],
Expand Down Expand Up @@ -1690,12 +1781,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'],
Expand Down Expand Up @@ -1826,7 +1918,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'],
Expand Down Expand Up @@ -1915,11 +2007,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"],
Expand Down Expand Up @@ -2037,6 +2130,8 @@ def limit_individual_line_extension(n, maxext):

patch_electricity_network(n)

define_spatial(pop_layout.index)

if snakemake.config["foresight"] == 'myopic':

add_lifetime_wind_solar(n, costs)
Expand Down Expand Up @@ -2089,6 +2184,9 @@ def limit_individual_line_extension(n, maxext):
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:
Expand Down
19 changes: 19 additions & 0 deletions scripts/solve_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pypsa

import numpy as np
import pandas as pd

from pypsa.linopt import get_var, linexpr, define_constraints

Expand Down Expand Up @@ -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):
Expand Down