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.

from openaq import OpenAQ

# Get all station locations around a lat-long
lat = 5.614818
lon = -0.205874
client = OpenAQ(api_key="ABCD")

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 limits
Headers(x_ratelimit_limit=60, x_ratelimit_remaining=59, x_ratelimit_used=1, x_ratelimit_reset=60)
response.meta
#Number of results
Meta(name='openaq-api', website='/', page=1, limit=1000, found=73)
response.results[0]
#Sample of a result
Location(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 += 1
data = 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()