Yield pollen and AQI max for PAQI metric (closes: #20)

* Make the combined provider keep track of the AQI and pollen maximum
  value
* Extend the `Forecast` struct with the `aqi_max` and `pollen_max`
  fields
* Fill the `aqi_max` and `pollen_max` fields when the PAQI metric is
  selected
* Update the documentation
* Extend the tests
This commit is contained in:
Paul van Tilburg 2022-05-07 21:37:11 +02:00
parent 7feae97ee2
commit 5972697cf1
Signed by: paul
GPG Key ID: C6DE073EDA9EEC4D
4 changed files with 97 additions and 14 deletions

View File

@ -105,8 +105,8 @@ position:
```json
{
"lat": 34.567890,
"lon": 1.234567,
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1645800043,
"UVI": [
{
@ -133,6 +133,41 @@ position:
}
```
#### Combined metric PAQI
The PAQI (pollen/air quality index) metric is a special combined metric.
If selected, it not only merges items from the AQI and pollen metric into
`PAQI` by selecting the maximum value for each hour, but it also yields the
24-hour maximum forecast item for air quality index in `AQI_max` and for
pollen in `pollen_max` seperately:
``` json
{
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1645800043,
"AQI_max": {
"time": 1652022000,
"value": 6.65
},
"PAQI": [
{
"time": 1651951457,
"value": 6.04
},
{
"time": 1651955057,
"value": 6.04
},
...
],
"pollen_max": {
"time": 1652034257,
"value": 6
}
}
```
## Map API endpoint
The `/map` API endpoint basically only exists for debugging purposes. Given an

View File

@ -33,6 +33,10 @@ pub(crate) struct Forecast {
#[serde(rename = "AQI", skip_serializing_if = "Option::is_none")]
aqi: Option<Vec<LuchtmeetnetItem>>,
/// The maximum air quality index value (when asked for PAQI).
#[serde(rename = "AQI_max", skip_serializing_if = "Option::is_none")]
aqi_max: Option<LuchtmeetnetItem>,
/// The NO₂ concentration (when asked for).
#[serde(rename = "NO2", skip_serializing_if = "Option::is_none")]
no2: Option<Vec<LuchtmeetnetItem>>,
@ -53,6 +57,10 @@ pub(crate) struct Forecast {
#[serde(skip_serializing_if = "Option::is_none")]
pollen: Option<Vec<BuienradarSample>>,
/// The maximum pollen in the air (when asked for PAQI).
#[serde(skip_serializing_if = "Option::is_none")]
pollen_max: Option<BuienradarSample>,
/// The precipitation (when asked for).
#[serde(skip_serializing_if = "Option::is_none")]
precipitation: Option<Vec<BuienradarItem>>,
@ -136,7 +144,13 @@ pub(crate) async fn forecast(
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await,
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await,
Metric::PAQI => {
forecast.paqi = providers::combined::get(position, metric, maps_handle).await
if let Some((paqi, pollen_max, aqi_max)) =
providers::combined::get(position, metric, maps_handle).await
{
forecast.paqi = Some(paqi);
forecast.aqi_max = aqi_max;
forecast.pollen_max = pollen_max;
}
}
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
Metric::Pollen => {

View File

@ -140,11 +140,13 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633);
assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), None);
assert_matches!(json.get("AQI_max"), None);
assert_matches!(json.get("NO2"), None);
assert_matches!(json.get("O3"), None);
assert_matches!(json.get("PAQI"), None);
assert_matches!(json.get("PM10"), None);
assert_matches!(json.get("pollen"), None);
assert_matches!(json.get("pollen_max"), None);
assert_matches!(json.get("precipitation"), None);
assert_matches!(json.get("UVI"), None);
@ -158,11 +160,13 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633);
assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
}
@ -180,11 +184,13 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), None);
assert_matches!(json.get("AQI_max"), None);
assert_matches!(json.get("NO2"), None);
assert_matches!(json.get("O3"), None);
assert_matches!(json.get("PAQI"), None);
assert_matches!(json.get("PM10"), None);
assert_matches!(json.get("pollen"), None);
assert_matches!(json.get("pollen_max"), None);
assert_matches!(json.get("precipitation"), None);
assert_matches!(json.get("UVI"), None);
@ -198,11 +204,13 @@ mod tests {
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
assert_matches!(json["time"], JsonValue::Number(_));
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
}

View File

@ -27,16 +27,23 @@ pub(crate) struct Item {
/// Merges pollen samples and AQI items into combined items.
///
/// This drops items from either the pollen samples or from the AQI items if they are not stamped
/// with half an hour of the first item of the latest stating series, thus lining them before they
/// are combined.
/// The merging drops items from either the pollen samples or from the AQI items if they are not
/// stamped with half an hour of the first item of the latest starting series, thus lining them
/// before they are combined.
///
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
/// lining them up fails.
/// This function also finds the maximum pollen sample and AQI item.
///
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
/// lining them up fails. Returns [`None`] for the maximum pollen sample or maximum AQI item
/// if there are no samples or items.
fn merge(
pollen_samples: Vec<BuienradarSample>,
aqi_items: Vec<LuchtmeetnetItem>,
) -> Option<Vec<Item>> {
) -> Option<(
Vec<Item>,
Option<BuienradarSample>,
Option<LuchtmeetnetItem>,
)> {
let mut pollen_samples = pollen_samples;
let mut aqi_items = aqi_items;
@ -65,6 +72,16 @@ fn merge(
aqi_items.drain(..idx);
}
// Find the maximum sample/item of each series.
let pollen_max = pollen_samples
.iter()
.max_by_key(|sample| sample.score)
.cloned();
let aqi_max = aqi_items
.iter()
.max_by_key(|item| (item.value * 1_000.0) as u32)
.cloned();
// Combine the samples with items by taking the maximum of pollen sample score and AQI item
// value.
let items = pollen_samples
@ -78,18 +95,23 @@ fn merge(
})
.collect();
Some(items)
Some((items, pollen_max, aqi_max))
}
/// Retrieves the combined forecasted items for the provided position and metric.
///
/// Besides the combined items, it also yields the maxium pollen sample and AQI item.
/// Note that the maximum values are calculated before combining them, so the time stamp
/// corresponds to the one in the original series, not to a timestamp of an item after merging.
///
/// It supports the following metric:
/// * [`Metric::PAQI`]
///
/// Returns [`None`] if retrieving data from either the Buienradar or the Luchtmeetnet provider
/// fails or if they cannot be combined.
/// Returns [`None`] for the combined items if retrieving data from either the Buienradar or the
/// Luchtmeetnet provider fails or if they cannot be combined. Returns [`None`] for the maxiumum
/// pollen sample or AQI item if there are no samples or items.
///
/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and
/// If the result is [`Some`], it will be cached for 30 minutes for the the given position and
/// metric.
#[cached(
time = 1800,
@ -101,7 +123,11 @@ pub(crate) async fn get(
position: Position,
metric: Metric,
maps_handle: &MapsHandle,
) -> Option<Vec<Item>> {
) -> Option<(
Vec<Item>,
Option<BuienradarSample>,
Option<LuchtmeetnetItem>,
)> {
if metric != Metric::PAQI {
return None;
};