npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@verifyhash/air-quality-index

v2.0.0

Published

Zero-dependency US EPA Air Quality Index (AQI) calculator for PM2.5, PM10, O3, CO, SO2 and NO2.

Readme

air-quality-index

A zero-dependency, zero-network Node.js library that computes the U.S. EPA Air Quality Index (AQI) from raw pollutant concentrations. Pure functions, no runtime deps, no I/O — give it a number, get an AQI back.

What it is

The AQI is a 0–500 scale the EPA uses to report air quality. Each pollutant has a published breakpoint table; a measured concentration is mapped to a sub-index with a piecewise-linear interpolation, and the reported AQI for a location is the maximum of the available sub-indices. This library implements that math for six criteria pollutants and the six EPA category bands.

Who it is for

Developers building air-quality dashboards, widgets, weather add-ons, IoT displays, or data pipelines who have concentration values (from a sensor, an API like AirNow/OpenAQ, or a model) and need the corresponding AQI number, category label, and color — without pulling in a heavy dependency or making a network call.

Breakpoint source

Breakpoints and the interpolation formula are taken from the current EPA Technical Assistance Document, "Technical Assistance Document for the Reporting of Daily Air Quality – the Air Quality Index (AQI)", EPA-403/B-26-003 (May 2026), Table 6. That table is the same one codified as Table 2 to section 3.4 of 40 CFR part 58 appendix G. The interpolation is EPA Equation 1:

Ip = ((IHi - ILo) / (BPHi - BPLo)) * (Cp - BPLo) + ILo

where Cp is the concentration truncated to the table's decimal precision, and BPLo/BPHi are the breakpoints straddling Cp.

PM2.5: the 2024 revision (history)

EPA revised the PM2.5 sub-index in the final rule Reconsideration of the National Ambient Air Quality Standards for Particulate Matter (89 FR 16202, published 2024-03-06, effective 2024-05-06). PM2.5 was the only sub-index the rule touched — the PM10, ozone, CO, SO₂ and NO₂ columns were left alone.

This library carries the current table. Versions up to and including 1.0.1 carried the pre-2024 table, which is now history:

| AQI band | Category | pre-2024 PM2.5 (µg/m³) | current PM2.5 (µg/m³) | | --- | --- | --- | --- | | 0–50 | Good | 0.0 – 12.0 | 0.0 – 9.0 | | 51–100 | Moderate | 12.1 – 35.4 | 9.1 – 35.4 | | 101–150 | Unhealthy for Sensitive Groups | 35.5 – 55.4 | 35.5 – 55.4 | | 151–200 | Unhealthy | 55.5 – 150.4 | 55.5 – 125.4 | | 201–300 | Very Unhealthy | 150.5 – 250.4 | 125.5 – 225.4 | | 301–400 | Hazardous | 250.5 – 350.4 | (row removed) | | 401–500 | Hazardous | 350.5 – 500.4 | — | | 301–500 | Hazardous | — | 225.5 – 325.4 |

Three things to note. The 100 and 150 breakpoints were retained at 35.4 and 55.4, so nothing changes at the Moderate→USG or USG→Unhealthy edges. The two Hazardous rows were collapsed into one by removing the 400 breakpoint, and 325.4 µg/m³ is the concentration EPA assigns to an AQI of exactly 500 (footnote 4 to the table); above 325.4 this library returns null rather than extrapolating. And the change is not cosmetic in the reassuring direction: a reading of 10.0 µg/m³ used to come out as AQI 42 / Good and now comes out as AQI 53 / Moderate, which is what AirNow reports.

The test suite keeps the pre-2024 fixtures (12.0 → 50, 35.5 → 101, 55.5 → 151) as explicitly-labelled historical vectors so the difference stays visible and regressions in the other direction are caught.

Pollutants and units

| Function | Pollutant | Averaging | Unit | Truncation | |-------------------|-----------|-----------|-------|------------| | aqiFromPM25(c) | PM2.5 | 24-hour | µg/m³ | 1 decimal | | aqiFromPM10(c) | PM10 | 24-hour | µg/m³ | integer | | aqiFromO3_8h(c) | Ozone | 8-hour | ppm | 3 decimals | | aqiFromCO(c) | CO | 8-hour | ppm | 1 decimal | | aqiFromSO2(c) | SO₂ | 1-hour | ppb | integer | | aqiFromNO2(c) | NO₂ | 1-hour | ppb | integer |

Each returns a rounded integer AQI, or null if the input is not a usable finite number in range.

API

const aqi = require('@verifyhash/air-quality-index');

aqi.aqiFromPM25(35.5);        // 101
aqi.aqiFromPM25(9.0);         // 50
aqi.aqiFromPM25(9.1);         // 51
aqi.aqiFromPM25(12.0);        // 56   (was 50 under the pre-2024 table)
aqi.aqiFromO3_8h(0.071);      // 101
aqi.aqiFromPM25(-5);          // null  (bad input)

aqi.category(101);
// { index: 3, label: 'Unhealthy for Sensitive Groups', color: '#ff7e00' }

