from openaq import OpenAQ
# Get all station locations around a lat-long
lat = 5.614818
lon = -0.205874
client = OpenAQ(api_key="ABCD")10 Accessing raw data from Open AQ API
Open AQ hosts data from a lot of air quality monitoring stations across the world. You can download station-wise data from their portal directly or use their API for bulk download. To use the API, you should first get the API key.
In this tutorial, we will download PM2.5 raw data from all monitoring stations in a city or airshed. We are working with the city Accra in Ghana for this tutorial.
OpenAQ API is structured this way:
1. Every monitoring station is called a location
2. Each location(station) has multiple sensors. Each sensor gives raw data on one pollutant.
3. To access raw data, we need sensor_id of all required sensors in a city.
10.1 Querying sensor ids
#response = client.locations.list(coordinates=(lat, lon), radius=12000, limit=1000)
## STEP1: Query all stations/locations from Accra's bbox
response = client.locations.list(bbox=(-0.3969,5.4889,-0.0220,5.7321), limit=1000)The following are the important outputs from this API call:
response.headers
#Rate limitsHeaders(x_ratelimit_limit=60, x_ratelimit_remaining=59, x_ratelimit_used=1, x_ratelimit_reset=60)
response.meta
#Number of resultsMeta(name='openaq-api', website='/', page=1, limit=1000, found=73)
response.results[0]
#Sample of a resultLocation(id=3, name='NMA - Nima', locality=None, timezone='Africa/Accra', country=CountryBase(id=152, code='GH', name='Ghana'), owner=OwnerBase(id=4, name='Unknown Governmental Organization'), provider=ProviderBase(id=209, name='Dr. Raphael E. Arku and Colleagues'), is_mobile=False, is_monitor=True, instruments=[InstrumentBase(id=2, name='Government Monitor')], sensors=[SensorBase(id=6, name='pm10 µg/m³', parameter=ParameterBase(id=1, name='pm10', units='µg/m³', display_name='PM10')), SensorBase(id=5, name='pm25 µg/m³', parameter=ParameterBase(id=2, name='pm25', units='µg/m³', display_name='PM2.5'))], coordinates=Coordinates(latitude=5.58389, longitude=-0.19968), bounds=(-0.19968, 5.58389, -0.19968, 5.58389), distance=None, datetime_first=None, datetime_last=None)
# STEP2: Extract and flatten sensor details along with location data
import pandas as pd
rows = []
for location in response.results:
for sensor in location.sensors:
rows.append({
'sensor_id': sensor.id,
'sensor_name': sensor.name,
'location_id': location.id,
'location_name': location.name,
'country': location.country.name if hasattr(location.country, 'name') else location.country,
'timezone': location.timezone,
'latitude': location.coordinates.latitude,
'longitude': location.coordinates.longitude
})
# 3. Build DataFrame and export to CSV
df = pd.DataFrame(rows)
df.to_csv('data/open_aq_sensors_accra.csv', index=False)df.head()| sensor_id | sensor_name | location_id | location_name | country | timezone | latitude | longitude | |
|---|---|---|---|---|---|---|---|---|
| 0 | 6 | pm10 µg/m³ | 3 | NMA - Nima | Ghana | Africa/Accra | 5.583890 | -0.199680 |
| 1 | 5 | pm25 µg/m³ | 3 | NMA - Nima | Ghana | Africa/Accra | 5.583890 | -0.199680 |
| 2 | 7 | pm10 µg/m³ | 4 | NMT - Nima | Ghana | Africa/Accra | 5.581650 | -0.198980 |
| 3 | 8 | pm25 µg/m³ | 4 | NMT - Nima | Ghana | Africa/Accra | 5.581650 | -0.198980 |
| 4 | 10 | pm10 µg/m³ | 5 | JTA - Jamestown | Ghana | Africa/Accra | 5.540114 | -0.210397 |
10.2 Get raw data from one sensor
We can loop it for all sensors later. OpenAQ paginates the result with a maximum of 1000 results per page. We should get data from all pages present.
raw_data = client.measurements.list(sensors_id=7971309, limit=1000, page=2)rows = []
for measurements in raw_data.results:
rows.append({
'datetime_from_utc': measurements.period.datetime_from.utc,
'datetime_from_local': measurements.period.datetime_from.local,
'datetime_to_utc': measurements.period.datetime_to.utc,
'datetime_to_local': measurements.period.datetime_to.local,
'value': measurements.value,
})
data = pd.DataFrame(rows) data| datetime_from_utc | datetime_from_local | datetime_to_utc | datetime_to_local | value | |
|---|---|---|---|---|---|
| 0 | 2024-05-03T04:00:00Z | 2024-05-03T04:00:00Z | 2024-05-03T05:00:00Z | 2024-05-03T05:00:00Z | 24.215476 |
| 1 | 2024-05-03T05:00:00Z | 2024-05-03T05:00:00Z | 2024-05-03T06:00:00Z | 2024-05-03T06:00:00Z | 17.803571 |
| 2 | 2024-05-03T06:00:00Z | 2024-05-03T06:00:00Z | 2024-05-03T07:00:00Z | 2024-05-03T07:00:00Z | 17.246429 |
| 3 | 2024-05-03T07:00:00Z | 2024-05-03T07:00:00Z | 2024-05-03T08:00:00Z | 2024-05-03T08:00:00Z | 16.100000 |
| 4 | 2024-05-03T08:00:00Z | 2024-05-03T08:00:00Z | 2024-05-03T09:00:00Z | 2024-05-03T09:00:00Z | 14.116666 |
| ... | ... | ... | ... | ... | ... |
| 995 | 2024-06-15T20:00:00Z | 2024-06-15T20:00:00Z | 2024-06-15T21:00:00Z | 2024-06-15T21:00:00Z | 36.019841 |
| 996 | 2024-06-15T21:00:00Z | 2024-06-15T21:00:00Z | 2024-06-15T22:00:00Z | 2024-06-15T22:00:00Z | 30.697223 |
| 997 | 2024-06-15T22:00:00Z | 2024-06-15T22:00:00Z | 2024-06-15T23:00:00Z | 2024-06-15T23:00:00Z | 44.876389 |
| 998 | 2024-06-15T23:00:00Z | 2024-06-15T23:00:00Z | 2024-06-16T00:00:00Z | 2024-06-16T00:00:00Z | 49.185417 |
| 999 | 2024-06-16T00:00:00Z | 2024-06-16T00:00:00Z | 2024-06-16T01:00:00Z | 2024-06-16T01:00:00Z | 47.755952 |
1000 rows × 5 columns
Looping it for all sensors and all pages.
Note:
1. Chosen a smaller limit of 300 instead of max limit of 1000 per API call. This increases the number of pages.
2. A few sensors API calls are breaking after reaching a certain page number. That is the reason for chosing a smaller limit so that we can get more data.
3. Whenever the API call fails, the code retries 3 more times and then proceeds to the next sensor.
Show hidden function that calculates number of monitors asper CPCB guidelines
import time
from tqdm import tqdm
rows = []
max_retries = 3 # give up on this sensor/page after this many consecutive failures
limit=300
for sensor_id in tqdm(df[df.sensor_name == 'pm25 µg/m³'].sensor_id):
execute = True
page_num = 1
while execute:
time.sleep(3)
delay = 2
retries = 0
success = False
while retries <= max_retries:
try:
raw_data = client.measurements.list(
sensors_id=sensor_id,
limit=limit,
page=page_num
)
success = True
break # success, exit retry loop
except Exception as e:
retries += 1
print(f"Error on sensor {sensor_id}, page {page_num} "
f"(attempt {retries}/{max_retries}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay = min(delay * 2, 60)
if not success:
print(f"Giving up on sensor_id {sensor_id} at page {page_num} "
f"after {max_retries} retries. Moving to next sensor.")
break # break out of the while-execute loop, go to next sensor_id
if raw_data.meta.found != f'>{limit}':
execute = False
print(f"Sensor_id: {sensor_id} | Page: {page_num} | Found: {raw_data.meta.found} | "
f"Rate remaining: {raw_data.headers.x_ratelimit_remaining}")
for measurements in raw_data.results:
rows.append({
'datetime_from_utc': measurements.period.datetime_from.utc,
'datetime_from_local': measurements.period.datetime_from.local,
'datetime_to_utc': measurements.period.datetime_to.utc,
'datetime_to_local': measurements.period.datetime_to.local,
'value': measurements.value,
'sensor_id': sensor_id
})
page_num += 1data = pd.DataFrame(rows) # Merge sensor id information with location information
accra = data.merge(df,on='sensor_id',how='left')accra| datetime_from_utc | datetime_from_local | datetime_to_utc | datetime_to_local | value | sensor_id | sensor_name | location_id | location_name | country | timezone | latitude | longitude | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2020-05-01T00:00:00Z | 2020-05-01T00:00:00Z | 2020-05-01T01:00:00Z | 2020-05-01T01:00:00Z | 26.00 | 30469 | pm25 µg/m³ | 9764 | US Diplomatic Post: Accra | Ghana | Africa/Accra | 5.579447 | -0.170699 |
| 1 | 2020-05-01T01:00:00Z | 2020-05-01T01:00:00Z | 2020-05-01T02:00:00Z | 2020-05-01T02:00:00Z | 27.00 | 30469 | pm25 µg/m³ | 9764 | US Diplomatic Post: Accra | Ghana | Africa/Accra | 5.579447 | -0.170699 |
| 2 | 2020-05-01T02:00:00Z | 2020-05-01T02:00:00Z | 2020-05-01T03:00:00Z | 2020-05-01T03:00:00Z | 42.00 | 30469 | pm25 µg/m³ | 9764 | US Diplomatic Post: Accra | Ghana | Africa/Accra | 5.579447 | -0.170699 |
| 3 | 2020-05-01T03:00:00Z | 2020-05-01T03:00:00Z | 2020-05-01T04:00:00Z | 2020-05-01T04:00:00Z | 40.00 | 30469 | pm25 µg/m³ | 9764 | US Diplomatic Post: Accra | Ghana | Africa/Accra | 5.579447 | -0.170699 |
| 4 | 2020-05-01T04:00:00Z | 2020-05-01T04:00:00Z | 2020-05-01T05:00:00Z | 2020-05-01T05:00:00Z | 41.00 | 30469 | pm25 µg/m³ | 9764 | US Diplomatic Post: Accra | Ghana | Africa/Accra | 5.579447 | -0.170699 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 528700 | 2026-08-06T19:53:32Z | 2026-08-06T19:53:32Z | 2026-08-06T19:58:32Z | 2026-08-06T19:58:32Z | 68.23 | 16644413 | pm25 µg/m³ | 6406583 | Mpoase Latter Day Saints Church | Ghana | Africa/Accra | 5.526410 | -0.270680 |
| 528701 | 2026-08-06T20:10:29Z | 2026-08-06T20:10:29Z | 2026-08-06T20:15:29Z | 2026-08-06T20:15:29Z | 64.32 | 16644413 | pm25 µg/m³ | 6406583 | Mpoase Latter Day Saints Church | Ghana | Africa/Accra | 5.526410 | -0.270680 |
| 528702 | 2026-08-06T20:27:28Z | 2026-08-06T20:27:28Z | 2026-08-06T20:32:28Z | 2026-08-06T20:32:28Z | 60.13 | 16644413 | pm25 µg/m³ | 6406583 | Mpoase Latter Day Saints Church | Ghana | Africa/Accra | 5.526410 | -0.270680 |
| 528703 | 2026-08-06T20:44:23Z | 2026-08-06T20:44:23Z | 2026-08-06T20:49:23Z | 2026-08-06T20:49:23Z | 30.45 | 16644413 | pm25 µg/m³ | 6406583 | Mpoase Latter Day Saints Church | Ghana | Africa/Accra | 5.526410 | -0.270680 |
| 528704 | 2026-08-06T21:01:23Z | 2026-08-06T21:01:23Z | 2026-08-06T21:06:23Z | 2026-08-06T21:06:23Z | 24.92 | 16644413 | pm25 µg/m³ | 6406583 | Mpoase Latter Day Saints Church | Ghana | Africa/Accra | 5.526410 | -0.270680 |
528705 rows × 13 columns
accra.to_csv('data/accra_raw_openaq.csv',index=False)client.close()