How to Calculate the US EPA PM2.5 AQI

The following code is used to calculate the US EPA PM2.5 AQI from a Raw PM 2.5 value. It is written in JavaScript. The US EPA PM2.5 AQI is calculated using the EPA’s breakpoints as described in this link: AQI Breakpoints | Air Quality System | US EPA. The AQI equation can be found on here: https://forum.airnowtech.org/t/the-aqi-equation/169.

var AQI = aqiFromPM(pm25value);

function aqiFromPM(pm) {
    if (isNaN(pm)) return "-"; 
    if (pm == undefined) return "-";
    if (pm < 0) return pm; 
    if (pm > 1000) return "-"; 
    /*                                  AQI         RAW PM2.5
    Good                               0 - 50   |   0.0 – 12.0
    Moderate                          51 - 100  |  12.1 – 35.4
    Unhealthy for Sensitive Groups   101 – 150  |  35.5 – 55.4
    Unhealthy                        151 – 200  |  55.5 – 150.4
    Very Unhealthy                   201 – 300  |  150.5 – 250.4
    Hazardous                        301 – 400  |  250.5 – 350.4
    Hazardous                        401 – 500  |  350.5 – 500.4
    */
    if (pm > 350.5) {
        return calcAQI(pm, 500, 401, 500.4, 350.5); //Hazardous
    } else if (pm > 250.5) {
        return calcAQI(pm, 400, 301, 350.4, 250.5); //Hazardous
    } else if (pm > 150.5) {
        return calcAQI(pm, 300, 201, 250.4, 150.5); //Very Unhealthy
    } else if (pm > 55.5) {
        return calcAQI(pm, 200, 151, 150.4, 55.5); //Unhealthy
    } else if (pm > 35.5) {
        return calcAQI(pm, 150, 101, 55.4, 35.5); //Unhealthy for Sensitive Groups
    } else if (pm > 12.1) {
        return calcAQI(pm, 100, 51, 35.4, 12.1); //Moderate
    } else if (pm >= 0) {
        return calcAQI(pm, 50, 0, 12, 0); //Good
    } else {
        return undefined;
    }
}

function calcAQI(Cp, Ih, Il, BPh, BPl) {
    var a = (Ih - Il);
    var b = (BPh - BPl);
    var c = (Cp - BPl);
    return Math.round((a/b) * c + Il);
}

Hi Ethan,

Looks like I need to verify if my URL is correct. I’m trying to get the data from Luna Park. My URL is:

https://api.purpleair.com/v1/sensors/78307?api_key={my api key}

I’m getting what appears to be valid data, but the purpleair.com map shows 60 and none of the numbers are even close.

I get the following:

