Urban Heat Island Effect Changes#

Authors & Contributors#

Notebook#

  • Jean Iaquinta, University of Oslo (Norway), @j34ni

Contributors#

  • Anne Fouilloux, Simula Research Laboratory (Norway), @annefou

RoHub

Overview

Questions
  • What is the Urban Heat Island?
  • How do I compute the Urban Heat island effect?
  • What data can I use to compute the Urban Heat Island Effect?
  • How can I represent the Urban Heat Island effect on a plot?
Objectives
  • Learn about the Urban Heat Island
  • Learn about Open Meteo Historical Weather Data and API
  • Learn to compute and plot the Urban Heat Island Effect

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
Hide 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')
_images/ad9b213ceedc987287e01367fabaa79d60e4418b6c71bfd1b5c867cd0b723823.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())
_images/e30191d70e3b567e20d6302628bba351e63f07783b379b88f89c7661d5e55f51.png
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#

[pdt20]

The pandas development team. Pandas-dev/pandas: pandas. February 2020. URL: https://doi.org/10.5281/zenodo.3509134, doi:10.5281/zenodo.3509134.

[Zip23]

Patrick Zippenfenig. Open-meteo.com weather api. 2023. URL: https://open-meteo.com/, doi:10.5281/zenodo.7970649.