pypsa-eur/scripts/build_egs_potentials.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

194 lines
6.0 KiB
Python
Raw Normal View History

2023-09-04 18:26:00 +00:00
# -*- coding: utf-8 -*-
# SPDX-FileCopyrightText: : 2023 @LukasFranken, The PyPSA-Eur Authors
#
# SPDX-License-Identifier: MIT
"""
This rule extracts potential and cost for electricity generation through
enhanced geothermal systems.
2023-09-04 18:26:00 +00:00
For this, we use data from "From hot rock to useful energy..." by Aghahosseini, Breyer (2020)
'https://www.sciencedirect.com/science/article/pii/S0306261920312551'
Note that we input data used here is not the same as in the paper, but was passed on by the authors.
The data provides a lon-lat gridded map of Europe (1° x 1°), with each grid cell assigned
a heat potential (in GWh) and a cost (in EUR/MW).
This scripts overlays that map with the network's regions, and builds a csv with CAPEX, OPEX and p_nom_max
"""
import logging
logger = logging.getLogger(__name__)
import json
import numpy as np
import pandas as pd
import xarray as xr
import geopandas as gpd
from shapely.geometry import Polygon
2023-09-04 18:26:00 +00:00
def prepare_egs_data(egs_file):
with open(egs_file) as f:
jsondata = json.load(f)
def point_to_square(p, lon_extent=1.0, lat_extent=1.0):
2023-09-04 18:26:00 +00:00
try:
x, y = p.coords.xy[0][0], p.coords.xy[1][0]
except IndexError:
return p
return Polygon(
[
[x - lon_extent / 2, y - lat_extent / 2],
[x - lon_extent / 2, y + lat_extent / 2],
[x + lon_extent / 2, y + lat_extent / 2],
[x + lon_extent / 2, y - lat_extent / 2],
]
)
2023-09-04 18:26:00 +00:00
years = [2015, 2020, 2025, 2030, 2035, 2040, 2045, 2050]
lcoes = ["LCOE50", "LCOE100", "LCOE150"]
egs_data = dict()
for year in years:
df = pd.DataFrame(columns=["Lon", "Lat", "CAPEX", "HeatSust", "PowerSust"])
for lcoe in lcoes:
for country_data in jsondata[lcoe]:
try:
country_df = pd.DataFrame(
columns=df.columns,
index=range(len(country_data[0][years.index(year)]["Lon"])),
)
2023-09-04 18:26:00 +00:00
except TypeError:
country_df = pd.DataFrame(columns=df.columns, index=range(0))
for col in df.columns:
country_df[col] = country_data[0][years.index(year)][col]
if country_df.dropna().empty:
continue
elif df.empty:
df = country_df.dropna()
else:
df = pd.concat((df, country_df.dropna()), ignore_index=True)
2023-09-04 18:26:00 +00:00
gdf = gpd.GeoDataFrame(
df.drop(columns=["Lon", "Lat"]), geometry=gpd.points_from_xy(df.Lon, df.Lat)
).reset_index(drop=True)
2023-09-04 18:26:00 +00:00
gdf["geometry"] = gdf.geometry.apply(lambda geom: point_to_square(geom))
egs_data[year] = gdf
return egs_data
2023-09-04 18:26:00 +00:00
def get_capacity_factors(
network_regions_file,
air_temperatures_file
):
"""
Performance of EGS is higher for lower temperatures, due to more efficient air cooling
Data from Ricks et al.: The Role of Flexible Geothermal Power in Decarbonized Elec Systems
"""
delta_T = [-15, -10, -5, 0, 5, 10, 15, 20]
cf = [1.17, 1.13, 1.07, 1, 0.925, 0.84, 0.75, 0.65]
x = np.linspace(-15, 20, 200)
y = np.interp(x, delta_T, cf)
upper_x = np.linspace(20, 25, 50)
m_upper = (y[-1] - y[-2]) / (x[-1] - x[-2])
upper_y = upper_x * m_upper - x[-1] * m_upper + y[-1]
lower_x = np.linspace(-20, -15, 50)
m_lower = (y[1] - y[0]) / (x[1] - x[0])
lower_y = lower_x * m_lower - x[0] * m_lower + y[0]
x = np.hstack((lower_x, x, upper_x))
y = np.hstack((lower_y, y, upper_y))
network_regions = gpd.read_file(network_regions_file).set_crs(epsg=4326)
index = network_regions["name"]
air_temp = xr.open_dataset(air_temperatures_file)
snapshots = pd.date_range(freq="h", **snakemake.params.snapshots)
capacity_factors = pd.DataFrame(index=snapshots)
for bus in index:
temp = air_temp.sel(name=bus).to_dataframe()["temperature"]
capacity_factors[bus] = np.interp((temp - temp.mean()).values, x, y)
return capacity_factors
2023-09-04 18:26:00 +00:00
if __name__ == "__main__":
if "snakemake" not in globals():
from _helpers import mock_snakemake
snakemake = mock_snakemake(
"build_egs_potentials",
simpl="",
clusters=37,
)
sustainability_factor = 0.0025
# the share of heat that is replenished from the earth's core.
# we are not constraining ourselves to the sustainable share, but
# inversely apply it to our underlying data, which refers to the
# sustainable heat.
2023-09-04 18:26:00 +00:00
config = snakemake.config
egs_data = prepare_egs_data(snakemake.input.egs_cost)
if config["sector"]["enhanced_geothermal_optimism"]:
egs_data = egs_data[(year := config["costs"]["year"])]
logger.info(
f"EGS optimism! Building EGS potentials with costs estimated for {year}."
)
2023-09-04 18:26:00 +00:00
else:
egs_data = egs_data[(default_year := 2020)]
logger.info(
f"No EGS optimism! Building EGS potentials with {default_year} costs."
)
2023-09-04 18:26:00 +00:00
egs_data = egs_data.loc[egs_data["PowerSust"] > 0].reset_index(drop=True)
egs_regions = egs_data.geometry
network_regions = (
gpd.read_file(snakemake.input.regions)
.set_index("name", drop=True)
.set_crs(epsg=4326)
)
overlap_matrix = pd.DataFrame(
index=network_regions.index,
columns=egs_data.index,
)
2023-09-04 18:26:00 +00:00
for name, polygon in network_regions.geometry.items():
overlap_matrix.loc[name] = (
egs_regions.intersection(polygon).area
) / egs_regions.area
2023-09-04 18:26:00 +00:00
overlap_matrix.to_csv(snakemake.output["egs_overlap"])
# consider not only replenished heat
egs_data["p_nom_max"] = egs_data["PowerSust"] / sustainability_factor
2023-09-04 18:26:00 +00:00
egs_data[["p_nom_max", "CAPEX"]].to_csv(snakemake.output["egs_potentials"])
capacity_factors = get_capacity_factors(
snakemake.input["regions"],
snakemake.input["air_temperature"],
)
capacity_factors.to_csv(snakemake.output["egs_capacity_factors"])