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
This commit is contained in:
parent
7d0cd4a822
commit
69ef08002c
|
@ -2136,6 +2136,7 @@ dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"reqwest 0.11.10",
|
"reqwest 0.11.10",
|
||||||
"rocket",
|
"rocket",
|
||||||
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -20,6 +20,7 @@ geocoding = "0.3.1"
|
||||||
image = "0.24.1"
|
image = "0.24.1"
|
||||||
reqwest = { version = "0.11.9", features = ["json"] }
|
reqwest = { version = "0.11.9", features = ["json"] }
|
||||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||||
|
thiserror = "1.0.31"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_float_eq = "1.1.3"
|
assert_float_eq = "1.1.3"
|
||||||
|
|
|
@ -131,24 +131,33 @@ pub(crate) async fn forecast(
|
||||||
for metric in metrics {
|
for metric in metrics {
|
||||||
match metric {
|
match metric {
|
||||||
// This should have been expanded to all the metrics matched below.
|
// This should have been expanded to all the metrics matched below.
|
||||||
|
// FIXME: Handle the errors!
|
||||||
Metric::All => unreachable!("The all metric should have been expanded"),
|
Metric::All => unreachable!("The all metric should have been expanded"),
|
||||||
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await,
|
Metric::AQI => forecast.aqi = providers::luchtmeetnet::get(position, metric).await.ok(),
|
||||||
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await,
|
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
||||||
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await,
|
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await.ok(),
|
||||||
Metric::PAQI => {
|
Metric::PAQI => {
|
||||||
forecast.paqi = providers::combined::get(position, metric, maps_handle).await
|
forecast.paqi = providers::combined::get(position, metric, maps_handle)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
Metric::PM10 => {
|
||||||
|
forecast.pm10 = providers::luchtmeetnet::get(position, metric).await.ok()
|
||||||
}
|
}
|
||||||
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
|
|
||||||
Metric::Pollen => {
|
Metric::Pollen => {
|
||||||
forecast.pollen =
|
forecast.pollen = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||||
providers::buienradar::get_samples(position, metric, maps_handle).await
|
.await
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::Precipitation => {
|
Metric::Precipitation => {
|
||||||
forecast.precipitation = providers::buienradar::get_items(position, metric).await
|
forecast.precipitation = providers::buienradar::get_items(position, metric)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
Metric::UVI => {
|
Metric::UVI => {
|
||||||
forecast.uvi =
|
forecast.uvi = providers::buienradar::get_samples(position, metric, maps_handle)
|
||||||
providers::buienradar::get_samples(position, metric, maps_handle).await
|
.await
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
47
src/lib.rs
47
src/lib.rs
|
@ -24,6 +24,45 @@ pub(crate) mod maps;
|
||||||
pub(crate) mod position;
|
pub(crate) mod position;
|
||||||
pub(crate) mod providers;
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Responder)]
|
||||||
#[response(content_type = "image/png")]
|
#[response(content_type = "image/png")]
|
||||||
struct PngImageData(Vec<u8>);
|
struct PngImageData(Vec<u8>);
|
||||||
|
@ -35,7 +74,7 @@ async fn forecast_address(
|
||||||
metrics: Vec<Metric>,
|
metrics: Vec<Metric>,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<Json<Forecast>> {
|
) -> Option<Json<Forecast>> {
|
||||||
let position = resolve_address(address).await?;
|
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
||||||
let forecast = forecast(position, metrics, maps_handle).await;
|
let forecast = forecast(position, metrics, maps_handle).await;
|
||||||
|
|
||||||
Some(Json(forecast))
|
Some(Json(forecast))
|
||||||
|
@ -65,10 +104,10 @@ async fn map_address(
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &State<MapsHandle>,
|
maps_handle: &State<MapsHandle>,
|
||||||
) -> Option<PngImageData> {
|
) -> Option<PngImageData> {
|
||||||
let position = resolve_address(address).await?;
|
let position = resolve_address(address).await.ok()?; // FIXME: Handle error!
|
||||||
let image_data = mark_map(position, metric, maps_handle).await;
|
let image_data = mark_map(position, metric, maps_handle).await;
|
||||||
|
|
||||||
image_data.map(PngImageData)
|
image_data.map(PngImageData).ok() // FIXME: Handle the error!
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||||
|
@ -84,7 +123,7 @@ async fn map_geo(
|
||||||
let position = Position::new(lat, lon);
|
let position = Position::new(lat, lon);
|
||||||
let image_data = mark_map(position, metric, maps_handle).await;
|
let image_data = mark_map(position, metric, maps_handle).await;
|
||||||
|
|
||||||
image_data.map(PngImageData)
|
image_data.map(PngImageData).ok() // FIXME: Handle the error!
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up Rocket.
|
/// Sets up Rocket.
|
||||||
|
|
249
src/maps.rs
249
src/maps.rs
|
@ -9,7 +9,9 @@ use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::serde::ts_seconds;
|
use chrono::serde::ts_seconds;
|
||||||
use chrono::{DateTime, Duration, NaiveDateTime, Utc};
|
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 reqwest::Url;
|
||||||
use rocket::serde::Serialize;
|
use rocket::serde::Serialize;
|
||||||
use rocket::tokio;
|
use rocket::tokio;
|
||||||
|
@ -18,6 +20,57 @@ use rocket::tokio::time::sleep;
|
||||||
use crate::forecast::Metric;
|
use crate::forecast::Metric;
|
||||||
use crate::position::Position;
|
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),
|
||||||
|
|
||||||
|
/// Could not find Last-Modified header.
|
||||||
|
#[error("Could not find Last-Modified HTTP header")]
|
||||||
|
MissingLastModifiedHttpHeader,
|
||||||
|
|
||||||
|
/// 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.
|
/// A handle to access the in-memory cached maps.
|
||||||
pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
|
pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
|
||||||
|
|
||||||
|
@ -113,10 +166,10 @@ trait MapsRefresh {
|
||||||
fn is_uvi_stale(&self) -> bool;
|
fn is_uvi_stale(&self) -> bool;
|
||||||
|
|
||||||
/// Updates the pollen maps.
|
/// Updates the pollen maps.
|
||||||
fn set_pollen(&self, result: Option<RetrievedMaps>);
|
fn set_pollen(&self, result: Result<RetrievedMaps>);
|
||||||
|
|
||||||
/// Updates the UV index maps.
|
/// 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.
|
/// Container type for all in-memory cached maps.
|
||||||
|
@ -142,71 +195,53 @@ impl Maps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a current pollen map that marks the provided position.
|
/// Returns a current pollen map that marks the provided position.
|
||||||
///
|
pub(crate) fn pollen_mark(&self, position: Position) -> Result<DynamicImage> {
|
||||||
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for
|
let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
|
||||||
/// the current moment or if the provided position is not within the bounds of the map.
|
let image = &maps.image;
|
||||||
pub(crate) fn pollen_mark(&self, position: Position) -> Option<DynamicImage> {
|
let stamp = maps.timestamp_base;
|
||||||
self.pollen.as_ref().and_then(|maps| {
|
let marked_image = map_at(
|
||||||
let image = &maps.image;
|
image,
|
||||||
let stamp = maps.timestamp_base;
|
stamp,
|
||||||
let marked_image = map_at(
|
POLLEN_MAP_INTERVAL,
|
||||||
image,
|
POLLEN_MAP_COUNT,
|
||||||
stamp,
|
Utc::now(),
|
||||||
POLLEN_MAP_INTERVAL,
|
)?;
|
||||||
POLLEN_MAP_COUNT,
|
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
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.
|
/// Samples the pollen maps for the given position.
|
||||||
///
|
pub(crate) fn pollen_samples(&self, position: Position) -> Result<Vec<Sample>> {
|
||||||
/// This returns [`None`] if the maps are not in the cache yet.
|
let maps = self.pollen.as_ref().ok_or(Error::NoMapsYet)?;
|
||||||
/// Otherwise, it returns [`Some`] with a list of pollen sample, one for each map
|
let image = &maps.image;
|
||||||
/// in the series of maps.
|
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
|
||||||
pub(crate) fn pollen_samples(&self, position: Position) -> Option<Vec<Sample>> {
|
let coords = project(&*map, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
self.pollen.as_ref().and_then(|maps| {
|
let stamp = maps.timestamp_base;
|
||||||
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.
|
/// Returns a current UV index map that marks the provided position.
|
||||||
///
|
pub(crate) fn uvi_mark(&self, position: Position) -> Result<DynamicImage> {
|
||||||
/// This returns [`None`] if the maps are not in the cache yet, there is no matching map for
|
let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
|
||||||
/// the current moment or if the provided position is not within the bounds of the map.
|
let image = &maps.image;
|
||||||
pub(crate) fn uvi_mark(&self, position: Position) -> Option<DynamicImage> {
|
let stamp = maps.timestamp_base;
|
||||||
self.uvi.as_ref().and_then(|maps| {
|
let marked_image = map_at(image, stamp, UVI_MAP_INTERVAL, UVI_MAP_COUNT, Utc::now())?;
|
||||||
let image = &maps.image;
|
let coords = project(&marked_image, POLLEN_MAP_REF_POINTS, position)?;
|
||||||
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.
|
/// Samples the UV index maps for the given position.
|
||||||
///
|
pub(crate) fn uvi_samples(&self, position: Position) -> Result<Vec<Sample>> {
|
||||||
/// This returns [`None`] if the maps are not in the cache yet.
|
let maps = self.uvi.as_ref().ok_or(Error::NoMapsYet)?;
|
||||||
/// Otherwise, it returns [`Some`] with a list of UV index sample, one for each map
|
let image = &maps.image;
|
||||||
/// in the series of maps.
|
let map = image.view(0, 0, image.width() / UVI_MAP_COUNT, image.height());
|
||||||
pub(crate) fn uvi_samples(&self, position: Position) -> Option<Vec<Sample>> {
|
let coords = project(&*map, UVI_MAP_REF_POINTS, position)?;
|
||||||
self.uvi.as_ref().and_then(|maps| {
|
let stamp = maps.timestamp_base;
|
||||||
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 +298,17 @@ impl MapsRefresh for MapsHandle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_pollen(&self, retrieved_maps: Option<RetrievedMaps>) {
|
fn set_pollen(&self, retrieved_maps: Result<RetrievedMaps>) {
|
||||||
if retrieved_maps.is_some() || self.is_pollen_stale() {
|
if retrieved_maps.is_ok() || self.is_pollen_stale() {
|
||||||
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
|
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>) {
|
fn set_uvi(&self, retrieved_maps: Result<RetrievedMaps>) {
|
||||||
if retrieved_maps.is_some() || self.is_uvi_stale() {
|
if retrieved_maps.is_ok() || self.is_uvi_stale() {
|
||||||
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
|
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||||
maps.uvi = retrieved_maps;
|
maps.uvi = retrieved_maps.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -315,15 +350,13 @@ fn map_key_histogram() -> MapKeyHistogram {
|
||||||
/// Samples the provided maps at the given (map-relative) coordinates and starting timestamp.
|
/// 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.
|
/// 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.
|
/// 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>>>(
|
fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
|
||||||
image: &I,
|
image: &I,
|
||||||
stamp: DateTime<Utc>,
|
stamp: DateTime<Utc>,
|
||||||
interval: i64,
|
interval: i64,
|
||||||
count: u32,
|
count: u32,
|
||||||
coords: (u32, u32),
|
coords: (u32, u32),
|
||||||
) -> Option<Vec<Sample>> {
|
) -> Result<Vec<Sample>> {
|
||||||
let (x, y) = coords;
|
let (x, y) = coords;
|
||||||
let width = image.width() / count;
|
let width = image.width() / count;
|
||||||
let height = image.height();
|
let height = image.height();
|
||||||
|
@ -351,7 +384,7 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
|
||||||
.max_by_key(|(_color, count)| *count)
|
.max_by_key(|(_color, count)| *count)
|
||||||
.expect("Map key is never empty");
|
.expect("Map key is never empty");
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return None;
|
return Err(Error::NoKnownColorsInSamples);
|
||||||
}
|
}
|
||||||
|
|
||||||
let score = MAP_KEY
|
let score = MAP_KEY
|
||||||
|
@ -365,7 +398,7 @@ fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
|
||||||
offset += width;
|
offset += width;
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(samples)
|
Ok(samples)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A retrieved image with some metadata.
|
/// A retrieved image with some metadata.
|
||||||
|
@ -396,49 +429,44 @@ impl RetrievedMaps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves an image from the provided URL.
|
/// Retrieves an image from the provided URL.
|
||||||
///
|
async fn retrieve_image(url: Url) -> Result<RetrievedMaps> {
|
||||||
/// This returns [`None`] if it fails in either performing the request, parsing the `Last-Modified`
|
let response = reqwest::get(url).await?;
|
||||||
/// reponse HTTP header, retrieving the bytes from the image or loading and the decoding the data
|
let mtime_header = response
|
||||||
/// 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()
|
.headers()
|
||||||
.get(reqwest::header::LAST_MODIFIED)
|
.get(reqwest::header::LAST_MODIFIED)
|
||||||
.and_then(|dt| dt.to_str().ok())
|
.ok_or(Error::MissingLastModifiedHttpHeader)?;
|
||||||
.map(chrono::DateTime::parse_from_rfc2822)?
|
let mtime_header = mtime_header.to_str()?;
|
||||||
.map(DateTime::<Utc>::from)
|
let mtime = DateTime::<Utc>::from(chrono::DateTime::parse_from_rfc2822(mtime_header)?);
|
||||||
.ok()?;
|
|
||||||
let timestamp_base = {
|
let timestamp_base = {
|
||||||
let path = response.url().path();
|
let path = response.url().path();
|
||||||
let (_, filename) = path.rsplit_once('/')?;
|
let (_, filename) = path
|
||||||
let (timestamp_str, _) = filename.split_once("__")?;
|
.rsplit_once('/')
|
||||||
let timestamp = NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M").ok()?;
|
.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)
|
DateTime::<Utc>::from_utc(timestamp, Utc)
|
||||||
};
|
};
|
||||||
let bytes = response.bytes().await.ok()?;
|
let bytes = response.bytes().await?;
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
if let Ok(image) = image::load_from_memory_with_format(&bytes, ImageFormat::Png) {
|
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||||
Some(RetrievedMaps {
|
.map(|image| RetrievedMaps {
|
||||||
image,
|
image,
|
||||||
mtime,
|
mtime,
|
||||||
timestamp_base,
|
timestamp_base,
|
||||||
})
|
})
|
||||||
} else {
|
.map_err(Error::from)
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.await
|
.await?
|
||||||
.ok()?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the pollen maps from Buienradar.
|
/// Retrieves the pollen maps from Buienradar.
|
||||||
///
|
///
|
||||||
/// See [`POLLEN_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
|
/// 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 timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
|
||||||
let mut url = Url::parse(POLLEN_BASE_URL).unwrap();
|
let mut url = Url::parse(POLLEN_BASE_URL).unwrap();
|
||||||
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
||||||
|
@ -450,7 +478,7 @@ async fn retrieve_pollen_maps() -> Option<RetrievedMaps> {
|
||||||
/// Retrieves the UV index maps from Buienradar.
|
/// Retrieves the UV index maps from Buienradar.
|
||||||
///
|
///
|
||||||
/// See [`UVI_BASE_URL`] for the base URL and [`retrieve_image`] for the retrieval function.
|
/// 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 timestamp = format!("{}", chrono::Local::now().format("%y%m%d%H%M"));
|
||||||
let mut url = Url::parse(UVI_BASE_URL).unwrap();
|
let mut url = Url::parse(UVI_BASE_URL).unwrap();
|
||||||
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
url.query_pairs_mut().append_pair("timestamp", ×tamp);
|
||||||
|
@ -460,25 +488,22 @@ async fn retrieve_uvi_maps() -> Option<RetrievedMaps> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the map for the given instant.
|
/// 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(
|
fn map_at(
|
||||||
image: &DynamicImage,
|
image: &DynamicImage,
|
||||||
stamp: DateTime<Utc>,
|
stamp: DateTime<Utc>,
|
||||||
interval: i64,
|
interval: i64,
|
||||||
count: u32,
|
count: u32,
|
||||||
instant: DateTime<Utc>,
|
instant: DateTime<Utc>,
|
||||||
) -> Option<DynamicImage> {
|
) -> Result<DynamicImage> {
|
||||||
let duration = instant.signed_duration_since(stamp);
|
let duration = instant.signed_duration_since(stamp);
|
||||||
let offset = (duration.num_seconds() / interval) as u32;
|
let offset = (duration.num_seconds() / interval) as u32;
|
||||||
// Check if out of bounds.
|
// Check if out of bounds.
|
||||||
if offset >= count {
|
if offset >= count {
|
||||||
return None;
|
return Err(Error::OutOfBoundOffset(offset));
|
||||||
}
|
}
|
||||||
let width = image.width() / count;
|
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.
|
/// Marks the provided coordinates on the map using a horizontal and vertical line.
|
||||||
|
@ -499,13 +524,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
|
/// 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.
|
/// 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>(
|
fn project<I: GenericImageView>(
|
||||||
image: &I,
|
image: &I,
|
||||||
ref_points: [(Position, (u32, u32)); 2],
|
ref_points: [(Position, (u32, u32)); 2],
|
||||||
pos: Position,
|
pos: Position,
|
||||||
) -> Option<(u32, u32)> {
|
) -> Result<(u32, u32)> {
|
||||||
// Get the data from the reference points.
|
// Get the data from the reference points.
|
||||||
let (ref1, (ref1_y, ref1_x)) = ref_points[0];
|
let (ref1, (ref1_y, ref1_x)) = ref_points[0];
|
||||||
let (ref2, (ref2_y, ref2_x)) = ref_points[1];
|
let (ref2, (ref2_y, ref2_x)) = ref_points[1];
|
||||||
|
@ -522,9 +545,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;
|
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) {
|
if image.in_bounds(x, y) {
|
||||||
Some((x, y))
|
Ok((x, y))
|
||||||
} else {
|
} else {
|
||||||
None
|
Err(Error::OutOfBoundCoords(x, y))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -535,7 +558,7 @@ pub(crate) async fn mark_map(
|
||||||
position: Position,
|
position: Position,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &MapsHandle,
|
maps_handle: &MapsHandle,
|
||||||
) -> Option<Vec<u8>> {
|
) -> crate::Result<Vec<u8>> {
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
let maps_handle = Arc::clone(maps_handle);
|
let maps_handle = Arc::clone(maps_handle);
|
||||||
|
@ -545,24 +568,22 @@ pub(crate) async fn mark_map(
|
||||||
Metric::PAQI => maps.pollen_mark(position),
|
Metric::PAQI => maps.pollen_mark(position),
|
||||||
Metric::Pollen => maps.pollen_mark(position),
|
Metric::Pollen => maps.pollen_mark(position),
|
||||||
Metric::UVI => maps.uvi_mark(position),
|
Metric::UVI => maps.uvi_mark(position),
|
||||||
_ => return None, // Unsupported metric
|
_ => return Err(crate::Error::UnsupportedMetric(metric)),
|
||||||
}?;
|
}?;
|
||||||
drop(maps);
|
drop(maps);
|
||||||
|
|
||||||
// Encode the image as PNG image data.
|
// Encode the image as PNG image data.
|
||||||
let mut image_data = Cursor::new(Vec::new());
|
let mut image_data = Cursor::new(Vec::new());
|
||||||
image
|
match image.write_to(
|
||||||
.write_to(
|
&mut image_data,
|
||||||
&mut image_data,
|
image::ImageOutputFormat::from(image::ImageFormat::Png),
|
||||||
image::ImageOutputFormat::from(image::ImageFormat::Png),
|
) {
|
||||||
)
|
Ok(()) => Ok(image_data.into_inner()),
|
||||||
.ok()?;
|
Err(err) => Err(crate::Error::from(Error::from(err))),
|
||||||
|
}
|
||||||
Some(image_data.into_inner())
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.ok()
|
.map_err(Error::from)?
|
||||||
.flatten()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a loop that keeps refreshing the maps when necessary.
|
/// Runs a loop that keeps refreshing the maps when necessary.
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
//!
|
//!
|
||||||
//! This module contains everything related to geographic coordinate system functionality.
|
//! This module contains everything related to geographic coordinate system functionality.
|
||||||
|
|
||||||
|
use std::f64::consts::PI;
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
|
|
||||||
use cached::proc_macro::cached;
|
use cached::proc_macro::cached;
|
||||||
use geocoding::{Forward, Openstreetmap, Point};
|
use geocoding::{Forward, Openstreetmap, Point};
|
||||||
use rocket::tokio;
|
use rocket::tokio;
|
||||||
|
|
||||||
use std::f64::consts::PI;
|
use crate::{Error, Result};
|
||||||
|
|
||||||
/// A (geocoded) position.
|
/// A (geocoded) position.
|
||||||
///
|
///
|
||||||
|
@ -98,21 +99,19 @@ impl Eq for Position {}
|
||||||
|
|
||||||
/// Resolves the geocoded position for a given address.
|
/// Resolves the geocoded position for a given address.
|
||||||
///
|
///
|
||||||
/// Returns [`None`] if the address could not be geocoded or the OpenStreetMap Nomatim API could
|
/// If the result is [`Ok`], it will be cached.
|
||||||
/// not be contacted.
|
|
||||||
///
|
|
||||||
/// If the result is [`Some`], it will be cached.
|
|
||||||
/// Note that only the 100 least recently used addresses will be cached.
|
/// Note that only the 100 least recently used addresses will be cached.
|
||||||
#[cached(size = 100)]
|
#[cached(size = 100, result = true)]
|
||||||
pub(crate) async fn resolve_address(address: String) -> Option<Position> {
|
pub(crate) async fn resolve_address(address: String) -> Result<Position> {
|
||||||
println!("🌍 Geocoding the position of the address: {}", address);
|
println!("🌍 Geocoding the position of the address: {}", address);
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let osm = Openstreetmap::new();
|
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
|
.await?
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ use rocket::serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::maps::MapsHandle;
|
use crate::maps::MapsHandle;
|
||||||
use crate::position::Position;
|
use crate::position::Position;
|
||||||
use crate::Metric;
|
use crate::{Error, Metric, Result};
|
||||||
|
|
||||||
/// The base URL for the Buienradar API.
|
/// The base URL for the Buienradar API.
|
||||||
const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext";
|
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.
|
/// Retrieves the Buienradar forecasted precipitation items for the provided position.
|
||||||
///
|
///
|
||||||
/// Returns [`None`] if retrieval or deserialization fails.
|
/// If the result is [`Ok`] it will be cached for 5 minutes for the the given position.
|
||||||
///
|
#[cached(time = 300, result = true)]
|
||||||
/// If the result is [`Some`] it will be cached for 5 minutes for the the given position.
|
async fn get_precipitation(position: Position) -> Result<Vec<Item>> {
|
||||||
#[cached(time = 300, option = true)]
|
|
||||||
async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
|
|
||||||
let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap();
|
let mut url = Url::parse(BUIENRADAR_BASE_URL).unwrap();
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("lat", &position.lat_as_str(2))
|
.append_pair("lat", &position.lat_as_str(2))
|
||||||
.append_pair("lon", &position.lon_as_str(2));
|
.append_pair("lon", &position.lon_as_str(2));
|
||||||
|
|
||||||
println!("▶️ Retrieving Buienradar data from: {url}");
|
println!("▶️ Retrieving Buienradar data from: {url}");
|
||||||
let response = reqwest::get(url).await.ok()?;
|
let response = reqwest::get(url).await?;
|
||||||
let output = match response.error_for_status() {
|
let output = response.error_for_status()?.text().await?;
|
||||||
Ok(res) => res.text().await.ok()?,
|
|
||||||
Err(_err) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut rdr = ReaderBuilder::new()
|
let mut rdr = ReaderBuilder::new()
|
||||||
.has_headers(false)
|
.has_headers(false)
|
||||||
.delimiter(b'|')
|
.delimiter(b'|')
|
||||||
.from_reader(output.as_bytes());
|
.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.
|
// 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
|
// 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)
|
.map(|(it1, it2)| it1.time > it2.time)
|
||||||
== Some(true)
|
== Some(true)
|
||||||
{
|
{
|
||||||
Some(fix_items_day_boundary(items))
|
Ok(fix_items_day_boundary(items))
|
||||||
} else {
|
} else {
|
||||||
Some(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the Buienradar forecasted pollen samples for the provided position.
|
/// Retrieves the Buienradar forecasted pollen samples for the provided position.
|
||||||
///
|
///
|
||||||
/// Returns [`None`] if the sampling fails.
|
/// If the result is [`Ok`] if will be cached for 1 hour for the given position.
|
||||||
///
|
|
||||||
/// If the result is [`Some`] if will be cached for 1 hour for the given position.
|
|
||||||
#[cached(
|
#[cached(
|
||||||
time = 3_600,
|
time = 3_600,
|
||||||
key = "Position",
|
key = "Position",
|
||||||
convert = r#"{ 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
|
maps_handle
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Maps handle mutex was poisoned")
|
.expect("Maps handle mutex was poisoned")
|
||||||
.pollen_samples(position)
|
.pollen_samples(position)
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the Buienradar forecasted UV index samples for the provided position.
|
/// Retrieves the Buienradar forecasted UV index samples for the provided position.
|
||||||
///
|
///
|
||||||
/// Returns [`None`] if the sampling fails.
|
/// If the result is [`Ok`] if will be cached for 1 day for the given position.
|
||||||
///
|
|
||||||
/// If the result is [`Some`] if will be cached for 1 day for the given position.
|
|
||||||
#[cached(
|
#[cached(
|
||||||
time = 86_400,
|
time = 86_400,
|
||||||
key = "Position",
|
key = "Position",
|
||||||
convert = r#"{ 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
|
maps_handle
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Maps handle mutex was poisoned")
|
.expect("Maps handle mutex was poisoned")
|
||||||
.uvi_samples(position)
|
.uvi_samples(position)
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the Buienradar forecasted map samples for the provided position.
|
/// 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:
|
/// It only supports the following metric:
|
||||||
/// * [`Metric::Pollen`]
|
/// * [`Metric::Pollen`]
|
||||||
/// * [`Metric::UVI`]
|
/// * [`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(
|
pub(crate) async fn get_samples(
|
||||||
position: Position,
|
position: Position,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &MapsHandle,
|
maps_handle: &MapsHandle,
|
||||||
) -> Option<Vec<Sample>> {
|
) -> Result<Vec<Sample>> {
|
||||||
match metric {
|
match metric {
|
||||||
Metric::Pollen => get_pollen(position, maps_handle).await,
|
Metric::Pollen => get_pollen(position, maps_handle).await,
|
||||||
Metric::UVI => get_uvi(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:
|
/// It only supports the following metric:
|
||||||
/// * [`Metric::Precipitation`]
|
/// * [`Metric::Precipitation`]
|
||||||
///
|
///
|
||||||
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
|
pub(crate) async fn get_items(position: Position, metric: Metric) -> Result<Vec<Item>> {
|
||||||
/// this provider.
|
|
||||||
pub(crate) async fn get_items(position: Position, metric: Metric) -> Option<Vec<Item>> {
|
|
||||||
match metric {
|
match metric {
|
||||||
Metric::Precipitation => get_precipitation(position).await,
|
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};
|
pub(crate) use super::luchtmeetnet::{self, Item as LuchtmeetnetItem};
|
||||||
use crate::maps::MapsHandle;
|
use crate::maps::MapsHandle;
|
||||||
use crate::position::Position;
|
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.
|
/// The combined data item.
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||||
|
@ -35,15 +56,12 @@ impl Item {
|
||||||
/// Merges pollen samples and AQI items into combined items.
|
/// 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
|
/// The merging drops items from either the pollen samples or from the AQI items if they are not
|
||||||
/// stamped within 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.
|
/// before they are combined.
|
||||||
///
|
|
||||||
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
|
|
||||||
/// lining them up fails.
|
|
||||||
fn merge(
|
fn merge(
|
||||||
pollen_samples: Vec<BuienradarSample>,
|
pollen_samples: Vec<BuienradarSample>,
|
||||||
aqi_items: Vec<LuchtmeetnetItem>,
|
aqi_items: Vec<LuchtmeetnetItem>,
|
||||||
) -> Option<Vec<Item>> {
|
) -> Result<Vec<Item>, MergeError> {
|
||||||
let mut pollen_samples = pollen_samples;
|
let mut pollen_samples = pollen_samples;
|
||||||
let mut aqi_items = aqi_items;
|
let mut aqi_items = aqi_items;
|
||||||
|
|
||||||
|
@ -53,27 +71,36 @@ fn merge(
|
||||||
aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600);
|
aqi_items.retain(|item| item.time.signed_duration_since(now).num_seconds() > -3600);
|
||||||
|
|
||||||
// Align the iterators based on the (hourly) timestamps!
|
// Align the iterators based on the (hourly) timestamps!
|
||||||
let pollen_first_time = pollen_samples.first()?.time;
|
let pollen_first_time = pollen_samples
|
||||||
let aqi_first_time = aqi_items.first()?.time;
|
.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 {
|
if pollen_first_time < aqi_first_time {
|
||||||
// Drain one or more pollen samples to line up.
|
// Drain one or more pollen samples to line up.
|
||||||
let idx = pollen_samples.iter().position(|smp| {
|
let idx = pollen_samples
|
||||||
smp.time
|
.iter()
|
||||||
.signed_duration_since(aqi_first_time)
|
.position(|smp| {
|
||||||
.num_seconds()
|
smp.time
|
||||||
.abs()
|
.signed_duration_since(aqi_first_time)
|
||||||
< 1800
|
.num_seconds()
|
||||||
})?;
|
.abs()
|
||||||
|
< 1800
|
||||||
|
})
|
||||||
|
.ok_or(MergeError::NoCloseAqiItemFound)?;
|
||||||
pollen_samples.drain(..idx);
|
pollen_samples.drain(..idx);
|
||||||
} else {
|
} else {
|
||||||
// Drain one or more AQI items to line up.
|
// Drain one or more AQI items to line up.
|
||||||
let idx = aqi_items.iter().position(|item| {
|
let idx = aqi_items
|
||||||
item.time
|
.iter()
|
||||||
.signed_duration_since(pollen_first_time)
|
.position(|item| {
|
||||||
.num_seconds()
|
item.time
|
||||||
.abs()
|
.signed_duration_since(pollen_first_time)
|
||||||
< 1800
|
.num_seconds()
|
||||||
})?;
|
.abs()
|
||||||
|
< 1800
|
||||||
|
})
|
||||||
|
.ok_or(MergeError::NoClosePollenItemFound)?;
|
||||||
aqi_items.drain(..idx);
|
aqi_items.drain(..idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,37 +117,32 @@ fn merge(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves the combined forecasted items for the provided position and metric.
|
/// Retrieves the combined forecasted items for the provided position and metric.
|
||||||
///
|
///
|
||||||
/// It supports the following metric:
|
/// It supports the following metric:
|
||||||
/// * [`Metric::PAQI`]
|
/// * [`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.
|
|
||||||
///
|
|
||||||
/// If the result is [`Some`], it will be cached for 30 minutes for the the given position and
|
|
||||||
/// metric.
|
|
||||||
#[cached(
|
#[cached(
|
||||||
time = 1800,
|
time = 1800,
|
||||||
key = "(Position, Metric)",
|
key = "(Position, Metric)",
|
||||||
convert = r#"{ (position, metric) }"#,
|
convert = r#"{ (position, metric) }"#,
|
||||||
option = true
|
result = true
|
||||||
)]
|
)]
|
||||||
pub(crate) async fn get(
|
pub(crate) async fn get(
|
||||||
position: Position,
|
position: Position,
|
||||||
metric: Metric,
|
metric: Metric,
|
||||||
maps_handle: &MapsHandle,
|
maps_handle: &MapsHandle,
|
||||||
) -> Option<Vec<Item>> {
|
) -> Result<Vec<Item>, Error> {
|
||||||
if metric != Metric::PAQI {
|
if metric != Metric::PAQI {
|
||||||
return None;
|
return Err(Error::UnsupportedMetric(metric));
|
||||||
};
|
};
|
||||||
let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await;
|
let pollen_items = buienradar::get_samples(position, Metric::Pollen, maps_handle).await?;
|
||||||
let aqi_items = luchtmeetnet::get(position, Metric::AQI).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)]
|
#[cfg(test)]
|
||||||
|
@ -159,7 +181,7 @@ mod tests {
|
||||||
|
|
||||||
// Perform a normal merge.
|
// Perform a normal merge.
|
||||||
let merged = super::merge(pollen_samples.clone(), aqi_items.clone());
|
let merged = super::merge(pollen_samples.clone(), aqi_items.clone());
|
||||||
assert!(merged.is_some());
|
assert!(merged.is_ok());
|
||||||
let paqi = merged.unwrap();
|
let paqi = merged.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
paqi,
|
paqi,
|
||||||
|
@ -180,7 +202,7 @@ mod tests {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
|
let merged = super::merge(shifted_pollen_samples, aqi_items.clone());
|
||||||
assert!(merged.is_some());
|
assert!(merged.is_ok());
|
||||||
let paqi = merged.unwrap();
|
let paqi = merged.unwrap();
|
||||||
assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)]));
|
assert_eq!(paqi, Vec::from([Item::new(t_1, 2.9), Item::new(t_2, 3.0)]));
|
||||||
|
|
||||||
|
@ -194,18 +216,18 @@ mod tests {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
|
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
|
||||||
assert!(merged.is_some());
|
assert!(merged.is_ok());
|
||||||
let paqi = merged.unwrap();
|
let paqi = merged.unwrap();
|
||||||
assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)]));
|
assert_eq!(paqi, Vec::from([Item::new(t_1, 3.0), Item::new(t_2, 2.9)]));
|
||||||
|
|
||||||
// The maximum sample/item should not be later then the interval the PAQI items cover.
|
// 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());
|
let merged = super::merge(pollen_samples[..3].to_vec(), aqi_items.clone());
|
||||||
assert!(merged.is_some());
|
assert!(merged.is_ok());
|
||||||
let paqi = merged.unwrap();
|
let paqi = merged.unwrap();
|
||||||
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
|
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
|
||||||
|
|
||||||
let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec());
|
let merged = super::merge(pollen_samples.clone(), aqi_items[..3].to_vec());
|
||||||
assert!(merged.is_some());
|
assert!(merged.is_ok());
|
||||||
let paqi = merged.unwrap();
|
let paqi = merged.unwrap();
|
||||||
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
|
assert_eq!(paqi, Vec::from([Item::new(t_0, 1.1)]));
|
||||||
|
|
||||||
|
@ -219,18 +241,29 @@ mod tests {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let merged = super::merge(pollen_samples.clone(), shifted_aqi_items);
|
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.
|
// The pollen samples list is empty, or everything is too old.
|
||||||
let merged = super::merge(Vec::new(), aqi_items.clone());
|
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());
|
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.
|
// The AQI items list is empty, or everything is too old.
|
||||||
let merged = super::merge(pollen_samples.clone(), Vec::new());
|
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());
|
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 rocket::serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::position::Position;
|
use crate::position::Position;
|
||||||
use crate::Metric;
|
use crate::{Error, Metric, Result};
|
||||||
|
|
||||||
/// The base URL for the Luchtmeetnet API.
|
/// The base URL for the Luchtmeetnet API.
|
||||||
const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations";
|
const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations";
|
||||||
|
@ -54,20 +54,14 @@ impl Item {
|
||||||
/// * [`Metric::NO2`]
|
/// * [`Metric::NO2`]
|
||||||
/// * [`Metric::O3`]
|
/// * [`Metric::O3`]
|
||||||
/// * [`Metric::PM10`]
|
/// * [`Metric::PM10`]
|
||||||
///
|
#[cached(time = 1800, result = true)]
|
||||||
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
|
pub(crate) async fn get(position: Position, metric: Metric) -> Result<Vec<Item>> {
|
||||||
/// 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>> {
|
|
||||||
let formula = match metric {
|
let formula = match metric {
|
||||||
Metric::AQI => "lki",
|
Metric::AQI => "lki",
|
||||||
Metric::NO2 => "no2",
|
Metric::NO2 => "no2",
|
||||||
Metric::O3 => "o3",
|
Metric::O3 => "o3",
|
||||||
Metric::PM10 => "pm10",
|
Metric::PM10 => "pm10",
|
||||||
_ => return None, // Unsupported metric
|
_ => return Err(Error::UnsupportedMetric(metric)),
|
||||||
};
|
};
|
||||||
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
|
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
|
||||||
url.query_pairs_mut()
|
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));
|
.append_pair("longitude", &position.lon_as_str(5));
|
||||||
|
|
||||||
println!("▶️ Retrieving Luchtmeetnet data from: {url}");
|
println!("▶️ Retrieving Luchtmeetnet data from: {url}");
|
||||||
let response = reqwest::get(url).await.ok()?;
|
let response = reqwest::get(url).await?;
|
||||||
let root: Container = match response.error_for_status() {
|
let root: Container = response.error_for_status()?.json().await?;
|
||||||
Ok(res) => res.json().await.ok()?,
|
|
||||||
Err(_err) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter items that are older than one hour before now. They seem to occur sometimes?
|
// Filter items that are older than one hour before now. They seem to occur sometimes?
|
||||||
let too_old = Utc::now() - Duration::hours(1);
|
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)
|
.filter(|item| item.time > too_old)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Some(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue