Skip to content

Commit 10e8447

Browse files
Merge pull request #251 from open-energy-transition/feat/create_gsp_shapefile
Feat/create gsp shapefile
2 parents 78fe3c9 + 9cb74fc commit 10e8447

File tree

9 files changed

+402
-55
lines changed

9 files changed

+402
-55
lines changed

config/config.default.gb.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,7 @@ urls:
14961496
"fes-costing-workbook": ""
14971497
"dukes-5.11": ""
14981498
"gsp-coordinates": ""
1499+
"gsp-shapes": ""
14991500
etys: ""
15001501
"etys-chart-data": ""
15011502
"low-carbon-contracts": ""
@@ -1555,6 +1556,7 @@ transmission_availability:
15551556
grid_supply_points:
15561557
manual_mapping: {}
15571558
"fill-lat-lons": {}
1559+
combine_gsps: {}
15581560

15591561
# docs in https://gb-dispatch-model.readthedocs.io/en/latest/configuration.html#fes
15601562
fes:

config/config.gb.2024.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ urls:
315315
fes-costing-workbook: https://www.neso.energy/document/181961/download
316316
dukes-5.11: https://assets.publishing.service.gov.uk/media/688cb64ee8ba9507fc1b0953/DUKES_5.11.xlsx
317317
gsp-coordinates: https://api.neso.energy/dataset/963525d6-5d83-4448-a99c-663f1c76330a/resource/21c2b09c-24ff-4837-a3b1-b6aea88f8124/download/fes2024_regional_breakdown_gsp_info.csv
318+
gsp-shapes: https://api.neso.energy/dataset/2810092e-d4b2-472f-b955-d8bea01f9ec0/resource/c5647312-afab-4a58-8158-2f1efed1d7fc/download/gsp_regions_20251204.zip
318319
etys: https://www.neso.energy/document/286591/download # ETYS November 2023
319320
etys-chart-data: https://www.neso.energy/document/280936/download # ETYS November 2022 (uses the NOA 2022 recommendations)
320321
low-carbon-contracts: https://dp.lowcarboncontracts.uk/dataset/8e8ca0d5-c774-4dc8-a079-347f1c180c0f/resource/5279a55d-4996-4b1e-ba07-f411d8fd31f0/download/actual_cfd_generation_and_avoided_ghg_emissions.csv
@@ -604,6 +605,13 @@ grid_supply_points:
604605
Port Ham: # Substation lat/lon from openinframap
605606
Latitude: 51.87165
606607
Longitude: -2.26621
608+
combine_gsps:
609+
DUMF: ["G_EXTRA_1", "G_EXTRA_2", "G_EXTRA_3"]
610+
DUNB: ["DUNB_A", "DUNB_B"]
611+
CROO: ["G_EXTRA_10", "G_EXTRA_11"]
612+
SACO: ["G_EXTRA_8", "G_EXTRA_9"]
613+
KILB: ["G_EXTRA_6", "G_EXTRA_7"]
614+
GRMO: ["G_EXTRA_4", "G_EXTRA_5"]
607615
fes:
608616
fes_year: 2024
609617
scenario_mapping:

