moonaqi

    Air quality index in pure MoonBit: the Chinese HJ 633-2026 and US EPA breakpoint tables, a day of hourly readings averaged into the indicators the standards describe, a period summarised, the dominant pollutant, and the EPA NowCast.

    air-quality
    aqi
    environment
    air-pollution
    pm25
    health
    Download zip
    Author
    Version
    0.3.0
    License
    Apache-2.0
    Last updated
    16 days ago
    Downloads
    36

    No dependencies

    #moonaqi

    Air quality index computation in pure MoonBit.

    Each pollutant's concentration is mapped onto a common 0–500 scale by piecewise-linear interpolation against a table of breakpoints, and the index is the worst of those sub-indices.

    A complete guide — installing it, the four levels of use, switching standards, units, and the mistakes worth knowing about — is in docs/usage.md.

    #Quick start

    ///|
    let table = @moonaqi.hj633_2026(@moonaqi.Pm25, @moonaqi.Daily).unwrap()
    let sub = @moonaqi.iaqi(table, 60.0) // Some(100)

    ///|
    let readings = [
    @moonaqi.Reading::of(@moonaqi.Pm25, @moonaqi.Daily, 60.0),
    @moonaqi.Reading::of(@moonaqi.No2, @moonaqi.Daily, 40.0),
    ]
    let air = @moonaqi.aqi(readings).unwrap()
    air.index() // 100
    air.category().name() // "Good"
    air.dominant() // [Pm25]

    #Interpolation

    A breakpoint table maps concentration ranges onto index ranges. The sub-index is

    IAQI = (I_high - I_low) / (C_high - C_low) * (C - C_low) + I_low

    carried up to the next integer, which is what the standard asks for (向上进位取整).

    ///|
    let iaqi = @moonaqi.iaqi(
    [
    @moonaqi.Breakpoint::new(0.0, 35.0, 0, 50),
    @moonaqi.Breakpoint::new(35.0, 75.0, 50, 100),
    @moonaqi.Breakpoint::new(75.0, 115.0, 100, 150),
    ],
    55.0,
    )

    A concentration above the last row gives that row's index. A negative one gives None.

    #The standard's tables

    hj633_2026 returns the column of 表3 (HJ 633—2026) for a pollutant and averaging window:

    PollutantOne hourEight hoursDaily
    PM2.5——✓
    PM10——✓
    SO₂✓—✓
    NO₂✓—✓
    CO✓—✓
    O₃✓✓—

    A pair with no column returns None.

    Two columns stop at 800 µg/m³, and the standard gives the sub-index above that instead of carrying the column on: SO₂ over one hour as 200, O₃ over eight hours as 300.

    HJ 633—2026 replaced HJ 633—2012 on 2026-03-01. The PM10 and PM2.5 columns tightened at the 100 step, from 150 and 75 µg/m³ to 120 and 60. The older tables are carried too, as hj633_2012 and Standard::China2012, so a report can be computed under either.

    #The index

    aqi combines a report's readings. Reading::of picks the window the standard judges that pollutant over, which is where its exceptions for ozone, PM10 and PM2.5 live.

    ///|
    let air = @moonaqi.aqi([
    @moonaqi.Reading::of(@moonaqi.Pm25, @moonaqi.Daily, 60.0),
    @moonaqi.Reading::of(@moonaqi.No2, @moonaqi.Daily, 40.0),
    ]).unwrap()

    dominant() names the pollutants that set the index, once it is above 50. non_attainment() names the ones past 100.

    A reading the standard publishes no column for is left out rather than refused. A report with nothing usable in it gives None.

    #The EPA's tables

    The United States EPA's index is the same arithmetic over different tables, and the differences are the ones that matter:

    ChinaEPA
    Roundingcarried upnearest integer
    Before the lookupas measuredtruncated to 1–3 places
    Unitsµg/m³, mg/m³µg/m³, ppm, ppb

    epa returns the EPA's column for a pollutant and window. aqi_with(Epa, readings) runs a report through them.

    ///|
    let air = @moonaqi.aqi_with(@moonaqi.Epa, [
    @moonaqi.Reading::new(@moonaqi.Pm25, @moonaqi.Daily, 20.0),
    @moonaqi.Reading::new(@moonaqi.O3, @moonaqi.EightHour, 0.07),
    ]).unwrap()

    Two of the EPA's columns start partway up rather than at zero, because that is where they are used from: the ozone one-hour column at 101, and the sulphur dioxide daily column at 201.

    #NowCast

    nowcast_pm25 is the EPA's real-time estimate: a weighted average of the last twelve hourly PM2.5 concentrations, most recent first.

    ///|
    let nowcast = @moonaqi.nowcast_pm25([Some(30.0), Some(20.0), Some(10.0)])

    The weight is the ratio of the smallest to the largest concentration in the window, floored at a half, so a steady window is averaged almost evenly and a moving one puts most of its weight on the recent hours. Hours with no measurement are left out of both sums, and a NowCast is only computed when at least two of the last three hours have one.

    #A day of hours

    A monitor reports concentrations, not indices, and it reports them an hour at a time with hours missing when an instrument is down. Between the raw hours and the index sits the work the standards actually describe, and Hourly and Day do it.

    ///|
    let day = @moonaqi.Day::new("STATION")

    ///|
    // One pass per hour, recording every pollutant, so that gaps in one series
    // cannot shift another's hours.
    for hour = 0; hour < 24; hour = hour + 1 {
    day.record(@moonaqi.Pm25, Some(35.0))
    day.record(@moonaqi.O3, Some(if hour < 12 { 100.0 } else { 160.0 }))
    }

    ///|
    let rules = @moonaqi.DayRules::new(
    @moonaqi.Window::new(24, 20), // a day over 24 hours, at least 20 reported
    @moonaqi.Window::new(8, 6), // ozone over its highest 8, at least 6
    )

    ///|
    day.aqi(@moonaqi.China, rules).unwrap()

    Three things this layer does that a table lookup cannot:

    • A missing hour is not a nought. An average is taken over the hours that carry a reading, so a monitor that was down for an hour does not drag the day towards a number nobody measured.
    • Ozone is its highest eight-hour average, not its daily average. The window slides, so the worst stretch can straddle any boundary; on a day of twelve hours at 100 and twelve at 160, the whole-day average is 130 and the answer is 160.
    • An indicator too thin to average is left out. The standards call that a missing indicator, and it is reported as one rather than guessed at.

    The minimum number of reported hours is a parameter rather than a constant: both standards that this library carries set one, both have revised it, and a caller reporting to an authority supplies that authority's number.

    Day::merged builds a city's day from its stations': the average, hour by hour, of the stations that reported that hour.

    #A period

    Summary counts a run of days the way a report describes one: how many were good, how many were heavy, the worst of them and where it was, and which pollutant kept setting the index.

    ///|
    let summary = @moonaqi.Summary::new()

    ///|
    summary.add("STATION A", day.aqi(@moonaqi.China, rules).unwrap())

    ///|
    summary.days() // days counted
    summary.good_days() // index at or below 100
    summary.heavy_days() // index above 200
    summary.good_rate() // the share of the period, as a fraction of one
    summary.worst_index() // and the station it came from
    summary.most_frequent_primary()

    Every figure is counted from days the library has already indexed, so a summary cannot disagree with the days it summarises.

    #Bands

    IndexBand中文Colour
    0–50Excellent优Green 绿色
    51–100Good良Yellow 黄色
    101–150Lightly polluted轻度污染Orange 橙色
    151–200Moderately polluted中度污染Red 红色
    201–300Heavily polluted重度污染Purple 紫色
    301+Severely polluted严重污染Maroon 褐红色

    AqiCategory::rgb gives the colour's channels, and colour_name and chinese_colour_name its name in either language.

    #Demo

    cmd/main takes six concentrations and prints the report a monitoring station publishes:

    $ moon run cmd/main daily report (日报) pollutant concentration IAQI PM2.5 115 ug/m3 150 PM10 120 ug/m3 100 SO2 50 ug/m3 50 NO2 40 ug/m3 50 CO 4 mg/m3 100 O3 160 ug/m3 100 AQI 150 Lightly polluted / 轻度污染 primary pollutant PM2.5 over the limit PM2.5

    --realtime reads the same values as a real-time report, where the one-hour columns are much more lenient. --pm25, --pm10, --so2, --no2, --co and --o3 override a concentration. --json prints the report as JSON instead:

    A day of hours can be fed in directly, and is averaged and indexed the way the standards describe:

    $ moon run cmd/main -- --station STATION \ --series PM2.5=35x24 --series NO2=,40x23 --series O3=100x12,160x12 moonaqi — a day of hours, averaged and indexed station STATION (HJ 633—2026) rules a day over 24 hours with at least 20 reported, ozone over its highest 8 with at least 6 PM2.5 24 hours, 24 reported 24-hour average 35 ug/m3 NO2 24 hours, 23 reported 24-hour average 40 ug/m3 O3 24 hours, 24 reported 8-hour maximum 160 ug/m3 AQI 100 Good / 良 Yellow / 黄色 primary pollutant O3

    A series element is a concentration, value xN for a run of hours, or nothing at all for an hour with no reading. --standard epa reads the same hours under the EPA's tables, and the unit each indicator is read in is printed, so a concentration fed in the wrong standard's units is visibly wrong rather than quietly indexed.

    $ moon run cmd/main -- --json { "report": "daily", "aqi": 150, "category": "Lightly polluted", "chinese_category": "轻度污染", ... }

    #Sources

    Every breakpoint and every rule in this library comes from one of the documents below. The numbers are transcribed from them rather than inferred, and each transcription is pinned by a test.

    WhatWhere it comes from
    China's breakpoints, categories and roundingHJ 633—2026《环境空气质量指数(AQI)技术规定》, Ministry of Ecology and Environment of China, in force from 2026-03-01. 表3 holds the breakpoint values, §4.2.6 says the index is carried up rather than rounded, and 表3's footnotes say what to report above 800 µg/m³.
    China's superseded breakpointsHJ 633—2012, which the 2026 standard replaced on the same date.
    The EPA's breakpointsThe US EPA Air Quality System code table aqi_breakpoints, where the EPA publishes the current values.
    The EPA's truncation, rounding and band coloursEPA 454/B-18-007, Technical Assistance Document for the Reporting of Daily Air Quality — the Air Quality Index (AQI).
    The NowCast algorithmThe EPA's own implementation, nowcast_pm25, in the US EPA sensortoolkit repository.
    The averaging ruleThe window lengths are the standards'; the minimum number of reported hours is a parameter, because each authority sets it and has revised it.

    No file from those sources is redistributed here. The tables are transcriptions of published values, and the tests assert the values at their published steps.

    #Testing

    moon test

    #Licence

    Apache-2.0.

    AirQuality

    pub struct AirQuality {
    index : Int
    sub_indices : Array[(Pollutant, Int)]
    dominant : Array[Pollutant]
    non_attainment : Array[Pollutant]
    }

    The index a report's readings add up to.

    AirQuality::category

    fn AirQuality::category(self : AirQuality) -> AqiCategory

    The band the index falls in.

    AirQuality::dominant

    fn AirQuality::dominant(self : AirQuality) -> Array[Pollutant]

    The pollutant or pollutants that set the index, which the standard names only once the index is above 50.

    AirQuality::index

    fn AirQuality::index(self : AirQuality) -> Int

    The index itself.

    AirQuality::non_attainment

    fn AirQuality::non_attainment(self : AirQuality) -> Array[Pollutant]

    The pollutants that went past the standard's second limit.

    AirQuality::sub_index

    fn AirQuality::sub_index(self : AirQuality, pollutant : Pollutant) -> Int?

    The sub-index for one pollutant, when the report carried it.

    AirQuality::sub_indices

    fn AirQuality::sub_indices(self : AirQuality) -> Array[(Pollutant, Int)]

    Every sub-index the report produced, in the order the readings came in.

    AqiCategory

    pub(all) enum AqiCategory {
    Excellent
    Good
    LightlyPolluted
    ModeratelyPolluted
    HeavilyPolluted
    SeverelyPolluted
    }

    The band the index falls in.

    AqiCategory::all

    Every band, worst last.

    AqiCategory::chinese_colour_name

    fn AqiCategory::chinese_colour_name(self : AqiCategory) -> String

    The colour's name as the Chinese standard writes it.

    AqiCategory::chinese_name

    fn AqiCategory::chinese_name(self : AqiCategory) -> String

    The band's name as the Chinese standard writes it.

    AqiCategory::colour_name

    fn AqiCategory::colour_name(self : AqiCategory) -> String

    The colour's English name.

    AqiCategory::lower

    fn AqiCategory::lower(self : AqiCategory) -> Int

    The lowest index in the band.

    AqiCategory::name

    fn AqiCategory::name(self : AqiCategory) -> String

    The band's English name.

    AqiCategory::of

    fn AqiCategory::of(index : Int) -> AqiCategory

    The band an index falls in: 50, 100, 150, 200 and 300 are the boundaries, as the Chinese standard draws them.

    AqiCategory::rgb

    fn AqiCategory::rgb(self : AqiCategory) -> Rgb

    The colour the band is shown in.

    Averaging

    pub(all) enum Averaging {
    OneHour
    EightHour
    Daily
    }

    The averaging window a concentration is reported over.

    Averaging::all

    fn Averaging::all() -> Array[Averaging]

    Every averaging window.

    Breakpoint

    pub struct Breakpoint {
    concentration_low : Double
    concentration_high : Double
    index_low : Int
    index_high : Int
    }

    One row of a breakpoint table: a concentration range and the index range it maps onto, in the unit the standard measures that pollutant in.

    Breakpoint::concentration_high

    fn Breakpoint::concentration_high(self : Breakpoint) -> Double

    The concentration at the high end of the row.

    Breakpoint::concentration_low

    fn Breakpoint::concentration_low(self : Breakpoint) -> Double

    The concentration at the low end of the row.

    Breakpoint::index_high

    fn Breakpoint::index_high(self : Breakpoint) -> Int

    The index at the high end of the row.

    Breakpoint::index_low

    fn Breakpoint::index_low(self : Breakpoint) -> Int

    The index at the low end of the row.

    Breakpoint::new

    fn Breakpoint::new(concentration_low : Double, concentration_high : Double, index_low : Int, index_high : Int) -> Breakpoint

    One row of a breakpoint table.

    Day

    pub struct Day {
    station : String
    hours : Array[Hourly]
    }

    A station's day: the hours recorded for each pollutant.

    Day::aqi

    fn Day::aqi(self : Day, standard : Standard, rules : DayRules) -> AirQuality?

    The day's index under a standard's tables.

    Day::hourly

    fn Day::hourly(self : Day, pollutant : Pollutant) -> Hourly?

    The hours recorded for one pollutant, when it has any.

    Day::hours

    fn Day::hours(self : Day) -> Array[Hourly]

    One entry per pollutant that has been recorded, in the order the pollutants were first seen.

    Day::latest_readings

    fn Day::latest_readings(self : Day) -> Array[Reading]

    The latest hour of every pollutant, which is what a real-time report is made of.

    Day::merged

    fn Day::merged(station : String, stations : Array[Day]) -> Day

    A city's day, from its stations': the average, hour by hour, of the stations that reported that hour.

    A station that did not report an hour does not drag the city's hour towards zero, and a pollutant no station recorded does not appear. The result is a day like any other and is reported with the same rules.

    Day::new

    fn Day::new(station : String) -> Day

    An empty day at a station.

    Day::readings

    fn Day::readings(self : Day, rules : DayRules) -> Array[Reading]

    The day's indicators, ready to be turned into an index: the average of the day's hours for every pollutant but ozone, and the day's highest eight-hour average for ozone.

    A pollutant whose hours were too thin to average under the rules is left out rather than guessed at, which is what the standards mean by a missing indicator.

    Day::record

    fn Day::record(self : Day, pollutant : Pollutant, value : Double?) -> Unit

    Record one hour of one pollutant, starting that pollutant's series the first time it is seen.

    An hour is recorded for every pollutant on every pass, so that the series stay aligned and a gap in one pollutant cannot silently shift another's hours. None records an hour without a reading.

    Day::station

    fn Day::station(self : Day) -> String

    The station the day is from.

    DayRules

    pub struct DayRules {
    daily : Window
    ozone : Window
    }

    How a report averages its hours.

    Both windows are rules rather than numbers because both are the authority's to set: what counts as enough of a day, and enough of an eight-hour stretch, has been revised more than once in both of the standards this library carries.

    DayRules::daily

    fn DayRules::daily(self : DayRules) -> Window

    The rule the day's other pollutants are averaged under.

    DayRules::new

    fn DayRules::new(daily : Window, ozone : Window) -> DayRules

    The shape a report is usually built to: a day averaged over daily hours, and ozone judged over its highest ozone-hour average.

    DayRules::ozone

    fn DayRules::ozone(self : DayRules) -> Window

    The rule ozone is judged over.

    Hourly

    pub struct Hourly {
    pollutant : Pollutant
    values : Array[Double?]
    }

    One pollutant's hours, in order.

    Hourly::at

    fn Hourly::at(self : Hourly, index : Int) -> Double?

    The reading at index, which counts from the first hour recorded.

    Hourly::length

    fn Hourly::length(self : Hourly) -> Int

    How many hours have been recorded, readings or not.

    Hourly::maximum_rolling

    fn Hourly::maximum_rolling(self : Hourly, rule : Window) -> Double?

    The highest window average in the day, which is what a standard asks of ozone over eight hours: not the whole-day average but the worst eight-hour stretch within it.

    Hourly::mean

    fn Hourly::mean(self : Hourly, minimum_valid : Int) -> Double?

    The average over every hour recorded, or None when fewer than minimum_valid of them carry a reading.

    Hourly::new

    fn Hourly::new(pollutant : Pollutant) -> Hourly

    An empty day for one pollutant.

    Hourly::pollutant

    fn Hourly::pollutant(self : Hourly) -> Pollutant

    The pollutant these hours are of.

    Hourly::push

    fn Hourly::push(self : Hourly, value : Double?) -> Unit

    Add the next hour.

    None is an hour with no reading, which is not the same as an hour of zero: a monitor that is down did not measure a clean hour, and averaging its outage in as a nought would drag every average it touches towards a number nobody measured.

    Hourly::rolling

    fn Hourly::rolling(self : Hourly, rule : Window) -> Array[Double?]

    Every window ending at an hour of the day, in hour order, with None where that window was too thin to average.

    Hourly::valid_between

    fn Hourly::valid_between(self : Hourly, start : Int, end : Int) -> Int

    How many of the hours from start to end inclusive carry a reading.

    Hourly::values

    fn Hourly::values(self : Hourly) -> Array[Double?]

    Every hour, in order, with None where there is no reading.

    Hourly::window_mean

    fn Hourly::window_mean(self : Hourly, end : Int, rule : Window) -> Double?

    The average of the rule's hours ending at end, or None when fewer than the rule's minimum carry a reading.

    A window that runs off the front of the day is shortened rather than refused, so the first hours of a day can still be averaged once enough of them have arrived. The average is taken over the hours that carry a reading, not over the window: a partly missing window is averaged over what was measured.

    Pollutant

    pub(all) enum Pollutant {
    Pm25
    Pm10
    So2
    No2
    Co
    O3
    }

    The pollutants the standard's tables cover.
    impl Eq for Pollutant

    Pollutant::all

    fn Pollutant::all() -> Array[Pollutant]

    Every pollutant, in the order the standard's table lists them.

    Pollutant::equal

    fn Pollutant::equal(self : Pollutant, other : Pollutant) -> Bool

    Two pollutants are the same when they are the same pollutant.

    Pollutant::name

    fn Pollutant::name(self : Pollutant) -> String

    The name the standard uses.

    Pollutant::not_equal

    fn Pollutant::not_equal(x : Pollutant, y : Pollutant) -> Bool

    Two pollutants are the same when they are the same pollutant.

    Pollutant::unit

    fn Pollutant::unit(self : Pollutant) -> String

    The unit a concentration is measured in: mg/m3 for carbon monoxide, ug/m3 for the rest.

    Reading

    pub struct Reading {
    pollutant : Pollutant
    averaging : Averaging
    concentration : Double
    }

    One pollutant's concentration.

    Reading::averaging

    fn Reading::averaging(self : Reading) -> Averaging

    The averaging window the concentration is over.

    Reading::concentration

    fn Reading::concentration(self : Reading) -> Double

    The concentration, in the unit Pollutant::unit names.

    Reading::new

    fn Reading::new(pollutant : Pollutant, averaging : Averaging, concentration : Double) -> Reading

    A reading over an averaging window, named directly.

    Reading::of

    fn Reading::of(pollutant : Pollutant, report : Report, concentration : Double) -> Reading

    A reading for a report, over the window that report judges the pollutant over. This is where the standard's exceptions for ozone, PM10 and PM2.5 live.

    Reading::pollutant

    fn Reading::pollutant(self : Reading) -> Pollutant

    The pollutant the reading is of.

    Report

    pub(all) enum Report {
    Daily
    RealTime
    }

    Which report a set of readings is for.

    Report::all

    fn Report::all() -> Array[Report]

    Both reports.

    Rgb

    pub struct Rgb {
    red : Int
    green : Int
    blue : Int
    }

    A colour, as its three channels.

    Rgb::blue

    fn Rgb::blue(self : Rgb) -> Int

    Rgb::green

    fn Rgb::green(self : Rgb) -> Int

    Rgb::new

    fn Rgb::new(red : Int, green : Int, blue : Int) -> Rgb

    Rgb::red

    fn Rgb::red(self : Rgb) -> Int

    Rounding

    pub(all) enum Rounding {
    CarryUp
    Nearest
    }

    How a computed index is turned into an integer. The two standards the library carries disagree, so the choice is the caller's.

    Standard

    pub(all) enum Standard {
    China
    China2012
    Epa
    }

    Which standard's breakpoints an index is computed from.

    Summary

    pub(all) struct Summary {
    days : Int
    total_index : Int
    good_days : Int
    heavy_days : Int
    worst_index : Int?
    worst_station : String?
    primary_counts : Array[(Pollutant, Int)]
    }

    A running summary of days.

    Summary::add

    fn Summary::add(self : Summary, station : String, air : AirQuality) -> Unit

    Add one day's index, and the station it came from.

    A day is counted as good when its index is at or below 100, which is the boundary both standards draw for the second band; it is counted as heavy above 200, where the fourth band begins. Those are the bands' own boundaries rather than numbers invented here.

    Summary::average_index

    fn Summary::average_index(self : Summary) -> Double?

    The mean index over the days, or None for an empty summary.

    Summary::days

    fn Summary::days(self : Summary) -> Int

    How many days have been added.

    Summary::good_days

    fn Summary::good_days(self : Summary) -> Int

    How many of them were at or below 100.

    Summary::good_rate

    fn Summary::good_rate(self : Summary) -> Double?

    The share of days at or below 100, as a fraction of one, or None for an empty summary.

    Summary::heavy_days

    fn Summary::heavy_days(self : Summary) -> Int

    How many days were above 200.

    Summary::most_frequent_primary

    fn Summary::most_frequent_primary(self : Summary) -> Pollutant?

    The pollutant that set the index most often, or None when no day named one.

    Summary::new

    fn Summary::new() -> Summary

    An empty summary.

    Summary::primary_counts

    fn Summary::primary_counts(self : Summary) -> Array[(Pollutant, Int)]

    How often each pollutant set the index, as pairs of pollutant and count, in the order they were first seen.

    Summary::worst_index

    fn Summary::worst_index(self : Summary) -> Int?

    The worst index in the period.

    Summary::worst_station

    fn Summary::worst_station(self : Summary) -> String?

    The station the worst day came from.

    Window

    pub struct Window {
    window : Int
    minimum_valid : Int
    }

    The averaging rule an authority asks for: how many consecutive hours an average is taken over, and how many of them have to carry a reading.

    The second number is deliberately not a constant of this library. The United States and China both require averages, and both require a minimum number of hours before an average may be reported, but the numbers are theirs to set and have been revised; a caller that reports to one of them supplies that authority's rule.

    Window::minimum_valid

    fn Window::minimum_valid(self : Window) -> Int

    How many of those hours must carry a reading.

    Window::new

    fn Window::new(window : Int, minimum_valid : Int) -> Window

    A rule of window hours, of which minimum_valid must carry a reading.

    Window::window

    fn Window::window(self : Window) -> Int

    How many consecutive hours the average is over.

    aqi

    fn aqi(readings : Array[Reading]) -> AirQuality?

    The index for a set of readings, from China's tables.

    aqi_with

    fn aqi_with(standard : Standard, readings : Array[Reading]) -> AirQuality?

    The index for a set of readings, from a chosen standard's tables.

    A reading the standard publishes no column for is left out rather than refused. The EPA truncates a concentration before it looks it up, so that is done here too. A report with nothing usable in it gives None.

    breakpoints

    fn breakpoints(standard : Standard, pollutant : Pollutant, averaging : Averaging) -> Array[Breakpoint]?

    The breakpoint table a standard publishes for a pollutant and window.

    epa

    fn epa(pollutant : Pollutant, averaging : Averaging) -> Array[Breakpoint]?

    The breakpoint table the EPA publishes for a pollutant and averaging window, or None for a pair it does not carry.

    Note the shapes the EPA's own tables have: the ozone one-hour column only starts at an index of 101, because it is only used when the eight-hour concentration goes above 0.200 ppm; the sulphur dioxide one-hour column stops at 200 for the same kind of reason, and its daily column only starts above 304 ppb.

    epa_truncate

    fn epa_truncate(pollutant : Pollutant, value : Double) -> Double

    value truncated the way the EPA truncates a concentration of pollutant before using it.

    epa_unit

    fn epa_unit(pollutant : Pollutant) -> String

    The unit a concentration is measured in, in the EPA's tables.

    hj633_2012

    fn hj633_2012(pollutant : Pollutant, averaging : Averaging) -> Array[Breakpoint]?

    The tables of HJ 633—2012.

    HJ 633—2026 replaced this standard on 2026-03-01, and two steps moved: at an index of 100, PM10 went from 150 to 120 ug/m3 and PM2.5 from 75 to 60. Everything else in 表1 is the same in both, so the two functions return the same rows for the other columns.

    The 2012 table also has the same two footnotes the 2026 one kept: an SO₂ one-hour concentration above 800 ug/m3 is reported as 200, and an O₃ eight-hour concentration above 800 ug/m3 as 300. Those columns are stored truncated here for the same reason, and iaqi clamps to the last row.

    hj633_2026

    fn hj633_2026(pollutant : Pollutant, averaging : Averaging) -> Array[Breakpoint]?

    The breakpoint table for a pollutant and averaging window — 表3 of HJ 633—2026.

    None for a pair the standard publishes no column for.

    iaqi

    fn iaqi(table : Array[Breakpoint], concentration : Double) -> Int?

    The sub-index for one pollutant, carried up to the next integer the way China's HJ 633 asks for. Use iaqi_with for the EPA's rounding.

    iaqi_with

    fn iaqi_with(table : Array[Breakpoint], concentration : Double, rounding : Rounding) -> Int?

    The sub-index for one pollutant: where concentration falls in table.

    A concentration above the last row is reported as that row's index. A negative concentration is not a measurement, and gives None.

    nowcast_pm25

    fn nowcast_pm25(hourly : Array[Double?]) -> Double?

    The EPA's NowCast for PM2.5: a weighted average of the last twelve hourly concentrations.

    hourly is most recent first, and up to twelve hours are read. The weight is the ratio of the smallest to the largest concentration in the window, floored at a half, so a steady window is averaged almost evenly and a window that is moving puts most of the weight on the recent hours. Hours with no measurement are left out of both sums.

    The EPA computes a NowCast only when at least two of the last three hours have a measurement, and neither does this.

    rounding_of

    fn rounding_of(standard : Standard) -> Rounding

    The rounding a standard asks for: China carries the index up, the EPA rounds to the nearest integer.

    window_for

    fn window_for(pollutant : Pollutant, report : Report) -> Averaging

    The averaging window the standard judges a pollutant over in a report.

    Two are not the obvious one: ozone is judged over its maximum eight-hour average in the daily report, and PM10 and PM2.5 have no one-hour limit, so the real-time report reads them against the daily column.