Array
(
    [api_version] => V1.0.10-0.0.17
    [time_stamp] => 1657815652
    [data_time_stamp] => 1657815636
    [sensor] => Array
        (
            [sensor_index] => 78307
            [last_modified] => 1603145243
            [date_created] => 1602089165
            [last_seen] => 1657815525
            [private] => 0
            [is_owner] => 0
            [name] => Luna Park
            [icon] => 0
            [location_type] => 0
            [model] => PA-II
            [hardware] => 2.0+BME280+PMSX003-B+PMSX003-A
            [led_brightness] => 35
            [firmware_version] => 7.00
            [rssi] => -54
            [uptime] => 14070
            [pa_latency] => 382
            [memory] => 15728
            [position_rating] => 5
            [latitude] => 37.357487
            [longitude] => -121.88776
            [altitude] => 73
            [channel_state] => 3
            [channel_flags] => 0
            [channel_flags_manual] => 0
            [channel_flags_auto] => 0
            [confidence] => 100
            [confidence_auto] => 100
            [confidence_manual] => 100
            [humidity] => 43
            [humidity_a] => 43
            [temperature] => 80
            [temperature_a] => 80
            [pressure] => 1013.9
            [pressure_a] => 1013.88
            [analog_input] => 0.05
            [pm1.0] => 10
            [pm1.0_a] => 10.1
            [pm1.0_b] => 9.8
            [pm1.0_atm] => 10
            [pm1.0_cf_1] => 10
            [pm2.5] => 17.3
            [pm2.5_a] => 17.4
            [pm2.5_b] => 17.2
            [pm2.5_atm] => 17.3
            [pm2.5_cf_1] => 17.3
            [pm2.5_alt] => 10.8
            [pm2.5_alt_a] => 10.6
            [pm2.5_alt_b] => 10.9
            [pm10.0] => 18.6
            [pm10.0_a] => 18.5
            [pm10.0_b] => 18.6
            [pm10.0_atm] => 18.6
            [pm10.0_cf_1] => 18.6
            [scattering_coefficient] => 28.7
            [scattering_coefficient_a] => 29.4
            [scattering_coefficient_b] => 28.1
            [deciviews] => 14.9
            [deciviews_a] => 15.1
            [deciviews_b] => 14.7
            [visual_range] => 87.7
            [visual_range_a] => 86.2
            [visual_range_b] => 89.3
            [0.3_um_count] => 1915
            [0.3_um_count_a] => 1959
            [0.3_um_count_b] => 1872
            [0.5_um_count] => 550
            [0.5_um_count_a] => 561
            [0.5_um_count_b] => 540
            [1.0_um_count] => 124
            [1.0_um_count_a] => 121
            [1.0_um_count_b] => 128
            [2.5_um_count] => 9
            [2.5_um_count_a] => 10
            [2.5_um_count_b] => 9
            [5.0_um_count] => 1
            [5.0_um_count_a] => 1
            [5.0_um_count_b] => 2
            [10.0_um_count] => 0
            [10.0_um_count_a] => 0
            [10.0_um_count_b] => 1
            [pm1.0_atm_a] => 10.12
            [pm2.5_atm_a] => 17.35
            [pm10.0_atm_a] => 18.49
            [pm1.0_cf_1_a] => 10.12
            [pm2.5_cf_1_a] => 17.35
            [pm10.0_cf_1_a] => 18.49
            [pm1.0_atm_b] => 9.85
            [pm2.5_atm_b] => 17.17
            [pm10.0_atm_b] => 18.63
            [pm1.0_cf_1_b] => 9.85
            [pm2.5_cf_1_b] => 17.17
            [pm10.0_cf_1_b] => 18.63
            [primary_id_a] => 1177312
            [primary_key_a] => WOK8AWYYN2Q5B3Z1
            [primary_id_b] => 1177314
            [primary_key_b] => MA6IRH6WTMJLUIYR
            [secondary_id_a] => 1177313
            [secondary_key_a] => 6MS419QA63EN2S61
            [secondary_id_b] => 1177315
            [secondary_key_b] => BBZQO50JDETGOJEN
            [stats] => Array
                (
                    [pm2.5] => 17.3
                    [pm2.5_10minute] => 16.4
                    [pm2.5_30minute] => 15
                    [pm2.5_60minute] => 13.4
                    [pm2.5_6hour] => 7.6
                    [pm2.5_24hour] => 5.3
                    [pm2.5_1week] => 5.2
                    [time_stamp] => 1657815525
                )

            [stats_a] => Array
                (
                    [pm2.5] => 17.4
                    [pm2.5_10minute] => 16.2
                    [pm2.5_30minute] => 14.9
                    [pm2.5_60minute] => 13.2
                    [pm2.5_6hour] => 7.4
                    [pm2.5_24hour] => 5.2
                    [pm2.5_1week] => 5
                    [time_stamp] => 1657815525
                )

            [stats_b] => Array
                (
                    [pm2.5] => 17.2
                    [pm2.5_10minute] => 16.5
                    [pm2.5_30minute] => 15.2
                    [pm2.5_60minute] => 13.5
                    [pm2.5_6hour] => 7.7
                    [pm2.5_24hour] => 5.4
                    [pm2.5_1week] => 5.3
                    [time_stamp] => 1657815525
                )

        )

)

