///|
/// Combining a report's readings into an index.
///
/// ```moonbit nocheck
/// 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.dominant() // [Pm25]
/// ```
///|
/// Which report a set of readings is for.
pub(all) enum Report {
/// 日报: the day's averages.
Daily
/// 实时报: the latest hour.
RealTime
}
///|
/// Both reports.
pub fn Report::all() -> Array[Report] {
[Daily, RealTime]
}
///|
/// 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.
pub fn window_for(pollutant : Pollutant, report : Report) -> Averaging {
match (pollutant, report) {
(O3, Daily) => EightHour
(Pm25, RealTime) => Daily
(Pm10, RealTime) => Daily
(_, Daily) => Daily
(_, RealTime) => OneHour
}
}
///|
/// One pollutant's concentration.
pub struct Reading {
pollutant : Pollutant
averaging : Averaging
concentration : Double
}
///|
/// A reading over an averaging window, named directly.
pub fn Reading::new(
pollutant : Pollutant,
averaging : Averaging,
concentration : Double,
) -> Reading {
{ pollutant, averaging, concentration, }
}
///|
/// 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.
pub fn Reading::of(
pollutant : Pollutant,
report : Report,
concentration : Double,
) -> Reading {
{ pollutant, averaging: window_for(pollutant, report), concentration, }
}
///|
/// The pollutant the reading is of.
pub fn Reading::pollutant(self : Reading) -> Pollutant {
self.pollutant
}
///|
/// The averaging window the concentration is over.
pub fn Reading::averaging(self : Reading) -> Averaging {
self.averaging
}
///|
/// The concentration, in the unit `Pollutant::unit` names.
pub fn Reading::concentration(self : Reading) -> Double {
self.concentration
}
///|
/// The index a report's readings add up to.
pub struct AirQuality {
index : Int
sub_indices : Array[(Pollutant, Int)]
dominant : Array[Pollutant]
non_attainment : Array[Pollutant]
}
///|
/// Which standard's breakpoints an index is computed from.
pub(all) enum Standard {
/// China's HJ 633—2026, in force since 2026-03-01.
China
/// China's HJ 633—2012, which the 2026 standard replaced.
China2012
/// The United States EPA's AQI.
Epa
}
///|
/// The breakpoint table a standard publishes for a pollutant and window.
pub fn breakpoints(
standard : Standard,
pollutant : Pollutant,
averaging : Averaging,
) -> Array[Breakpoint]? {
match standard {
China => hj633_2026(pollutant, averaging)
China2012 => hj633_2012(pollutant, averaging)
Epa => epa(pollutant, averaging)
}
}
///|
/// The rounding a standard asks for: China carries the index up, the EPA
/// rounds to the nearest integer.
pub fn rounding_of(standard : Standard) -> Rounding {
match standard {
China | China2012 => CarryUp
Epa => Nearest
}
}
///|
/// Put a report's sub-indices together into an index.
fn combine(sub_indices : Array[(Pollutant, Int)]) -> AirQuality? {
if sub_indices.length() == 0 {
return None
}
let mut index = 0
for entry in sub_indices {
let (_, sub_index) = entry
if sub_index > index {
index = sub_index
}
}
let dominant : Array[Pollutant] = []
let non_attainment : Array[Pollutant] = []
for entry in sub_indices {
let (pollutant, sub_index) = entry
// The standard names a primary pollutant only once the index is above
// 50, and allows more than one to be named.
if index > 50 && sub_index == index {
dominant.push(pollutant)
}
if sub_index > 100 {
non_attainment.push(pollutant)
}
}
Some({ index, sub_indices, dominant, non_attainment, })
}
///|
/// 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`.
pub fn aqi_with(standard : Standard, readings : Array[Reading]) -> AirQuality? {
let sub_indices : Array[(Pollutant, Int)] = []
for reading in readings {
match breakpoints(standard, reading.pollutant, reading.averaging) {
Some(table) => {
let concentration = match standard {
Epa => epa_truncate(reading.pollutant, reading.concentration)
China | China2012 => reading.concentration
}
match iaqi_with(table, concentration, rounding_of(standard)) {
Some(sub_index) => sub_indices.push((reading.pollutant, sub_index))
None => ()
}
}
None => ()
}
}
combine(sub_indices)
}
///|
/// The index for a set of readings, from China's tables.
pub fn aqi(readings : Array[Reading]) -> AirQuality? {
aqi_with(China, readings)
}
///|
/// The index itself.
pub fn AirQuality::index(self : AirQuality) -> Int {
self.index
}
///|
/// The band the index falls in.
pub fn AirQuality::category(self : AirQuality) -> AqiCategory {
AqiCategory::of(self.index)
}
///|
/// The pollutant or pollutants that set the index, which the standard names
/// only once the index is above 50.
pub fn AirQuality::dominant(self : AirQuality) -> Array[Pollutant] {
self.dominant
}
///|
/// The pollutants that went past the standard's second limit.
pub fn AirQuality::non_attainment(self : AirQuality) -> Array[Pollutant] {
self.non_attainment
}
///|
/// Every sub-index the report produced, in the order the readings came in.
pub fn AirQuality::sub_indices(self : AirQuality) -> Array[(Pollutant, Int)] {
self.sub_indices
}
///|
/// The sub-index for one pollutant, when the report carried it.
pub fn AirQuality::sub_index(self : AirQuality, pollutant : Pollutant) -> Int? {
for entry in self.sub_indices {
let (reported, sub_index) = entry
if reported == pollutant {
return Some(sub_index)
}
}
None
}