xAire: High-Resolution NO₂ Citizen Science Dataset (Barcelona)¶
Category: Air Quality · Size: 77.6 kB · Format: CSV License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH
725 nitrogen dioxide measurements collected by 1,650 participants from 18 Barcelona schools using Palmes diffusion tubes, with asthma health-impact analysis.
The data is mounted read-only at /srv/data/xaire-no2-barcelona/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
Just one small CSV. Each row is one measurement point — a Palmes diffusion tube left out for ~4 weeks in 2017. Volunteers from 18 Barcelona schools placed the tubes; the lab returned an NO₂ concentration for each one.
from pathlib import Path
DATA = Path('/srv/data/xaire-no2-barcelona')
for f in sorted(DATA.rglob('*')):
if f.is_file():
print(f"{f.relative_to(DATA)} ({f.stat().st_size/1e3:,.1f} kB)")
xaire_datainbrief.csv (77.6 kB)
Load the data¶
This file uses a semicolon (;) separator and Latin-1 encoding (Catalan/Spanish
street names carry accents like plaça and província). If you read it with the
defaults you get mojibake, so we state both explicitly.
import pandas as pd
csv_path = DATA / 'xaire_datainbrief.csv'
df = pd.read_csv(csv_path, sep=';', encoding='latin-1')
print(df.shape[0], 'measurements,', df.shape[1], 'columns')
df.head()
725 measurements, 9 columns
| tube_code | school | lat | long | address | type | no2_raw | no2_unbiased | no2_2017 | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1003091 | xAire | 41.397720 | 2.149740 | carrer denia, 2-4 | traffic | 38.290447 | 41 | 39 |
| 1 | 1003092 | xAire | 41.398180 | 2.148380 | plaça cardona, 1-2 | background | 42.617421 | 46 | 44 |
| 2 | 1003093 | xAire | 41.399155 | 2.145611 | carrer aribau 265 | traffic | 26.454660 | 28 | 27 |
| 3 | 1003094 | xAire | 41.401366 | 2.148259 | via augusta, 114 | traffic | 60.009157 | 64 | 62 |
| 4 | 1003095 | xAire | 41.403742 | 2.142602 | carrer balmes 350-352 | traffic | 98.931474 | 106 | 102 |
The columns¶
| column | meaning |
|---|---|
tube_code |
ID of the diffusion tube |
school |
school whose pupils placed the tube (district in parentheses) |
lat, long |
tube location |
address |
street address |
type |
micro-environment: traffic, background, playground, classroom |
no2_raw |
measured NO₂ (µg/m³) |
no2_unbiased |
NO₂ corrected for the diffusion-tube bias |
no2_2017 |
NO₂ scaled to a full-year 2017 average |
We use no2_2017 as our headline number: it is the bias-corrected,
annualised concentration, which is what air-quality limits are defined against.
The key reference values for annual NO₂ are the EU legal limit = 40 µg/m³
and the stricter WHO 2021 guideline = 10 µg/m³.
df.info()
print()
print('NO2 (no2_2017, µg/m3) summary:')
print(df['no2_2017'].describe().round(1))
print()
print('Measurements per micro-environment:')
print(df['type'].value_counts())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 725 entries, 0 to 724 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 tube_code 725 non-null int64 1 school 725 non-null object 2 lat 725 non-null float64 3 long 725 non-null float64 4 address 725 non-null object 5 type 725 non-null object 6 no2_raw 725 non-null float64 7 no2_unbiased 725 non-null int64 8 no2_2017 725 non-null int64 dtypes: float64(3), int64(3), object(3) memory usage: 51.1+ KB NO2 (no2_2017, µg/m3) summary: count 725.0 mean 47.9 std 15.6 min 13.0 25% 38.0 50% 44.0 75% 54.0 max 126.0 Name: no2_2017, dtype: float64 Measurements per micro-environment: type traffic 482 background 189 playground 31 classroom 23 Name: count, dtype: int64
1. How polluted is the air? Distribution vs. the limits¶
A histogram of every tube's annualised NO₂, with the EU limit and WHO guideline drawn on top. The question is simple: how many measurement points breach each line?
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style='whitegrid')
EU_LIMIT, WHO_2021 = 40, 10
fig, ax = plt.subplots(figsize=(9, 5))
sns.histplot(df['no2_2017'], bins=30, color='#4C72B0', edgecolor='white', ax=ax)
ax.axvline(EU_LIMIT, color='#C44E52', lw=2, ls='--', label=f'EU limit {EU_LIMIT} µg/m³')
ax.axvline(WHO_2021, color='#55A868', lw=2, ls='--', label=f'WHO 2021 guideline {WHO_2021} µg/m³')
ax.set(xlabel='Annualised NO₂ (µg/m³)', ylabel='Number of tubes',
title='xAire Barcelona — NO₂ across 725 measurement points')
ax.legend()
plt.tight_layout()
plt.show()
over_eu = (df['no2_2017'] > EU_LIMIT).mean() * 100
over_who = (df['no2_2017'] > WHO_2021).mean() * 100
print(f'{over_eu:.0f}% of points exceed the EU limit (40 µg/m³)')
print(f'{over_who:.0f}% of points exceed the WHO 2021 guideline (10 µg/m³)')
63% of points exceed the EU limit (40 µg/m³) 100% of points exceed the WHO 2021 guideline (10 µg/m³)
Almost every point clears the strict WHO guideline, and a large share sit above the EU legal limit — a first hint that traffic dominates the signal. Let's test that.
2. Does traffic drive the pollution? NO₂ by micro-environment¶
Each tube is tagged by where it sat: next to traffic, at a quiet background
spot, in a school playground, or inside a classroom. If cars are the source,
traffic tubes should read highest and classroom lowest.
order = ['traffic', 'background', 'playground', 'classroom']
palette = {'traffic': '#C44E52', 'background': '#8172B3',
'playground': '#CCB974', 'classroom': '#55A868'}
fig, ax = plt.subplots(figsize=(9, 5))
sns.boxplot(data=df, x='type', y='no2_2017', order=order, palette=palette, ax=ax)
sns.stripplot(data=df, x='type', y='no2_2017', order=order,
color='black', alpha=0.25, size=3, ax=ax)
ax.axhline(EU_LIMIT, color='#C44E52', lw=1.5, ls='--')
ax.text(3.4, EU_LIMIT + 1, 'EU limit', color='#C44E52', ha='right', va='bottom')
ax.set(xlabel='Micro-environment', ylabel='Annualised NO₂ (µg/m³)',
title='NO₂ by where the tube was placed')
plt.tight_layout()
plt.show()
print(df.groupby('type')['no2_2017'].agg(['median', 'mean', 'count']).round(1)
.reindex(order))
/tmp/ipykernel_42184/2880106356.py:6: FutureWarning: Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect. sns.boxplot(data=df, x='type', y='no2_2017', order=order, palette=palette, ax=ax)
median mean count type traffic 49.0 52.2 482 background 39.0 40.7 189 playground 38.0 37.2 31 classroom 28.0 31.3 23
The gradient is exactly what you would expect from a traffic source: tubes at the kerbside read highest, quiet background spots and school playgrounds/classrooms read lower. Yet even the classroom median is not far below the WHO guideline — children are exposed indoors too.
3. Where are the hotspots? A map of Barcelona¶
The tubes carry lat/long, so we can plot them geographically without any
online map tiles — the spread of points itself traces the street grid. Colour
encodes NO₂, so red clusters are the pollution hotspots.
fig, ax = plt.subplots(figsize=(8, 8))
sc = ax.scatter(df['long'], df['lat'], c=df['no2_2017'],
cmap='inferno', s=28, edgecolor='white', linewidth=0.3)
cb = fig.colorbar(sc, ax=ax, shrink=0.8)
cb.set_label('Annualised NO₂ (µg/m³)')
# mark the single worst point
worst = df.loc[df['no2_2017'].idxmax()]
ax.scatter(worst['long'], worst['lat'], s=180, facecolor='none',
edgecolor='cyan', linewidth=2)
ax.annotate(f" worst: {worst['no2_2017']:.0f} µg/m³\n {worst['address']}",
(worst['long'], worst['lat']), color='black', fontsize=8)
ax.set_aspect(1 / 0.75) # rough lat/long aspect for Barcelona's latitude
ax.set(xlabel='Longitude', ylabel='Latitude',
title='NO₂ hotspots across Barcelona (each dot = one tube)')
plt.tight_layout()
plt.show()
The hottest points concentrate in the dense central districts (the Eixample grid and the main avenues) where traffic is heaviest, while the outer, greener edges read cooler.
4. Which schools face the worst air?¶
The school label doubles as a neighbourhood proxy — the district is written in
parentheses. Ranking schools by their median NO₂ shows which communities'
children breathe the dirtiest air. (We drop the campaign-wide xAire label,
which is not a single school.)
schools = df[df['school'] != 'xAire']
rank = (schools.groupby('school')['no2_2017']
.median().sort_values(ascending=False))
fig, ax = plt.subplots(figsize=(9, 7))
colors = ['#C44E52' if v > EU_LIMIT else '#4C72B0' for v in rank.values]
ax.barh(rank.index[::-1], rank.values[::-1], color=colors[::-1])
ax.axvline(EU_LIMIT, color='#C44E52', lw=1.5, ls='--', label='EU limit 40 µg/m³')
ax.set(xlabel='Median annualised NO₂ (µg/m³)', ylabel='',
title='Schools ranked by median NO₂ (red = above EU limit)')
ax.legend()
plt.tight_layout()
plt.show()
/tmp/ipykernel_42184/3783466070.py:12: UserWarning: Glyph 146 (\x92) missing from font(s) DejaVu Sans. plt.tight_layout()
/opt/tljh/user/lib/python3.10/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 146 (\x92) missing from font(s) DejaVu Sans. fig.canvas.print_figure(bytes_io, **kw)
Schools in the central Eixample and Gràcia districts top the ranking, several with a median already above the EU limit — meaning at least half of their tubes breach it. This is the core public-health finding of xAire: routine exposure around many Barcelona schools exceeds legal air-quality limits.
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 xAire: High-Resolution NO₂ Citizen Science Dataset (Barcelona), license CC-BY-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 -- xaire-no2-barcelona.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"