Composite Air Quality Index (AQI)
Roll PM2.5, CO2, TVOC, formaldehyde, and ozone into one EPA-style air quality index plus a Good-to-Hazardous label for indoor and outdoor dashboards.
Who it’s for
Facility and building managers, workplace-wellness and school teams, environmental and smart-city operators asking “what is one honest number for the air in this room?”, “which pollutant is dragging the air quality down right now?”, “is it safe for sensitive people today?” - when a wall of separate PM2.5, CO2, TVOC, and ozone readings hides the one answer everyone actually wants.
What it does
Reads the latest pm25, co2, tvoc, hcho (formaldehyde), and o3 (ozone)
telemetry, converts each into an AQI sub-index against its own breakpoint table -
PM2.5 spans the full 0-500 range, the other four top out at 300 - and returns the
worst of them as the composite aqi. It also returns
aqiLevel (the category name, Good through Hazardous) and aqiDriver (which
pollutant set the number), plus each pollutant’s own sub-index (pm25Aqi,
co2Aqi, tvocAqi, hchoAqi, o3Aqi). Every value recomputes on each update.
Any subset of the five keys works. A sensor that only reports CO2 still produces an index from CO2 alone, because a key with no data stays at the -1 default and is skipped. A reading above the top of a pollutant’s table caps at that pollutant’s highest defined sub-index rather than overshooting the scale - 300 (Very Unhealthy) for CO2, TVOC, formaldehyde and ozone, and 500 (Hazardous) for PM2.5, the only one with an official category that high.
Breakpoints and formula
Each pollutant maps to the standard AQI categories. Within a category the sub-index is
linear: subIndex = (C - Clow) / (Chigh - Clow) * (Ihigh - Ilow) + Ilow, and the
composite aqi = max of every available sub-index.
| Pollutant (key, unit) | Good <= | Moderate <= | Sensitive <= | Unhealthy <= | Very Unhealthy <= | Hazardous <= |
|---|---|---|---|---|---|---|
PM2.5 (pm25, ug/m3) | 12 | 35 | 55 | 150 | 250 | 500 |
Ozone (o3, ppb) | 54 | 70 | 85 | 105 | 200 | - |
CO2 (co2, ppm) | 1000 | 1500 | 2000 | 5000 | 10000 | - |
TVOC (tvoc, ppb) | 220 | 660 | 2200 | 5500 | 20000 | - |
Formaldehyde (hcho, ug/m3) | 30 | 100 | 200 | 500 | 1000 | - |
AQI number ranges: Good 0-50, Moderate 51-100, Unhealthy for Sensitive Groups 101-150, Unhealthy 151-200, Very Unhealthy 201-300, Hazardous 301-500. Only PM2.5 continues into the Hazardous range (301-500). A 400 ug/m3 PM2.5 reading returns 420. The other four top out at 300 (Very Unhealthy): the EPA 8-hour ozone scale ends at 300, and CO2, TVOC and formaldehyde have no official AQI category above it, so the field caps each at its highest defined value rather than inventing one.
PM2.5 and ozone breakpoints follow the US EPA AQI (rounded to whole units). CO2, TVOC, and formaldehyde have no official EPA AQI, so their ranges follow widely used indoor-air health guidance (ASHRAE ventilation levels, WHO formaldehyde limits) onto the same scale. Adjust any row in the script to match your local standard.
How to set up
The field reads five time-series keys and needs none of them to be present. Any subset works, because a pollutant with no data stays at its -1 default and is skipped.
- Open the entity or profile where the field should run (Entities -> Devices -> [device name], or the device profile to apply it to every device of that type).
- Go to the Calculated fields tab and click +.
- Import the field, or create it as Script type and paste the expression.
- Check the five arguments (
pm25,co2,tvoc,hcho,o3) against your sensor’s actual key names and rename any that differ. - Save. The outputs (
aqi,aqiLevel,aqiDriverand the per-pollutantpm25Aqi/co2Aqi/tvocAqi/hchoAqi/o3Aqi) appear on the next telemetry update.
A device that reports only CO2 still produces a valid index, from CO2 alone.
How to customize
- To match your sensor’s keys - change the Time series key on the
pm25,co2,tvoc,hcho, ando3arguments (e.g.pm25->pm2_5,tvoc->voc). Leave a pollutant you do not measure on its default and it is simply skipped. - To change a breakpoint - open the script and edit the matching
Tablerow. Each table is a plain array of concentration-to-index pairs, so you can retune one pollutant without touching the rest. - To add a pollutant (PM10, NO2, SO2, CO) - add an argument, add a breakpoint
table for it in the script, and add one
subIndex(...)block following the existing pattern. It joins themaxautomatically. - To rename the outputs - open the script and replace the keys in the
resultobject (aqi,aqiLevel,aqiDriver, and the per-pollutant...Aqikeys) with the names your dashboards and alarms expect. - To change the output precision - the
(int)cast insidesubIndexis what makes every index a whole number. Remove it and the raw decimal value is returned instead. Decimals by default is not rendered for a Script field, so there is nothing to change there. - To use a different fallback when a pollutant has no data - the
-1default is the “skip me” sentinel. Keep it at a negative number so a missing pollutant never inflates the index.
See also
Render the aqi key on any value or gauge card, and color the thresholds at 50 /
100 / 150 / 200 / 300 to reproduce those categories.
To raise an alert when the air degrades, pair the index with the published air-quality alarm rule:
Indoor Air Quality Degraded AlarmShare Your Calculated Field with the Community
Built a useful data transformation? Export it as a JSON from ThingsBoard and publish it to the IoT Hub. Share it with thousands of ThingsBoard developers worldwide.