Urban Heat Island Effect Changes#
Context#
The term “Urban Heat Island” (UHI) effect describes the phenomenon where urban environments exhibit higher air temperatures than their rural counterparts, a difference that is especially pronounced at night. This effect arises from the greater capacity of urban materials and man-made structures, such as buildings and pavements, to absorb, store, and then re-radiate heat compared to natural landscapes.
First identified over two centuries ago, the UHI effect is subject of research to understand, measure, and mitigate its impacts on society, economic activities, and public health. Although traditionally the prerogative of specialists, the UHI is also attracting increasing interest among citizens. However, not all have the necessary technical expertise or infrastructure access to source relevant data (from in-situ measurements, satellite remote sensing, or numerical models), process it efficiently, synthesize it and interpret the changes over time or between different locations.
The UHI-Stream tool was specifically developed to bridge this gap and quickly analyze temperature differences between two points anywhere on Earth’s by leveraging EGI compute and storage resources (owned by CESNET) and ERA5-Land reanalysis data (available from 1950, as part of the Copernicus Climate Change Service). The corresponding hourly 2m air temperatures are streamed from S3 buckets, processed on-the-fly and visualized as annual heat-maps or animations spanning user-defined time-frames.
Conveniently hosted on RoHub as a FAIR (Findable, Accessible, Interoperable, and Reusable) Executable Research Object, UHI-Stream is expected to be further converted into a Galaxy tool with a Graphical User Interface as part of the EuroScienceGateway project, potentially incorporating additional features to help users pinpoint representative urban and adjacent rural areas, or account for more grid cells.
In summary, UHI-Stream is poised to become a valuable asset in urban climatology studies, enabling easier identification of UHI patterns and estimating climate impacts on a regional scale. The tool’s versatility in analyzing any two geographic points enhances its usefulness beyond the mere urban-rural context, allowing for comparative analyses of temperature changes across diverse locales, regardless of their relationship.
Data#
We will be using ERA5-Land HRES dataset from Open Meteo Historical Weather.
The ERA5-Land HRES dataset has been produced at a resolution of 9 km, (~0.08°) and in a (octahedral) reduced Gaussian grid (represented as TCo1279).
Warning
Note that to prevent discontinuities when IFS data is used instead of ERA5 data (~ from January 2017) one can specify which model is to be used with the parameter “models”: “era5_land”, for instance
Setup#
This episode uses the following main Python packages:
Please install these packages if not already available in your Python environment.
Packages#
In this episode, some Python packages are imported when we start to use them. However, for best software practices, we recommend you to install and import all the necessary libraries at the top of your Jupyter notebook.
Package Installation#
! pip install openmeteo-requests
! pip install requests-cache retry-requests
! pip install cmcrameri
Show code cell output
Requirement already satisfied: openmeteo-requests in /srv/conda/envs/notebook/lib/python3.12/site-packages (1.3.0)
Requirement already satisfied: openmeteo-sdk>=1.4.0 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from openmeteo-requests) (1.14.1)
Requirement already satisfied: requests in /srv/conda/envs/notebook/lib/python3.12/site-packages (from openmeteo-requests) (2.32.3)
Requirement already satisfied: flatbuffers>=24.0.0 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from openmeteo-sdk>=1.4.0->openmeteo-requests) (24.3.25)
Requirement already satisfied: charset-normalizer<4,>=2 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests->openmeteo-requests) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests->openmeteo-requests) (3.7)
Requirement already satisfied: urllib3<3,>=1.21.1 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests->openmeteo-requests) (1.26.19)
Requirement already satisfied: certifi>=2017.4.17 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests->openmeteo-requests) (2024.7.4)
Requirement already satisfied: requests-cache in /srv/conda/envs/notebook/lib/python3.12/site-packages (1.2.1)
Requirement already satisfied: retry-requests in /srv/conda/envs/notebook/lib/python3.12/site-packages (2.0.0)
Requirement already satisfied: attrs>=21.2 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (24.2.0)
Requirement already satisfied: cattrs>=22.2 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (23.2.3)
Requirement already satisfied: platformdirs>=2.5 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (4.2.2)
Requirement already satisfied: requests>=2.22 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (2.32.3)
Requirement already satisfied: url-normalize>=1.4 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (1.4.3)
Requirement already satisfied: urllib3>=1.25.5 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests-cache) (1.26.19)
Requirement already satisfied: charset-normalizer<4,>=2 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests>=2.22->requests-cache) (3.3.2)
Requirement already satisfied: idna<4,>=2.5 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests>=2.22->requests-cache) (3.7)
Requirement already satisfied: certifi>=2017.4.17 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from requests>=2.22->requests-cache) (2024.7.4)
Requirement already satisfied: six in /srv/conda/envs/notebook/lib/python3.12/site-packages (from url-normalize>=1.4->requests-cache) (1.16.0)
Requirement already satisfied: cmcrameri in /srv/conda/envs/notebook/lib/python3.12/site-packages (1.9)
Requirement already satisfied: matplotlib in /srv/conda/envs/notebook/lib/python3.12/site-packages (from cmcrameri) (3.9.2)
Requirement already satisfied: numpy in /srv/conda/envs/notebook/lib/python3.12/site-packages (from cmcrameri) (1.26.4)
Requirement already satisfied: packaging in /srv/conda/envs/notebook/lib/python3.12/site-packages (from cmcrameri) (24.1)
Requirement already satisfied: contourpy>=1.0.1 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (1.2.1)
Requirement already satisfied: cycler>=0.10 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (4.53.1)
Requirement already satisfied: kiwisolver>=1.3.1 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (1.4.5)
Requirement already satisfied: pillow>=8 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (10.4.0)
Requirement already satisfied: pyparsing>=2.3.1 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (3.1.2)
Requirement already satisfied: python-dateutil>=2.7 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from matplotlib->cmcrameri) (2.8.2)
Requirement already satisfied: six>=1.5 in /srv/conda/envs/notebook/lib/python3.12/site-packages (from python-dateutil>=2.7->matplotlib->cmcrameri) (1.16.0)
Load Libraries#
from cmcrameri import cm
import openmeteo_requests
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from IPython.display import HTML
import pandas as pd
import requests_cache
import seaborn as sns
from retry_requests import retry
import hvplot.pandas
Setup the Open-Meteo API client with cache and retry on error#
cache_session = requests_cache.CachedSession('.cache', expire_after = -1)
retry_session = retry(cache_session, retries = 5, backoff_factor = 0.2)
openmeteo = openmeteo_requests.Client(session = retry_session)
Define the period of interest#
start_year = 1950
end_year = 2023
Define locations for which the 2m temperature is to be compared over the period of interest#
For each Area of Interest, we define two distinct (but nearby) locations to compare in order to identify potential Urban Heat Island effects.
oslo_area = {
"area" : "Norway",
"urban" : "Oslo",
"latitude_urban" : 59.92000036537046,
"longitude_urban" : 10.700146410119512,
"rural" : "Mortensrud",
"latitude_rural" : 59.85344259770861,
"longitude_rural" : 10.821229728045472,
"time_zone" : "Europe/Oslo"
}
paris_area = {
"area" : "Paris, France",
"urban" : "Montsouris public park",
"latitude_urban" : 48.82,
"longitude_urban" : 2.33,
"rural" : "Melun",
"latitude_rural" : 48.61,
"longitude_rural" : 2.67,
"time_zone" : "Europe/Paris"
}
geirangerfjorden_area = {
"area" : "Geirangerfjorden, Norway",
"urban" : "Geiranger",
"latitude_urban" : 62.09947881213586,
"longitude_urban" : 7.20271411119387,
"rural" : "Dalen Gaard",
"latitude_rural" : 62.06916486996622,
"longitude_rural" : 7.255376960289076,
"time_zone" : "Europe/Oslo"
}
brazil_area = {
"area" : "Brazil",
"urban" : "Fortaleza",
"latitude_urban" : -3.673056,
"longitude_urban" : -38.942222,
"rural" : "São Gonçalo do Amarante",
"latitude_rural" : -3.795,
"longitude_rural" : -38.558333,
"time_zone" : "America/Sao_Paulo"
}
Get the data from openmeteo archive <- time in GMT+0#
Here we fetch data for a given location.
class coordinates:
def __init__(self, latitude, longitude):
self.latitude = latitude
self.longitude = longitude
def get_data(urban_coords, rural_coords, start_year, end_year):
url = "https://archive-api.open-meteo.com/v1/archive"
params = {
"latitude": [urban_coords.latitude, rural_coords.latitude],
"longitude": [urban_coords.longitude, rural_coords.longitude],
"start_date": str(start_year) + "-01-01",
"end_date": str(end_year) + "-12-31",
"hourly": "temperature_2m",
"timezone": "auto",
"models": "era5_land"
}
responses = openmeteo.weather_api(url, params=params)
return responses
Get Data for Brazil Area#
area_params = brazil_area
urban_coords = coordinates( brazil_area["latitude_urban"], brazil_area["longitude_urban"])
rural_coords = coordinates( brazil_area["latitude_rural"], brazil_area["longitude_rural"])
responses = get_data(urban_coords, rural_coords, start_year, end_year)
Compute Urban Heat Island#
# First location - Urban
print(f"Urban location")
print(f"Coordinates {responses[0].Latitude()}°N {responses[0].Longitude()}°E")
print(f"Elevation {responses[0].Elevation()} m above sea level")
print(f"Timezone {responses[0].Timezone()} {responses[0].TimezoneAbbreviation()}")
print(f"Timezone difference to GMT+0 {responses[0].UtcOffsetSeconds()/3600.} hours")
print()
# Second location - Rural area
print(f"Rural location")
print(f"Coordinates {responses[1].Latitude()}°N {responses[1].Longitude()}°E")
print(f"Elevation {responses[1].Elevation()} m above sea level")
print(f"Timezone {responses[1].Timezone()} {responses[1].TimezoneAbbreviation()}")
print(f"Timezone difference to GMT+0 {responses[1].UtcOffsetSeconds()/3600.} hours")
print()
# Process hourly data
hourly = responses[0].Hourly()
hourly_temperature_2m_urban = responses[0].Hourly().Variables(0).ValuesAsNumpy()
hourly_temperature_2m_rural = responses[1].Hourly().Variables(0).ValuesAsNumpy()
hourly_data = {"date": pd.date_range(
start = pd.to_datetime(hourly.Time(), unit = "s", utc = True),
end = pd.to_datetime(hourly.TimeEnd(), unit = "s", utc = True),
freq = pd.Timedelta(seconds = hourly.Interval()),
inclusive = "left"
)}
hourly_data["temperature_2m_urban-rural"] = hourly_temperature_2m_urban - hourly_temperature_2m_rural
hourly_dataframe = pd.DataFrame(data = hourly_data)
Urban location
Coordinates -3.6999969482421875°N -38.899993896484375°E
Elevation 34.0 m above sea level
Timezone b'America/Fortaleza' b'-03'
Timezone difference to GMT+0 -3.0 hours
Rural location
Coordinates -3.7999954223632812°N -38.59999084472656°E
Elevation 27.0 m above sea level
Timezone b'America/Fortaleza' b'-03'
Timezone difference to GMT+0 -3.0 hours
hourly_dataframe = hourly_dataframe.set_index("date")
hourly_dataframe
| temperature_2m_urban-rural | |
|---|---|
| date | |
| 1950-01-01 03:00:00+00:00 | 0.050001 |
| 1950-01-01 04:00:00+00:00 | 0.049999 |
| 1950-01-01 05:00:00+00:00 | 0.049999 |
| 1950-01-01 06:00:00+00:00 | 0.050001 |
| 1950-01-01 07:00:00+00:00 | -0.099998 |
| ... | ... |
| 2023-12-31 22:00:00+00:00 | 0.049999 |
| 2023-12-31 23:00:00+00:00 | 0.049999 |
| 2024-01-01 00:00:00+00:00 | 0.000000 |
| 2024-01-01 01:00:00+00:00 | 0.000000 |
| 2024-01-01 02:00:00+00:00 | -0.049999 |
648672 rows × 1 columns
grouped = hourly_dataframe.groupby([hourly_dataframe.index.year, hourly_dataframe.index.month, hourly_dataframe.index.hour]).mean()
grouped
| temperature_2m_urban-rural | |||
|---|---|---|---|
| date | date | date | |
| 1950 | 1 | 0 | -0.040000 |
| 1 | -0.085000 | ||
| 2 | -0.113334 | ||
| 3 | -0.091935 | ||
| 4 | -0.072581 | ||
| ... | ... | ... | ... |
| 2023 | 12 | 22 | 0.114516 |
| 23 | 0.017742 | ||
| 2024 | 1 | 0 | 0.000000 |
| 1 | 0.000000 | ||
| 2 | -0.049999 |
21315 rows × 1 columns
grouped.index = grouped.index.set_names('year', level=0)
grouped.index = grouped.index.set_names('month', level=1)
grouped.index = grouped.index.set_names('hour', level=2)
grouped.reset_index().set_index('year')
| month | hour | temperature_2m_urban-rural | |
|---|---|---|---|
| year | |||
| 1950 | 1 | 0 | -0.040000 |
| 1950 | 1 | 1 | -0.085000 |
| 1950 | 1 | 2 | -0.113334 |
| 1950 | 1 | 3 | -0.091935 |
| 1950 | 1 | 4 | -0.072581 |
| ... | ... | ... | ... |
| 2023 | 12 | 22 | 0.114516 |
| 2023 | 12 | 23 | 0.017742 |
| 2024 | 1 | 0 | 0.000000 |
| 2024 | 1 | 1 | 0.000000 |
| 2024 | 1 | 2 | -0.049999 |
21315 rows × 3 columns
hm = grouped.reset_index()
hm.min(), hm.max()
(year 1950.000000
month 1.000000
hour 0.000000
temperature_2m_urban-rural -0.498333
dtype: float64,
year 2024.000000
month 12.000000
hour 23.000000
temperature_2m_urban-rural 1.382258
dtype: float64)
# Adjust min & max for the plots
vmin = hm["temperature_2m_urban-rural"].min() - 0.1
vmax = hm["temperature_2m_urban-rural"].max() + 0.1
year = 1950
z = hm.loc[hm['year'] == year]
print(z.min(), z.max())
year 1950.000000
month 1.000000
hour 0.000000
temperature_2m_urban-rural -0.295161
dtype: float64 year 1950.000000
month 12.000000
hour 23.000000
temperature_2m_urban-rural 1.183871
dtype: float64
# 1950 - Entire year
year = 1950
z = hm.loc[hm['year'] == year].pivot(index='month', columns='hour', values='temperature_2m_urban-rural')
z.loc[0:11].min().transpose().hvplot.line()
# 2023 - Entire year
year = 2023
z = hm.loc[hm['year'] == year].pivot(index='month', columns='hour', values='temperature_2m_urban-rural')
z.loc[0:11].mean().transpose().hvplot.line()
fig, ax = plt.subplots(figsize=(16,8))
ax = plt.subplot(1, 1, 1)
plt.title(area_params["urban"] + ' vs. ' + area_params["rural"] + ' (' + area_params["area"] + ') - Year '+ str(year) +'\n Hourly ERA-Land reanalysis data (from https://open-meteo.com)', fontsize=16)
sns.heatmap(z, cmap=cm.roma_r, vmin=vmin, vmax=vmax, cbar_kws={'label': 'Air (2m) temperature difference between urban and rural areas [° Celcius]'}, ax=ax)
ax.set_ylabel('Month of the year', fontsize=16)
ax.set_xlabel('Local time (hours)', fontsize=16)
plt.savefig('UHI_' + area_params["urban"] + '_' + str(year) + '.png')
years = hm['year'].unique()
def myheatmap(year, ax, start_year, area_params, hm):
ax.clear()
plt.clf()
fig = plt.figure(1, figsize=[16,8])
ax = plt.subplot(1, 1, 1)
plt.title(area_params["urban"] + ' vs. ' + area_params["rural"] + ' (' + area_params["area"] + ') - Year '+ str(year + start_year) +'\n Hourly ERA-Land reanalysis data (from https://open-meteo.com)', fontsize=16)
z = hm.loc[hm['year'] == (year + start_year)].pivot(index='month', columns='hour', values='temperature_2m_urban-rural')
ax = sns.heatmap(z, cmap=cm.roma_r, vmin=vmin, vmax=vmax, cbar_kws={'label': 'Air (2m) temperature difference between urban and rural areas [° Celcius]'})
ax.set_ylabel('Month of the year', fontsize=16)
ax.set_xlabel('Local time (hours)', fontsize=16)
Create animation and save to HTML#
FFMpegWriter = animation.writers['ffmpeg']
metadata = dict(title='UHI in ' + area_params["urban"] + '(' + area_params["area"] + ')', artist='Jean Iaquinta', comment='ERA5-Land data from https://open-meteo.com')
writer = FFMpegWriter(fps=25, metadata=metadata)
fig = plt.figure(1, figsize=[16,8])
ax = plt.subplot(1, 1, 1)
ani = animation.FuncAnimation(fig, myheatmap, frames=range(end_year - start_year + 1), fargs=(ax, start_year, area_params, hm), interval=200)
vid = HTML(ani.to_html5_video())
with open('UHI_' + area_params["urban"] + '_' + str(start_year) + '-' + str(end_year) + '.html', 'w') as f:
print(ani.to_html5_video(), file=f)
Packages citation#
The pandas development team. Pandas-dev/pandas: pandas. February 2020. URL: https://doi.org/10.5281/zenodo.3509134, doi:10.5281/zenodo.3509134.
Patrick Zippenfenig. Open-meteo.com weather api. 2023. URL: https://open-meteo.com/, doi:10.5281/zenodo.7970649.