///|
/// Air quality index computation from pollutant concentrations.
///
/// ```moonbit nocheck
/// let table = @moonaqi.hj633_2026(@moonaqi.Pm25, @moonaqi.Daily).unwrap()
/// let sub = @moonaqi.iaqi(table, 60.0) // Some(100)
/// let category = @moonaqi.AqiCategory::of(100) // Good
/// ```
///|
/// 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.
pub struct Breakpoint {
concentration_low : Double
concentration_high : Double
index_low : Int
index_high : Int
}
///|
/// One row of a breakpoint table.
pub fn Breakpoint::new(
concentration_low : Double,
concentration_high : Double,
index_low : Int,
index_high : Int,
) -> Breakpoint {
{ concentration_low, concentration_high, index_low, index_high, }
}
///|
/// The concentration at the low end of the row.
pub fn Breakpoint::concentration_low(self : Breakpoint) -> Double {
self.concentration_low
}
///|
/// The concentration at the high end of the row.
pub fn Breakpoint::concentration_high(self : Breakpoint) -> Double {
self.concentration_high
}
///|
/// The index at the low end of the row.
pub fn Breakpoint::index_low(self : Breakpoint) -> Int {
self.index_low
}
///|
/// The index at the high end of the row.
pub fn Breakpoint::index_high(self : Breakpoint) -> Int {
self.index_high
}
///|
/// How a computed index is turned into an integer. The two standards the
/// library carries disagree, so the choice is the caller's.
pub(all) enum Rounding {
/// Carried up to the next integer, as HJ 633 §4.2.6 asks for.
CarryUp
/// Rounded to the nearest integer, as the EPA's TAD asks for.
Nearest
}
///|
/// `value` rounded the way `rounding` asks for.
fn round_index(value : Double, rounding : Rounding) -> Int {
match rounding {
CarryUp => carry_up(value)
Nearest => (value + 0.5).to_int()
}
}
///|
/// `value` carried up to the next integer: 75.0 stays 75, 75.1 becomes 76.
///
/// The tolerance absorbs the last-bit error of an interpolation that lands on
/// an integer.
fn carry_up(value : Double) -> Int {
let truncated = value.to_int()
if value - truncated.to_double() > 1.0e-9 {
truncated + 1
} else {
truncated
}
}
///|
/// 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`.
pub fn iaqi_with(
table : Array[Breakpoint],
concentration : Double,
rounding : Rounding,
) -> Int? {
if table.length() == 0 || concentration < 0.0 {
return None
}
let last = table[table.length() - 1]
if concentration >= last.concentration_high {
return Some(last.index_high)
}
for row in table {
if concentration >= row.concentration_low &&
concentration <= row.concentration_high {
let span = row.concentration_high - row.concentration_low
if span <= 0.0 {
return None
}
let index_span = (row.index_high - row.index_low).to_double()
let scaled = index_span / span * (concentration - row.concentration_low) +
row.index_low.to_double()
return Some(round_index(scaled, rounding))
}
}
None
}
///|
/// 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.
pub fn iaqi(table : Array[Breakpoint], concentration : Double) -> Int? {
iaqi_with(table, concentration, CarryUp)
}
///|
/// The band the index falls in.
pub(all) enum AqiCategory {
/// 优: good enough that no one has to think about it.
Excellent
/// 良: acceptable.
Good
/// 轻度污染: light pollution.
LightlyPolluted
/// 中度污染: moderate pollution.
ModeratelyPolluted
/// 重度污染: heavy pollution.
HeavilyPolluted
/// 严重污染: severe pollution.
SeverelyPolluted
}
///|
/// The band an index falls in: 50, 100, 150, 200 and 300 are the boundaries,
/// as the Chinese standard draws them.
pub fn AqiCategory::of(index : Int) -> AqiCategory {
if index <= 50 {
Excellent
} else if index <= 100 {
Good
} else if index <= 150 {
LightlyPolluted
} else if index <= 200 {
ModeratelyPolluted
} else if index <= 300 {
HeavilyPolluted
} else {
SeverelyPolluted
}
}
///|
/// Every band, worst last.
pub fn AqiCategory::all() -> Array[AqiCategory] {
[
Excellent,
Good,
LightlyPolluted,
ModeratelyPolluted,
HeavilyPolluted,
SeverelyPolluted,
]
}
///|
/// The band's English name.
pub fn AqiCategory::name(self : AqiCategory) -> String {
match self {
Excellent => "Excellent"
Good => "Good"
LightlyPolluted => "Lightly polluted"
ModeratelyPolluted => "Moderately polluted"
HeavilyPolluted => "Heavily polluted"
SeverelyPolluted => "Severely polluted"
}
}
///|
/// The band's name as the Chinese standard writes it.
pub fn AqiCategory::chinese_name(self : AqiCategory) -> String {
match self {
Excellent => "优"
Good => "良"
LightlyPolluted => "轻度污染"
ModeratelyPolluted => "中度污染"
HeavilyPolluted => "重度污染"
SeverelyPolluted => "严重污染"
}
}
///|
/// The lowest index in the band.
pub fn AqiCategory::lower(self : AqiCategory) -> Int {
match self {
Excellent => 0
Good => 51
LightlyPolluted => 101
ModeratelyPolluted => 151
HeavilyPolluted => 201
SeverelyPolluted => 301
}
}