Leuven.cool Citizen Science Urban Weather Station Network¶
Category: Meteorology · Size: 12.9 GB · Format: CSV, CSV.gz License: CC-BY-NC-4.0 (Non-commercial) · Zenodo record · Data sheet on the CSDH
Over 1 billion raw observations from ~110 low-cost weather stations spread across Leuven (Belgium), at 16-second resolution from June 2019 to March 2025.
The data is mounted read-only at /srv/data/leuven-urban-weather/.
Save anything you produce in your personal folder (~/).
⚠️ Large dataset (12.9 GB). Your Hub session has 4 GB RAM — do not load the whole file into memory or the kernel will crash. Work like the pros: read only the columns you need, process the file in chunks, or query it straight from disk with DuckDB (no full load). Copy-paste patterns are in "Working with data larger than memory" near the end of this notebook.
What's in the dataset¶
The network stores two kinds of files:
STATIONS.csv- one row per weather station, with its coordinates and altitude. Small enough to load whole.RAWDATA<YEAR>Q<Q>.csv.gz- the raw 16-second observations, split by calendar quarter. Each is hundreds of MB gzipped and expands to billions of rows in total, so we never load one whole.
Let's list them and load only the small station table.
from pathlib import Path
import pandas as pd
DATA = Path('/srv/data/leuven-urban-weather')
for f in sorted(DATA.glob('*')):
print(f"{f.name:28s} {f.stat().st_size/1e6:8,.1f} MB")
stations = pd.read_csv(DATA / 'STATIONS.csv')
print(f"\n{len(stations)} stations")
stations.head()
RAWDATA2019Q2.csv.gz 6.4 MB RAWDATA2019Q3.csv.gz 185.2 MB RAWDATA2019Q4.csv.gz 444.2 MB RAWDATA2020Q1.csv.gz 505.7 MB RAWDATA2020Q2.csv.gz 500.3 MB RAWDATA2020Q3.csv.gz 523.5 MB RAWDATA2020Q4.csv.gz 486.9 MB RAWDATA2021Q1.csv.gz 527.1 MB RAWDATA2021Q2.csv.gz 635.0 MB RAWDATA2021Q3.csv.gz 602.4 MB RAWDATA2021Q4.csv.gz 561.7 MB RAWDATA2022Q1.csv.gz 605.8 MB RAWDATA2022Q2.csv.gz 686.8 MB RAWDATA2022Q3.csv.gz 707.5 MB RAWDATA2022Q4.csv.gz 615.0 MB RAWDATA2023Q1.csv.gz 619.3 MB RAWDATA2023Q2.csv.gz 695.5 MB RAWDATA2023Q3.csv.gz 648.9 MB RAWDATA2023Q4.csv.gz 610.2 MB RAWDATA2024Q1.csv.gz 580.3 MB RAWDATA2024Q2.csv.gz 601.9 MB RAWDATA2024Q3.csv.gz 599.8 MB RAWDATA2024Q4.csv.gz 505.9 MB RAWDATA2025Q1.csv.gz 439.3 MB STATIONS.csv 0.0 MB 155 stations
| ID | WOWID | LATITUDE | LONGITUDE | ALTITUDE | |
|---|---|---|---|---|---|
| 0 | GARMON01 | b1f9850c-d686-e911-80e7-0003ff59889d | 50.8710 | 4.694 | 21 |
| 1 | GARMON002 | 83a89aa6-2695-e911-80e7-0003ff59883f | 50.8468 | 4.756 | 47 |
| 2 | GARMON003 | 2a3596b2-2795-e911-80e7-0003ff59889d | 50.8700 | 4.728 | 44 |
| 3 | GARMON004 | 7d43d8ab-2895-e911-80e7-0003ff59883f | 50.8708 | 4.685 | 31 |
| 4 | GARMON005 | 3f67b310-5597-e911-80e7-0003ff59883f | 50.8814 | 4.713 | 26 |
The measurement schema¶
The raw files carry a full amateur-weather-station record. The columns we care about for the urban heat-island question are:
| column | meaning |
|---|---|
ID |
station id (joins to STATIONS.csv) |
TEMPF |
air temperature in degrees Fahrenheit |
HUMIDITY |
relative humidity (%) |
DATEUTC |
timestamp in UTC |
DuckDB can read a .csv.gz straight from disk and only pull the aggregated result into memory - no 4 GB blow-up. Let's peek at five rows of one quarter to confirm the layout.
import duckdb
SUMMER = DATA / 'RAWDATA2019Q3.csv.gz' # Jul-Sep 2019: a full summer quarter
duckdb.sql(f"""
SELECT ID, TEMPF, HUMIDITY, DATEUTC
FROM read_csv_auto('{SUMMER}')
LIMIT 5
""").df()
| ID | TEMPF | HUMIDITY | DATEUTC | |
|---|---|---|---|---|
| 0 | GARMON022 | 64.9 | 71 | 2019-07-01 00:00:02 |
| 1 | GARMON012 | 65.7 | 71 | 2019-07-01 00:00:02 |
| 2 | GARMON014 | 66.7 | 66 | 2019-07-01 00:00:03 |
| 3 | GARMON003 | 65.8 | 69 | 2019-07-01 00:00:03 |
| 4 | GARMON013 | 64.2 | 74 | 2019-07-01 00:00:03 |
Aggregate on disk: mean temperature per station and hour¶
To ask "is one part of town warmer, and does the gap open up at night?" we need, for each station, the average temperature at each hour of the day.
We do this in a single DuckDB pass over the ~4 GB (uncompressed) summer file:
- convert Fahrenheit to Celsius:
(TEMPF - 32) x 5/9, - drop physically impossible readings (keep -40 to 45 C),
- shift UTC to local Leuven summer time (UTC+2, CEST) so "hour 3" really is the small hours,
- group by station and local hour.
The result is only ~110 stations x 24 hours, which fits easily in memory.
q = f"""
SELECT
ID,
extract('hour' FROM DATEUTC + INTERVAL 2 HOUR) AS local_hour,
avg((TEMPF - 32) * 5/9.0) AS tempC,
count(*) AS n_obs
FROM read_csv_auto('{SUMMER}')
WHERE (TEMPF - 32) * 5/9.0 BETWEEN -40 AND 45
GROUP BY ID, local_hour
"""
hourly = duckdb.sql(q).df()
print('rows:', len(hourly), '| stations:', hourly.ID.nunique())
hourly.head()
rows: 1738 | stations: 74
| ID | local_hour | tempC | n_obs | |
|---|---|---|---|---|
| 0 | GARMON064 | 16 | 22.872390 | 9452 |
| 1 | GARMON014 | 16 | 24.723931 | 15022 |
| 2 | GARMON049 | 16 | 24.738412 | 12357 |
| 3 | GARMON040 | 16 | 24.123120 | 14831 |
| 4 | GARMON005 | 16 | 24.498026 | 13253 |
We keep only stations that (a) reported through the whole day (all 24 hours) and (b) have a healthy sample, then attach their coordinates. This gives us a clean station-by-hour temperature grid.
# total observations per station in the quarter
totals = hourly.groupby('ID')['n_obs'].sum()
good = totals[totals > 200_000].index # well-sampled stations
grid = hourly[hourly.ID.isin(good)]
# station x hour table of mean temperature
pivot = grid.pivot(index='ID', columns='local_hour', values='tempC').dropna()
pivot = pivot.join(stations.set_index('ID')[['LATITUDE', 'LONGITUDE', 'ALTITUDE']], how='inner')
# diurnally-unbiased daily mean = average of the 24 hourly means
hours = list(range(24))
pivot['mean_tempC'] = pivot[hours].mean(axis=1)
print(f'{len(pivot)} well-sampled, fully-covered stations')
pivot[['mean_tempC', 'LATITUDE', 'LONGITUDE', 'ALTITUDE']].round(2).head()
48 well-sampled, fully-covered stations
| mean_tempC | LATITUDE | LONGITUDE | ALTITUDE | |
|---|---|---|---|---|
| ID | ||||
| GARMON002 | 18.46 | 50.85 | 4.76 | 47 |
| GARMON003 | 18.58 | 50.87 | 4.73 | 44 |
| GARMON004 | 19.05 | 50.87 | 4.68 | 31 |
| GARMON005 | 19.84 | 50.88 | 4.71 | 26 |
| GARMON006 | 19.18 | 50.91 | 4.72 | 13 |
1. Where are the warm spots? A temperature map¶
Each dot is a station, placed by its real coordinates and coloured by its summer-mean temperature. Because the stations span the city centre and the surrounding villages, a warmer core would show up as the urban heat island.
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize=(8, 7))
sc = ax.scatter(pivot['LONGITUDE'], pivot['LATITUDE'],
c=pivot['mean_tempC'], cmap='inferno', s=90,
edgecolor='k', linewidth=0.4)
# mark the historic centre of Leuven for reference
ax.scatter(4.7005, 50.8798, marker='*', s=320, c='cyan',
edgecolor='k', zorder=5, label='Leuven centre')
ax.set_aspect(1/np.cos(np.radians(50.88))) # keep lon/lat visually square
ax.set_xlabel('Longitude'); ax.set_ylabel('Latitude')
ax.set_title('Mean summer temperature per station (2019 Q3)')
ax.legend(loc='upper right')
fig.colorbar(sc, ax=ax, label='Mean temperature (C)')
plt.tight_layout()
plt.show()
2. Does the gap grow at night?¶
The heat-island signature is not just "the centre is warm" - it is that the difference between stations widens after sunset, when built-up ground slowly releases the heat it stored during the day while open fields cool quickly.
We measure the spread between stations (the standard deviation of the station means) at each hour of the day. If the urban heat island is real, this spread should peak in the small hours and shrink around midday.
spread = pivot[hours].std(axis=0) # std across stations, per hour
network_mean = pivot[hours].mean(axis=0) # city-wide mean diurnal cycle
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.5))
ax1.plot(hours, network_mean, marker='o', color='#c1440e')
ax1.set_title('City-wide mean temperature by hour')
ax1.set_xlabel('Hour of day (local, CEST)'); ax1.set_ylabel('Temperature (C)')
ax1.grid(alpha=0.3)
ax2.plot(hours, spread, marker='o', color='#2166ac')
ax2.fill_between(hours, spread, alpha=0.15, color='#2166ac')
ax2.axvspan(0, 5, alpha=0.08, color='navy')
ax2.axvspan(22, 23, alpha=0.08, color='navy')
ax2.set_title('Spread BETWEEN stations by hour\n(heat-island intensity)')
ax2.set_xlabel('Hour of day (local, CEST)')
ax2.set_ylabel('Std. dev. across stations (C)')
ax2.grid(alpha=0.3)
plt.tight_layout(); plt.show()
print(f"Between-station spread at 03:00 = {spread[3]:.2f} C")
print(f"Between-station spread at 14:00 = {spread[14]:.2f} C")
Between-station spread at 03:00 = 0.95 C Between-station spread at 14:00 = 0.65 C
3. Who is warmest at night, and what does their day look like?¶
Finally we rank stations by their night-time temperature (00:00-05:00 average), take the warmest and coolest few, and plot their full daily temperature curve. Warm-at-night stations should sit near the city core; the day-time curves of all stations tend to converge.
night = pivot[[0, 1, 2, 3, 4, 5]].mean(axis=1)
order = night.sort_values()
coolest = order.index[:3]
warmest = order.index[-3:]
fig, (axm, axc) = plt.subplots(1, 2, figsize=(13, 5.5))
# map: night anomaly relative to the network night mean
night_anom = night - night.mean()
lim = night_anom.abs().max()
sm = axm.scatter(pivot['LONGITUDE'], pivot['LATITUDE'], c=night_anom,
cmap='coolwarm', s=90, edgecolor='k', linewidth=0.4,
vmin=-lim, vmax=lim)
axm.scatter(4.7005, 50.8798, marker='*', s=320, c='yellow',
edgecolor='k', zorder=5)
axm.set_aspect(1/np.cos(np.radians(50.88)))
axm.set_xlabel('Longitude'); axm.set_ylabel('Latitude')
axm.set_title('Night-time (00-05h) temperature anomaly')
fig.colorbar(sm, ax=axm, label='C vs network night mean')
# diurnal curves for the extremes
for s in warmest:
axc.plot(hours, pivot.loc[s, hours], color='#b2182b', alpha=0.9,
label=f'{s} (warm)')
for s in coolest:
axc.plot(hours, pivot.loc[s, hours], color='#2166ac', alpha=0.9,
label=f'{s} (cool)')
axc.plot(hours, network_mean, 'k--', lw=2, label='network mean')
axc.set_xlabel('Hour of day (local, CEST)'); axc.set_ylabel('Temperature (C)')
axc.set_title('Daily cycle: warmest vs coolest stations at night')
axc.legend(fontsize=8); axc.grid(alpha=0.3)
plt.tight_layout(); plt.show()
gap = night.max() - night.min()
print(f"Warmest-minus-coolest station at night: {gap:.2f} C")
Warmest-minus-coolest station at night: 4.30 C
What we found & where to go next¶
Even from a single summer quarter, read straight off disk with DuckDB, the network shows a clear urban heat-island fingerprint: the spread between stations widens overnight and the warmest-at-night stations cluster toward the built-up core.
Try next: repeat the DuckDB query on a winter quarter (e.g. RAWDATA2020Q1.csv.gz) and compare the night-time spread - is the heat island weaker in winter? Or bring in HUMIDITY / SOLARRADIATION to explain why some stations cool faster.
Your turn¶
This is just the starting point. Some ideas:
- Check the dataset challenge on its CSDH data sheet.
- Work on a copy: right-click the file → Duplicate (or Save Notebook As…). Your changes only live in your Hub space — they're never pushed to GitHub.
- Edited this notebook and want the original back? Use the Restore cell
below (or the
restore.ipynbnotebook). - Questions and results: on the platform forum.
Attribution: data from Leuven.cool Citizen Science Urban Weather Station Network, license CC-BY-NC-4.0. Notebook from the Citizen Science Data Hub (CSDH) — Fundación Ibercivis.
# ⚠️ RESTORE: this DISCARDS YOUR CHANGES to this notebook and resets it to the original.
# 1. Uncomment the line below (remove the #) 2. Run this cell
# 3. Then: menu File → Reload Notebook from Disk
# !git -C ~/citizen-science-data fetch -q origin && git -C ~/citizen-science-data checkout origin/main -- leuven-urban-weather.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"