Compare commits
11 Commits
Author | SHA1 | Date |
---|---|---|
Paul van Tilburg | 789bb1d1ac | |
Paul van Tilburg | 2e999f5a78 | |
Paul van Tilburg | 712b3a9acf | |
Paul van Tilburg | 2b23885692 | |
Paul van Tilburg | dc47c1c73c | |
Paul van Tilburg | 014ca5a151 | |
Paul van Tilburg | 8a2a6d769d | |
Paul van Tilburg | 69ef08002c | |
Paul van Tilburg | 7d0cd4a822 | |
Paul van Tilburg | fb8236696d | |
Paul van Tilburg | aab3b737be |
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
51
README.md
51
README.md
|
@ -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
|
||||
|
||||
|
|
105
src/forecast.rs
105
src/forecast.rs
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
109
src/lib.rs
109
src/lib.rs
|
@ -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
|
||||
|
|
26
src/main.rs
26
src/main.rs
|
@ -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()
|
||||
}
|
||||
|
|
265
src/maps.rs
265
src/maps.rs
|
@ -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", ×tamp);
|
||||
|
||||
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", ×tamp);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue