Compare commits

...

11 Commits

Author SHA1 Message Date
Paul van Tilburg 789bb1d1ac
Update the changelog 2022-07-05 14:46:51 +02:00
Paul van Tilburg 2e999f5a78
Bump the version to 0.2.4 2022-07-05 14:42:51 +02:00
Paul van Tilburg 712b3a9acf
Check sample coordinate bounds (closes: #24) 2022-06-06 19:51:07 +02:00
Paul van Tilburg 2b23885692
Default to now if Last-Modified header missing
As a result, if the header is missing, it is no longer considered an
error.
2022-06-06 19:39:32 +02:00
Paul van Tilburg dc47c1c73c Merge pull request 'Handle errors internally and show them via the API' (#25) from handle-errors into main
Reviewed-on: #25
2022-06-06 16:49:03 +02:00
Paul van Tilburg 014ca5a151
Handle errors on the API side
* The map endpoints return an HTTP 404 error in case of unknown or
  out-of-bound locations
* The forecast endpoint with an address returns an HTTP 404 with error
  JSON in case geocoding fails
* The forecast endpoints return the errors per metric in the `errors`
  field of the forecast
* Implement `Display` for `Metric`
* Use a `BTreeMap` to have an ordered `errors` field/object
* Also log the errors to the console
* Update the tests
* Document the errors that can occur
2022-06-06 16:46:52 +02:00
Paul van Tilburg 8a2a6d769d
Log errors of the map refresher task separately 2022-06-06 15:38:38 +02:00
Paul van Tilburg 69ef08002c
Introduce error types, switch to Results everywhere
* Add dependency on the `thiserror` crate
* Add a global `Error` type, but also `maps::Error` and
  `providers::combined::MergeError` for convenience
* Add matching `Result` types that default to the respective `Error`
  type
* Refactor code to yield all kinds of error variants
* Add FIXMEs where library errors still need to be handled
* Remove documentation that explained why `None` was returned, this is
  captured in the error now
2022-06-06 15:37:54 +02:00
Paul van Tilburg 7d0cd4a822
Drop pollen and AQI max for PAQI metric
* This was introduced as per #20 but no longer deemed necessary
* Fix up some comments
* Keep the PAQI documentation in `README.md`
2022-06-05 21:47:12 +02:00
Paul van Tilburg fb8236696d
Use world map emoji! 2022-06-05 21:29:12 +02:00
Paul van Tilburg aab3b737be
Run map refresher as an ad hoc liftoff fairing
* Simplify the library `setup()` method
* Simplify launching Rocket
* Drop dependency on color-eyre
2022-06-05 21:25:56 +02:00
12 changed files with 495 additions and 466 deletions

View File

@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.4] - 2022-07-05
### Added
* Add proper error handling and show them via the API (#25)
### Changed
* Run map refresher as an ad hoc liftoff fairing in Rocket
* Changed emojis in log output
### Removed
* Removed `AQI_max` and `pollen_max` from the forecast JSON introduced in
version 0.2.0
### Fixed
* Verify sample coordinate bounds (#24)
* Default to current time if `Last-Modified` HTTP header is missing for
retrieved maps
## [0.2.3] - 2022-05-21
### Fixed
@ -47,7 +69,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Initial release.
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.3...HEAD
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.4...HEAD
[0.2.4]: https://git.luon.net/paul/sinoptik/compare/v0.2.3...v0.2.4
[0.2.3]: https://git.luon.net/paul/sinoptik/compare/v0.2.2...v0.2.3
[0.2.2]: https://git.luon.net/paul/sinoptik/compare/v0.2.1...v0.2.2
[0.2.1]: https://git.luon.net/paul/sinoptik/compare/v0.2.0...v0.2.1

108
Cargo.lock generated
View File

@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "addr2line"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
@ -152,21 +143,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "backtrace"
version = "0.3.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61"
dependencies = [
"addr2line",
"cc",
"cfg-if 1.0.0",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
]
[[package]]
name = "base64"
version = "0.13.0"
@ -341,33 +317,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "color-eyre"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ebf286c900a6d5867aeff75cfee3192857bb7f24b547d4f0df2ed6baa812c90"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ba75b3d9449ecdccb27ecbc479fdc0b87fa2dd43d2f8298f9bf0e59aacc8dce"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]]
name = "color_quant"
version = "1.1.0"
@ -631,16 +580,6 @@ dependencies = [
"threadpool",
]
[[package]]
name = "eyre"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb"
dependencies = [
"indenter",
"once_cell",
]
[[package]]
name = "fastrand"
version = "1.7.0"
@ -907,12 +846,6 @@ dependencies = [
"weezl",
]
[[package]]
name = "gimli"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78cc372d058dcf6d5ecd98510e7fbc9e5aec4d21de70f65fea8fecebcd881bd4"
[[package]]
name = "glob"
version = "0.3.0"
@ -1164,12 +1097,6 @@ dependencies = [
"tiff",
]
[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
[[package]]
name = "indexmap"
version = "1.8.1"
@ -1528,15 +1455,6 @@ dependencies = [
"libc",
]
[[package]]
name = "object"
version = "0.28.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e42c982f2d955fac81dd7e1d0e1426a7d702acd9c98d19ab01083a6a0328c424"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.10.0"
@ -1594,12 +1512,6 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "owo-colors"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf7381921fea4dcb2549c5667eda59b3ec297ab7e2b5fc33eac69d2e7da87b"
[[package]]
name = "parking_lot"
version = "0.12.0"
@ -2075,12 +1987,6 @@ dependencies = [
"uncased",
]
[[package]]
name = "rustc-demangle"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
[[package]]
name = "rustversion"
version = "1.0.6"
@ -2218,19 +2124,19 @@ dependencies = [
[[package]]
name = "sinoptik"
version = "0.2.3"
version = "0.2.4"
dependencies = [
"assert_float_eq",
"assert_matches",
"cached",
"chrono",
"chrono-tz",
"color-eyre",
"csv",
"geocoding",
"image",
"reqwest 0.11.10",
"rocket",
"thiserror",
]
[[package]]
@ -2599,16 +2505,6 @@ dependencies = [
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d686ec1c0f384b1277f097b2f279a2ecc11afe8c133c1aabf036a27cb4cd206e"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-futures"
version = "0.2.5"

View File

@ -1,6 +1,6 @@
[package]
name = "sinoptik"
version = "0.2.3"
version = "0.2.4"
authors = [
"Admar Schoonen <admar@luon.net",
"Paul van Tilburg <paul@luon.net>"
@ -15,12 +15,12 @@ license = "MIT"
cached = { version = "0.34.0", features = ["async"] }
chrono = "0.4.19"
chrono-tz = "0.6.1"
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.2", features = ["json"] }
thiserror = "1.0.31"
[dev-dependencies]
assert_float_eq = "1.1.3"

View File

@ -136,20 +136,14 @@ 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
maximum forecast item for air quality index in `AQI_max` and for
pollen in `pollen_max` seperately (out the items that `PAQI` combined):
If selected, it merges items from the AQI and pollen metric into `PAQI` by
selecting the maximum value for each hour:
``` json
```json
{
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1652189065,
"AQI_max": {
"time": 1652191200,
"value": 6.09
},
"PAQI": [
{
"time": 1652187600,
@ -160,10 +154,38 @@ pollen in `pollen_max` seperately (out the items that `PAQI` combined):
"value": 6.09
},
...
],
"pollen_max": {
"time": 1652209200,
"value": 6
]
}
```
#### Errors
If geocoding of an address is requested but fails, a not found error is returned (HTTP 404).
with the following body (this will change in the future):
```json
{
"error": {
"code": 404,
"reason": "Not Found",
"description": "The requested resource could not be found."
}
}
```
If for any specific metric an error occurs, the list with forecast items will be absent.
However, the `errors` field will contain the error message for each failed metric.
For example, say Buienradar is down and precipitation forecast items can not be
retrieved:
```json
{
"lat": 52.0905169,
"lon": 5.1109709,
"time": 1654524574,
...
"errors": {
"precipitation": "HTTP request error: error sending request for url (https://gpsgadget.buienradar.nl/data/raintext?lat=52.09&lon=5.11): error trying to connect: tcp connect error: Connection refused (os error 111)"
}
}
```
@ -192,7 +214,8 @@ GET /map?lat=52.0902&lon=5.1114&metric=pollen
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).
returned (HTTP 404). If the maps cannot/have not been downloaded or cached yet,
a service unavailable error is returned (HTTP 503).
## License

View File

@ -3,20 +3,21 @@
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for
//! the requested metrics from their providers.
use std::collections::BTreeMap;
use std::fmt;
use rocket::serde::Serialize;
use crate::maps::MapsHandle;
use crate::position::Position;
use crate::providers;
use crate::providers::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
use crate::providers::combined::Item as CombinedItem;
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
use crate::{providers, Error};
/// The current forecast for a specific location.
///
/// Only the metrics asked for are included as well as the position and current time.
///
// TODO: Fill in missing data (#4)
#[derive(Debug, Default, Serialize)]
#[serde(crate = "rocket::serde")]
pub(crate) struct Forecast {
@ -33,10 +34,6 @@ 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>>,
@ -57,10 +54,6 @@ 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>>,
@ -68,6 +61,10 @@ pub(crate) struct Forecast {
/// The UV index (when asked for).
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
uvi: Option<Vec<BuienradarSample>>,
/// Any errors that occurred.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
errors: BTreeMap<Metric, String>,
}
impl Forecast {
@ -80,13 +77,21 @@ impl Forecast {
..Default::default()
}
}
fn log_error(&mut self, metric: Metric, error: Error) {
eprintln!("💥 Encountered error during forecast: {}", error);
self.errors.insert(metric, error.to_string());
}
}
/// The supported forecast metrics.
///
/// This is used for selecting which metrics should be calculated & returned.
#[allow(clippy::upper_case_acronyms)]
#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, rocket::FromFormField)]
#[derive(
Copy, Clone, Debug, Eq, Hash, Ord, PartialOrd, PartialEq, Serialize, rocket::FromFormField,
)]
#[serde(crate = "rocket::serde")]
pub(crate) enum Metric {
/// All metrics.
#[field(value = "all")]
@ -102,7 +107,9 @@ pub(crate) enum Metric {
/// The particulate matter in the air.
PM10,
/// The pollen in the air.
#[serde(rename(serialize = "pollen"))]
Pollen,
#[serde(rename(serialize = "precipitation"))]
/// The precipitation.
Precipitation,
/// The UV index.
@ -118,6 +125,22 @@ impl Metric {
}
}
impl fmt::Display for Metric {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Metric::All => write!(f, "All"),
Metric::AQI => write!(f, "AQI"),
Metric::NO2 => write!(f, "NO2"),
Metric::O3 => write!(f, "O3"),
Metric::PAQI => write!(f, "PAQI"),
Metric::PM10 => write!(f, "PM10"),
Metric::Pollen => write!(f, "pollen"),
Metric::Precipitation => write!(f, "precipitation"),
Metric::UVI => write!(f, "UVI"),
}
}
}
/// Calculates and returns the forecast.
///
/// The provided list `metrics` determines what will be included in the forecast.
@ -140,29 +163,53 @@ pub(crate) async fn forecast(
match metric {
// This should have been expanded to all the metrics matched below.
Metric::All => unreachable!("The all metric should have been expanded"),
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await,
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await,
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await,
Metric::PAQI => {
if let Some((paqi, pollen_max, aqi_max)) =
providers::combined::get(position, metric, maps_handle).await
{
forecast.paqi = Some(paqi);
forecast.aqi_max = Some(aqi_max);
forecast.pollen_max = Some(pollen_max);
}
Metric::AQI => {
forecast.aqi = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::NO2 => {
forecast.no2 = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::O3 => {
forecast.o3 = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::PAQI => {
forecast.paqi = providers::combined::get(position, metric, maps_handle)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::PM10 => {
forecast.pm10 = providers::luchtmeetnet::get(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
Metric::Pollen => {
forecast.pollen =
providers::buienradar::get_samples(position, metric, maps_handle).await
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::Precipitation => {
forecast.precipitation = providers::buienradar::get_items(position, metric).await
forecast.precipitation = providers::buienradar::get_items(position, metric)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
Metric::UVI => {
forecast.uvi =
providers::buienradar::get_samples(position, metric, maps_handle).await
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
.await
.map_err(|err| forecast.log_error(metric, err))
.ok()
}
}
}

View File

@ -7,16 +7,16 @@
)]
#![deny(missing_docs)]
use std::future::Future;
use std::sync::{Arc, Mutex};
use rocket::fairing::AdHoc;
use rocket::http::Status;
use rocket::response::Responder;
use rocket::serde::json::Json;
use rocket::{get, routes, Build, Rocket, State};
use rocket::{get, routes, Build, Request, Rocket, State};
pub(crate) use self::forecast::Metric;
use self::forecast::{forecast, Forecast};
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
use self::forecast::{forecast, Forecast, Metric};
use self::maps::{mark_map, Error as MapsError, Maps, MapsHandle};
use self::position::{resolve_address, Position};
pub(crate) mod forecast;
@ -24,6 +24,61 @@ pub(crate) mod maps;
pub(crate) mod position;
pub(crate) mod providers;
/// The possible provider errors that can occur.
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
/// A CSV parse error occurred.
#[error("CSV parse error: {0}")]
CsvParse(#[from] csv::Error),
/// A geocoding error occurred.
#[error("Geocoding error: {0}")]
Geocoding(#[from] geocoding::GeocodingError),
/// An HTTP request error occurred.
#[error("HTTP request error: {0}")]
HttpRequest(#[from] reqwest::Error),
/// Failed to join a task.
#[error("Failed to join a task: {0}")]
Join(#[from] rocket::tokio::task::JoinError),
/// Failed to merge AQI & pollen items.
#[error("Failed to merge AQI & pollen items: {0}")]
Merge(#[from] self::providers::combined::MergeError),
/// Failed to retrieve or sample the maps.
#[error("Failed to retrieve or sample the maps: {0}")]
Maps(#[from] self::maps::Error),
/// No geocoded position could be found.
#[error("No geocoded position could be found")]
NoPositionFound,
/// Encountered an unsupported metric.
#[error("Encountered an unsupported metric: {0}")]
UnsupportedMetric(Metric),
}
impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error {
fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> {
eprintln!("💥 Encountered error during request: {}", self);
let status = match self {
Error::NoPositionFound => Status::NotFound,
Error::Maps(MapsError::NoMapsYet) => Status::ServiceUnavailable,
Error::Maps(MapsError::OutOfBoundCoords(_, _)) => Status::NotFound,
Error::Maps(MapsError::OutOfBoundOffset(_)) => Status::NotFound,
_ => Status::InternalServerError,
};
Err(status)
}
}
/// Result type that defaults to [`Error`] as the default error type.
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Responder)]
#[response(content_type = "image/png")]
struct PngImageData(Vec<u8>);
@ -34,11 +89,11 @@ async fn forecast_address(
address: String,
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> {
) -> Result<Json<Forecast>> {
let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast))
Ok(Json(forecast))
}
/// Handler for retrieving the forecast for a geocoded position.
@ -64,7 +119,7 @@ async fn map_address(
address: String,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> {
) -> Result<PngImageData> {
let position = resolve_address(address).await?;
let image_data = mark_map(position, metric, maps_handle).await;
@ -80,7 +135,7 @@ async fn map_geo(
lon: f64,
metric: Metric,
maps_handle: &State<MapsHandle>,
) -> Option<PngImageData> {
) -> Result<PngImageData> {
let position = Position::new(lat, lon);
let image_data = mark_map(position, metric, maps_handle).await;
@ -89,20 +144,28 @@ async fn map_geo(
/// 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],
)
let maps_refresher = maps::run(Arc::clone(&maps_handle));
rocket::build()
.mount(
"/",
routes![forecast_address, forecast_geo, map_address, map_geo],
)
.manage(maps_handle)
.attach(AdHoc::on_liftoff("Maps refresher", |_| {
Box::pin(async move {
// We don't care about the join handle nor error results?
let _ = rocket::tokio::spawn(maps_refresher);
})
}))
}
/// Sets up Rocket and the maps cache refresher task.
pub fn setup() -> (Rocket<Build>, impl Future<Output = ()>) {
pub fn setup() -> Rocket<Build> {
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)
rocket(maps_handle)
}
#[cfg(test)]
@ -146,13 +209,11 @@ 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);
@ -166,13 +227,11 @@ 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(_)));
}
@ -190,13 +249,11 @@ 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);
@ -210,13 +267,11 @@ 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(_)));
}
@ -231,7 +286,7 @@ mod tests {
let response = client
.get("/map?address=eindhoven&metric=pollen")
.dispatch();
assert_eq!(response.status(), Status::NotFound);
assert_eq!(response.status(), Status::ServiceUnavailable);
// Load some dummy map.
let mut maps = maps_handle_clone
@ -268,7 +323,7 @@ mod tests {
// No maps available yet.
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
assert_eq!(response.status(), Status::NotFound);
assert_eq!(response.status(), Status::ServiceUnavailable);
// Load some dummy map.
let mut maps = maps_handle_clone

View File

@ -7,28 +7,8 @@
)]
#![deny(missing_docs)]
use color_eyre::Result;
use rocket::tokio::{self, select};
/// Starts the main maps refresh task and sets up and launches Rocket.
#[rocket::main]
async fn main() -> Result<()> {
color_eyre::install()?;
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.map(|_| ())?
}
result = maps_refresher => {
shutdown.notify();
result?
}
}
Ok(())
#[rocket::launch]
async fn rocket() -> _ {
sinoptik::setup()
}

View File

@ -9,7 +9,9 @@ use std::sync::{Arc, Mutex};
use chrono::serde::ts_seconds;
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgb, Rgba};
use image::{
DynamicImage, GenericImage, GenericImageView, ImageError, ImageFormat, Pixel, Rgb, Rgba,
};
use reqwest::Url;
use rocket::serde::Serialize;
use rocket::tokio;
@ -18,6 +20,53 @@ use rocket::tokio::time::sleep;
use crate::forecast::Metric;
use crate::position::Position;
/// The possible maps errors that can occur.
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
/// A timestamp parse error occurred.
#[error("Timestamp parse error: {0}")]
ChronoParse(#[from] chrono::ParseError),
/// A HTTP request error occurred.
#[error("HTTP request error: {0}")]
HttpRequest(#[from] reqwest::Error),
/// Failed to represent HTTP header as a string.
#[error("Failed to represent HTTP header as a string")]
HttpHeaderToStr(#[from] reqwest::header::ToStrError),
/// An image error occurred.
#[error("Image error: {0}")]
Image(#[from] ImageError),
/// Encountered an invalid image file path.
#[error("Invalid image file path: {0}")]
InvalidImagePath(String),
/// Failed to join a task.
#[error("Failed to join a task: {0}")]
Join(#[from] rocket::tokio::task::JoinError),
/// Found no known (map key) colors in samples.
#[error("Found not known colors in samples")]
NoKnownColorsInSamples,
/// No maps found (yet).
#[error("No maps found (yet)")]
NoMapsYet,
/// Got out of bound coordinates for a map.
#[error("Got out of bound coordinates for a map: ({0}, {1})")]
OutOfBoundCoords(u32, u32),
/// Got out of bound offset for a map.
#[error("Got out of bound offset for a map: {0}")]
OutOfBoundOffset(u32),
}
/// Result type that defaults to [`Error`] as the default error type.
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
/// A handle to access the in-memory cached maps.
pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
@ -113,10 +162,10 @@ trait MapsRefresh {
fn is_uvi_stale(&self) -> bool;
/// Updates the pollen maps.
fn set_pollen(&self, result: Option<RetrievedMaps>);
fn set_pollen(&self, result: Result<RetrievedMaps>);
/// Updates the UV index maps.
fn set_uvi(&self, result: Option<RetrievedMaps>);
fn set_uvi(&self, result: Result<RetrievedMaps>);
}
/// Container type for all in-memory cached maps.
@ -142,71 +191,53 @@ impl Maps {
}
/// Returns a current pollen map that marks the provided position.
///
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for
/// the current moment or if the provided position is not within the bounds of the map.
pub(crate) fn pollen_mark(&self, position: Position) -> Option<DynamicImage> {
self.pollen.as_ref().and_then(|maps| {
let image = &maps.image;
let stamp = maps.timestamp_base;
let marked_image = map_at(
image,
stamp,
POLLEN_MAP_INTERVAL,
POLLEN_MAP_COUNT,
Utc::now(),
)?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
pub(crate) fn pollen_mark(&self, position: Position) -> Result<DynamicImage> {
let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
let image = &maps.image;
let stamp = maps.timestamp_base;
let marked_image = map_at(
image,
stamp,
POLLEN_MAP_INTERVAL,
POLLEN_MAP_COUNT,
Utc::now(),
)?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
Some(mark(marked_image, coords))
})
Ok(mark(marked_image, coords))
}
/// Samples the pollen maps for the given position.
///
/// This returns [`None`] if the maps are not in the cache yet.
/// Otherwise, it returns [`Some`] with a list of pollen sample, one for each map
/// in the series of maps.
pub(crate) fn pollen_samples(&self, position: Position) -> Option<Vec<Sample>> {
self.pollen.as_ref().and_then(|maps| {
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
pub(crate) fn pollen_samples(&self, position: Position) -> Result<Vec<Sample>> {
let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords)
})
sample(image, stamp, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords)
}
/// Returns a current UV index map that marks the provided position.
///
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for
/// the current moment or if the provided position is not within the bounds of the map.
pub(crate) fn uvi_mark(&self, position: Position) -> Option<DynamicImage> {
self.uvi.as_ref().and_then(|maps| {
let image = &maps.image;
let stamp = maps.timestamp_base;
let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
pub(crate) fn uvi_mark(&self, position: Position) -> Result<DynamicImage> {
let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
let image = &maps.image;
let stamp = maps.timestamp_base;
let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?;
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
Some(mark(marked_image, coords))
})
Ok(mark(marked_image, coords))
}
/// Samples the UV index maps for the given position.
///
/// This returns [`None`] if the maps are not in the cache yet.
/// Otherwise, it returns [`Some`] with a list of UV index sample, one for each map
/// in the series of maps.
pub(crate) fn uvi_samples(&self, position: Position) -> Option<Vec<Sample>> {
self.uvi.as_ref().and_then(|maps| {
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
pub(crate) fn uvi_samples(&self, position: Position) -> Result<Vec<Sample>> {
let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
let image = &maps.image;
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
let stamp = maps.timestamp_base;
sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords)
})
sample(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords)
}
}
@ -263,17 +294,17 @@ impl MapsRefresh for MapsHandle {
}
}
fn set_pollen(&self, retrieved_maps: Option<RetrievedMaps>) {
if retrieved_maps.is_some() || self.is_pollen_stale() {
fn set_pollen(&self, retrieved_maps: Result<RetrievedMaps>) {
if retrieved_maps.is_ok() || self.is_pollen_stale() {
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
maps.pollen = retrieved_maps;
maps.pollen = retrieved_maps.ok();
}
}
fn set_uvi(&self, retrieved_maps: Option<RetrievedMaps>) {
if retrieved_maps.is_some() || self.is_uvi_stale() {
fn set_uvi(&self, retrieved_maps: Result<RetrievedMaps>) {
if retrieved_maps.is_ok() || self.is_uvi_stale() {
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
maps.uvi = retrieved_maps;
maps.uvi = retrieved_maps.ok();
}
}
}
@ -315,18 +346,19 @@ fn map_key_histogram() -> MapKeyHistogram {
/// Samples the provided maps at the given (map-relative) coordinates and starting timestamp.
/// It assumes the provided coordinates are within bounds of at least one map.
/// The interval is the number of seconds the timestamp is bumped for each map.
///
/// Returns [`None`] if it encounters no known colors in any of the samples.
fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
image: &I,
stamp: DateTime<Utc>,
interval: i64,
count: u32,
coords: (u32, u32),
) -> Option<Vec<Sample>> {
) -> Result<Vec<Sample>> {
let (x, y) = coords;
let width = image.width() / count;
let height = image.height();
if x > width || y > height {
return Err(Error::OutOfBoundCoords(x, y));
}
let max_sample_width = (width - x).min(MAP_SAMPLE_SIZE[0]);
let max_sample_height = (height - y).min(MAP_SAMPLE_SIZE[1]);
let mut samples = Vec::with_capacity(count as usize);
@ -351,7 +383,7 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
.max_by_key(|(_color, count)| *count)
.expect("Map key is never empty");
if count == 0 {
return None;
return Err(Error::NoKnownColorsInSamples);
}
let score = MAP_KEY
@ -365,7 +397,7 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
offset += width;
}
Some(samples)
Ok(samples)
}
/// A retrieved image with some metadata.
@ -396,89 +428,84 @@ impl RetrievedMaps {
}
/// Retrieves an image from the provided URL.
///
/// This returns [`None`] if it fails in either performing the request, parsing the `Last-Modified`
/// reponse HTTP header, retrieving the bytes from the image or loading and the decoding the data
/// into [`DynamicImage`].
async fn retrieve_image(url: Url) -> Option<RetrievedMaps> {
// TODO: Handle or log errors!
let response = reqwest::get(url).await.ok()?;
let mtime = response
.headers()
.get(reqwest::header::LAST_MODIFIED)
.and_then(|dt| dt.to_str().ok())
.map(chrono::DateTime::parse_from_rfc2822)?
.map(DateTime::<Utc>::from)
.ok()?;
async fn retrieve_image(url: Url) -> Result<RetrievedMaps> {
let response = reqwest::get(url).await?;
let mtime = match response.headers().get(reqwest::header::LAST_MODIFIED) {
Some(mtime_header) => {
let mtime_headr_str = mtime_header.to_str()?;
DateTime::from(DateTime::parse_from_rfc2822(mtime_headr_str)?)
}
None => Utc::now(),
};
let timestamp_base = {
let path = response.url().path();
let (_, filename) = path.rsplit_once('/')?;
let (timestamp_str, _) = filename.split_once("__")?;
let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M").ok()?;
let (_, filename) = path
.rsplit_once('/')
.ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?;
let (timestamp_str, _) = filename
.split_once("__")
.ok_or_else(|| Error::InvalidImagePath(path.to_owned()))?;
let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M")?;
DateTime::<Utc>::from_utc(timestamp, Utc)
};
let bytes = response.bytes().await.ok()?;
let bytes = response.bytes().await?;
tokio::task::spawn_blocking(move || {
if let Ok(image) = image::load_from_memory_with_format(&bytes, ImageFormat::Png) {
Some(RetrievedMaps {
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
.map(|image| RetrievedMaps {
image,
mtime,
timestamp_base,
})
} else {
None
}
.map_err(Error::from)
})
.await
.ok()?
.await?
}
/// Retrieves the pollen maps from Buienradar.
///
/// See [`POLLEN_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
async fn retrieve_pollen_maps() -> Option<RetrievedMaps> {
async fn retrieve_pollen_maps() -> Result<RetrievedMaps> {
let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
let mut url = Url::parse(POLLEN_BASE_URL).unwrap();
url.query_pairs_mut().append_pair("timestamp", &timestamp);
println!("🔽 Refreshing pollen maps from: {}", url);
println!("🗺️ Refreshing pollen maps from: {}", url);
retrieve_image(url).await
}
/// Retrieves the UV index maps from Buienradar.
///
/// See [`UVI_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
async fn retrieve_uvi_maps() -> Option<RetrievedMaps> {
async fn retrieve_uvi_maps() -> Result<RetrievedMaps> {
let timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
let mut url = Url::parse(UVI_BASE_URL).unwrap();
url.query_pairs_mut().append_pair("timestamp", &timestamp);
println!("🔽 Refreshing UV index maps from: {}", url);
println!("🗺️ Refreshing UV index maps from: {}", url);
retrieve_image(url).await
}
/// Returns the map for the given instant.
///
/// This returns [`None`] if `instant` is too far in the future with respect to the number of
/// cached maps.
fn map_at(
image: &DynamicImage,
stamp: DateTime<Utc>,
interval: i64,
count: u32,
instant: DateTime<Utc>,
) -> Option<DynamicImage> {
) -> Result<DynamicImage> {
let duration = instant.signed_duration_since(stamp);
let offset = (duration.num_seconds() / interval) as u32;
// Check if out of bounds.
if offset >= count {
return None;
return Err(Error::OutOfBoundOffset(offset));
}
let width = image.width() / count;
Some(image.crop_imm(offset * width, 0, width, image.height()))
Ok(image.crop_imm(offset * width, 0, width, image.height()))
}
/// Marks the provided coordinates on the map using a horizontal and vertical line.
@ -499,13 +526,11 @@ fn mark(mut image: DynamicImage, coords: (u32, u32)) -> DynamicImage {
///
/// This uses two reference points and a Mercator projection on the y-coordinates of those points
/// to calculate how the map scales with respect to the provided position.
///
/// Returns [`None`] if the resulting coordinate is not within the bounds of the map.
fn project<I: GenericImageView>(
image: &I,
ref_points: [(Position, (u32, u32)); 2],
pos: Position,
) -> Option<(u32, u32)> {
) -> Result<(u32, u32)> {
// Get the data from the reference points.
let (ref1, (ref1_y, ref1_x)) = ref_points[0];
let (ref2, (ref2_y, ref2_x)) = ref_points[1];
@ -522,9 +547,9 @@ fn project<I: GenericImageView>(
let y = ((ref2_merc_y - mercator_y(pos.lat_as_rad())) * scale_y + ref2_y as f64).round() as u32;
if image.in_bounds(x, y) {
Some((x, y))
Ok((x, y))
} else {
None
Err(Error::OutOfBoundCoords(x, y))
}
}
@ -535,7 +560,7 @@ pub(crate) async fn mark_map(
position: Position,
metric: Metric,
maps_handle: &MapsHandle,
) -> Option<Vec<u8>> {
) -> crate::Result<Vec<u8>> {
use std::io::Cursor;
let maps_handle = Arc::clone(maps_handle);
@ -545,24 +570,22 @@ pub(crate) async fn mark_map(
Metric::PAQI => maps.pollen_mark(position),
Metric::Pollen => maps.pollen_mark(position),
Metric::UVI => maps.uvi_mark(position),
_ => return None, // Unsupported metric
_ => return Err(crate::Error::UnsupportedMetric(metric)),
}?;
drop(maps);
// Encode the image as PNG image data.
let mut image_data = Cursor::new(Vec::new());
image
.write_to(
&mut image_data,
image::ImageOutputFormat::from(image::ImageFormat::Png),
)
.ok()?;
Some(image_data.into_inner())
match image.write_to(
&mut image_data,
image::ImageOutputFormat::from(image::ImageFormat::Png),
) {
Ok(()) => Ok(image_data.into_inner()),
Err(err) => Err(crate::Error::from(Error::from(err))),
}
})
.await
.ok()
.flatten()
.map_err(Error::from)?
}
/// Runs a loop that keeps refreshing the maps when necessary.
@ -575,11 +598,17 @@ pub(crate) async fn run(maps_handle: MapsHandle) {
if maps_handle.needs_pollen_refresh() {
let retrieved_maps = retrieve_pollen_maps().await;
if let Err(e) = retrieved_maps.as_ref() {
eprintln!("💥 Encountered error during pollen maps refresh: {}", e);
}
maps_handle.set_pollen(retrieved_maps);
}
if maps_handle.needs_uvi_refresh() {
let retrieved_maps = retrieve_uvi_maps().await;
if let Err(e) = retrieved_maps.as_ref() {
eprintln!("💥 Encountered error during UVI maps refresh: {}", e);
}
maps_handle.set_uvi(retrieved_maps);
}

View File

@ -2,13 +2,14 @@
//!
//! This module contains everything related to geographic coordinate system functionality.
use std::f64::consts::PI;
use std::hash::Hash;
use cached::proc_macro::cached;
use geocoding::{Forward, Openstreetmap, Point};
use rocket::tokio;
use std::f64::consts::PI;
use crate::{Error, Result};
/// A (geocoded) position.
///
@ -98,21 +99,19 @@ impl Eq for Position {}
/// Resolves the geocoded position for a given address.
///
/// Returns [`None`] if the address could not be geocoded or the OpenStreetMap Nomatim API could
/// not be contacted.
///
/// If the result is [`Some`], it will be cached.
/// If the result is [`Ok`], it will be cached.
/// Note that only the 100 least recently used addresses will be cached.
#[cached(size = 100)]
pub(crate) async fn resolve_address(address: String) -> Option<Position> {
#[cached(size = 100, result = true)]
pub(crate) async fn resolve_address(address: String) -> Result<Position> {
println!("🌍 Geocoding the position of the address: {}", address);
tokio::task::spawn_blocking(move || {
let osm = Openstreetmap::new();
let points: Vec<Point<f64>> = osm.forward(&address).ok()?;
let points: Vec<Point<f64>> = osm.forward(&address)?;
points.get(0).map(Position::from)
points
.get(0)
.ok_or(Error::NoPositionFound)
.map(Position::from)
})
.await
.ok()
.flatten()
.await?
}

View File

@ -14,7 +14,7 @@ use rocket::serde::{Deserialize, Serialize};
use crate::maps::MapsHandle;
use crate::position::Position;
use crate::Metric;
use crate::{Error, Metric, Result};
/// The base URL for the Buienradar API.
const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext";
@ -128,28 +128,23 @@ fn fix_items_day_boundary(items: Vec<Item>) -> Vec<Item> {
/// Retrieves the Buienradar forecasted precipitation items for the provided position.
///
/// Returns [`None`] if retrieval or deserialization fails.
///
/// If the result is [`Some`] it will be cached for 5 minutes for the the given position.
#[cached(time = 300, option = true)]
async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
/// If the result is [`Ok`] it will be cached for 5 minutes for the the given position.
#[cached(time = 300, result = true)]
async fn get_precipitation(position: Position) -> Result<Vec<Item>> {
let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap();
url.query_pairs_mut()
.append_pair("lat", &position.lat_as_str(2))
.append_pair("lon", &position.lon_as_str(2));
println!("▶️ Retrieving Buienradar data from: {url}");
let response = reqwest::get(url).await.ok()?;
let output = match response.error_for_status() {
Ok(res) => res.text().await.ok()?,
Err(_err) => return None,
};
let response = reqwest::get(url).await?;
let output = response.error_for_status()?.text().await?;
let mut rdr = ReaderBuilder::new()
.has_headers(false)
.delimiter(b'|')
.from_reader(output.as_bytes());
let items: Vec<Item> = rdr.deserialize().collect::<Result<_, _>>().ok()?;
let items: Vec<Item> = rdr.deserialize().collect::<Result<_, _>>()?;
// Check if the first item stamp is (timewise) later than the last item stamp.
// In this case `parse_time` interpreted e.g. 23:00 and later 0:30 in the same day and some
@ -160,46 +155,44 @@ async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
.map(|(it1, it2)| it1.time > it2.time)
== Some(true)
{
Some(fix_items_day_boundary(items))
Ok(fix_items_day_boundary(items))
} else {
Some(items)
Ok(items)
}
}
/// Retrieves the Buienradar forecasted pollen samples for the provided position.
///
/// Returns [`None`] if the sampling fails.
///
/// If the result is [`Some`] if will be cached for 1 hour for the given position.
/// If the result is [`Ok`] if will be cached for 1 hour for the given position.
#[cached(
time = 3_600,
key = "Position",
convert = r#"{ position }"#,
option = true
result = true
)]
async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> {
async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Result<Vec<Sample>> {
maps_handle
.lock()
.expect("Maps handle mutex was poisoned")
.pollen_samples(position)
.map_err(Into::into)
}
/// Retrieves the Buienradar forecasted UV index samples for the provided position.
///
/// Returns [`None`] if the sampling fails.
///
/// If the result is [`Some`] if will be cached for 1 day for the given position.
/// If the result is [`Ok`] if will be cached for 1 day for the given position.
#[cached(
time = 86_400,
key = "Position",
convert = r#"{ position }"#,
option = true
result = true
)]
async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> {
async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Result<Vec<Sample>> {
maps_handle
.lock()
.expect("Maps handle mutex was poisoned")
.uvi_samples(position)
.map_err(Into::into)
}
/// Retrieves the Buienradar forecasted map samples for the provided position.
@ -207,18 +200,15 @@ async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sam
/// It only supports the following metric:
/// * [`Metric::Pollen`]
/// * [`Metric::UVI`]
///
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
/// this provider.
pub(crate) async fn get_samples(
position: Position,
metric: Metric,
maps_handle: &MapsHandle,
) -> Option<Vec<Sample>> {
) -> Result<Vec<Sample>> {
match metric {
Metric::Pollen => get_pollen(position, maps_handle).await,
Metric::UVI => get_uvi(position, maps_handle).await,
_ => None,
_ => Err(Error::UnsupportedMetric(metric)),
}
}
@ -227,11 +217,9 @@ pub(crate) async fn get_samples(
/// It only supports the following metric:
/// * [`Metric::Precipitation`]
///
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
/// this provider.
pub(crate) async fn get_items(position: Position, metric: Metric) -> Option<Vec<Item>> {
pub(crate) async fn get_items(position: Position, metric: Metric) -> Result<Vec<Item>> {
match metric {
Metric::Precipitation => get_precipitation(position).await,
_ => None,
_ => Err(Error::UnsupportedMetric(metric)),
}
}

View File

@ -11,7 +11,28 @@ pub(crate) use super::buienradar::{self, Sample as BuienradarSample};
pub(crate) use super::luchtmeetnet::{self, Item as LuchtmeetnetItem};
use crate::maps::MapsHandle;
use crate::position::Position;
use crate::Metric;
use crate::{Error, Metric};
/// The possible merge errors that can occur.
#[allow(clippy::enum_variant_names)]
#[derive(Debug, thiserror::Error, PartialEq)]
pub(crate) enum MergeError {
/// No AQI item found.
#[error("No AQI item found")]
NoAqiItemFound,
/// No pollen item found.
#[error("No pollen item found")]
NoPollenItemFound,
/// No AQI item found within 30 minutes of first pollen item.
#[error("No AQI item found within 30 minutes of first pollen item")]
NoCloseAqiItemFound,
/// No pollen item found within 30 minutes of first AQI item.
#[error("No pollen item found within 30 minutes of first AQI item")]
NoClosePollenItemFound,
}
/// The combined data item.
#[derive(Clone, Debug, PartialEq, Serialize)]
@ -35,18 +56,12 @@ impl Item {
/// Merges pollen samples and AQI items into combined items.
///
/// 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
/// stamped within half an hour of the first item of the latest starting series, thus lining them
/// before they are combined.
///
/// 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>, BuienradarSample, LuchtmeetnetItem)> {
) -> Result<Vec<Item>, MergeError> {
let mut pollen_samples = pollen_samples;
let mut aqi_items = aqi_items;
@ -56,47 +71,39 @@ fn merge(
aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600);
// Align the iterators based on the (hourly) timestamps!
let pollen_first_time = pollen_samples.first()?.time;
let aqi_first_time = aqi_items.first()?.time;
let pollen_first_time = pollen_samples
.first()
.ok_or(MergeError::NoPollenItemFound)?
.time;
let aqi_first_time = aqi_items.first().ok_or(MergeError::NoAqiItemFound)?.time;
if pollen_first_time < aqi_first_time {
// Drain one or more pollen samples to line up.
let idx = pollen_samples.iter().position(|smp| {
smp.time
.signed_duration_since(aqi_first_time)
.num_seconds()
.abs()
< 1800
})?;
let idx = pollen_samples
.iter()
.position(|smp| {
smp.time
.signed_duration_since(aqi_first_time)
.num_seconds()
.abs()
< 1800
})
.ok_or(MergeError::NoCloseAqiItemFound)?;
pollen_samples.drain(..idx);
} else {
// Drain one or more AQI items to line up.
let idx = aqi_items.iter().position(|item| {
item.time
.signed_duration_since(pollen_first_time)
.num_seconds()
.abs()
< 1800
})?;
let idx = aqi_items
.iter()
.position(|item| {
item.time
.signed_duration_since(pollen_first_time)
.num_seconds()
.abs()
< 1800
})
.ok_or(MergeError::NoClosePollenItemFound)?;
aqi_items.drain(..idx);
}
// Find the maximum sample/item of each series.
// Note 1: Unwrapping is possible because each series has at least an item otherwise `.first`
// would have failed above.
// Note 2: Ensure that the maximum sample/item is in scope of the time range covered by the
// combined items.
let zip_len = std::cmp::min(pollen_samples.len(), aqi_items.len());
let pollen_max = pollen_samples[..zip_len]
.iter()
.max_by_key(|sample| sample.score)
.cloned()
.unwrap();
let aqi_max = aqi_items[..zip_len]
.iter()
.max_by_key(|item| (item.value * 1_000.0) as u32)
.cloned()
.unwrap();
// Combine the samples with items by taking the maximum of pollen sample score and AQI item
// value.
let items = pollen_samples
@ -110,42 +117,32 @@ fn merge(
})
.collect();
Some((items, pollen_max, aqi_max))
Ok(items)
}
/// 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`] 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
/// metric.
#[cached(
time = 1800,
key = "(Position, Metric)",
convert = r#"{ (position, metric) }"#,
option = true
result = true
)]
pub(crate) async fn get(
position: Position,
metric: Metric,
maps_handle: &MapsHandle,
) -> Option<(Vec<Item>, BuienradarSample, LuchtmeetnetItem)> {
) -> Result<Vec<Item>, Error> {
if metric != Metric::PAQI {
return None;
return Err(Error::UnsupportedMetric(metric));
};
let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await;
let aqi_items = luchtmeetnet::get(position, Metric::AQI).await;
let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await?;
let aqi_items = luchtmeetnet::get(position, Metric::AQI).await?;
let items = merge(pollen_items, aqi_items)?;
merge(pollen_items?, aqi_items?)
Ok(items)
}
#[cfg(test)]
@ -184,8 +181,8 @@ mod tests {
// Perform a normal merge.
let merged = super::merge(pollen_samples.clone(), aqi_items.clone());
assert!(merged.is_some());
let (paqi, max_pollen, max_aqi) = merged.unwrap();
assert!(merged.is_ok());
let paqi = merged.unwrap();
assert_eq!(
paqi,
Vec::from([
@ -194,8 +191,6 @@ mod tests {
Item::new(t_2, 2.4),
])
);
assert_eq!(max_pollen, BuienradarSample::new(t_1, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_1, 2.9));
// The pollen samples are shifted, i.e. one hour in the future.
let shifted_pollen_samples = pollen_samples[2..]
@ -207,11 +202,9 @@ mod tests {
})
.collect::<Vec<_>>();
let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
assert!(merged.is_some());
let (paqi, max_pollen, max_aqi) = merged.unwrap();
assert!(merged.is_ok());
let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)]));
assert_eq!(max_pollen, BuienradarSample::new(t_2, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_1, 2.9));
// The AQI items are shifted, i.e. one hour in the future.
let shifted_aqi_items = aqi_items[2..]
@ -223,26 +216,20 @@ mod tests {
})
.collect::<Vec<_>>();
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
assert!(merged.is_some());
let (paqi, max_pollen, max_aqi) = merged.unwrap();
assert!(merged.is_ok());
let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)]));
assert_eq!(max_pollen, BuienradarSample::new(t_1, 3));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_2, 2.9));
// The maximum sample/item should not be later then the interval the PAQI items cover.
let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone());
assert!(merged.is_some());
let (paqi, max_pollen, max_aqi) = merged.unwrap();
assert!(merged.is_ok());
let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
assert_eq!(max_pollen, BuienradarSample::new(t_0, 1));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_0, 1.1));
let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec());
assert!(merged.is_some());
let (paqi, max_pollen, max_aqi) = merged.unwrap();
assert!(merged.is_ok());
let paqi = merged.unwrap();
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
assert_eq!(max_pollen, BuienradarSample::new(t_0, 1));
assert_eq!(max_aqi, LuchtmeetnetItem::new(t_0, 1.1));
// Merging fails because the samples/items are too far (6 hours) apart.
let shifted_aqi_items = aqi_items
@ -254,18 +241,29 @@ mod tests {
})
.collect::<Vec<_>>();
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
assert_eq!(merged, None);
assert_eq!(merged, Err(MergeError::NoCloseAqiItemFound));
let shifted_pollen_samples = pollen_samples
.iter()
.cloned()
.map(|mut item| {
item.time = item.time.checked_add_signed(Duration::hours(6)).unwrap();
item
})
.collect::<Vec<_>>();
let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
assert_eq!(merged, Err(MergeError::NoClosePollenItemFound));
// The pollen samples list is empty, or everything is too old.
let merged = super::merge(Vec::new(), aqi_items.clone());
assert_eq!(merged, None);
assert_eq!(merged, Err(MergeError::NoPollenItemFound));
let merged = super::merge(pollen_samples[0..2].to_vec(), aqi_items.clone());
assert_eq!(merged, None);
assert_eq!(merged, Err(MergeError::NoPollenItemFound));
// The AQI items list is empty, or everything is too old.
let merged = super::merge(pollen_samples.clone(), Vec::new());
assert_eq!(merged, None);
assert_eq!(merged, Err(MergeError::NoAqiItemFound));
let merged = super::merge(pollen_samples, aqi_items[0..2].to_vec());
assert_eq!(merged, None);
assert_eq!(merged, Err(MergeError::NoAqiItemFound));
}
}

