Skip to content

Commit 579985c

Browse files
Use Global Buildings datasets to estimate solar-rooftop potentials (#1629)
* Use global buildings dataset to estimate solar rooftop potentials * add to test runs in config.sector.yaml * split workflow by countries to reduce memory * simplify buildings during download, reduce memory by 80% * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add a simplified model to test runs, add release notes * use parquet to speed up savings and retrieval, add documentations in scripts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add a definition for install_ration * Add an explanation for tolerance
1 parent d4fb613 commit 579985c

File tree

8 files changed

+423
-6
lines changed

8 files changed

+423
-6
lines changed

Snakefile

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ if config["enable"].get("retrieve_databundle", True):
163163
"scripts/retrieve_databundle_light.py"
164164

165165

166+
if config["enable"].get("download_global_buildings", True):
167+
168+
rule download_global_buildings:
169+
params:
170+
crs=config["crs"],
171+
output:
172+
"data/global_buildings/{country}_global_buildings_raw.parquet",
173+
script:
174+
"scripts/download_global_buildings.py"
175+
176+
166177
if config["enable"].get("download_osm_data", True):
167178

168179
rule download_osm_data:
@@ -695,6 +706,41 @@ rule cluster_network:
695706
"scripts/cluster_network.py"
696707

697708

709+
solar_rooftop_config = config["sector"]["solar_rooftop"]
710+
if isinstance(solar_rooftop_config, dict):
711+
solar_rooftop_enable = (
712+
solar_rooftop_config["enable"] and solar_rooftop_config["use_building_size"]
713+
)
714+
solar_rooftop_params = {
715+
"solar_rooftop_enable": solar_rooftop_enable,
716+
"install_ratio": solar_rooftop_config["install_ratio"],
717+
"tolerance": solar_rooftop_config["tolerance"],
718+
}
719+
else:
720+
solar_rooftop_params = {}
721+
solar_rooftop_enable = config["sector"]["solar_rooftop"]
722+
723+
724+
rule cluster_global_buildings:
725+
params:
726+
**solar_rooftop_params,
727+
crs=config["crs"],
728+
input:
729+
country_buildings="data/global_buildings/{country}_global_buildings_raw.parquet",
730+
regions_onshore="resources/"
731+
+ RDIR
732+
+ "bus_regions/regions_onshore_elec_s{simpl}_{clusters}.geojson",
733+
output:
734+
solar_rooftop_layout=branch(
735+
solar_rooftop_enable,
736+
"resources/"
737+
+ RDIR
738+
+ "solar_rooftop/solar_rooftop_layout_elec_s{simpl}_{clusters}_{country}.csv",
739+
),
740+
script:
741+
"scripts/cluster_global_buildings.py"
742+
743+
698744
if config["augmented_line_connection"].get("add_to_snakefile") == True:
699745

700746
rule augmented_line_connections:
@@ -1109,6 +1155,16 @@ rule prepare_sector_network:
11091155
input:
11101156
**branch(sector_enable["land_transport"], TRANSPORT),
11111157
**branch(sector_enable["heat"], HEAT),
1158+
**branch(
1159+
solar_rooftop_enable,
1160+
{
1161+
f"solar_rooftop_layout_{country}": "resources/"
1162+
+ RDIR
1163+
+ "solar_rooftop/solar_rooftop_layout_elec_s{simpl}_{clusters}_"
1164+
+ f"{country}.csv"
1165+
for country in config["countries"]
1166+
},
1167+
),
11121168
network=RESDIR
11131169
+ "prenetworks/elec_s{simpl}_{clusters}_ec_l{ll}_{opts}_{sopts}_{planning_horizons}_{discountrate}_{demand}_presec.nc",
11141170
costs="resources/" + RDIR + "costs_{planning_horizons}.csv",

config.default.yaml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enable:
2323
retrieve_databundle_sector: true
2424
retrieve_cost_data: true # true: retrieves cost data from technology data and saves in resources/costs.csv, false: uses cost data in data/costs.csv
2525
download_osm_data: true # If 'true', OpenStreetMap data will be downloaded for the above given countries
26+
download_global_buildings: false # If 'true', GlobalMLBuildingFootprints data will be downloaded for the above given countries
2627
build_natura_raster: false # If 'true', then an exclusion raster will be build. Otherwise use pregenerated raster.
2728
build_cutout: false
2829
# If "build_cutout" : true, then environmental data is extracted according to `snapshots` date range and `countries`
@@ -661,7 +662,22 @@ sector:
661662
efficiency_heat_gas_to_elec: 0.9
662663

663664
electricity_distribution_grid: true # adds low voltage buses and shifts AC loads, BEVs, heat pumps, and resistive heaters, micro CHPs to low voltage buses if technologies are present
664-
solar_rooftop: true # adds distribution side customer rooftop PV (only work if electricity_distribution_grid: true)
665+
solar_rooftop: # adds distribution side customer rooftop PV (only work if electricity_distribution_grid: true)
666+
enable: true
667+
kW_per_m2: 0.1
668+
m2_per_person: 20
669+
use_building_size: false
670+
# proportion of rooftop suitable for PV installation
671+
install_ratio:
672+
0: 0
673+
10: 0.3
674+
100: 0.36
675+
200: 0.41
676+
450: 0.49
677+
2300: 0.66
678+
# maximum distance [km] to allocate a building within the nearest bus shapes
679+
tolerance: 100
680+
665681
home_battery: true # adds home batteries to low voltage buses ((only work if electricity_distribution_grid: true)
666682
transmission_efficiency:
667683
electricity distribution grid:

configs/bundle_config.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ databundles:
4646
- data/gadm/gadm36_NGA/gadm36_NGA.gpkg # needed in sector-coupled model
4747
- data/gadm/gadm36_BEN/gadm36_BEN.gpkg # needed in sector-coupled model
4848

49+
# tutorial bundle specific for Nigeria and Benin only (TODO: merge with above)
50+
bundle_tutorial_NGBJ_global_buildings:
51+
countries: [NG, BJ]
52+
tutorial: true
53+
category: global_buildings
54+
destination: "data"
55+
urls:
56+
gdrive: https://drive.google.com/file/d/1nnseF1A740Gp0IaryeCQ8IRcEF91-aUe/view?usp=drive_link
57+
output:
58+
- data/global_buildings/NG_global_buildings_raw.parquet # needed in cluster_global_buildings
59+
- data/global_buildings/BJ_global_buildings_raw.parquet # needed in cluster_global_buildings
60+
4961
# tutorial bundle specific for Botswana only
5062
bundle_tutorial_BW:
5163
countries: [BW]

doc/release_notes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ This part of documentation collects descriptive release notes to capture the mai
1717

1818
* Added a hot-fix to handle UNSD data downtime causing CI to fail `PR #1653 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1653>`__
1919

20+
* Add an option to estimate solar-rooftop potentials using `GlobalMLBuildingFootprints <https://github.com/microsoft/GlobalMLBuildingFootprints>`_, a simplified data is provided for tutorials, `PR #1629 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1629>`__
21+
2022
* Align PyPSA-Earth costs with the reference units used in `technology-data`, avoiding discrepancies when combining technologies with different original currency years `PR #1604 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1604>`__
2123

2224
* Add an option to redefine countries into subregions in ``cluster_networks`` `PR #1542 <https://github.com/pypsa-meets-earth/pypsa-earth/pull/1542>`__
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# -*- coding: utf-8 -*-
2+
# SPDX-FileCopyrightText: PyPSA-Earth Authors
3+
#
4+
# SPDX-License-Identifier: AGPL-3.0-or-later
5+
# -*- coding: utf-8 -*-
6+
"""
7+
This script processes global building data to calculate solar rooftop area.
8+
"""
9+
import country_converter as coco
10+
import geopandas as gpd
11+
import numpy as np
12+
import pandas as pd
13+
from _helpers import (
14+
BASE_DIR,
15+
configure_logging,
16+
create_logger,
17+
)
18+
from shapely import Point
19+
20+
cc = coco.CountryConverter()
21+
22+
logger = create_logger(__name__)
23+
24+
25+
def calculate_solar_rooftop_area(
26+
df,
27+
country_code,
28+
shapes,
29+
output,
30+
crs,
31+
install_ratio,
32+
tolerance=100,
33+
):
34+
"""
35+
Calculates the usable solar rooftop area for buildings within a country, considering
36+
that only a portion of each rooftop can be allocated for PV installation based on its size.
37+
Smaller buildings are less likely to host PV systems, while larger buildings can dedicate
38+
a greater share of their rooftop to PVs. This approach is based on the methodology
39+
described in a paper by Hideaki Obane (https://eneken.ieej.or.jp/data/12710.pdf).
40+
41+
Parameters
42+
----------
43+
df : pandas.DataFrame
44+
DataFrame containing building areas and center points.
45+
country_code : str
46+
The ISO2 country code.
47+
shapes : geopandas.GeoDataFrame
48+
GeoDataFrame of regional shapes with 'country' and 'geometry' columns.
49+
output : str
50+
The file path to save the calculated solar rooftop area.
51+
crs : dict
52+
A dictionary containing coordinate reference systems (CRS) for 'distance_crs' and 'geo_crs'.
53+
install_ratio : dict
54+
A dictionary mapping building area thresholds to solar installation ratios.
55+
tolerance : int, optional
56+
Maximum distance in kilometers for spatial join to find nearest shapes.
57+
The default is 100.
58+
"""
59+
distance_crs = crs["distance_crs"]
60+
geo_crs = crs["geo_crs"]
61+
62+
keys = np.array(sorted(install_ratio.keys()))
63+
values = np.array([install_ratio[k] for k in keys])
64+
65+
def get_ratio(area):
66+
# find the largest key <= area
67+
idx = np.searchsorted(keys, area, side="right") - 1
68+
if idx < 0:
69+
return np.nan # or 0 if you prefer a default
70+
return values[idx]
71+
72+
df["usefull_area"] = df["area"] * df["area"].apply(get_ratio)
73+
gdf = gpd.GeoDataFrame(df, geometry="center", crs=geo_crs).to_crs(distance_crs)
74+
shapes_country = shapes[shapes.country == country_code].to_crs(distance_crs)
75+
76+
joined = gpd.sjoin(gdf, shapes_country, how="left", predicate="intersects")
77+
unmatched = joined[joined.name.isna()]
78+
unmatched = unmatched.drop(["name", "country"], axis=1)
79+
80+
if not unmatched.empty:
81+
nearest = gpd.sjoin_nearest(
82+
unmatched,
83+
shapes_country,
84+
max_distance=tolerance * 1e3,
85+
)
86+
87+
# Replace the unmatched rows in `joined` with the nearest results
88+
matched = joined[joined.name.notna()]
89+
joined = pd.concat([matched, nearest], ignore_index=True)
90+
91+
usefull_area = joined.groupby("name")["usefull_area"].sum()
92+
shapes_country["usefull_area"] = shapes_country.index.map(usefull_area)
93+
94+
# Save file
95+
shapes_country["usefull_area"].to_csv(output)
96+
97+
98+
if __name__ == "__main__":
99+
if "snakemake" not in globals():
100+
from _helpers import mock_snakemake
101+
102+
snakemake = mock_snakemake(
103+
"cluster_global_buildings", simpl="", clusters=10, country="NG"
104+
)
105+
106+
configure_logging(snakemake)
107+
country_code = snakemake.wildcards.country
108+
109+
# Retrieve files
110+
logger.info(f"Reading Global Buildings for {country_code}")
111+
df = pd.read_parquet(snakemake.input.country_buildings)
112+
df["center"] = df.apply(lambda row: Point(row["x"], row["y"]), axis=1)
113+
114+
shapes = gpd.read_file(snakemake.input.regions_onshore).set_index("name")[
115+
["country", "geometry"]
116+
]
117+
118+
if snakemake.params.solar_rooftop_enable:
119+
logger.info(f"Calculate solar rooftop area for {country_code}")
120+
121+
calculate_solar_rooftop_area(
122+
df,
123+
country_code,
124+
shapes,
125+
snakemake.output.solar_rooftop_layout,
126+
crs=snakemake.params.crs,
127+
install_ratio=snakemake.params.install_ratio,
128+
tolerance=snakemake.params.tolerance,
129+
)

0 commit comments

Comments
 (0)