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.

In [1]:
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.

In [2]:
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
Out[2]:
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³.

In [3]:
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?

In [4]:
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³)')
No description has been provided for this image
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.

In [5]:
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)
No description has been provided for this image
            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.

In [6]:
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()
No description has been provided for this image

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.)

In [7]:
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)
No description has been provided for this image

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.ipynb notebook).
  • 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.

In [8]:
# ⚠️ 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"