config/schema.default.gb.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3753,6 +3753,16 @@
37533753
},
37543754
"description": "Manual GSP coordinate assignments, to add latitudes and longitudes for GSPs missing from the GIS data. Keys are FES workbook GSP names.",
37553755
"type": "object"
3756+
},
3757+
"combine_gsps": {
3758+
"additionalProperties": {
3759+
"items": {
3760+
"type": "string"
3761+
},
3762+
"type": "array"
3763+
},
3764+
"description": "Groups of GSPs to combine. Key is the name of the resulting combined GSP, value is a list of FES workbook GSP names to combine . This is used to combine GSPs which are split in the FES workbook but represented as a single GSP in the GIS data.",
3765+
"type": "object"
37563766
}
37573767
}
37583768
},
@@ -7601,6 +7611,11 @@
76017611
"description": "URL for GSP coordinates",
76027612
"type": "string"
76037613
},
7614+
"gsp-shapes": {
7615+
"default": "",
7616+
"description": "URL for GSP shapes",
7617+
"type": "string"
7618+
},
76047619
"etys": {
76057620
"default": "",
76067621
"description": "URL for ETYS report",
@@ -15908,6 +15923,11 @@
1590815923
"description": "URL for GSP coordinates",
1590915924
"type": "string"
1591015925
},
15926+
"gsp-shapes": {
15927+
"default": "",
15928+
"description": "URL for GSP shapes",
15929+
"type": "string"
15930+
},
1591115931
"etys": {
1591215932
"default": "",
1591315933
"description": "URL for ETYS report",
@@ -16273,6 +16293,16 @@
1627316293
},
1627416294
"description": "Manual GSP coordinate assignments, to add latitudes and longitudes for GSPs missing from the GIS data. Keys are FES workbook GSP names.",
1627516295
"type": "object"
16296+
},
16297+
"combine_gsps": {
16298+
"additionalProperties": {
16299+
"items": {
16300+
"type": "string"
16301+
},
16302+
"type": "array"
16303+
},
16304+
"description": "Groups of GSPs to combine. Key is the name of the resulting combined GSP, value is a list of FES workbook GSP names to combine . This is used to combine GSPs which are split in the FES workbook but represented as a single GSP in the GIS data.",
16305+
"type": "object"
1627616306
}
1627716307
}
1627816308
},

doc/gb-model/data_sources.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,11 @@ These are calculated relative to the GB wholesale market price as given by the s
152152
Elexon BMU Fuel Map
153153
-------------------
154154
The `Elexon BMU Fuel Map <https://www.elexon.co.uk/documents/data/operational-data/bmu-fuel-type/>` provides a mapping of the balancing mechanism units (BMU) to their respective fuel types.
155-
This information is required to calculate bid / offer multiplier carrier-wise for redispatch. This is a static copy in the repo and can be updated by navigating to `<https://bmrs.elexon.co.uk/generation-by-fuel-type?>`
155+
This information is required to calculate bid / offer multiplier carrier-wise for redispatch. This is a static copy in the repo and can be updated by navigating to `<https://bmrs.elexon.co.uk/generation-by-fuel-type?>`
156+
157+
-----------
158+
GSP shapes
159+
-----------
160+
The `GSP shapes < https://www.neso.energy/data-portal/gis-boundaries-gb-grid-supply-points>` provide a shapefile of the GSP regions.
161+
The FES workbook on one hand contains GSP names and the shapefile itself contains GSP ID. The GSP coordinates data is used as a bridge to map the GSP shape data to FES workbook data.
162+
The shape data will be useful to disaggregate renewable capacity factors by GSP regions rather than by GB regions.

doc/gb-model/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Release Notes
1212
Unreleased
1313
==========
1414