The numbers returned by the API are raw PM2.5 and must be converted to the AQI. You will most likely want to use the pm2.5_atm or pm2.5_cf_1 fields. Plugging those values (17.3) into the code written above outputs 62 AQI.

Thank you, Ethan. That works very well, it’s only off by a few points, but for my needs that is fine.

Here’s the PHP code I used:

// get url
$url = ('https://api.purpleair.com/v1/sensors/78307?api_key={my api key}');

// Get the sensor data via JSON
$json  = @file_get_contents($url);
$array = json_decode($json, true);

// get pm25value
$pm25value = $array['sensor']['pm2.5_cf_1_a'];

// check condition
if ($pm25value > 0 AND $pm25value < 1000)
{
	// get aqi
	$aqi = $this->aqiFromPM($pm25value);
}
else
{
	$aqi = 'n/a';
}

function aqiFromPM($pm) 
{
	/*                                  AQI         RAW PM2.5
	Good                               0 - 50   |   0.0 – 12.0
	Moderate                          51 - 100  |  12.1 – 35.4
	Unhealthy for Sensitive Groups   101 – 150  |  35.5 – 55.4
	Unhealthy                        151 – 200  |  55.5 – 150.4
	Very Unhealthy                   201 – 300  |  150.5 – 250.4
	Hazardous                        301 – 400  |  250.5 – 350.4
	Hazardous                        401 – 500  |  350.5 – 500.4
	*/

	if ($pm > 350.5) 
	{
		return $this->calcAQI($pm, 500, 401, 500.4, 350.5); // Hazardous
	} 
	else if ($pm > 250.5) 
	{
		return $this->calcAQI($pm, 400, 301, 350.4, 250.5); // Hazardous
	} 
	else if ($pm > 150.5) 
	{
		return $this->calcAQI($pm, 300, 201, 250.4, 150.5); // Very Unhealthy
	} 
	else if ($pm > 55.5) 
	{
		return $this->calcAQI($pm, 200, 151, 150.4, 55.5); // Unhealthy
	} 
	else if ($pm > 35.5) 
	{
		return $this->calcAQI($pm, 150, 101, 55.4, 35.5); // Unhealthy for Sensitive Groups
	} 
	else if ($pm > 12.1) 
	{
		return $this->calcAQI($pm, 100, 51, 35.4, 12.1); // Moderate
	} 
	else if ($pm >= 0) 
	{
		return $this->calcAQI($pm, 50, 0, 12, 0); // Good
	}
}

function calcAQI($Cp, $Ih, $Il, $BPh, $BPl) 
{
	$a = ($Ih - $Il);
	$b = ($BPh - $BPl);
	$c = ($Cp - $BPl);
	return round(($a/$b) * $c + $Il);
}

Today I checked my calculation (see post above) against the purpleair real-time map and the map shows 40 for the location I’m using, while my calculation shows 26. Is there another value I need to use to get closer to the 40 AQI?

Thank you.

What is the raw pm2.5 value you input? Also, ensure that you are viewing the US EPA PM2.5 AQI data layer without any conversion factors on the map.

Hi Ethan,

I was looking at the pm2.5_cf_1_a value. I since changed it to pm2.5_cf_1 and my calculated AQI is much closer.

Sure would be nice if someone knew the exact code to match what is shown in the real time map. Or better yet if the API would include this value.

Thank you.

@AndyB We might have the same issue. I thought my math was off until I realized the Purple Air map uses a 10 minute average as the main value rather than a point value to calculate AQI (although further down you can see the real-time data on the map also). Once I switched to using the 10-minute average my calculated values match the main value on the map.

I’m using Python but I believe in php it would be:

$pm25value = $array['sensor']['stats']['pm2.5_10minute']

There are some other handy averages for longer time periods in the data stream.
screenshot of options:

Found on: Loading...