forked from paul/sinoptik
Compare commits
37 Commits
main
...
implement-
Author | SHA1 | Date |
---|---|---|
Admar Schoonen | a369dabdfa | |
Admar Schoonen | fd0725be5c | |
Admar Schoonen | 5cd2b56176 | |
Paul van Tilburg | f1a303edc0 | |
Paul van Tilburg | 4920ab4abd | |
Paul van Tilburg | f67f3dfe82 | |
Paul van Tilburg | 8d2717b392 | |
Paul van Tilburg | 7061842bd3 | |
Paul van Tilburg | 88b24a83ff | |
Paul van Tilburg | f4a12dacdb | |
Paul van Tilburg | 9531114eec | |
Paul van Tilburg | 3a48f234e9 | |
Paul van Tilburg | b2f63db6b4 | |
Paul van Tilburg | c76e2315b5 | |
Paul van Tilburg | 0c5367f87f | |
Paul van Tilburg | 79981314d3 | |
Paul van Tilburg | 8d19dbb517 | |
Paul van Tilburg | 927cb0ad92 | |
Paul van Tilburg | c231447ce9 | |
Paul van Tilburg | 859288a329 | |
Paul van Tilburg | 309c79d83c | |
Paul van Tilburg | 576bcc6640 | |
Paul van Tilburg | f6b26c9659 | |
Paul van Tilburg | 79dac18655 | |
Paul van Tilburg | 4232263a45 | |
Paul van Tilburg | 6279d379ab | |
Paul van Tilburg | d432bb4cd6 | |
Paul van Tilburg | 66abc9c4db | |
Paul van Tilburg | 59c177d508 | |
Paul van Tilburg | cbd686bd60 | |
Paul van Tilburg | cf77dbb5e7 | |
Paul van Tilburg | 9b9b1a5f77 | |
Paul van Tilburg | 72fe9577bd | |
Paul van Tilburg | d058ab4448 | |
Paul van Tilburg | b5dae45868 | |
Paul van Tilburg | 6b24c4f6e7 | |
Paul van Tilburg | ae2d2c1c56 |
File diff suppressed because it is too large
Load Diff
|
@ -4,6 +4,12 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
cached = { version = "0.30.0", features = ["async"] }
|
||||
chrono = "0.4.19"
|
||||
chrono-tz = "0.6.1"
|
||||
color-eyre = "0.5.6"
|
||||
csv = "1.1.6"
|
||||
geocoding = "0.3.1"
|
||||
image = "0.24.0"
|
||||
reqwest = { version = "0.11.9", features = ["json"] }
|
||||
rocket = { version = "0.5.0-rc.1", features = ["json"] }
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
//! Forecast retrieval and construction.
|
||||
//!
|
||||
//! This module is used to construct a [`Forecast`] for the given position by retrieving data for
|
||||
//! the requested metrics from their providers.
|
||||
|
||||
use rocket::serde::Serialize;
|
||||
|
||||
use crate::maps::MapsHandle;
|
||||
use crate::position::Position;
|
||||
use crate::providers;
|
||||
use crate::providers::buienradar::Item as BuienradarItem;
|
||||
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
|
||||
|
||||
/// 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 (#3)
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Forecast {
|
||||
/// The latitude of the position.
|
||||
lat: f64,
|
||||
|
||||
/// The longitude of the position.
|
||||
lon: f64,
|
||||
|
||||
/// The current time (in seconds since the UNIX epoch).
|
||||
time: i64,
|
||||
|
||||
/// The air quality index (when asked for).
|
||||
#[serde(rename = "AQI", skip_serializing_if = "Option::is_none")]
|
||||
aqi: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
||||
/// The NO₂ concentration (when asked for).
|
||||
#[serde(rename = "NO2", skip_serializing_if = "Option::is_none")]
|
||||
no2: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
||||
/// The O₃ concentration (when asked for).
|
||||
#[serde(rename = "O3", skip_serializing_if = "Option::is_none")]
|
||||
o3: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
||||
/// The combination of pollen + air quality index (when asked for).
|
||||
#[serde(rename = "PAQI", skip_serializing_if = "Option::is_none")]
|
||||
paqi: Option<()>,
|
||||
|
||||
/// The particulate matter in the air (when asked for).
|
||||
#[serde(rename = "PM10", skip_serializing_if = "Option::is_none")]
|
||||
pm10: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
||||
/// The pollen in the air (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pollen: Option<()>,
|
||||
|
||||
/// The precipitation (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
precipitation: Option<Vec<BuienradarItem>>,
|
||||
|
||||
/// The UV index (when asked for).
|
||||
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
||||
uvi: Option<()>,
|
||||
}
|
||||
|
||||
impl Forecast {
|
||||
fn new(position: Position) -> Self {
|
||||
Self {
|
||||
lat: position.lat,
|
||||
lon: position.lon,
|
||||
time: chrono::Utc::now().timestamp(),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub(crate) enum Metric {
|
||||
/// All metrics.
|
||||
#[field(value = "all")]
|
||||
All,
|
||||
/// The air quality index.
|
||||
AQI,
|
||||
/// The NO₂ concentration.
|
||||
NO2,
|
||||
/// The O₃ concentration.
|
||||
O3,
|
||||
/// The combination of pollen + air quality index.
|
||||
PAQI,
|
||||
/// The particulate matter in the air.
|
||||
PM10,
|
||||
/// The pollen in the air.
|
||||
Pollen,
|
||||
/// The precipitation.
|
||||
Precipitation,
|
||||
/// The UV index.
|
||||
UVI,
|
||||
}
|
||||
|
||||
impl Metric {
|
||||
/// Returns all supported metrics.
|
||||
fn all() -> Vec<Metric> {
|
||||
use Metric::*;
|
||||
|
||||
Vec::from([AQI, NO2, O3, PAQI, PM10, Pollen, Precipitation, UVI])
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates and returns the forecast.
|
||||
///
|
||||
/// The provided list `metrics` determines what will be included in the forecast.
|
||||
pub(crate) async fn forecast(
|
||||
position: Position,
|
||||
metrics: Vec<Metric>,
|
||||
_maps_handle: &MapsHandle,
|
||||
) -> Forecast {
|
||||
let mut forecast = Forecast::new(position);
|
||||
|
||||
// Expand the `All` metric if present, deduplicate otherwise.
|
||||
let mut metrics = metrics;
|
||||
if metrics.contains(&Metric::All) {
|
||||
metrics = Metric::all();
|
||||
} else {
|
||||
metrics.dedup()
|
||||
}
|
||||
|
||||
for metric in metrics {
|
||||
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 => forecast.paqi = Some(()),
|
||||
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
|
||||
Metric::Pollen => forecast.pollen = Some(()),
|
||||
Metric::Precipitation => {
|
||||
forecast.precipitation = providers::buienradar::get(position, metric).await
|
||||
}
|
||||
Metric::UVI => forecast.uvi = Some(()),
|
||||
}
|
||||
}
|
||||
|
||||
forecast
|
||||
}
|
335
src/main.rs
335
src/main.rs
|
@ -11,170 +11,207 @@
|
|||
)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use geocoding::{Forward, Openstreetmap, Point};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use color_eyre::Result;
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::content::Custom;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::serde::Serialize;
|
||||
use rocket::{get, launch, routes, FromFormField};
|
||||
use rocket::tokio::time::Instant;
|
||||
use rocket::tokio::{self, select};
|
||||
use rocket::{get, routes, State};
|
||||
|
||||
/// The current for a specific location.
|
||||
///
|
||||
/// Only the metrics asked for are included as well as the position and current time.
|
||||
///
|
||||
/// TODO: Fill the metrics with actual data!
|
||||
#[derive(Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Forecast {
|
||||
/// The latitude of the position.
|
||||
lat: f64,
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// The longitude of the position.
|
||||
lon: f64,
|
||||
pub(crate) use self::forecast::Metric;
|
||||
use self::forecast::{forecast, Forecast};
|
||||
pub(crate) use self::maps::{Maps, MapsHandle};
|
||||
use self::position::{resolve_address, Position};
|
||||
|
||||
/// The current time (in seconds since the UNIX epoch).
|
||||
time: i64,
|
||||
|
||||
/// The air quality index (when asked for).
|
||||
#[serde(rename = "AQI", skip_serializing_if = "Option::is_none")]
|
||||
aqi: Option<u8>,
|
||||
|
||||
/// The NO₂ concentration (when asked for).
|
||||
#[serde(rename = "NO2", skip_serializing_if = "Option::is_none")]
|
||||
no2: Option<u8>,
|
||||
|
||||
/// The O₃ concentration (when asked for).
|
||||
#[serde(rename = "O3", skip_serializing_if = "Option::is_none")]
|
||||
o3: Option<u8>,
|
||||
|
||||
/// The combination of pollen + air quality index (when asked for).
|
||||
#[serde(rename = "PAQI", skip_serializing_if = "Option::is_none")]
|
||||
paqi: Option<u8>,
|
||||
|
||||
/// The particulate matter in the air (when asked for).
|
||||
#[serde(rename = "PM10", skip_serializing_if = "Option::is_none")]
|
||||
pm10: Option<u8>,
|
||||
|
||||
/// The pollen in the air (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pollen: Option<u8>,
|
||||
|
||||
/// The precipitation (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
precipitation: Option<u8>,
|
||||
|
||||
/// The UV index (when asked for).
|
||||
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
||||
uvi: Option<u8>,
|
||||
}
|
||||
|
||||
impl Forecast {
|
||||
fn new(lat: f64, lon: f64) -> Self {
|
||||
let time = chrono::Utc::now().timestamp();
|
||||
|
||||
Self {
|
||||
lat,
|
||||
lon,
|
||||
time,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The supported metrics.
|
||||
///
|
||||
/// This is used for selecting which metrics should be calculated & returned.
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, FromFormField)]
|
||||
enum Metric {
|
||||
/// All metrics.
|
||||
#[field(value = "all")]
|
||||
All,
|
||||
/// The air quality index.
|
||||
AQI,
|
||||
/// The NO₂ concentration.
|
||||
NO2,
|
||||
/// The O₃ concentration.
|
||||
O3,
|
||||
/// The combination of pollen + air quality index.
|
||||
PAQI,
|
||||
/// The particulate matter in the air.
|
||||
PM10,
|
||||
/// The pollen in the air.
|
||||
Pollen,
|
||||
/// The precipitation.
|
||||
Precipitation,
|
||||
/// The UV index.
|
||||
UVI,
|
||||
}
|
||||
|
||||
impl Metric {
|
||||
/// Returns all supported metrics.
|
||||
fn all() -> Vec<Metric> {
|
||||
use Metric::*;
|
||||
|
||||
Vec::from([AQI, NO2, O3, PAQI, PM10, Pollen, Precipitation, UVI])
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates and returns the forecast.
|
||||
///
|
||||
/// The provided list `metrics` determines what will be included in the forecast.
|
||||
async fn forecast(lat: f64, lon: f64, metrics: Vec<Metric>) -> Forecast {
|
||||
let mut forecast = Forecast::new(lat, lon);
|
||||
|
||||
// Expand the `All` metric if present, deduplicate otherwise.
|
||||
let mut metrics = metrics;
|
||||
if metrics.contains(&Metric::All) {
|
||||
metrics = Metric::all();
|
||||
} else {
|
||||
metrics.dedup()
|
||||
}
|
||||
|
||||
for metric in metrics {
|
||||
match metric {
|
||||
// This should have been expanded to all the metrics matched below.
|
||||
Metric::All => unreachable!("should have been expanded"),
|
||||
Metric::AQI => forecast.aqi = Some(1),
|
||||
Metric::NO2 => forecast.no2 = Some(2),
|
||||
Metric::O3 => forecast.o3 = Some(3),
|
||||
Metric::PAQI => forecast.paqi = Some(4),
|
||||
Metric::PM10 => forecast.pm10 = Some(5),
|
||||
Metric::Pollen => forecast.pollen = Some(6),
|
||||
Metric::Precipitation => forecast.precipitation = Some(7),
|
||||
Metric::UVI => forecast.uvi = Some(8),
|
||||
}
|
||||
}
|
||||
|
||||
forecast
|
||||
}
|
||||
|
||||
/// Retrieves the geocoded position for the given address.
|
||||
async fn address_position(address: &str) -> Option<(f64, f64)> {
|
||||
let osm = Openstreetmap::new();
|
||||
// FIXME: Handle or log the error.
|
||||
let points: Vec<Point<f64>> = osm.forward(address).ok()?;
|
||||
|
||||
points.get(0).map(|point| (point.x(), point.y()))
|
||||
}
|
||||
pub(crate) mod forecast;
|
||||
pub(crate) mod maps;
|
||||
pub(crate) mod position;
|
||||
pub(crate) mod providers;
|
||||
|
||||
/// Handler for retrieving the forecast for an address.
|
||||
#[get("/forecast?<address>&<metrics>")]
|
||||
async fn forecast_address(address: String, metrics: Vec<Metric>) -> Option<Json<Forecast>> {
|
||||
let (lat, lon) = address_position(&address).await?;
|
||||
let forecast = forecast(lat, lon, metrics).await;
|
||||
async fn forecast_address(
|
||||
address: String,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Json<Forecast>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Some(Json(forecast))
|
||||
}
|
||||
|
||||
/// Handler for retrieving the forecast for a geocoded position.
|
||||
#[get("/forecast?<lat>&<lon>&<metrics>", rank = 2)]
|
||||
async fn forecast_geo(lat: f64, lon: f64, metrics: Vec<Metric>) -> Json<Forecast> {
|
||||
let forecast = forecast(lat, lon, metrics).await;
|
||||
async fn forecast_geo(
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Json<Forecast> {
|
||||
let position = Position::new(lat, lon);
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Json(forecast)
|
||||
}
|
||||
|
||||
/// Launches rocket.
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
rocket::build().mount("/", routes![forecast_address, forecast_geo])
|
||||
fn deg2rad(x: f64)->f64 {
|
||||
let y: f64 = x * PI / 180.0;
|
||||
return y;
|
||||
}
|
||||
|
||||
/// Mercator projection from https://stackoverflow.com/questions/18838915/convert-lat-lon-to-pixel-coordinate
|
||||
|
||||
fn mercator_y(lat: f64)->f64 {
|
||||
return f64::ln(f64::tan(lat / 2.0 + PI / 4.0));
|
||||
}
|
||||
|
||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||
///
|
||||
/// Note: This handler is mosly used for debugging purposes!
|
||||
#[get("/map?<address>&<metric>")]
|
||||
async fn show_map(
|
||||
address: String,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Custom<Vec<u8>>> {
|
||||
use image::{GenericImage, Rgba};
|
||||
use std::io::Cursor;
|
||||
|
||||
let position = resolve_address(address).await?;
|
||||
let lat = position.lat;
|
||||
let lon = position.lon;
|
||||
|
||||
let now = Instant::now();
|
||||
let maps = maps_handle.lock().expect("Maps handle lock was poisoned");
|
||||
let mut image = match metric {
|
||||
Metric::PAQI => maps.pollen_at(now)?,
|
||||
Metric::Pollen => maps.pollen_at(now)?,
|
||||
Metric::UVI => maps.uvi_at(now)?,
|
||||
_ => return None, // Unsupported metric
|
||||
};
|
||||
|
||||
// GPS coordinates from Google Maps
|
||||
let vlissingen_lat: f64 = deg2rad(51.44);
|
||||
let vlissingen_lon: f64 = deg2rad(3.57);
|
||||
let vlissingen_x: u32 = 84;
|
||||
let vlissingen_y: u32 = 745;
|
||||
|
||||
let winschoten_lat: f64 = deg2rad(53.14);
|
||||
let winschoten_lon: f64 = deg2rad(7.04);
|
||||
let winschoten_x: u32 = 729;
|
||||
let winschoten_y: u32 = 185;
|
||||
|
||||
let lauwersoog_lat: f64 = deg2rad(53.40);
|
||||
let lauwersoog_lon: f64 = deg2rad(6.22);
|
||||
let lauwersoog_x: u32 = 566;
|
||||
let lauwersoog_y: u32 = 111;
|
||||
|
||||
let enschede_lat: f64 = deg2rad(52.22);
|
||||
let enschede_lon: f64 = deg2rad(6.90);
|
||||
let enschede_x: u32 = 694;
|
||||
let enschede_y: u32 = 494;
|
||||
|
||||
let ref_x1 = vlissingen_x;
|
||||
let ref_lon1 = vlissingen_lon;
|
||||
let ref_y1 = vlissingen_y;
|
||||
let ref_lat1 = vlissingen_lat;
|
||||
|
||||
let ref_x2 = enschede_x;
|
||||
let ref_lon2 = enschede_lon;
|
||||
let ref_y2 = lauwersoog_y;
|
||||
let ref_lat2 = lauwersoog_lat;
|
||||
|
||||
let y_min = mercator_y(ref_lat1);
|
||||
let y_max = mercator_y(ref_lat2);
|
||||
let x_factor = ((ref_x2 - ref_x1) as f64) / (ref_lon2 - ref_lon1);
|
||||
let y_factor = ((ref_y1 - ref_y2) as f64) / (y_max - y_min);
|
||||
println!("x_factor: {}, y_factor: {}", x_factor, y_factor);
|
||||
|
||||
println!("y_min: {}, y_max: {}, lat: {}", y_min * y_factor, y_max * y_factor, mercator_y(deg2rad(lat)) * y_factor);
|
||||
let mut x_f64 = (deg2rad(lon) - ref_lon1) * x_factor + ref_x1 as f64;
|
||||
let mut y_f64 = (y_max - mercator_y(deg2rad(lat))) * y_factor + ref_y2 as f64;
|
||||
|
||||
println!("x: {}, y: {}", x_f64, y_f64);
|
||||
if x_f64 < 0.0 {
|
||||
x_f64 = 0.0;
|
||||
}
|
||||
if x_f64 > (image.width() - 1) as f64 {
|
||||
x_f64 = (image.width() - 1) as f64;
|
||||
}
|
||||
if y_f64 < 0.0 {
|
||||
y_f64 = 0.0;
|
||||
}
|
||||
if y_f64 > (image.height() - 1) as f64 {
|
||||
y_f64 = (image.height() - 1) as f64;
|
||||
}
|
||||
println!("bounded to x: {}, y: {}", x_f64, y_f64);
|
||||
|
||||
for px in ref_x1 - 5..=ref_x1 + 5 {
|
||||
for py in ref_y1 - 5..=ref_y1 + 5 {
|
||||
image.put_pixel(px, py, Rgba::from([0x00, 0xff, 0xff, 0x70]));
|
||||
}
|
||||
}
|
||||
|
||||
for px in ref_x2 - 5..=ref_x2 + 5 {
|
||||
for py in ref_y2 - 5..=ref_y2 + 5 {
|
||||
image.put_pixel(px, py, Rgba::from([0xff, 0x00, 0xff, 0x70]));
|
||||
}
|
||||
}
|
||||
|
||||
for py in 0..(image.height() - 1) {
|
||||
image.put_pixel(x_f64 as u32, py, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
||||
}
|
||||
|
||||
for px in 0..(image.width() - 1) {
|
||||
image.put_pixel(px, y_f64 as u32, Rgba::from([0x00, 0x00, 0x00, 0x70]));
|
||||
}
|
||||
|
||||
// Encode the image as PNG image data.
|
||||
// FIXME: This encoding call blocks the worker thread!
|
||||
let mut image_data = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(
|
||||
&mut image_data,
|
||||
image::ImageOutputFormat::from(image::ImageFormat::Png),
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
Some(Custom(ContentType::PNG, image_data.into_inner()))
|
||||
}
|
||||
|
||||
/// Starts the main maps refresh loop and sets up and launches Rocket.
|
||||
///
|
||||
/// See [`maps::run`] for the maps refresh loop.
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let maps = Maps::new();
|
||||
let maps_handle = Arc::new(Mutex::new(maps));
|
||||
let maps_updater = tokio::spawn(maps::run(Arc::clone(&maps_handle)));
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(maps_handle)
|
||||
.mount("/", routes![forecast_address, forecast_geo, show_map])
|
||||
.ignite()
|
||||
.await?;
|
||||
let shutdown = rocket.shutdown();
|
||||
|
||||
select! {
|
||||
result = rocket.launch() => {
|
||||
result?
|
||||
}
|
||||
result = maps_updater => {
|
||||
shutdown.notify();
|
||||
result?
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,249 @@
|
|||
//! Maps retrieval and caching.
|
||||
//!
|
||||
//! This module provides a task that keeps maps up-to-date using a maps-specific refresh interval.
|
||||
//! It stores all the maps as [`DynamicImage`]s in memory.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use reqwest::Url;
|
||||
use rocket::tokio;
|
||||
use rocket::tokio::time::{sleep, Duration, Instant};
|
||||
|
||||
/// A handle to access the in-memory cached maps.
|
||||
pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
|
||||
|
||||
/// The interval between map refreshes (in seconds).
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The base URL for retrieving the pollen maps from Buienradar.
|
||||
const POLLEN_BASE_URL: &str =
|
||||
"https://image.buienradar.nl/2.0/image/sprite/WeatherMapPollenRadarHourlyNL\
|
||||
?width=820&height=988&extension=png&renderBackground=False&renderBranding=False\
|
||||
&renderText=False&history=0&forecast=24&skip=0";
|
||||
|
||||
/// The interval for retrieving pollen maps.
|
||||
///
|
||||
/// The endpoint provides a map for every hour, 24 in total.
|
||||
const POLLEN_INTERVAL: Duration = Duration::from_secs(3_600);
|
||||
|
||||
/// The number of pollen maps retained.
|
||||
const POLLEN_MAP_COUNT: u32 = 24;
|
||||
|
||||
/// The number of seconds each pollen map is for.
|
||||
const POLLEN_MAP_INTERVAL: u64 = 3_600;
|
||||
|
||||
/// The base URL for retrieving the UV index maps from Buienradar.
|
||||
const UVI_BASE_URL: &str = "https://image.buienradar.nl/2.0/image/sprite/WeatherMapUVIndexNL\
|
||||
?width=820&height=988&extension=png&&renderBackground=False&renderBranding=False\
|
||||
&renderText=False&history=0&forecast=5&skip=0";
|
||||
|
||||
/// The interval for retrieving UV index maps.
|
||||
///
|
||||
/// The endpoint provides a map for every day, 5 in total.
|
||||
const UVI_INTERVAL: Duration = Duration::from_secs(24 * 3_600);
|
||||
|
||||
/// The number of UV index maps retained.
|
||||
const UVI_MAP_COUNT: u32 = 5;
|
||||
|
||||
/// The number of seconds each UV index map is for.
|
||||
const UVI_MAP_INTERVAL: u64 = 24 * 3_600;
|
||||
|
||||
/// The `MapsRefresh` trait is used to reduce the time a lock needs to be held when updating maps.
|
||||
///
|
||||
/// When refreshing maps, the lock only needs to be held when checking whether a refresh is
|
||||
/// necessary and when the new maps have been retrieved and can be updated.
|
||||
trait MapsRefresh {
|
||||
/// Determines whether the pollen maps need to be refreshed.
|
||||
fn needs_pollen_refresh(&self) -> bool;
|
||||
|
||||
/// Determines whether the UV index maps need to be refreshed.
|
||||
fn needs_uvi_refresh(&self) -> bool;
|
||||
|
||||
/// Determines whether the pollen maps are stale.
|
||||
fn is_pollen_stale(&self) -> bool;
|
||||
|
||||
/// Determines whether the UV index maps are stale.
|
||||
fn is_uvi_stale(&self) -> bool;
|
||||
|
||||
/// Updates the pollen maps.
|
||||
fn set_pollen(&self, pollen: Option<DynamicImage>);
|
||||
|
||||
/// Updates the UV index maps.
|
||||
fn set_uvi(&self, uvi: Option<DynamicImage>);
|
||||
}
|
||||
|
||||
/// Container type for all in-memory cached maps.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Maps {
|
||||
/// The pollen maps (from Buienradar).
|
||||
pub(crate) pollen: Option<DynamicImage>,
|
||||
|
||||
/// The timestamp the pollen maps were last refreshed.
|
||||
pollen_stamp: Instant,
|
||||
|
||||
/// The UV index maps (from Buienradar).
|
||||
pub(crate) uvi: Option<DynamicImage>,
|
||||
|
||||
/// The timestamp the UV index maps were last refreshed.
|
||||
uvi_stamp: Instant,
|
||||
}
|
||||
|
||||
impl Maps {
|
||||
/// Creates a new maps cache.
|
||||
///
|
||||
/// It contains an [`DynamicImage`] per maps type, if downloaded, and the timestamp of the last
|
||||
/// update.
|
||||
pub(crate) fn new() -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
pollen: None,
|
||||
pollen_stamp: now,
|
||||
uvi: None,
|
||||
uvi_stamp: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the pollen map for the given instant.
|
||||
///
|
||||
/// This returns [`None`] if the map is not in the cache yet, or if `instant` is too far in the
|
||||
/// future with respect to the cached maps.
|
||||
pub(crate) fn pollen_at(&self, instant: Instant) -> Option<DynamicImage> {
|
||||
let duration = instant.duration_since(self.pollen_stamp);
|
||||
let offset = (duration.as_secs() / POLLEN_MAP_INTERVAL) as u32;
|
||||
// Check if out of bounds.
|
||||
if offset >= POLLEN_MAP_COUNT {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.pollen.as_ref().map(|map| {
|
||||
let width = map.width() / POLLEN_MAP_COUNT;
|
||||
|
||||
map.crop_imm(offset * width, 0, width, map.height())
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the UV index map for the given instant.
|
||||
///
|
||||
/// This returns [`None`] if the map is not in the cache yet, or if `instant` is too far in
|
||||
/// the future with respect to the cached maps.
|
||||
pub(crate) fn uvi_at(&self, instant: Instant) -> Option<DynamicImage> {
|
||||
let duration = instant.duration_since(self.uvi_stamp);
|
||||
let offset = (duration.as_secs() / UVI_MAP_INTERVAL) as u32;
|
||||
// Check if out of bounds.
|
||||
if offset >= UVI_MAP_COUNT {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.uvi.as_ref().map(|map| {
|
||||
let width = map.width() / UVI_MAP_COUNT;
|
||||
|
||||
map.crop_imm(offset * width, 0, width, map.height())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MapsRefresh for MapsHandle {
|
||||
fn is_pollen_stale(&self) -> bool {
|
||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
|
||||
Instant::now().duration_since(maps.pollen_stamp)
|
||||
> Duration::from_secs(POLLEN_MAP_COUNT as u64 * POLLEN_MAP_INTERVAL)
|
||||
}
|
||||
|
||||
fn is_uvi_stale(&self) -> bool {
|
||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
|
||||
Instant::now().duration_since(maps.uvi_stamp)
|
||||
> Duration::from_secs(UVI_MAP_COUNT as u64 * UVI_MAP_INTERVAL)
|
||||
}
|
||||
|
||||
fn needs_pollen_refresh(&self) -> bool {
|
||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
maps.pollen.is_none() || Instant::now().duration_since(maps.pollen_stamp) > POLLEN_INTERVAL
|
||||
}
|
||||
|
||||
fn needs_uvi_refresh(&self) -> bool {
|
||||
let maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
maps.uvi.is_none() || Instant::now().duration_since(maps.uvi_stamp) > UVI_INTERVAL
|
||||
}
|
||||
|
||||
fn set_pollen(&self, pollen: Option<DynamicImage>) {
|
||||
if pollen.is_some() || self.is_pollen_stale() {
|
||||
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
maps.pollen = pollen;
|
||||
maps.pollen_stamp = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn set_uvi(&self, uvi: Option<DynamicImage>) {
|
||||
if uvi.is_some() || self.is_uvi_stale() {
|
||||
let mut maps = self.lock().expect("Maps handle mutex was poisoned");
|
||||
maps.uvi = uvi;
|
||||
maps.uvi_stamp = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves an image from the provided URL.
|
||||
///
|
||||
/// This returns [`None`] if it fails in either performing the request, retrieving the bytes from
|
||||
/// the image or loading and the decoding the data into [`DynamicImage`].
|
||||
async fn retrieve_image(url: Url) -> Option<DynamicImage> {
|
||||
// TODO: Handle or log errors!
|
||||
let response = reqwest::get(url).await.ok()?;
|
||||
let bytes = response.bytes().await.ok()?;
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
image::load_from_memory_with_format(&bytes, ImageFormat::Png)
|
||||
})
|
||||
.await
|
||||
.ok()?
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 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<DynamicImage> {
|
||||
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);
|
||||
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<DynamicImage> {
|
||||
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);
|
||||
retrieve_image(url).await
|
||||
}
|
||||
|
||||
/// Runs a loop that keeps refreshing the maps when necessary.
|
||||
///
|
||||
/// Use [`MapsRefresh`] trait methods on `maps_handle` to check whether each maps type needs to be
|
||||
/// refreshed and uses its retrieval function to update it if necessary.
|
||||
pub(crate) async fn run(maps_handle: MapsHandle) -> ! {
|
||||
loop {
|
||||
println!("🕔 Refreshing the maps (if necessary)...");
|
||||
|
||||
if maps_handle.needs_pollen_refresh() {
|
||||
let pollen = retrieve_pollen_maps().await;
|
||||
maps_handle.set_pollen(pollen);
|
||||
}
|
||||
|
||||
if maps_handle.needs_uvi_refresh() {
|
||||
let uvi = retrieve_uvi_maps().await;
|
||||
maps_handle.set_uvi(uvi);
|
||||
}
|
||||
|
||||
sleep(REFRESH_INTERVAL).await;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
//! Positions in the geographic coordinate system.
|
||||
//!
|
||||
//! This module contains everything related to geographic coordinate system functionality.
|
||||
|
||||
use std::hash::Hash;
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use geocoding::{Forward, Openstreetmap, Point};
|
||||
use rocket::tokio;
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// A (geocoded) position.
|
||||
///
|
||||
/// This is used for measuring and communication positions directly on the Earth as latitude and
|
||||
/// longitude.
|
||||
///
|
||||
/// # Position equivalence and hashing
|
||||
///
|
||||
/// For caching purposes we need to check equivalence between two positions. If the positions match
|
||||
/// up to the 5th decimal, we consider them the same (see [`Position::lat_as_i32`] and
|
||||
/// [`Position::lon_as_i32`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Position {
|
||||
pub(crate) lat: f64,
|
||||
pub(crate) lon: f64,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
/// Creates a new (geocoded) position.
|
||||
pub(crate) fn new(lat: f64, lon: f64) -> Self {
|
||||
Self { lat, lon }
|
||||
}
|
||||
|
||||
/// Returns the latitude as an integer.
|
||||
///
|
||||
/// This is achieved by multiplying it by `10_000` and rounding it. Thus, this gives a
|
||||
/// precision of 5 decimals.
|
||||
fn lat_as_i32(&self) -> i32 {
|
||||
(self.lat * 10_000.0).round() as i32
|
||||
}
|
||||
|
||||
/// Returns the longitude as an integer.
|
||||
///
|
||||
/// This is achieved by multiplying it by `10_000` and rounding it. Thus, this gives a
|
||||
/// precision of 5 decimals.
|
||||
fn lon_as_i32(&self) -> i32 {
|
||||
(self.lon * 10_000.0).round() as i32
|
||||
}
|
||||
|
||||
/// Returns the latitude as a string with the given precision.
|
||||
pub(crate) fn lat_as_str(&self, precision: usize) -> String {
|
||||
format!("{:.*}", precision, self.lat)
|
||||
}
|
||||
|
||||
/// Returns the longitude as a string with the given precision.
|
||||
pub(crate) fn lon_as_str(&self, precision: usize) -> String {
|
||||
format!("{:.*}", precision, self.lon)
|
||||
}
|
||||
|
||||
/// Returns the latitude in radians
|
||||
fn lat_as_rad(&self) -> f64 {
|
||||
self.lat * PI / 180.0
|
||||
}
|
||||
|
||||
/// Returns the longitude in radians
|
||||
fn lon_as_rad(&self) -> f64 {
|
||||
self.lon * PI / 180.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Point<f64>> for Position {
|
||||
fn from(point: &Point<f64>) -> Self {
|
||||
// The `geocoding` API always returns (longitude, latitude) as (x, y).
|
||||
Position::new(point.y(), point.x())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Position {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
// Floats cannot be hashed. Use the 5-decimal precision integer representation of the
|
||||
// coordinates instead.
|
||||
self.lat_as_i32().hash(state);
|
||||
self.lon_as_i32().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Position {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.lat_as_i32() == other.lat_as_i32() && self.lon_as_i32() == other.lon_as_i32()
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// 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> {
|
||||
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()?;
|
||||
|
||||
points.get(0).map(Position::from)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
//! All supported metric data providers.
|
||||
//!
|
||||
//! Data is either provided via a direct (JSON) API or via looking up values on maps.
|
||||
|
||||
pub(crate) mod buienradar;
|
||||
pub(crate) mod luchtmeetnet;
|
|
@ -0,0 +1,119 @@
|
|||
//! The Buienradar data provider.
|
||||
//!
|
||||
//! For more information about Buienradar, see: <https://www.buienradar.nl/overbuienradar/contact>
|
||||
//! and <https://www.buienradar.nl/overbuienradar/gratis-weerdata>.
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use chrono::offset::TimeZone;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Local, NaiveTime, ParseError, Utc};
|
||||
use chrono_tz::Europe;
|
||||
use csv::ReaderBuilder;
|
||||
use reqwest::Url;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::position::Position;
|
||||
use crate::Metric;
|
||||
|
||||
/// The base URL for the Buienradar API.
|
||||
const BUIENRADAR_BASE_URL: &str = "https://gpsgadget.buienradar.nl/data/raintext";
|
||||
|
||||
/// A row in the precipitation text output.
|
||||
///
|
||||
/// This is an intermediate type used to represent rows of the output.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Row {
|
||||
/// The precipitation value in the range `0..=255`.
|
||||
value: u16,
|
||||
|
||||
/// The time in the `HH:MM` format.
|
||||
time: String,
|
||||
}
|
||||
|
||||
/// The Buienradar API precipitation data item.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(crate = "rocket::serde", try_from = "Row")]
|
||||
pub(crate) struct Item {
|
||||
/// The time(stamp) of the forecast.
|
||||
#[serde(serialize_with = "ts_seconds::serialize")]
|
||||
time: DateTime<Utc>,
|
||||
|
||||
/// The forecasted value.
|
||||
///
|
||||
/// Its unit is mm/h.
|
||||
value: f32,
|
||||
}
|
||||
|
||||
impl TryFrom<Row> for Item {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(row: Row) -> Result<Self, Self::Error> {
|
||||
let time = parse_time(&row.time)?;
|
||||
let value = convert_value(row.value);
|
||||
|
||||
Ok(Item { time, value })
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a time string to date/time in the UTC time zone.
|
||||
///
|
||||
/// The provided time has the format `HH:MM` and is considered to be in the Europe/Amsterdam
|
||||
/// time zone.
|
||||
fn parse_time(t: &str) -> Result<DateTime<Utc>, ParseError> {
|
||||
// First, get the naive time.
|
||||
let ntime = NaiveTime::parse_from_str(t, "%H:%M")?;
|
||||
// FIXME: This might actually be the day before when started on a machine that
|
||||
// doesn't run in the Europe/Amsterdam time zone.
|
||||
let ndtime = Local::today().naive_local().and_time(ntime);
|
||||
// Then, interpret the naive date/time in the Europe/Amsterdam time zone and convert it to the
|
||||
// UTC time zone.
|
||||
let ldtime = Europe::Amsterdam.from_local_datetime(&ndtime).unwrap();
|
||||
let dtime = ldtime.with_timezone(&Utc);
|
||||
|
||||
Ok(dtime)
|
||||
}
|
||||
|
||||
/// Converts a precipitation value into an precipitation intensity value in mm/h.
|
||||
///
|
||||
/// For the conversion formula, see: <https://www.buienradar.nl/overbuienradar/gratis-weerdata>.
|
||||
fn convert_value(v: u16) -> f32 {
|
||||
let base: f32 = 10.0;
|
||||
let value = base.powf((v as f32 - 109.0) / 32.0);
|
||||
|
||||
(value * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
/// Retrieves the Buienradar forecasted precipitation items for the provided position.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// If the result is [`Some`] it will be cached for 5 minutes for the the given position and
|
||||
/// metric.
|
||||
#[cached(time = 300, option = true)]
|
||||
pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>> {
|
||||
if metric != Metric::Precipitation {
|
||||
return None;
|
||||
}
|
||||
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 mut rdr = ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.delimiter(b'|')
|
||||
.from_reader(output.as_bytes());
|
||||
rdr.deserialize().collect::<Result<_, _>>().ok()
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
//! The Luchtmeetnet open data provider.
|
||||
//!
|
||||
//! For more information about Luchtmeetnet, see: <https://www.luchtmeetnet.nl/contact>.
|
||||
|
||||
use cached::proc_macro::cached;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Url;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::position::Position;
|
||||
use crate::Metric;
|
||||
|
||||
/// The base URL for the Luchtmeetnet API.
|
||||
const LUCHTMEETNET_BASE_URL: &str = "https://api.luchtmeetnet.nl/open_api/concentrations";
|
||||
|
||||
/// The Luchtmeetnet API data container.
|
||||
///
|
||||
/// This is only used temporarily during deserialization.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
struct Container {
|
||||
data: Vec<Item>,
|
||||
}
|
||||
|
||||
/// The Luchtmeetnet API data item.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Item {
|
||||
/// The time(stamp) of the forecast.
|
||||
#[serde(
|
||||
rename(deserialize = "timestamp_measured"),
|
||||
serialize_with = "ts_seconds::serialize"
|
||||
)]
|
||||
time: DateTime<Utc>,
|
||||
|
||||
/// The forecasted value.
|
||||
///
|
||||
/// The unit depends on the selected [metric](Metric).
|
||||
value: f32,
|
||||
}
|
||||
|
||||
/// Retrieves the Luchtmeetnet forecasted items for the provided position and metric.
|
||||
///
|
||||
/// It supports the following metrics:
|
||||
/// * [`Metric::AQI`]
|
||||
/// * [`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>> {
|
||||
let formula = match metric {
|
||||
Metric::AQI => "lki",
|
||||
Metric::NO2 => "no2",
|
||||
Metric::O3 => "o3",
|
||||
Metric::PM10 => "pm10",
|
||||
_ => return None, // Unsupported metric
|
||||
};
|
||||
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
|
||||
url.query_pairs_mut()
|
||||
.append_pair("formula", formula)
|
||||
.append_pair("latitude", &position.lat_as_str(5))
|
||||
.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,
|
||||
};
|
||||
|
||||
Some(root.data)
|
||||
}
|
Loading…
Reference in New Issue