Sign in

    moonaqi

    Air quality index in pure MoonBit: IAQI interpolation, the Chinese HJ 633-2026 and US EPA breakpoint tables, the dominant pollutant, and the EPA NowCast.

    air-quality
    aqi
    environment
    air-pollution
    pm25
    health
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    22 hours ago
    Downloads
    2

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

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

    #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:

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

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

    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.

    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.