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);
}
2 Likes

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...

here is the python variant of the code, in case someone needs it:

# Convert US AQI from raw pm2.5 data
def aqiFromPM(pm):
    if not float(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
    elif pm > 250.5:
        return calcAQI(pm, 400, 301, 350.4, 250.5)  # Hazardous
    elif pm > 150.5:
        return calcAQI(pm, 300, 201, 250.4, 150.5)  # Very Unhealthy
    elif pm > 55.5:
        return calcAQI(pm, 200, 151, 150.4, 55.5)  # Unhealthy
    elif pm > 35.5:
        return calcAQI(pm, 150, 101, 55.4, 35.5)  # Unhealthy for Sensitive Groups
    elif pm > 12.1:
        return calcAQI(pm, 100, 51, 35.4, 12.1)  # Moderate
    elif pm >= 0:
        return calcAQI(pm, 50, 0, 12, 0)  # Good
    else:
        return 'undefined'

# Calculate AQI from standard ranges
def calcAQI(Cp, Ih, Il, BPh, BPl):
    a = (Ih - Il)
    b = (BPh - BPl)
    c = (Cp - BPl)
    return round((a / b) * c + Il)
1 Like

I clean my data up before it gets to this point, so I took out the first four conditional statements.

Without those, here is the R variant of the code in case someone needs it:

aqiFromPM <- function(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(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)
    }
}

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

Excel VBA format

' Convert US AQI from raw pm2.5 data
Function AQIFromPM(pm) As Single

    Dim answer As Single
    
    If Not IsNumeric(pm) Then
        answer = -1
    ElseIf pm < 0 Then
        answer = pm
    ElseIf pm > 1000 Then
        answer = -1
    Else
        answer = 0
    End If

'                                        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 answer >= 0 Then
        If pm > 350.5 Then
            answer = calcAQI(pm, 500, 401, 500.4, 350.5)  ' Hazardous
        ElseIf pm > 250.5 Then
            answer = calcAQI(pm, 400, 301, 350.4, 250.5)  ' Hazardous
        ElseIf pm > 150.5 Then
            answer = calcAQI(pm, 300, 201, 250.4, 150.5)  ' Very Unhealthy
        ElseIf pm > 55.5 Then
            answer = calcAQI(pm, 200, 151, 150.4, 55.5)   ' Unhealthy
        ElseIf pm > 35.5 Then
            answer = calcAQI(pm, 150, 101, 55.4, 35.5)    ' Unhealthy for Sensitive Groups
        ElseIf pm > 12.1 Then
            answer = calcAQI(pm, 100, 51, 35.4, 12.1)     ' Moderate
        ElseIf pm >= 0 Then
            answer = calcAQI(pm, 50, 0, 12, 0)            ' Good
        Else
            answer = -1
        End If
    End If
    AQIFromPM = answer
End Function

' Calculate AQI from standard ranges
Function calcAQI(Cp, Ih, Il, BPh, BPl) As Single
    Dim a As Single, b As Single, c As Single
    a = (Ih - Il)
    b = (BPh - BPl)
    c = (Cp - BPl)
    calcAQI = Round((a / b) * c + Il)
End Function