View File

@ -9,7 +9,7 @@ use reqwest::Url;
use rocket::serde::{Deserialize, Serialize};
use crate::position::Position;
use crate::Metric;
use crate::{Error, Metric, Result};
/// The base URL for the Luchtmeetnet API.
const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations";
@ -54,20 +54,14 @@ impl Item {
/// * [`Metric::NO2`]
/// * [`Metric::O3`]
/// * [`Metric::PM10`]
///
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
/// this provider.
///
/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and
/// metric.
#[cached(time = 1800, option = true)]
pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>> {
#[cached(time = 1800, result = true)]
pub(crate) async fn get(position: Position, metric: Metric) -> Result<Vec<Item>> {
let formula = match metric {
Metric::AQI => "lki",
Metric::NO2 => "no2",
Metric::O3 => "o3",
Metric::PM10 => "pm10",
_ => return None, // Unsupported metric
_ => return Err(Error::UnsupportedMetric(metric)),
};
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
url.query_pairs_mut()
@ -76,11 +70,8 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>>
.append_pair("longitude", &position.lon_as_str(5));
println!("▶️ Retrieving Luchtmeetnet data from: {url}");
let response = reqwest::get(url).await.ok()?;
let root: Container = match response.error_for_status() {
Ok(res) => res.json().await.ok()?,
Err(_err) => return None,
};
let response = reqwest::get(url).await?;
let root: Container = response.error_for_status()?.json().await?;
// Filter items that are older than one hour before now. They seem to occur sometimes?
let too_old = Utc::now() - Duration::hours(1);
@ -90,5 +81,5 @@ pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>>
.filter(|item| item.time > too_old)
.collect();
Some(items)
Ok(items)
}