Compare commits

...

10 Commits

Author SHA1 Message Date
Paul van Tilburg 5d37c5b5ee
Bump the version to 0.2.0 2022-05-07 21:55:03 +02:00
Paul van Tilburg 2c04a92965
Update the changelog 2022-05-07 21:54:53 +02:00
Paul van Tilburg e408fbb91d
Add a changelog 2022-05-07 21:50:59 +02:00
Paul van Tilburg 5972697cf1
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
2022-05-07 21:43:35 +02:00
Paul van Tilburg 7feae97ee2
Bump dependencies; cargo update 2022-05-07 20:21:09 +02:00
Paul van Tilburg 0bf07bd134
Split off all functionality to a library crate
This way we can build Rockets from outside the crate and run benchmarks,
for example.

* Add top-level `setup()` function to create a Rocket and set up the
  maps refresher task
* Change the type of `maps::run` since `!` is still an unstable type
* Fix HTTP code blocks in `README.md` so they don't appear as doctests
  to rustdoc
2022-03-15 09:54:02 +01:00
Paul van Tilburg 4576f8d90a
More textual improvements 2022-03-11 16:35:38 +01:00
Paul van Tilburg 66bd02ea57
Also document the map API endpoint
Because, why not?
2022-03-11 16:18:04 +01:00
Paul van Tilburg 80fd9525c4
Fix missing endpoint in example URLs; tweak text a bit 2022-03-11 16:09:14 +01:00
Paul van Tilburg 32964cea21
Add basic top-level tests
This starts to address #14 but didn't turn into a full MR yet.

* Use crates `assert_float_eq` and `assert_matches` for extra assertions
* Split off a function to build a Rocket `rocket()` that can be used
  in the tests
2022-03-11 16:03:56 +01:00
9 changed files with 738 additions and 396 deletions

23
CHANGELOG.md Normal file
View File

