pypsa-eur/scripts/build_transmission_projects.py

568 lines
22 KiB
Python

# -*- coding: utf-8 -*-
# SPDX-FileCopyrightText: : 2017-2024 The PyPSA-Eur Authors
#
# SPDX-License-Identifier: MIT
# coding: utf-8
"""
Gets the transmission projects defined in the config file, concatenates and
deduplicates them. Projects are later included in :mod:`add_electricity.py`.
Relevant Settings
-----------------
.. code:: yaml
transmission_projects:
include:
#tyndp: true # For later, when other TYNDP projects are combined with new version
nep: true
status:
- confirmed
- in_permitting
- under_construction
#- under_consideration
link_under_construction: zero
.. seealso::
Documentation of the configuration file ``config/config.yaml`` at
:ref:`transmission_projects`
Inputs
------
- ``networks/base_network.nc``: Base network topology for the electricity grid. This is processed in :mod:`base_network.py`.
- ``data/transmission_projects/"project_name"/``: Takes the transmission projects from the subfolder of data/transmission_projects. The subfolder name is the project name.
- ``offshore_shapes.geojson``: Shapefile containing the offshore regions. Used to determine if a new bus should be added for a new line or link.
- ``europe_shape.geojson``: Shapefile containing the shape of Europe. Used to determine if a project is within the considered countries.
Outputs
-------
- ``transmission_projects/new_lines.csv``: New project lines to be added to the network. This includes new lines and upgraded lines.
- ``transmission_projects/new_links.csv``: New project links to be added to the network. This includes new links and upgraded links.
- ``transmission_projects/adjust_lines.csv``: For lines which are upgraded, the decommissioning year of the existing line is adjusted to the build year of the upgraded line.
- ``transmission_projects/adjust_links.csv``: For links which are upgraded, the decommissioning year of the existing link is adjusted to the build year of the upgraded link.
- ``transmission_projects/new_buses.csv``: For some links, we have to add new buses (e.g. North Sea Wind Power Hub).
"""
import logging
import os
from pathlib import Path
import geopandas as gpd
import numpy as np
import pandas as pd
import pypsa
import shapely
from _helpers import configure_logging, set_scenario_config
from pypsa.descriptors import nominal_attrs
from scipy import spatial
from shapely.geometry import LineString, Point
logger = logging.getLogger(__name__)
def add_new_buses(n, new_ports):
# Add new buses for the ports which do not have an existing bus close by. If there are multiple ports at the same location, only one bus is added.
duplicated = new_ports.duplicated(subset=["x", "y"], keep="first")
to_add = new_ports[~duplicated]
added_buses = n.madd(
"Bus",
names=to_add.index,
suffix=" bus",
x=to_add.x,
y=to_add.y,
v_nom=380,
under_construction=True,
symbol="substation",
substation_off=True,
substation_lv=False,
carrier="AC",
)
new_buses = n.buses.loc[added_buses].copy().dropna(axis=1, how="all")
new_ports.loc[to_add.index, "neighbor"] = added_buses
new_ports["neighbor"] = new_ports.groupby(["x", "y"])["neighbor"].transform("first")
return new_ports, new_buses
def find_country_for_bus(bus, shapes):
"""
Find the country of a bus based on its coordinates and the provided
shapefile.
Shapefile must contain a column "country" with the country names.
"""
point = Point(bus.x, bus.y)
country = shapes.loc[shapes.contains(point), "country"]
return country.values[0]
def connect_new_lines(
lines,
n,
new_buses_df,
offshore_shapes=None,
distance_upper_bound=np.inf,
bus_carrier="AC",
):
"""
Find the closest existing bus to the port of each line.
If closest bus is further away than distance_upper_bound and is
inside an offshore region, a new bus is created. and the line is
connected to it.
"""
bus_carrier = np.atleast_1d(bus_carrier)
buses = n.buses.query("carrier in @bus_carrier").copy()
bus_tree = spatial.KDTree(buses[["x", "y"]])
for port in [0, 1]:
lines_port = lines["geometry"].apply(
lambda x: pd.Series(
get_bus_coords_from_port(x, port=port), index=["x", "y"]
)
)
distances, indices = bus_tree.query(lines_port)
# Series of lines with closest bus in the existing network and whether they match the distance criterion
lines_port["neighbor"] = buses.iloc[indices].index
lines_port["match_distance"] = distances < distance_upper_bound
# For buses which are not close to any existing bus, only add a new bus if the line is going offshore (e.g. North Sea Wind Power Hub)
if not lines_port.match_distance.all() and offshore_shapes.union_all():
potential_new_buses = lines_port[~lines_port.match_distance]
is_offshore = potential_new_buses.apply(
lambda x: offshore_shapes.union_all().contains(Point(x.x, x.y)), axis=1
)
new_buses = potential_new_buses[is_offshore]
if not new_buses.empty:
new_port, new_buses = add_new_buses(n, new_buses)
new_buses["country"] = new_buses.apply(
lambda bus: find_country_for_bus(bus, offshore_shapes), axis=1
)
lines_port.loc[new_port.index, "match_distance"] = True
lines_port.loc[new_port.index, "neighbor"] = new_port.neighbor
new_buses_df = pd.concat([new_buses_df, new_buses])
if not lines_port.match_distance.all():
logging.warning(
"Could not find bus close enough to connect the the following lines:\n"
+ str(lines_port[~lines_port.match_distance].index.to_list())
+ "\n Lines will be ignored."
)
lines.drop(lines_port[~lines_port.match_distance].index, inplace=True)
lines_port = lines_port[lines_port.match_distance]
lines.loc[lines_port.index, f"bus{port}"] = lines_port["neighbor"]
lines = lines.assign(under_construction=True)
return lines, new_buses_df
def get_branch_coords_from_geometry(linestring, reversed=False):
"""
Reduces a linestring to its start and end points. Used to simplify the
linestring which can have more than two points.
Parameters:
linestring: Shapely linestring
reversed (bool, optional): If True, returns the end and start points instead of the start and end points.
Defaults to False.
Returns:
numpy.ndarray: Flattened array of start and end coordinates.
"""
coords = np.asarray(linestring.coords)
ind = [0, -1] if not reversed else [-1, 0]
start_end_coords = coords[ind]
return start_end_coords.flatten()
def get_branch_coords_from_buses(line):
"""
Gets line string for branch component in an pypsa network.
Parameters:
linestring: shapely linestring
reversed (bool, optional): If True, returns the end and start points instead of the start and end points.
Defaults to False.
Returns:
numpy.ndarray: Flattened array of start and end coordinates.
"""
start_coords = n.buses.loc[line.bus0, ["x", "y"]].values
end_coords = n.buses.loc[line.bus1, ["x", "y"]].values
return np.array([start_coords, end_coords]).flatten()
def get_bus_coords_from_port(linestring, port=0):
"""
Extracts the coordinates of a specified port from a given linestring.
Parameters:
linestring: The shapely linestring.
port (int): The index of the port to extract coordinates from. Default is 0.
Returns:
tuple: The coordinates of the specified port as a tuple (x, y).
"""
coords = np.asarray(linestring.coords)
ind = [0, -1]
coords = coords[ind]
coords = coords[port]
return coords
def find_closest_lines(lines, new_lines, distance_upper_bound=0.1, type="new"):
"""
Find the closest lines in the existing set of lines to a set of new lines.
Parameters:
lines (pandas.DataFrame): DataFrame of the existing lines.
new_lines (pandas.DataFrame): DataFrame with column geometry containing the new lines.
distance_upper_bound (float, optional): Maximum distance to consider a line as a match. Defaults to 0.1 which corresponds to approximately 15 km.
Returns:
pandas.Series: Series containing with index the new lines and values providing closest existing line.
"""
# get coordinates of start and end points of all lines, for new lines we need to check both directions
treelines = lines.apply(get_branch_coords_from_buses, axis=1)
querylines = pd.concat(
[
new_lines["geometry"].apply(get_branch_coords_from_geometry),
new_lines["geometry"].apply(get_branch_coords_from_geometry, reversed=True),
]
)
treelines = np.vstack(treelines)
querylines = np.vstack(querylines)
tree = spatial.KDTree(treelines)
dist, ind = tree.query(querylines, distance_upper_bound=distance_upper_bound)
found_b = ind < len(lines)
# since the new lines are checked in both directions, we need to find the correct index of the new line
found_i = np.arange(len(querylines))[found_b] % len(new_lines)
# create a DataFrame with the distances, new line and its closest existing line
line_map = pd.DataFrame(
dict(D=dist[found_b], existing_line=lines.index[ind[found_b] % len(lines)]),
index=new_lines.index[found_i].rename("new_lines"),
)
if type == "new":
if len(found_i) != 0:
logger.warning(
"Found new lines similar to existing lines:\n"
+ str(line_map["existing_line"].to_dict())
+ "\n Lines are assumed to be duplicated and will be ignored."
)
elif type == "upgraded":
if len(found_i) < len(new_lines):
not_found = new_lines.index.difference(line_map.index)
logger.warning(
"Could not find upgraded lines close enough to existing lines:\n"
+ str(not_found.to_list())
+ "\n Lines will be ignored."
)
# only keep the closer line of the new line pair (since lines are checked in both directions)
line_map = line_map.sort_values(by="D")[
lambda ds: ~ds.index.duplicated(keep="first")
].sort_index()["existing_line"]
return line_map
def adjust_decommissioning(upgraded_lines, line_map):
"""
Adjust the decommissioning year of the existing lines to the built year of
the upgraded lines.
"""
to_update = pd.DataFrame(index=line_map)
to_update["build_year"] = (
1990 # dummy build_year to make existing lines decommissioned when upgraded lines are built
)
to_update["lifetime"] = (
upgraded_lines.rename(line_map)["build_year"] - 1990
) # set lifetime to the difference between build year of upgraded line and existing line
return to_update
def get_upgraded_lines(branch_component, n, upgraded_lines, line_map):
"""
Get upgraded lines by merging information of existing line and upgraded
line.
"""
# get first the information of the existing lines which will be upgraded
lines_to_add = n.df(branch_component).loc[line_map].copy()
# get columns of upgraded lines which are not in existing lines
new_columns = upgraded_lines.columns.difference(lines_to_add.columns)
# rename upgraded lines to match existing lines
upgraded_lines = upgraded_lines.rename(line_map)
# set the same index names to be able to merge
upgraded_lines.index.name = lines_to_add.index.name
# merge upgraded lines with existing lines
lines_to_add.update(upgraded_lines)
# add column which was added in upgraded lines
lines_to_add = pd.concat([lines_to_add, upgraded_lines[new_columns]], axis=1)
# only consider columns of original upgraded lines and bus0 and bus1
lines_to_add = lines_to_add.loc[:, ["bus0", "bus1", *upgraded_lines.columns]]
# set capacity of upgraded lines to capacity of existing lines
lines_to_add[nominal_attrs[branch_component]] = n.df(branch_component).loc[
line_map, nominal_attrs[branch_component]
]
# change index of new lines to avoid duplicates
lines_to_add.index = lines_to_add.index.astype(str) + "_upgraded"
return lines_to_add
def get_project_files(path, skip=[]):
path = Path(path)
lines = {}
files = [
p
for p in path.iterdir()
if p.is_file()
and p.suffix == ".csv"
and not any(substring in p.name for substring in skip)
]
if not files:
logger.warning(f"No projects found for {path.parent.name}")
return lines
for file in files:
df = pd.read_csv(file, index_col=0)
df["geometry"] = df.apply(
lambda x: LineString([[x.x0, x.y0], [x.x1, x.y1]]), axis=1
)
df.drop(columns=["x0", "y0", "x1", "y1"], inplace=True)
lines[file.stem] = df
return lines
def remove_projects_outside_countries(lines, europe_shape):
"""
Remove projects which are not in the considered countries.
"""
europe_shape_prepped = shapely.prepared.prep(europe_shape)
is_within_covered_countries = lines["geometry"].apply(
lambda x: europe_shape_prepped.contains(x)
)
if not is_within_covered_countries.all():
logger.warning(
"Project lines outside of the covered area (skipping): "
+ ", ".join(str(i) for i in lines.loc[~is_within_covered_countries].index)
)
lines = lines.loc[is_within_covered_countries]
return lines
def is_similar(ds1, ds2, percentage=10):
"""
Check if values in series ds2 are within a specified percentage of series
ds1.
Returns:
- A boolean series where True indicates ds2 values are within the percentage range of ds2.
"""
lower_bound = ds1 * (1 - percentage / 100)
upper_bound = ds1 * (1 + percentage / 100)
return np.logical_and(ds2 >= lower_bound, ds2 <= upper_bound)
def set_underwater_fraction(new_links, offshore_shapes):
new_links_gds = gpd.GeoSeries(new_links["geometry"])
new_links["underwater_fraction"] = (
new_links_gds.intersection(offshore_shapes.union_all()).length
/ new_links_gds.length
).round(2)
def add_projects(
n,
new_lines_df,
new_links_df,
adjust_lines_df,
adjust_links_df,
new_buses_df,
europe_shape,
offshore_shapes,
path,
plan,
status=["confirmed", "under construction"],
skip=[],
):
lines_dict = get_project_files(path, skip=skip)
for key, lines in lines_dict.items():
logging.info(f"Processing {key.replace('_', ' ')} projects from {plan}.")
lines = remove_projects_outside_countries(lines, europe_shape)
if isinstance(status, dict):
status = status[plan]
lines = lines.loc[lines.project_status.isin(status)]
if lines.empty:
continue
if key == "new_lines":
new_lines, new_buses_df = connect_new_lines(
lines, n, new_buses_df, bus_carrier="AC"
)
duplicate_lines = find_closest_lines(
n.lines, new_lines, distance_upper_bound=0.10, type="new"
)
# TODO: think about using build_year instead of v_nom
# ignore duplicates where v_nom is not within a tolerance of 10%
to_ignore = is_similar(
new_lines.loc[duplicate_lines.index, "v_nom"],
duplicate_lines.map(n.lines["v_nom"]),
)
duplicate_lines = duplicate_lines[~to_ignore]
new_lines = new_lines.drop(duplicate_lines.index, errors="ignore")
new_lines_df = pd.concat([new_lines_df, new_lines])
# add new lines to network to be able to find added duplicates
n.madd("Line", new_lines.index, **new_lines)
elif key == "new_links":
new_links, new_buses_df = connect_new_lines(
lines,
n,
new_buses_df,
offshore_shapes=offshore_shapes,
distance_upper_bound=0.4,
bus_carrier=["AC", "DC"],
)
duplicate_links = find_closest_lines(
n.links, new_links, distance_upper_bound=0.10, type="new"
)
# TODO: think about using build_year instead of p_nom
# ignore duplicates where p_nom is not within a tolerance of 10%
to_ignore = is_similar(
new_links.loc[duplicate_links.index, "p_nom"],
duplicate_links.map(n.links["p_nom"]),
)
duplicate_links = duplicate_links[~to_ignore]
new_links = new_links.drop(duplicate_links.index, errors="ignore")
set_underwater_fraction(new_links, offshore_shapes)
new_links_df = pd.concat([new_links_df, new_links])
# add new links to network to be able to find added duplicates
n.madd("Link", new_links.index, **new_links)
elif key == "upgraded_lines":
line_map = find_closest_lines(
n.lines, lines, distance_upper_bound=0.30, type="upgraded"
)
upgraded_lines = lines.loc[line_map.index]
lines_to_adjust = adjust_decommissioning(upgraded_lines, line_map)
adjust_lines_df = pd.concat([adjust_lines_df, lines_to_adjust])
upgraded_lines = get_upgraded_lines("Line", n, upgraded_lines, line_map)
new_lines_df = pd.concat([new_lines_df, upgraded_lines])
elif key == "upgraded_links":
line_map = find_closest_lines(
n.links.query("carrier=='DC'"),
lines,
distance_upper_bound=0.30,
type="upgraded",
)
upgraded_links = lines.loc[line_map.index]
links_to_adjust = adjust_decommissioning(upgraded_links, line_map)
adjust_links_df = pd.concat([adjust_links_df, links_to_adjust])
upgraded_links = get_upgraded_lines("Link", n, upgraded_links, line_map)
new_links_df = pd.concat([new_links_df, upgraded_links])
set_underwater_fraction(new_links_df, offshore_shapes)
else:
logger.warning(f"Unknown project type {key}")
continue
return new_lines_df, new_links_df, adjust_lines_df, adjust_links_df, new_buses_df
def fill_length_from_geometry(line, line_factor=1.2):
if not pd.isna(line.length):
return line.length
length = gpd.GeoSeries(line["geometry"], crs=4326).to_crs(3035).length.values[0]
length = length / 1000 * line_factor
return round(length, 1)
if __name__ == "__main__":
if "snakemake" not in globals():
from _helpers import mock_snakemake
snakemake = mock_snakemake("build_transmission_projects", run="all")
configure_logging(snakemake)
set_scenario_config(snakemake)
line_factor = snakemake.params.line_factor
s_max_pu = snakemake.params.s_max_pu
n = pypsa.Network(snakemake.input.base_network)
new_lines_df = pd.DataFrame()
new_links_df = pd.DataFrame()
adjust_lines_df = pd.DataFrame()
adjust_links_df = pd.DataFrame()
new_buses_df = pd.DataFrame()
europe_shape = gpd.read_file(snakemake.input.europe_shape).loc[0, "geometry"]
offshore_shapes = gpd.read_file(snakemake.input.offshore_shapes).rename(
{"name": "country"}, axis=1
)
transmission_projects = snakemake.params.transmission_projects
projects = [
project
for project, include in transmission_projects["include"].items()
if include
]
paths = snakemake.input.transmission_projects
for project in projects:
path = list(filter(lambda path: project in path, paths))[0]
new_lines_df, new_links_df, adjust_lines_df, adjust_links_df, new_buses_df = (
add_projects(
n,
new_lines_df,
new_links_df,
adjust_lines_df,
adjust_links_df,
new_buses_df,
europe_shape,
offshore_shapes,
path=path,
plan=project,
status=transmission_projects["status"],
skip=transmission_projects["skip"],
)
)
if not new_lines_df.empty:
line_type = "Al/St 240/40 4-bundle 380.0"
# Add new line type for new lines
new_lines_df["type"] = new_lines_df["type"].fillna(line_type)
new_lines_df["num_parallel"] = new_lines_df["num_parallel"].fillna(2)
if "underground" in new_lines_df.columns:
new_lines_df["underground"] = (
new_lines_df["underground"].astype("bool").fillna(False)
)
# Add carrier types of lines
new_lines_df["carrier"] = "AC"
# Fill empty length values with length calculated from geometry
new_lines_df["length"] = new_lines_df.apply(
fill_length_from_geometry, args=(line_factor,), axis=1
)
# get s_nom from line type
new_lines_df["s_nom"] = (
np.sqrt(3)
* n.line_types.loc[new_lines_df["type"], "i_nom"].values
* new_lines_df["v_nom"]
* new_lines_df["num_parallel"]
).round(2)
# set s_max_pu
new_lines_df["s_max_pu"] = s_max_pu
if not new_links_df.empty:
# Add carrier types of lines and links
new_links_df["carrier"] = "DC"
# Fill empty length values with length calculated from geometry
new_links_df["length"] = new_links_df.apply(
fill_length_from_geometry, args=(line_factor,), axis=1
)
# Whether to keep existing link capacity or set to zero
not_upgraded = ~new_links_df.index.str.contains("upgraded")
if transmission_projects["new_link_capacity"] == "keep":
new_links_df.loc[not_upgraded, "p_nom"] = new_links_df["p_nom"].fillna(0)
elif transmission_projects["new_link_capacity"] == "zero":
new_links_df.loc[not_upgraded, "p_nom"] = 0
# export csv files for new buses, lines, links and adjusted lines and links
new_lines_df.to_csv(snakemake.output.new_lines)
new_links_df.to_csv(snakemake.output.new_links)
adjust_lines_df.to_csv(snakemake.output.adjust_lines)
adjust_links_df.to_csv(snakemake.output.adjust_links)
new_buses_df.to_csv(snakemake.output.new_buses)