aqi.overallAQI({ pm25: 35.5, o3_8h: 0.04, no2: 20 });
// {
//   aqi: 101,
//   dominant: 'pm25',
//   dominantLabel: 'PM2.5',
//   category: { index: 3, label: 'Unhealthy for Sensitive Groups', color: '#ff7e00' },
//   subIndices: { pm25: 101, o3_8h: 37, no2: 19 }
// }

category(aqi)

Returns { index, label, color } for the six EPA bands, or null for non-numbers or values outside 0–500:

| AQI | index | label | color | |-----------|-------|--------------------------------|-----------| | 0–50 | 1 | Good | #00e400 | | 51–100 | 2 | Moderate | #ffff00 | | 101–150 | 3 | Unhealthy for Sensitive Groups | #ff7e00 | | 151–200 | 4 | Unhealthy | #ff0000 | | 201–300 | 5 | Very Unhealthy | #8f3f97 | | 301–500 | 6 | Hazardous | #7e0023 |

overallAQI(readings)

readings is an object with any subset of the keys pm25, pm10, o3_8h, co, so2, no2. Each provided value is converted to a sub-index; the reported AQI is the maximum, and the pollutant producing it is dominant. Pollutants that are absent or produce a null sub-index (out of range) are ignored. Returns null if nothing usable was provided.

CATEGORIES

The exported CATEGORIES array is the EPA category table itself — six rows of { lo, hi, index, label, color } covering AQI 0–500 (e.g. { lo: 0, hi: 50, index: 1, label: 'Good', color: '#00e400' }). Use it to build legends or color scales without re-typing the bands.

BREAKPOINTS

The exported BREAKPOINTS object is the EPA breakpoint tables themselves, keyed by the six pollutant ids used by overallAQI (pm25, pm10, o3_8h, co, so2, no2). Each entry is { decimals, rows }: decimals is the precision the concentration is truncated to before lookup, and each row is [Clow, Chigh, Ilow, Ihigh] — the concentration range and the AQI range it maps onto. So BREAKPOINTS.pm25.rows[1] is [9.1, 35.4, 51, 100], the Moderate row. Use it to draw a concentration axis, label a gauge, or show a reading's band without re-typing the tables:

const { BREAKPOINTS } = require('@verifyhash/air-quality-index');

BREAKPOINTS.pm25.rows[0];       // [ 0, 9, 0, 50 ]
BREAKPOINTS.pm25.decimals;      // 1
BREAKPOINTS.so2.rows.length;    // 4

| key | decimals | rows | table tops out at | |---------|------------|------|--------------------------| | pm25 | 1 | 6 | 325.4 µg/m³ → AQI 500 | | pm10 | 0 | 7 | 604 µg/m³ → AQI 500 | | o3_8h | 3 | 5 | 0.200 ppm → AQI 300 | | co | 1 | 7 | 50.4 ppm → AQI 500 | | so2 | 0 | 4 | 304 ppb → AQI 200 | | no2 | 0 | 7 | 2049 ppb → AQI 500 |

Units and averaging periods per key are in Pollutants and units above. Two caveats: these are the live objects the calculator reads, not copies, so treat them as read-only — mutating a row changes what aqiFromPM25 and friends return; and the row sets are versioned data, not a frozen contract, so they change whenever EPA revises a table (the 2024 PM2.5 revision took pm25 from seven rows to six).

Error handling

Every function is total — it never throws on bad input. Undefined, null, NaN, non-numbers, negative values, and concentrations above the top breakpoint all return null (or are skipped, in overallAQI).

Honest limits

  • This is reported-AQI math, not sensor calibration. It assumes you already have a properly-averaged concentration (e.g. a 24-hour PM2.5 mean). It does not do NowCast, temporal averaging, unit conversion (µg/m³ ↔ ppb), or quality control of raw sensor data.
  • Ozone above 0.200 ppm returns null. The 8-hour ozone table only defines AQI up to 300; higher values require the 1-hour ozone table, which is not implemented.
  • SO₂ above 304 ppb returns null. The 1-hour SO₂ table only reaches AQI 200; AQI 201+ requires the 24-hour SO₂ average, which is not implemented.
  • PM2.5 uses the current (post-2024-05-06) breakpoints. If you are reproducing an AQI that was published before 2024-05-06, this library will not reproduce it for PM2.5 — see the history table above for the old rows.
  • Values above each pollutant's highest breakpoint (e.g. PM2.5 > 325.4 µg/m³) return null rather than an extrapolated number.

Running the tests

node test/index.test.js

The suite has no dependencies. It exits non-zero on any failure and prints a pass/fail summary. It covers each pollutant with hand-verifiable EPA fixtures (PM2.5 9.0 → 50, 9.1 → 51, 35.5 → 101, 55.5 → 151), every PM2.5 band boundary top and bottom, every category boundary, the superseded pre-2024 PM2.5 vectors re-labelled as history, out-of-range handling, and overallAQI dominant-pollutant selection.

License

MIT.

Install

npm install @verifyhash/air-quality-index
const aqi = require('@verifyhash/air-quality-index');

aqi.aqiFromPM25(35.5);                       // 101
aqi.overallAQI({ pm25: 35.5, o3_8h: 0.04 }); // { aqi: 101, dominant: 'pm25', ... }