PM2.5 to AQI Conversion

My home weather station measures PM2.5 concentration in micrograms per cubic meter (μg/m³). But if I’m concerned about air quality, I want to know the Air Quality Index (AQI). AQI is a policy tool and as such has a complicated definition. So, here’s a tool to convert between PM2.5 and AQI (assuming that PM2.5 is the only pollutant you care about).

Drag either slider below to choose the selected PM2.5 or AQI.

PM2.5 (μg/m³) / AQI

PM2.5 (μg/m³) AQI
0 Good
50 Moderate
100 Unhealthy for sensitive groups
150 Unhealthy
200 Very unhealthy
300 Hazardous

The cell below defines the six official Air Quality Index (AQI) categories: “Each category corresponds to a different level of health concern. Each category also has a specific color. The color makes it easy for people to quickly determine whether air quality is reaching unhealthy levels in their communities.”

categories = [
    {max:50,color:"green",name:"Good"},
    {max:100,color:"yellow",name:"Moderate"},
    {max:150,color:"orange",name:"Unhealthy for sensitive groups"},
    {max:200,color:"red",name:"Unhealthy"},
    {max:300,color:"purple",name:"Very unhealthy"},
    {max:500,color:"maroon",name:"Hazardous"}
];

The pm25_aqi function converts from a PM2.5 concentration in micrograms per cubic meter to the corresponding AQI value (assuming that PM2.5 is the only contributor to AQI). It is the inverse of aqi_pm25.

function pm25_aqi(pm25) {
    const c = Math.floor(10 * pm25) / 10;
    const a = c < 0 ? 0 :
        c < 12.1 ? lerp(0, 50, 0.0, 12.0, c) :
        c < 35.5 ? lerp(51, 100, 12.1, 35.4, c) :
        c < 55.5 ? lerp(101, 150, 35.5, 55.4, c) :
        c < 150.5 ? lerp(151, 200, 55.5, 150.4, c) :
        c < 250.5 ? lerp(201, 300, 150.5, 250.4, c) :
        c < 350.5 ? lerp(301, 400, 250.5, 350.4, c) :
        c < 500.5 ? lerp(401, 500, 350.5, 500.4, c) :
        500;
    return Math.round(a);
}

The aqi_pm25 function converts from an AQI value to the corresponding PM2.5 concentration in micrograms per cubic meter (assuming that PM2.5 is the only contributor to AQI). It is the inverse of pm25_aqi.

function aqi_pm25(aqi) {
    const a = Math.round(aqi);
    const c = a < 0 ? 0 :
        a <= 50 ? lerp(0.0, 12.0, 0, 50, a) :
        a <= 100 ? lerp(12.1, 35.4, 51, 100, a) :
        a <= 150 ? lerp(35.5, 55.4, 101, 150, a) :
        a <= 200 ? lerp(55.5, 150.4, 151, 200, a) :
        a <= 300 ? lerp(150.5, 250.4, 201, 300, a) :
        a <= 400 ? lerp(250.5, 350.4, 301, 400, a) :
        a <= 500 ? lerp(350.5, 500.4, 401, 500, a) :
        500;
    return Math.floor(10 * c) / 10;
}

The lerp function is like d3.scaleLinear, redux.

function lerp(ylo, yhi, xlo, xhi, x) {
    return ((x - xlo) / (xhi - xlo)) * (yhi - ylo) + ylo;
}

The bind function is like Inputs.bind, except it applies a transform to convert between units, and only propagates on trusted events. This way when the user interacts with the target, it’ll propagate to the source, but the source won’t propagate back to the target. This transform needs to be invertible so that you can drag either range input to affect the other.

function bind(target, source, {
    invalidation = Inputs.disposal(target),
    transform = d => d,
    invert = d => d
} = {}) {
    const onsource = (event) => {
        if (!event.isTrusted) return;
        target.value = transform(source.value);
    };

const ontarget = (event) => {
        if (!event.isTrusted) return;
        source.value = invert(target.value);
        source.dispatchEvent(new Event("input", { bubbles: true }));
    };

target.value = transform(source.value);
    target.addEventListener("input", ontarget);
    source.addEventListener("input", onsource);
    invalidation.then(() => source.removeEventListener("input", onsource));
    return target;
}