@ -0,0 +1,23 @@
# Changelog
All notable changes to Sinoptik will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.0] - 2022-5-07
### Added
* Add `AQI_max` and `pollen_max` to the forecast JSON (only when the PAQI
metric is selected) (#20)
## [0.1.0] - 2022-03-07
Initial release.
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.0...HEAD
[0.2.0]: https://git.luon.net/paul/sinoptik/compare/v0.1.0...v0.2.0
[0.1.0]: https://git.luon.net/paul/sinoptik/commits/tag/v0.1.0

553
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "sinoptik"
version = "0.1.0"
version = "0.2.0"
authors = [
"Admar Schoonen <admar@luon.net",
"Paul van Tilburg <paul@luon.net>"
@ -12,16 +12,20 @@ repository = "https://git.luon.net/paul/sinoptik"
license = "MIT"
[dependencies]
cached = { version = "0.32.0", features = ["async"] }
cached = { version = "0.34.0", features = ["async"] }
chrono = "0.4.19"
chrono-tz = "0.6.1"
color-eyre = "0.5.6"
color-eyre = "0.6.1"
csv = "1.1.6"
geocoding = "0.3.1"
image = "0.24.1"
reqwest = { version = "0.11.9", features = ["json"] }
rocket = { version = "0.5.0-rc.1", features = ["json"] }
[dev-dependencies]
assert_float_eq = "1.1.3"
assert_matches = "1.5.0"
[package.metadata.deb]
maintainer = "Paul van Tilburg <paul@luon.net>"
copyright = "2022, Paul van Tilburg"

103
README.md
View File

@ -27,10 +27,14 @@ Using Cargo, it is easy to build and run Sinoptik, just run:
```shell
$ cargo run --release
...
Compiling sinoptik v0.1.0 (/path/to/sinoptik)
Finished release [optimized] target(s) in 9m 26s
Running `/path/to/sinoptik/target/release/sinoptik`
```
(Note that Rocket listens on 127.0.0.1:3000 by default for debug builds, i.e. if you don't
add `--release`.)
(Note that Rocket listens on `127.0.0.1:3000` by default for debug builds, i.e.
builds when you don't add `--release`.)
You can provide Rocket with configuration to use a different address and/or port.
Just create a `Rocket.toml` file that contains (or copy `Rocket.toml.example`):
@ -38,30 +42,33 @@ Just create a `Rocket.toml` file that contains (or copy `Rocket.toml.example`):
```toml
[default]
address = "0.0.0.0"
port = 4321
port = 2356
```
## Forecast API
This will work independent of the type of build. For more about Rocket's
configuration, see: <https://rocket.rs/v0.5-rc/guide/configuration/>.
The `/forecast` endpoint provides forecasts per requested metric a list of
forecast item which are each comprised of a value and its (UNIX) timestamp.
It does so for a requested location.
## Forecast API endpoint
The `/forecast` API endpoint provides forecasts per requested metric a list of
forecast item which are each comprised of a value and its (UNIX) timestamp. It
does so for a requested location.
### Locations
To select a location, you can either provide an address, or a geocoded position
by providing a latitude and longitude.
For example to get forecasts for all metrics for the Stationsplein in Utrecht,
For example, to get forecasts for all metrics for the Stationsplein in Utrecht,
use:
```
```http
GET /forecast?address=Stationsplein,Utrecht&metrics[]=all
```
or directly by using its geocoded position:
```
```http
GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
```
@ -70,12 +77,13 @@ GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
When querying, the metrics need to be selected. It can be one of: `AQI`, `NO2`,
`O3`, `PAQI`, `PM10`, `pollen`, `precipitation` or `UVI`. If you use metric `all`, or
`all` is part of the selected metrics, all metrics will be retrieved.
Note that the parameter "array" as well as the repeated parameter notations are supported. For example:
Note that the parameter "array" notation as well as the repeated parameter
notation are supported. For example:
```
GET /address=Stationsplein,Utrecht&metrics[]=AQI&metrics[]=pollen
GET /address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen
GET /address=Stationsplein,Utrecht&metrics=all
```http
GET /forecast?address=Stationsplein,Utrecht&metrics[]=AQI&metrics[]=pollen
GET /forecast?address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen
GET /forecast?address=Stationsplein,Utrecht&metrics=all
```
### Response
@ -97,8 +105,8 @@ position:
```json
{
"lat": 34.567890,
"lon": 1.234567,
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1645800043,
"UVI": [
{
@ -125,6 +133,67 @@ 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
address or geocoded position, it shows the current map for the provided metric
and draws a crosshair on the position.
Currently, only the `PAQI`, `pollen` and `UVI` metrics are backed by a map.
For example, to get the current pollen map with a crosshair on Stationsplein in
Utrecht, use:
```http
GET /map?address=Stationsplein,Utrecht&metric=pollen
```
or directly by using its geocoded position:
```http
GET /map?lat=52.0902&lon=5.1114&metric=pollen
```
### Response
The response is a PNG image with a crosshair drawn on the map. If geocoding of
an address fails or if the position is out of bounds of the map, nothing is
returned (HTTP 404).
## License
Sinoptik is licensed under the MIT license (see the `LICENSE` file or

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 => {

283
src/lib.rs Normal file
View File

@ -0,0 +1,283 @@
#![doc = include_str!("../README.md")]
#![warn(
clippy::all,
missing_debug_implementations,
rust_2018_idioms,
rustdoc::broken_intra_doc_links
)]
#![deny(missing_docs)]
use std::future::Future;
use std::sync::{Arc, Mutex};
use rocket::http::ContentType;
use rocket::response::content::Custom;
use rocket::serde::json::Json;
use rocket::{get, routes, Build, Rocket, State};
pub(crate) use self::forecast::Metric;
use self::forecast::{forecast, Forecast};
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
use self::position::{resolve_address, Position};
pub(crate) mod forecast;
pub(crate) mod maps;
pub(crate) mod position;
pub(crate) mod providers;
/// Handler for retrieving the forecast for an address.
#[get("/forecast?<address>&<metrics>")]
async fn forecast_address(
address: String,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> {
let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast))
}
/// Handler for retrieving the forecast for a geocoded position.
#[get("/forecast?<lat>&<lon>&<metrics>", rank = 2)]
async fn forecast_geo(
lat: f64,
lon: f64,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Json<Forecast> {
let position = Position::new(lat, lon);
let forecast = forecast(position, metrics, maps_handle).await;
Json(forecast)
}
/// Handler for showing the current map with the geocoded position of an address for a specific
/// metric.
///
/// Note: This handler is mosly used for debugging purposes!
#[get("/map?<address>&<metric>")]
async fn map_address(
address: String,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<Custom<Vec<u8>>> {
let position = resolve_address(address).await?;
let image_data = mark_map(position, metric, maps_handle).await;
image_data.map(|id| Custom(ContentType::PNG, id))
}
/// Handler for showing the current map with the geocoded position for a specific metric.
///
/// Note: This handler is mosly used for debugging purposes!
#[get("/map?<lat>&<lon>&<metric>", rank = 2)]
async fn map_geo(
lat: f64,
lon: f64,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<Custom<Vec<u8>>> {
let position = Position::new(lat, lon);
let image_data = mark_map(position, metric, maps_handle).await;
image_data.map(|id| Custom(ContentType::PNG, id))
}
/// Sets up Rocket.
fn rocket(maps_handle: MapsHandle) -> Rocket<Build> {
rocket::build().manage(maps_handle).mount(
"/",
routes![forecast_address, forecast_geo, map_address, map_geo],
)
}
/// Sets up Rocket and the maps cache refresher task.
pub fn setup() -> (Rocket<Build>, impl Future<Output = ()>) {
let maps = Maps::new();
let maps_handle = Arc::new(Mutex::new(maps));
let maps_refresher = maps::run(Arc::clone(&maps_handle));
let rocket = rocket(maps_handle);
(rocket, maps_refresher)
}
#[cfg(test)]
mod tests {
use assert_float_eq::*;
use assert_matches::assert_matches;
use image::{DynamicImage, Rgba, RgbaImage};
use rocket::http::Status;
use rocket::local::blocking::Client;
use rocket::serde::json::Value as JsonValue;
use super::*;
fn maps_stub(map_count: u32) -> DynamicImage {
let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key.
DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color))
}
fn maps_handle_stub() -> MapsHandle {
let mut maps = Maps::new();
maps.pollen = Some(maps_stub(24));
maps.uvi = Some(maps_stub(5));
Arc::new(Mutex::new(maps))
}
#[test]
fn forecast_address() {
let maps_handle = maps_handle_stub();
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
// Get an empty forecast for the provided address.
let response = client.get("/forecast?address=eindhoven").dispatch();
assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648);
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);
// Get a forecast with all metrics for the provided address.
let response = client
.get("/forecast?address=eindhoven&metrics=all")
.dispatch();
assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648);
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(_)));
}
#[test]
fn forecast_geo() {
let maps_handle = maps_handle_stub();
let client = Client::tracked(rocket(maps_handle)).expect("valid Rocket instance");
// Get an empty forecast for the geocoded location.
let response = client.get("/forecast?lat=51.4&lon=5.5").dispatch();
assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4);
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);
// Get a forecast with all metrics for the geocoded location.
let response = client
.get("/forecast?lat=51.4&lon=5.5&metrics=all")
.dispatch();
assert_eq!(response.status(), Status::Ok);
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4);
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(_)));
}
#[test]
fn map_address() {
let maps_handle = Arc::new(Mutex::new(Maps::new()));
let maps_handle_clone = Arc::clone(&maps_handle);
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
// No maps available yet.
let response = client
.get("/map?address=eindhoven&metric=pollen")
.dispatch();
assert_eq!(response.status(), Status::NotFound);
// Load some dummy map.
let mut maps = maps_handle_clone
.lock()
.expect("Maps handle mutex was poisoned");
maps.pollen = Some(maps_stub(24));
drop(maps);
// There should be a map now.
let response = client
.get("/map?address=eindhoven&metric=pollen")
.dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::PNG));
// ... but not if it is out of bounds.
let response = client.get("/map?address=berlin&metric=pollen").dispatch();
assert_eq!(response.status(), Status::NotFound);
// No metric selected, don't know which map to show?
let response = client.get("/map?address=eindhoven").dispatch();
assert_eq!(response.status(), Status::NotFound);
}
#[test]
fn map_geo() {
let maps_handle = Arc::new(Mutex::new(Maps::new()));
let maps_handle_clone = Arc::clone(&maps_handle);
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
// No metric passed, don't know which map to show?
let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
assert_eq!(response.status(), Status::NotFound);
// No maps available yet.
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
assert_eq!(response.status(), Status::NotFound);
// Load some dummy map.
let mut maps = maps_handle_clone
.lock()
.expect("Maps handle mutex was poisoned");
maps.pollen = Some(maps_stub(24));
drop(maps);
// There should be a map now.
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::PNG));
// No metric passed, don't know which map to show?
let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
assert_eq!(response.status(), Status::NotFound);
}
}

