@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.
Maintainers
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) + ILowhere 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
nullrather than an extrapolated number.
Running the tests
node test/index.test.jsThe 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-indexconst aqi = require('@verifyhash/air-quality-index');
aqi.aqiFromPM25(35.5); // 101
aqi.overallAQI({ pm25: 35.5, o3_8h: 0.04 }); // { aqi: 101, dominant: 'pm25', ... }