use serde::{Deserialize, Serialize};
use crate::air_quality_aqicn::fetch_headline_aqi;
use crate::client::http_client;
use crate::error::Result;
use crate::geo::{detect_region, Region};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AqiStandard {
Us,
European,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AqiSource {
Aqicn,
#[default]
OpenMeteo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum UsAqiCategory {
Good,
Moderate,
UnhealthySensitive,
Unhealthy,
VeryUnhealthy,
Hazardous,
}
impl UsAqiCategory {
pub fn from_aqi(aqi: i32) -> Self {
match aqi {
0..=50 => Self::Good,
51..=100 => Self::Moderate,
101..=150 => Self::UnhealthySensitive,
151..=200 => Self::Unhealthy,
201..=300 => Self::VeryUnhealthy,
_ => Self::Hazardous,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EuAqiCategory {
Good,
Fair,
Moderate,
Poor,
VeryPoor,
ExtremelyPoor,
}
impl EuAqiCategory {
pub fn from_aqi(aqi: i32) -> Self {
match aqi {
0..=20 => Self::Good,
21..=40 => Self::Fair,
41..=60 => Self::Moderate,
61..=80 => Self::Poor,
81..=100 => Self::VeryPoor,
_ => Self::ExtremelyPoor,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "standard", content = "level")]
pub enum AqiCategory {
Us(UsAqiCategory),
Eu(EuAqiCategory),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirQualityData {
pub aqi: i32,
pub category: AqiCategory,
pub pm2_5: f32,
pub pm10: f32,
pub ozone: f32,
pub nitrogen_dioxide: f32,
pub carbon_monoxide: f32,
#[serde(default)]
pub aqi_source: AqiSource,
}
impl AirQualityData {
pub fn standard(&self) -> AqiStandard {
match self.category {
AqiCategory::Us(_) => AqiStandard::Us,
AqiCategory::Eu(_) => AqiStandard::European,
}
}
}
pub async fn fetch_air_quality(
latitude: f64,
longitude: f64,
aqicn_token: Option<&str>,
) -> Result<AirQualityData> {
let url = format!(
"https://air-quality-api.open-meteo.com/v1/air-quality?latitude={}&longitude={}¤t=us_aqi,european_aqi,pm2_5,pm10,ozone,nitrogen_dioxide,carbon_monoxide&timezone=auto",
latitude, longitude
);
let response = http_client()?.get(&url).send().await?.error_for_status()?;
let data: AirQualityResponse = response.json().await?;
let region = detect_region(latitude, longitude);
let aqicn_aqi = match (aqicn_token, region) {
(Some(token), r) if r != Region::Europe => {
fetch_headline_aqi(latitude, longitude, token).await
}
_ => None,
};
let (aqi, category, aqi_source) = resolve_headline_aqi(&data, region, aqicn_aqi);
Ok(AirQualityData {
aqi,
category,
pm2_5: data.current.pm2_5.unwrap_or(0.0),
pm10: data.current.pm10.unwrap_or(0.0),
ozone: data.current.ozone.unwrap_or(0.0),
nitrogen_dioxide: data.current.nitrogen_dioxide.unwrap_or(0.0),
carbon_monoxide: data.current.carbon_monoxide.unwrap_or(0.0),
aqi_source,
})
}
fn resolve_headline_aqi(
response: &AirQualityResponse,
region: Region,
aqicn_aqi: Option<i32>,
) -> (i32, AqiCategory, AqiSource) {
if let Some(val) = aqicn_aqi {
(
val,
AqiCategory::Us(UsAqiCategory::from_aqi(val)),
AqiSource::Aqicn,
)
} else if region == Region::Europe {
let val = response.current.european_aqi.unwrap_or_else(|| {
tracing::warn!("European AQI missing from API response, defaulting to 0");
0
});
(
val,
AqiCategory::Eu(EuAqiCategory::from_aqi(val)),
AqiSource::OpenMeteo,
)
} else {
let val = response.current.us_aqi.unwrap_or_else(|| {
tracing::warn!("US AQI missing from API response, defaulting to 0");
0
});
(
val,
AqiCategory::Us(UsAqiCategory::from_aqi(val)),
AqiSource::OpenMeteo,
)
}
}
#[derive(Debug, Deserialize)]
struct AirQualityResponse {
current: AirQualityCurrentData,
}
#[derive(Debug, Deserialize)]
struct AirQualityCurrentData {
us_aqi: Option<i32>,
european_aqi: Option<i32>,
pm2_5: Option<f32>,
pm10: Option<f32>,
ozone: Option<f32>,
nitrogen_dioxide: Option<f32>,
carbon_monoxide: Option<f32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aqi_source_defaults_to_open_meteo() {
assert_eq!(AqiSource::default(), AqiSource::OpenMeteo);
}
#[test]
fn resolve_headline_aqi_us_region_with_aqicn_returns_aqicn() {
let json = r#"{"current": {"us_aqi": 42, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Us, Some(88));
assert_eq!(
result,
(
88,
AqiCategory::Us(UsAqiCategory::Moderate),
AqiSource::Aqicn
)
);
}
#[test]
fn resolve_headline_aqi_us_region_without_aqicn_returns_open_meteo_us() {
let json = r#"{"current": {"us_aqi": 42, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Us, None);
assert_eq!(
result,
(
42,
AqiCategory::Us(UsAqiCategory::Good),
AqiSource::OpenMeteo
)
);
}
#[test]
fn resolve_headline_aqi_us_region_with_missing_us_aqi_defaults_to_zero() {
let json = r#"{"current": {"us_aqi": null, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Us, None);
assert_eq!(
result,
(
0,
AqiCategory::Us(UsAqiCategory::Good),
AqiSource::OpenMeteo
)
);
}
#[test]
fn resolve_headline_aqi_europe_region_without_aqicn_returns_open_meteo_eu() {
let json = r#"{"current": {"us_aqi": null, "european_aqi": 25, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Europe, None);
assert_eq!(
result,
(
25,
AqiCategory::Eu(EuAqiCategory::Fair),
AqiSource::OpenMeteo
)
);
}
#[test]
fn resolve_headline_aqi_europe_region_with_missing_european_aqi_defaults_to_zero() {
let json = r#"{"current": {"us_aqi": null, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Europe, None);
assert_eq!(
result,
(
0,
AqiCategory::Eu(EuAqiCategory::Good),
AqiSource::OpenMeteo
)
);
}
#[test]
fn resolve_headline_aqi_unknown_region_behaves_as_us() {
let json = r#"{"current": {"us_aqi": 175, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let result = resolve_headline_aqi(&data, Region::Unknown, None);
assert_eq!(
result,
(
175,
AqiCategory::Us(UsAqiCategory::Unhealthy),
AqiSource::OpenMeteo
)
);
}
#[test]
fn us_aqi_category_good_at_50() {
assert_eq!(UsAqiCategory::from_aqi(50), UsAqiCategory::Good);
assert_eq!(UsAqiCategory::from_aqi(0), UsAqiCategory::Good);
}
#[test]
fn us_aqi_category_moderate_at_51_and_100() {
assert_eq!(UsAqiCategory::from_aqi(51), UsAqiCategory::Moderate);
assert_eq!(UsAqiCategory::from_aqi(100), UsAqiCategory::Moderate);
}
#[test]
fn us_aqi_category_unhealthy_sensitive_at_101_and_150() {
assert_eq!(
UsAqiCategory::from_aqi(101),
UsAqiCategory::UnhealthySensitive
);
assert_eq!(
UsAqiCategory::from_aqi(150),
UsAqiCategory::UnhealthySensitive
);
}
#[test]
fn us_aqi_category_unhealthy_at_151_and_200() {
assert_eq!(UsAqiCategory::from_aqi(151), UsAqiCategory::Unhealthy);
assert_eq!(UsAqiCategory::from_aqi(200), UsAqiCategory::Unhealthy);
}
#[test]
fn us_aqi_category_very_unhealthy_at_201_and_300() {
assert_eq!(UsAqiCategory::from_aqi(201), UsAqiCategory::VeryUnhealthy);
assert_eq!(UsAqiCategory::from_aqi(300), UsAqiCategory::VeryUnhealthy);
}
#[test]
fn us_aqi_category_hazardous_at_301_and_above() {
assert_eq!(UsAqiCategory::from_aqi(301), UsAqiCategory::Hazardous);
assert_eq!(UsAqiCategory::from_aqi(999), UsAqiCategory::Hazardous);
}
#[test]
fn eu_aqi_category_good_at_20() {
assert_eq!(EuAqiCategory::from_aqi(20), EuAqiCategory::Good);
assert_eq!(EuAqiCategory::from_aqi(0), EuAqiCategory::Good);
}
#[test]
fn eu_aqi_category_fair_at_21_and_40() {
assert_eq!(EuAqiCategory::from_aqi(21), EuAqiCategory::Fair);
assert_eq!(EuAqiCategory::from_aqi(40), EuAqiCategory::Fair);
}
#[test]
fn eu_aqi_category_moderate_at_41_and_60() {
assert_eq!(EuAqiCategory::from_aqi(41), EuAqiCategory::Moderate);
assert_eq!(EuAqiCategory::from_aqi(60), EuAqiCategory::Moderate);
}
#[test]
fn eu_aqi_category_poor_at_61_and_80() {
assert_eq!(EuAqiCategory::from_aqi(61), EuAqiCategory::Poor);
assert_eq!(EuAqiCategory::from_aqi(80), EuAqiCategory::Poor);
}
#[test]
fn eu_aqi_category_very_poor_at_81_and_100() {
assert_eq!(EuAqiCategory::from_aqi(81), EuAqiCategory::VeryPoor);
assert_eq!(EuAqiCategory::from_aqi(100), EuAqiCategory::VeryPoor);
}
#[test]
fn eu_aqi_category_extremely_poor_at_101_and_above() {
assert_eq!(EuAqiCategory::from_aqi(101), EuAqiCategory::ExtremelyPoor);
assert_eq!(EuAqiCategory::from_aqi(999), EuAqiCategory::ExtremelyPoor);
}
#[test]
fn air_quality_data_standard_returns_us_for_us_variant() {
let data = AirQualityData {
aqi: 0,
category: AqiCategory::Us(UsAqiCategory::Good),
pm2_5: 0.0,
pm10: 0.0,
ozone: 0.0,
nitrogen_dioxide: 0.0,
carbon_monoxide: 0.0,
aqi_source: AqiSource::OpenMeteo,
};
assert_eq!(data.standard(), AqiStandard::Us);
}
#[test]
fn air_quality_data_standard_returns_european_for_eu_variant() {
let data = AirQualityData {
aqi: 0,
category: AqiCategory::Eu(EuAqiCategory::Good),
pm2_5: 0.0,
pm10: 0.0,
ozone: 0.0,
nitrogen_dioxide: 0.0,
carbon_monoxide: 0.0,
aqi_source: AqiSource::OpenMeteo,
};
assert_eq!(data.standard(), AqiStandard::European);
}
#[test]
fn air_quality_response_missing_pollutants_default_to_zero_after_fetch() {
let json = r#"{"current": {"us_aqi": 42, "european_aqi": null, "pm2_5": null,
"pm10": null, "ozone": null, "nitrogen_dioxide": null, "carbon_monoxide": null}}"#;
let data: AirQualityResponse = serde_json::from_str(json).unwrap();
let (aqi, category, aqi_source) = resolve_headline_aqi(&data, Region::Us, None);
let built = AirQualityData {
aqi,
category,
pm2_5: data.current.pm2_5.unwrap_or(0.0),
pm10: data.current.pm10.unwrap_or(0.0),
ozone: data.current.ozone.unwrap_or(0.0),
nitrogen_dioxide: data.current.nitrogen_dioxide.unwrap_or(0.0),
carbon_monoxide: data.current.carbon_monoxide.unwrap_or(0.0),
aqi_source,
};
assert_eq!(built.pm2_5, 0.0);
assert_eq!(built.pm10, 0.0);
assert_eq!(built.ozone, 0.0);
assert_eq!(built.nitrogen_dioxide, 0.0);
assert_eq!(built.carbon_monoxide, 0.0);
}
}