View File

@ -7,110 +7,24 @@
)]
#![deny(missing_docs)]
use std::sync::{Arc, Mutex};
use color_eyre::Result;
use rocket::http::ContentType;
use rocket::response::content::Custom;
use rocket::serde::json::Json;
use rocket::tokio::{self, select};
use rocket::{get, routes, State};
pub(crate) use self::forecast::Metric;
use self::forecast::{forecast, Forecast};
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
use self::position::{resolve_address, Position};
pub(crate) mod forecast;
pub(crate) mod maps;
pub(crate) mod position;
pub(crate) mod providers;
/// Handler for retrieving the forecast for an address.
#[get("/forecast?<address>&<metrics>")]
async fn forecast_address(
address: String,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> {
let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast))
}
/// Handler for retrieving the forecast for a geocoded position.
#[get("/forecast?<lat>&<lon>&<metrics>", rank = 2)]
async fn forecast_geo(
lat: f64,
lon: f64,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Json<Forecast> {
let position = Position::new(lat, lon);
let forecast = forecast(position, metrics, maps_handle).await;
Json(forecast)
}
/// Handler for showing the current map with the geocoded position of an address for a specific
/// metric.
///
/// Note: This handler is mosly used for debugging purposes!
#[get("/map?<address>&<metric>")]
async fn map_address(
address: String,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<Custom<Vec<u8>>> {
let position = resolve_address(address).await?;
let image_data = mark_map(position, metric, maps_handle).await;
image_data.map(|id| Custom(ContentType::PNG, id))
}
/// Handler for showing the current map with the geocoded position for a specific metric.
///
/// Note: This handler is mosly used for debugging purposes!
#[get("/map?<lat>&<lon>&<metric>", rank = 2)]
async fn map_geo(
lat: f64,
lon: f64,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<Custom<Vec<u8>>> {
let position = Position::new(lat, lon);
let image_data = mark_map(position, metric, maps_handle).await;
image_data.map(|id| Custom(ContentType::PNG, id))
}
/// Starts the main maps refresh loop and sets up and launches Rocket.
///
/// See [`maps::run`] for the maps refresh loop.
/// Starts the main maps refresh task and sets up and launches Rocket.
#[rocket::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let maps = Maps::new();
let maps_handle = Arc::new(Mutex::new(maps));
let maps_updater = tokio::spawn(maps::run(Arc::clone(&maps_handle)));
let rocket = rocket::build()
.manage(maps_handle)
.mount(
"/",
routes![forecast_address, forecast_geo, map_address, map_geo],
)
.ignite()
.await?;
let (rocket, maps_refresher) = sinoptik::setup();
let rocket = rocket.ignite().await?;
let shutdown = rocket.shutdown();
let maps_refresher = tokio::spawn(maps_refresher);
select! {
result = rocket.launch() => {
result?
}
result = maps_updater => {
result = maps_refresher => {
shutdown.notify();
result?
}

View File

@ -536,7 +536,7 @@ pub(crate) async fn mark_map(
///
/// Use [`MapsRefresh`] trait methods on `maps_handle` to check whether each maps type needs to be
/// refreshed and uses its retrieval function to update it if necessary.
pub(crate) async fn run(maps_handle: MapsHandle) -> ! {
pub(crate) async fn run(maps_handle: MapsHandle) {
loop {
println!("🕔 Refreshing the maps (if necessary)...");

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;
};