Newsletter
TechAnV Blog
Get updates on security engineering, Rust, eBPF, and DevSecOps. No spam, unsubscribe anytime.
Check your inbox and click the confirmation link to complete your subscription.
Calculating the AQI based on the Purple Air API for a sensor#
Purple Air sensors have an API at https://www.purpleair.com/map.json?show=SENSOR-ID-HERE, which returns JSON that looks something like this:
1{2 "mapVersion": "0.26",3 "baseVersion": "7",4 "mapVersionString": "",5 "results": [6 {7 "ID": 123,8 "Label": "Sensor label",9 "DEVICE_LOCATIONTYPE": "outside",10 "THINGSPEAK_PRIMARY_ID": "123",11 "THINGSPEAK_PRIMARY_ID_READ_KEY": "xxx",12 "THINGSPEAK_SECONDARY_ID": "1234",13 "THINGSPEAK_SECONDARY_ID_READ_KEY": "xxx",14 "Lat": 37.5,15 "Lon": -122.4,16 "PM2_5Value": "8.75",17 "LastSeen": 1630438756,18 "Type": "PMS5003+PMS5003+BME280",19 "Hidden": "false",20 "Flag": 1,21 "DEVICE_BRIGHTNESS": "15",22 "DEVICE_HARDWAREDISCOVERED": "2.0+OPENLOG+15476 MB+DS3231+BME280+PMSX003-B+PMSX003-A",23 "DEVICE_FIRMWAREVERSION": "6.01",24 "Version": "6.01",25 "LastUpdateCheck": 1630436115,26 "Created": 1622588142,27 "Uptime": "2701447",28 "RSSI": "-56",29 "Adc": "0.01",30 "p_0_3_um": "849.53",31 "p_0_5_um": "251.67",32 "p_1_0_um": "78.49",33 "p_2_5_um": "17.79",34 "p_5_0_um": "6.95",35 "p_10_0_um": "5.54",36 "pm1_0_cf_1": "3.58",37 "pm2_5_cf_1": "8.75",38 "pm10_0_cf_1": "14.04",39 "pm1_0_atm": "3.58",40 "pm2_5_atm": "8.75",41 "pm10_0_atm": "14.04",42 "isOwner": 0,43 "humidity": "48",44 "temp_f": "74",45 "pressure": "1005.56",46 "AGE": 1,47 "Stats": "{\"v\":8.75,\"v1\":9.4,\"v2\":10.22,\"v3\":10.96,\"v4\":14.17,\"v5\":15.51,\"v6\":12.53,\"pm\":8.75,\"lastModified\":1630438756184,\"timeSinceModified\":120110}"48 }49 ]50}There’s just one problem with this: it doesn’t give you the AQI number that is displayed on their site! Instead it gives you the raw numbers that can be used to calculate that AQI number.
I figured someone must have solved this, so I ran a GitHub code search for purpleair.com map json and found zakj/scriptable with code for decoding that. I adapted it to the following:
1// Adapted from https://github.com/zakj/scriptable by Zak Johnson2async function fetchAqi(sensorId) {3 const response = await fetch(4 `https://www.purpleair.com/json?show=${sensorId}`5 );6 const json = await response.json();7 const stats = json.results8 .filter((r) => !(r.Flag || r.A_H))9 .map((r) => JSON.parse(r.Stats));10 const pm2_5 = stats.reduce((acc, { v }) => acc + v, 0) / stats.length;11 const trend = stats[0].v1 - stats[0].v3;12 return {13 current: aqiFromPm(pm2_5),14 trend: Math.abs(trend) > 5 ? trend : 0,15 details: json,16 };17}18
19function aqiFromPm(pm) {20 const table = [21 [0.0, 12.0, 0, 50],22 [12.1, 35.4, 51, 100],23 [35.5, 55.4, 101, 150],24 [55.5, 150.4, 151, 200],25 [150.5, 250.4, 201, 300],26 [250.5, 500.4, 301, 500],27 ];28 const computeAqi = (concI, [concLo, concHi, aqiLo, aqiHi]) =>29 Math.round(30 ((concI - concLo) / (concHi - concLo)) * (aqiHi - aqiLo) + aqiLo31 );32 const values = table.find(([concLo, concHi, aqiLo, aqiHi]) => pm <= concHi);33 return values ? computeAqi(pm, values) : 500;34}I used this to build a simple demo at https://til.simonwillison.net/tools/aqi