Skip to main content Skills Marketplace Découvrez et explorez les compétences IA créées par la communauté.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Copier le promptAfficher les détails du prompt Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
npx skills add https://github.com/xjtulyc/awesome-rosetta-skills --skill air-quality-openaqLa commande reste sur une seule ligne. Faites défiler horizontalement pour la vérifier avant de la copier.
Vous préférez une copie locale ? Téléchargez les fichiers actuellement disponibles dans SkillsMP.
Télécharger Zip Téléchargement... name air-quality-openaq description Use this Skill to access OpenAQ air quality data (PM2.5, O3, NO2), compute spatial interpolation, health exposure indices, and city comparisons.
tags ["earth-science","air-quality","openaq","environmental-health","spatial-interpolation"] version 1.0.0 authors [{"name":"Rosetta Skills Contributors","github":"@xjtulyc"}] license MIT platforms ["claude-code","codex","gemini-cli","cursor"] dependencies {"python":["openaq>=2.0","pandas>=2.0","plotly>=5.15","pykrige>=1.7","matplotlib>=3.7","numpy>=1.24","geopandas>=0.14","requests>=2.31"]} last_updated 2026-03-17 status stable
Air Quality Analysis with OpenAQ
One-line summary : Download and analyze PM2.5/PM10/O3/NO2 air quality time series from OpenAQ, compute kriging interpolation, health exposure indices, and city comparisons.
When to Use This Skill
When accessing ground-level air quality measurements globally
When comparing air quality between cities or countries
When computing AQI (Air Quality Index) from raw pollutant data
When interpolating sparse station data to spatial grids (kriging)
When estimating population exposure to PM2.5 exceedances
When analyzing seasonal patterns and pollution episodes
Trigger keywords : OpenAQ, PM2.5, PM10, NO2, O3, air quality index, AQI, kriging interpolation, pollution, health exposure
Background & Key Concepts
OpenAQ Platform
OpenAQ aggregates real-time and historical air quality data from ~30,000 monitoring stations in 100+ countries. Data includes PM2.5, PM10, NO2, O3, SO2, CO, and BC.
WHO Air Quality Guidelines (2021)
Pollutant Annual mean 24h mean PM2.5 5 µg/m³ 15 µg/m³ PM10 15 µg/m³ 45 µg/m³ NO2 10 µg/m³ 25 µg/m³ O3 — 100 µg/m³ (8h)
Air Quality Index (US EPA)
$$
\text{AQI} = \frac{I_{hi} - I_{lo}}{C_{hi} - C_{lo}} \times (C - C_{lo}) + I_{lo}
$$
AQI categories: Good (0-50), Moderate (51-100), Unhealthy for Sensitive Groups (101-150), Unhealthy (151-200), Very Unhealthy (201-300), Hazardous (301+).
Ordinary Kriging
Spatial interpolation assuming stationary covariance:
$$
Z(x_0) = \sum_{i=1}^n \lambda_i Z(x_i), \quad \sum_i \lambda_i = 1
$$
Variogram model: $\gamma(h) = C_0 + C_1 (1 - e^{-h/a})$ (exponential).
Environment Setup
Install Dependencies
pip install openaq>=2.0 pandas>=2.0 plotly>=5.15 pykrige>=1.7 \
matplotlib>=3.7 numpy>=1.24 geopandas>=0.14 requests>=2.31
API Key Setup (OpenAQ v3)
export OPENAQ_API_KEY="<paste-your-key>"
import os
OPENAQ_KEY = os.getenv("OPENAQ_API_KEY" , "" )
if not OPENAQ_KEY:
print ("Warning: OPENAQ_API_KEY not set; using unauthenticated access (rate-limited)" )
Verify Installation import requests, pandas as pd
resp = requests.get("https://api.openaq.org/v3/parameters" , timeout=10 )
params = pd.DataFrame(resp.json()["results" ])
print (f"OpenAQ parameters available: {len (params)} " )
print (params[["name" , "displayName" , "units" ]].head(6 ).to_string(index=False ))
Core Workflow
Step 1: Query Locations and Download Data import requests
import pandas as pd
import numpy as np
import os
OPENAQ_KEY = os.getenv("OPENAQ_API_KEY" , "" )
HEADERS = {"X-API-Key" : OPENAQ_KEY} if OPENAQ_KEY else {}
def get_locations (city, country="CN" , parameter="pm25" , limit=50 ):
"""
Get monitoring station locations for a city/country.
Returns
-------
pd.DataFrame with columns: id, name, latitude, longitude, lastUpdated
"""
url = "https://api.openaq.org/v3/locations"
params = {
"city" : city,
"country" : country,
"parameters_name" : parameter,
"limit" : limit,
}
resp = requests.get(url, params=params, headers=HEADERS, timeout=15 )
resp.raise_for_status()
data = resp.json().get("results" , [])
if not data:
print (f"No stations found for {city} , {country} " )
return pd.DataFrame()
records = []
for loc in data:
coords = loc.get("coordinates" , {})
records.append({
"id" : loc["id" ],
"name" : loc.get("name" , "Unknown" ),
"latitude" : coords.get("latitude" ),
"longitude" : coords.get("longitude" ),
"lastUpdated" : loc.get("lastUpdated" ),
})
df = pd.DataFrame(records).dropna(subset=["latitude" , "longitude" ])
print (f"Found {len (df)} stations in {city} " )
return df
def get_measurements (location_id, parameter="pm25" ,
date_from="2024-01-01" , date_to="2024-12-31" ,
limit=1000 ):
"""
Download hourly measurements for one station.
Returns
-------
pd.DataFrame with datetime index and value column
"""
url = f"https://api.openaq.org/v3/locations/{location_id} /measurements"
params = {
"parameters_name" : parameter,
"date_from" : date_from,
"date_to" : date_to,
"limit" : limit,
}
resp = requests.get(url, params=params, headers=HEADERS, timeout=30 )
if resp.status_code != 200 :
return pd.DataFrame()
results = resp.json().get("results" , [])
records = [{"datetime" : r["period" ]["datetimeFrom" ]["local" ],
"value" : r["value" ], "unit" : r.get("parameter" , {}).get("units" , "" )}
for r in results]
df = pd.DataFrame(records)
if df.empty:
return df
df["datetime" ] = pd.to_datetime(df["datetime" ])
df = df.set_index("datetime" ).sort_index()
df["value" ] = pd.to_numeric(df["value" ], errors="coerce" )
df = df[df["value" ] >= 0 ]
return df
np.random.seed(42 )
dates = pd.date_range("2024-01-01" , "2024-12-31" , freq="h" )
seasonal = 20 + 30 * np.cos(2 * np.pi * (dates.dayofyear - 30 ) / 365 )
diurnal = 5 * np.sin(2 * np.pi * dates.hour / 24 )
noise = np.random.exponential(10 , len (dates))
pm25_sim = pd.DataFrame({
"value" : np.maximum(0 , seasonal + diurnal + noise)
}, index=dates)
print (f"Simulated PM2.5 data: {len (pm25_sim)} hourly records" )
print (pm25_sim["value" ].describe().round (2 ))
Step 2: Time Series Analysis and AQI Computation import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
def pm25_to_aqi (pm25 ):
"""
Convert PM2.5 (µg/m³) to US EPA AQI.
Breakpoints: https://aqs.epa.gov/aqsweb/documents/codetables/aqi_breakpoints.html
"""
breakpoints = [
(0.0 , 12.0 , 0 , 50 ),
(12.1 , 35.4 , 51 , 100 ),
(35.5 , 55.4 , 101 , 150 ),
(55.5 , 150.4 , 151 , 200 ),
(150.5 , 250.4 , 201 , 300 ),
(250.5 , 500.4 , 301 , 500 ),
]
if pd.isna(pm25) or pm25 < 0 :
return np.nan
for C_lo, C_hi, I_lo, I_hi in breakpoints:
if C_lo <= pm25 <= C_hi:
return int ((I_hi - I_lo) / (C_hi - C_lo) * (pm25 - C_lo) + I_lo)
return 500
AQI_CATEGORIES = {
(0 , 50 ): "Good" ,
(51 , 100 ): "Moderate" ,
(101 , 150 ): "Unhealthy (Sensitive)" ,
(151 , 200 ): "Unhealthy" ,
(201 , 300 ): "Very Unhealthy" ,
(301 , 500 ): "Hazardous" ,
}
AQI_COLORS = ["green" , "yellow" , "orange" , "red" , "purple" , "maroon" ]
def classify_aqi (aqi ):
for (lo, hi), cat in AQI_CATEGORIES.items():
if lo <= aqi <= hi:
return cat
return "Hazardous"
pm25_sim["aqi" ] = pm25_sim["value" ].apply(pm25_to_aqi)
daily = pm25_sim.resample("D" ).mean()
daily["category" ] = daily["aqi" ].apply(lambda x: classify_aqi(x) if not np.isnan(x) else "Unknown" )
print ("Days by AQI category:" )
print (daily["category" ].value_counts())
print (f"\nAnnual mean PM2.5: {pm25_sim['value' ].mean():.1 f} µg/m³ "
f"(WHO guideline: 5 µg/m³)" )
exceedance_days = (daily["value" ] > 15 ).sum ()
print (f"Days exceeding WHO 24h limit (15 µg/m³): {exceedance_days} " )
fig, axes = plt.subplots(2 , 1 , figsize=(12 , 8 ))
monthly = pm25_sim.resample("ME" )["value" ]
axes[0 ].boxplot([g[1 ].values for g in monthly],
labels=range (1 , 13 ), patch_artist=True )
axes[0 ].axhline(15 , color='r' , linestyle='--' , label="WHO 24h limit" )
axes[0 ].axhline(5 , color='orange' , linestyle=':' , label="WHO annual limit" )
axes[0 ].set_xlabel("Month" ); axes[0 ].set_ylabel("PM2.5 (µg/m³)" )
axes[0 ].set_title("Monthly PM2.5 Distribution" ); axes[0 ].legend()
axes[1 ].plot(daily.index, daily["value" ], linewidth=0.5 , alpha=0.7 )
axes[1 ].axhline(15 , color='r' , linestyle='--' )
axes[1 ].set_xlabel("Date" ); axes[1 ].set_ylabel("Daily mean PM2.5 (µg/m³)" )
axes[1 ].set_title("Daily Mean PM2.5 Time Series" )
plt.tight_layout()
plt.savefig("air_quality_analysis.png" , dpi=150 )
plt.show()
Step 3: Multi-City Comparison and Kriging import numpy as np
import matplotlib.pyplot as plt
cities_data = {
"Beijing" : {"lat" : 39.9 , "lon" : 116.4 , "pm25_annual" : 35.5 , "pop_M" : 21.5 },
"Shanghai" : {"lat" : 31.2 , "lon" : 121.5 , "pm25_annual" : 28.1 , "pop_M" : 24.8 },
"Guangzhou" : {"lat" : 23.1 , "lon" : 113.3 , "pm25_annual" : 22.4 , "pop_M" : 15.3 },
"London" : {"lat" : 51.5 , "lon" : -0.1 , "pm25_annual" : 9.2 , "pop_M" : 9.0 },
"Paris" : {"lat" : 48.9 , "lon" : 2.3 , "pm25_annual" : 11.0 , "pop_M" : 11.0 },
"New York" : {"lat" : 40.7 , "lon" : -74.0 , "pm25_annual" : 7.5 , "pop_M" : 8.3 },
"Delhi" : {"lat" : 28.6 , "lon" : 77.2 , "pm25_annual" : 98.0 , "pop_M" : 31.0 },
}
import pandas as pd
cities_df = pd.DataFrame(cities_data).T.reset_index()
cities_df.columns = ["city" , "lat" , "lon" , "pm25_annual" , "pop_M" ]
cities_df["who_exceedance" ] = cities_df["pm25_annual" ] / 5.0
cities_df["exposed_millions" ] = cities_df["pop_M" ] * (cities_df["pm25_annual" ] > 5 )
print ("City Air Quality Comparison:" )
print (cities_df[["city" , "pm25_annual" , "who_exceedance" ]].to_string(index=False ))
print (f"\nTotal people exposed above WHO guideline: {cities_df['exposed_millions' ].sum ():.1 f} M" )
fig, axes = plt.subplots(1 , 2 , figsize=(14 , 5 ))
colors = ["green" if x <= 5 else "orange" if x <= 15 else "red"
for x in cities_df["pm25_annual" ]]
axes[0 ].bar(cities_df["city" ], cities_df["pm25_annual" ], color=colors)
axes[0 ].axhline(5 , color='darkgreen' , linestyle='--' , linewidth=2 , label="WHO annual (5 µg/m³)" )
axes[0 ].axhline(15 , color='darkorange' , linestyle='--' , linewidth=2 , label="WHO 24h (15 µg/m³)" )
axes[0 ].set_ylabel("Annual mean PM2.5 (µg/m³)" )
axes[0 ].set_title("City PM2.5 Levels vs. WHO Guidelines" )
axes[0 ].legend(); axes[0 ].tick_params(axis='x' , rotation=30 )
scatter = axes[1 ].scatter(cities_df["lon" ], cities_df["lat" ],
s=cities_df["pop_M" ] * 10 ,
c=cities_df["pm25_annual" ],
cmap="RdYlGn_r" , vmin=0 , vmax=100 , alpha=0.8 )
plt.colorbar(scatter, ax=axes[1 ], label="PM2.5 (µg/m³)" )
for _, row in cities_df.iterrows():
axes[1 ].annotate(row["city" ], (row["lon" ], row["lat" ]),
textcoords="offset points" , xytext=(5 , 5 ), fontsize=8 )
axes[1 ].set_xlabel("Longitude" ); axes[1 ].set_ylabel("Latitude" )
axes[1 ].set_title("Global PM2.5 (bubble = population)" )
plt.tight_layout()
plt.savefig("city_comparison.png" , dpi=150 )
plt.show()
Advanced Usage
Kriging Interpolation import numpy as np
from pykrige.ok import OrdinaryKriging
import matplotlib.pyplot as plt
rng = np.random.default_rng(42 )
n_stations = 30
lons = rng.uniform(100 , 122 , n_stations)
lats = rng.uniform(20 , 42 , n_stations)
pm25_obs = 20 + 80 * np.exp(-0.01 * ((lons - 113 )**2 + (lats - 35 )**2 )) + \
rng.normal(0 , 5 , n_stations)
OK = OrdinaryKriging(
lons, lats, pm25_obs,
variogram_model="exponential" ,
enable_plotting=False ,
verbose=False ,
)
grid_lons = np.linspace(100 , 122 , 50 )
grid_lats = np.linspace(20 , 42 , 50 )
z_kriged, ss = OK.execute("grid" , grid_lons, grid_lats)
print (f"Kriging output range: {z_kriged.min ():.1 f} – {z_kriged.max ():.1 f} µg/m³" )
fig, axes = plt.subplots(1 , 2 , figsize=(14 , 6 ))
im0 = axes[0 ].pcolormesh(grid_lons, grid_lats, z_kriged, cmap="RdYlGn_r" ,
vmin=0 , vmax=100 , shading="auto" )
plt.colorbar(im0, ax=axes[0 ], label="PM2.5 (µg/m³)" )
axes[0 ].scatter(lons, lats, c=pm25_obs, cmap="RdYlGn_r" , vmin=0 , vmax=100 ,
edgecolors="k" , s=60 , linewidths=0.5 , label="Stations" )
axes[0 ].set_title("PM2.5 Kriging Interpolation" ); axes[0 ].legend()
im1 = axes[1 ].pcolormesh(grid_lons, grid_lats, np.sqrt(ss), cmap="Blues" , shading="auto" )
plt.colorbar(im1, ax=axes[1 ], label="Kriging std (µg/m³)" )
axes[1 ].set_title("Kriging Uncertainty (std)" )
plt.tight_layout()
plt.savefig("kriging_pm25.png" , dpi=150 )
plt.show()
Troubleshooting
Error: HTTP 429 Too Many Requests Cause : OpenAQ API rate limit exceeded.
import time
for station_id in station_ids:
data = get_measurements(station_id)
time.sleep(0.5 )
Issue: Missing data gaps in time series
pm25_hourly = pm25_data.resample("h" )["value" ].mean()
pm25_filled = pm25_hourly.interpolate(method="time" , limit=6 )
Version Compatibility Package Tested versions Known issues openaq 2.0, 3.0 v3 API requires API key for full access pykrige 1.7 None
External Resources
Official Documentation
Key Papers
Martin, R.V. et al. (2019). No one knows which city has the highest concentration of fine particulate matter . Atmospheric Environment.
Examples
Example 1: Annual Trend Analysis
import numpy as np, pandas as pd, matplotlib.pyplot as plt
from scipy import stats
np.random.seed(42 )
years = range (2014 , 2024 )
pm25_annual = np.array([65 , 58 , 55 , 50 , 48 , 44 , 40 , 38 , 35 , 32 ]) + \
np.random.normal(0 , 2 , 10 )
tau, p_value = stats.kendalltau(list (years), pm25_annual)
slope, intercept, r, p_lm, se = stats.linregress(list (years), pm25_annual)
fig, ax = plt.subplots(figsize=(9 , 5 ))
ax.bar(list (years), pm25_annual, color="steelblue" , alpha=0.7 , label="Annual PM2.5" )
ax.plot(list (years), [slope*y + intercept for y in years], 'r--' , linewidth=2 ,
label=f"Trend: {slope:.1 f} µg/m³/yr (p={p_lm:.3 f} )" )
ax.axhline(5 , color='darkgreen' , linestyle=':' , label="WHO annual guideline" )
ax.set_xlabel("Year" ); ax.set_ylabel("Annual mean PM2.5 (µg/m³)" )
ax.set_title(f"10-Year PM2.5 Trend (Mann-Kendall τ={tau:.3 f} , p={p_value:.3 f} )" )
ax.legend(); ax.set_ylim(0 )
plt.tight_layout()
plt.savefig("pm25_trend.png" , dpi=150 )
plt.show()
Interpreting these results : Negative slope and significant p-value (< 0.05) indicate a statistically significant improving trend. Compare with WHO guidelines to assess remaining health risk.
Last updated: 2026-03-17 | Maintainer: @xjtulyc
Issues: GitHub Issues
Métiers associés SOC
Basé sur la classification professionnelle SOC