15+
* Create GSP shapefile using GSP polygon, coordinate and FES workbook data (#251).
1516
* Disallow nuclear from being redispatched (#201).
1617
* Add missing carrier for non-networked electrolysis demand (#242).
1718
* Capitalise all EV carriers (#243).

rules/gb-model/preprocess.smk

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,24 @@ rule process_fes_gsp_data:
160160
logs("process_fes_gsp_data_{fes_scenario}.log"),
161161
script:
162162
scripts("gb_model/preprocess/process_fes_gsp_data.py")
163+
164+
165+
rule create_gsp_shapefile:
166+
message:
167+
"Create GSP shapefile"
168+
params:
169+
year_range=config["redispatch"]["year_range_incl"],
170+
fill_gsp_lat_lons=config["grid_supply_points"]["fill-lat-lons"],
171+
manual_gsp_mapping=config["grid_supply_points"]["manual_mapping"],
172+
combine_gsps=config["grid_supply_points"]["combine_gsps"],
173+
input:
174+
bb1_sheet=resources(f"gb-model/fes/BB1.csv"),
175+
gsp_coordinates="data/gb-model/downloaded/gsp-coordinates.csv",
176+
regions=resources("gb-model/merged_shapes.geojson"),
177+
gsp_shapes="data/gb-model/downloaded/gsp-shapes.zip",
178+
output:
179+
shapefile=resources("gb-model/{fes_scenario}/gsp-shapes.geojson"),
180+
log:
181+
logs("create_gsp_shapefile_{fes_scenario}.log"),
182+
script:
183+
scripts("gb_model/preprocess/create_gsp_shapefile.py")

scripts/gb_model/_update_config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ class URLsConfig(GBBaseConfig):
137137
gsp_coordinates: str = Field(
138138
alias="gsp-coordinates", description="URL for GSP coordinates", default=""
139139
)
140+
gsp_shapes: str = Field(
141+
alias="gsp-shapes", description="URL for GSP shapes", default=""
142+
)
140143
etys: str = Field(description="URL for ETYS report", default="")
141144
etys_chart_data: str = Field(
142145
alias="etys-chart-data",
@@ -338,6 +341,10 @@ class GridSupplyPointsConfig(GBBaseConfig):
338341
default_factory=dict,
339342
description="Manual GSP coordinate assignments, to add latitudes and longitudes for GSPs missing from the GIS data. Keys are FES workbook GSP names.",
340343
)
344+
combine_gsps: dict[str, list[str]] = Field(
345+
default_factory=dict,
346+
description="Groups of GSPs to combine. Key is the name of the resulting combined GSP, value is a list of FES workbook GSP names to combine . This is used to combine GSPs which are split in the FES workbook but represented as a single GSP in the GIS data.",
347+
)
341348

342349

343350
class FESDemandConfig(GBBaseConfig):
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# SPDX-FileCopyrightText: gb-dispatch-model contributors
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
6+
"""
7+
GSP-level data table generator.
8+
9+
This is a script to combine the BB1 sheet with the BB2 (metadata) sheet of the FES workbook.
10+
"""
11+
12+
import logging
13+
import re
14+
import zipfile
15+
from pathlib import Path
16+
17+
import geopandas as gpd
18+
import pandas as pd
19+
20+
from scripts._helpers import configure_logging, set_scenario_config
21+
from scripts.gb_model._helpers import (
22+
get_scenario_name,
23+
)
24+
from scripts.gb_model.preprocess.process_fes_gsp_data import (
25+
process_bb1_data,
26+
process_gsp_coordinates,
27+
)
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
def _merge_gsps(df: pd.DataFrame, gsps: str, key: str) -> pd.DataFrame:
33+
"""
34+
Merge multiple GSPs into a single GSP, by dissolving the geometries
35+
36+
Parameters
37+
----------
38+
df: pd.DataFrame
39+
The dataframe containing the GSPs to merge
40+
gsps: str
41+
The GSPs to merge, as a single string with "|" separating the different GSPs (e.g. "GSP1|GSP2|GSP3")s
42+
key: str
43+
column to merge the GSPs by
44+
"""
45+
46+
# All occurrences of the GSPs to merge, i.e. all rows where any of the GSPs to merge are mentioned in the "GSPs" column (there may be multiple rows for each GSP if there are multiple GSPs to merge)
47+
all_occurrences = df.loc[
48+
(df[key].str.contains(rf"({gsps})\b", regex=True)) & (df[key].notna())
49+
]
50+
51+
if len(all_occurrences) > 1:
52+
# Dissolve the rows of geometries matching the GSPs to merge into a single row
53+
if key == "GSP":
54+
geo_key = "geometryshape"
55+
else:
56+
geo_key = "geometrycoord"
57+
df.loc[df[key] == gsps, geo_key] = (
58+
df.loc[all_occurrences.index]
59+
.set_geometry(geo_key)
60+
.dissolve()
61+
.iloc[0][geo_key]
62+
)
63+
64+
if key == "GSPs":
65+
# Assign the GSP name as the concatenation of the GSP names of the merged GSPs, separated by "|"
66+
df.loc[df[key] == gsps, "GSP"] = all_occurrences[
67+
all_occurrences[key] != gsps
68+
].GSP.str.cat(sep="|")
69+
70+
# Drop the other rows of the merged GSPs, keeping only the dissolved row
71+
indices = all_occurrences.index.tolist()
72+
indices_filter = [
73+
x
74+
for x in all_occurrences.index
75+
if x not in df.loc[df[key] == gsps].index.tolist()
76+
]
77+
if len(indices_filter) == len(indices):
78+
# For merging the GSPs to combine busbars, the earlier filter will not work
79+
retain_row = gsps.split("|")[-1]
80+
indices_filter = [
81+
x
82+
for x in all_occurrences.loc[
83+
all_occurrences["GSPs"] != retain_row
84+
].index.tolist()
85+
]
86+
df.drop(index=indices_filter, inplace=True)
87+
88+
return df
89+
90+
91+
def create_gsp_shapefile(
92+
df_gsp_coordinates: pd.DataFrame,
93+
df_gsp_shapes: gpd.GeoDataFrame,
94+
df_bb1: pd.DataFrame,
95+
gsp_mapping: dict,
96+
combine_gsps: dict,
97+
):
98+
"""
99+
Create a GSP shapefile by combining FES BB1 sheet data, GSP coordinate data and GSP shape data
100+
101+
Parameters
102+
----------
103+
df_gsp_coordinates: pd.DataFrame
104+
The GSP coordinate data dataframe
105+
df_gsp_shape: gpd.GeoDataFrame
106+
GSP polygon shape data
107+
df_bb1: pd.DataFrame
108+
FES BB1 sheet dataframe
109+
gsp_mapping: dict
110+
Manual mapping of GSP names between the FES workbook and the GSP coordinate/shape data
111+
combine_gsps: dict
112+
Groups of GSPs to combine
113+
"""
114+
115+
# Convert the GSP coordinate data to a GeoDataFrame
116+
gdf_gsps = gpd.GeoDataFrame(
117+
df_gsp_coordinates,
118+
geometry=gpd.points_from_xy(
119+
df_gsp_coordinates.Longitude, df_gsp_coordinates.Latitude
120+
),
121+
crs="EPSG:4326",
122+
)
123+
124+
df_bb1_gsp = pd.DataFrame(data=df_bb1.GSP.unique(), columns=["GSP"])
125+
df_bb1_gsp["GSP"] = df_bb1_gsp["GSP"].replace(gsp_mapping)
126+
127+
# Join GSP shape data with GSP coordinate data
128+
gsp_joined = df_gsp_shapes.set_index("GSPs").join(
129+
gdf_gsps.set_index("GSP ID"), lsuffix="shape", rsuffix="coord", how="outer"
130+
)
131+
132+
fes_merged = pd.merge(
133+
df_bb1_gsp,
134+
gsp_joined.reset_index(),
135+
left_on="GSP",
136+
right_on="Name",
137+
how="outer",
138+
)
139+
140+
# Drop unrequired columns
141+
fes_merged.drop(
142+
columns=["GSP Group", "Minor FLOP", "Latitude", "Longitude"], inplace=True
143+
)
144+
145+
# Some GSPs have not been matched with shape data as they are part of a geometry shape containing multiple GSPs
146+
unmatched_gsps = fes_merged.loc[
147+
(fes_merged.geometrycoord.isna()) & (fes_merged.GSPs.notna())
148+
]
149+
for gsps in unmatched_gsps["GSPs"].tolist():
150+
fes_merged = _merge_gsps(fes_merged, gsps, "GSPs")
151+
logger.info(
152+
"Merged GSP shape and coordinate data for GSPs that are part of a combined geometry shape in the FES workbook"
153+
)
154+
155+
# Some busbars are split into multiple GSPs in the FES workbook but represented as a single GSP in shape data
156+
for key in combine_gsps.keys():
157+
combine_gsps[key].append(key)
158+
gsps = "|".join(combine_gsps[key])
159+
fes_merged = _merge_gsps(fes_merged, gsps, "GSPs")
160+
logger.info(
161+
"Merged GSP shape and coordinate data for busbars that are split into multiple GSPs in the FES workbook but represented as a single GSP in shape data"
162+
)
163+
164+
# Some GSPs where coordinate data is available but shape data was not matched
165+
unmatched_gsp_name = fes_merged.loc[
166+
(fes_merged.geometryshape.isna()) & (fes_merged.GSPs.notna())
167+
]
168+
for gsps in unmatched_gsp_name["GSP"].tolist():
169+
fes_merged = _merge_gsps(fes_merged, gsps, "GSP")
170+
logger.info(
171+
"Merged GSP shape and coordinate data for GSPs where coordinate data is available but shape data was not matched"
172+
)
173+
174+
fes_merged = gpd.GeoDataFrame(fes_merged, geometry="geometryshape", crs="EPSG:4326")
175+
176+
# Merging GSPs with same GSP ID but different GSP groups.
177+
# Dissolving the shapes into a single GSP as the rows still have distinct geometries though adjoining each other
178+
fes_merged_notnan = fes_merged[fes_merged["geometryshape"].notna()]
179+
fes_merged_nan = fes_merged[fes_merged["geometryshape"].isna()]
180+
fes_merged_notnan = fes_merged_notnan.dissolve(by="GSPs", as_index=False)
181+
fes_merged = pd.concat([fes_merged_notnan, fes_merged_nan], ignore_index=True)
182+
logger.info("Merged GSP duplicates due to different GSP groups")
183+
184+
fes_merged["geometrycoord"] = fes_merged["geometrycoord"].to_wkt()
185+
186+
if (missing_shapes := fes_merged.geometryshape.isna()).any():
187+
logger.warning(
188+
f"There are {missing_shapes.sum()} GSPs with missing shape information after merging the GSP shape and coordinate data. These GSPs will be kept in the output but with null geometry.\n"
189+
f"{fes_merged[missing_shapes][['GSP', 'GSPs']]}"
190+
)
191+
192+
return fes_merged
193+
194+
195+
if __name__ == "__main__":
196+
if "snakemake" not in globals():
197+
from scripts._helpers import mock_snakemake
198+
199+
snakemake = mock_snakemake(Path(__file__).stem)
200+
configure_logging(snakemake)
201+
set_scenario_config(snakemake)
202+
203+
fes_scenario = get_scenario_name(snakemake)
204+
205+
df_gsp_coordinates = process_gsp_coordinates(
206+
gsp_coordinates_path=snakemake.input.gsp_coordinates,
207+
extra_gsp_coordinates=snakemake.params.fill_gsp_lat_lons,
208+
)
209+
210+
df_bb1 = process_bb1_data(
211+
bb1_path=snakemake.input.bb1_sheet,
212+
fes_scenario=fes_scenario,
213+
year_range=snakemake.params.year_range,
214+
)
215+
216+
# Read the GSP shapefile
217+
CRS = 4326
218+
zip_path = Path(snakemake.input.gsp_shapes)
219+
shp_filename = [
220+
x
221+
for x in zipfile.ZipFile(zip_path).namelist()
222+
if bool(re.search(rf"Proj_{CRS}/.*_{CRS}_.*\.geojson$", x))
223+
][0]
224+
df_gsp_shapes = gpd.read_file(f"{zip_path}!{shp_filename}")
225+
226+
shape = create_gsp_shapefile(
227+
df_gsp_coordinates,
228+
df_gsp_shapes,
229+
df_bb1,
230+
gsp_mapping=snakemake.params.manual_gsp_mapping,
231+
combine_gsps=snakemake.params.combine_gsps,
232+
)
233+
234+
logger.info(f"Exported the GSP shapefile to {snakemake.output.shapefile}")
235+
shape.to_file(snakemake.output.shapefile, driver="GeoJSON")

0 commit comments

Comments
 (0)