Compare commits
10 Commits
Author | SHA1 | Date |
---|---|---|
Paul van Tilburg | 5d37c5b5ee | |
Paul van Tilburg | 2c04a92965 | |
Paul van Tilburg | e408fbb91d | |
Paul van Tilburg | 5972697cf1 | |
Paul van Tilburg | 7feae97ee2 | |
Paul van Tilburg | 0bf07bd134 | |
Paul van Tilburg | 4576f8d90a | |
Paul van Tilburg | 66bd02ea57 | |
Paul van Tilburg | 80fd9525c4 | |
Paul van Tilburg | 32964cea21 |
|
@ -0,0 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to Sinoptik will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2022-5-07
|
||||
|
||||
### Added
|
||||
|
||||
* Add `AQI_max` and `pollen_max` to the forecast JSON (only when the PAQI
|
||||
metric is selected) (#20)
|
||||
|
||||
## [0.1.0] - 2022-03-07
|
||||
|
||||
Initial release.
|
||||
|
||||
[Unreleased]: https://git.luon.net/paul/sinoptik/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://git.luon.net/paul/sinoptik/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.luon.net/paul/sinoptik/commits/tag/v0.1.0
|
File diff suppressed because it is too large
Load Diff
10
Cargo.toml
10
Cargo.toml
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "sinoptik"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
authors = [
|
||||
"Admar Schoonen <admar@luon.net",
|
||||
"Paul van Tilburg <paul@luon.net>"
|
||||
|
@ -12,16 +12,20 @@ repository = "https://git.luon.net/paul/sinoptik"
|
|||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
cached = { version = "0.32.0", features = ["async"] }
|
||||
cached = { version = "0.34.0", features = ["async"] }
|
||||
chrono = "0.4.19"
|
||||
chrono-tz = "0.6.1"
|
||||
color-eyre = "0.5.6"
|
||||
color-eyre = "0.6.1"
|
||||
csv = "1.1.6"
|
||||
geocoding = "0.3.1"
|
||||
image = "0.24.1"
|
||||
reqwest = { version = "0.11.9", features = ["json"] }
|
||||
rocket = { version = "0.5.0-rc.1", features = ["json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_float_eq = "1.1.3"
|
||||
assert_matches = "1.5.0"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Paul van Tilburg <paul@luon.net>"
|
||||
copyright = "2022, Paul van Tilburg"
|
||||
|
|
103
README.md
103
README.md
|
@ -27,10 +27,14 @@ Using Cargo, it is easy to build and run Sinoptik, just run:
|
|||
|
||||
```shell
|
||||
$ cargo run --release
|
||||
...
|
||||
Compiling sinoptik v0.1.0 (/path/to/sinoptik)
|
||||
Finished release [optimized] target(s) in 9m 26s
|
||||
Running `/path/to/sinoptik/target/release/sinoptik`
|
||||
```
|
||||
|
||||
(Note that Rocket listens on 127.0.0.1:3000 by default for debug builds, i.e. if you don't
|
||||
add `--release`.)
|
||||
(Note that Rocket listens on `127.0.0.1:3000` by default for debug builds, i.e.
|
||||
builds when you don't add `--release`.)
|
||||
|
||||
You can provide Rocket with configuration to use a different address and/or port.
|
||||
Just create a `Rocket.toml` file that contains (or copy `Rocket.toml.example`):
|
||||
|
@ -38,30 +42,33 @@ Just create a `Rocket.toml` file that contains (or copy `Rocket.toml.example`):
|
|||
```toml
|
||||
[default]
|
||||
address = "0.0.0.0"
|
||||
port = 4321
|
||||
port = 2356
|
||||
```
|
||||
|
||||
## Forecast API
|
||||
This will work independent of the type of build. For more about Rocket's
|
||||
configuration, see: <https://rocket.rs/v0.5-rc/guide/configuration/>.
|
||||
|
||||
The `/forecast` endpoint provides forecasts per requested metric a list of
|
||||
forecast item which are each comprised of a value and its (UNIX) timestamp.
|
||||
It does so for a requested location.
|
||||
## Forecast API endpoint
|
||||
|
||||
The `/forecast` API endpoint provides forecasts per requested metric a list of
|
||||
forecast item which are each comprised of a value and its (UNIX) timestamp. It
|
||||
does so for a requested location.
|
||||
|
||||
### Locations
|
||||
|
||||
To select a location, you can either provide an address, or a geocoded position
|
||||
by providing a latitude and longitude.
|
||||
For example to get forecasts for all metrics for the Stationsplein in Utrecht,
|
||||
For example, to get forecasts for all metrics for the Stationsplein in Utrecht,
|
||||
use:
|
||||
|
||||
```
|
||||
```http
|
||||
GET /forecast?address=Stationsplein,Utrecht&metrics[]=all
|
||||
```
|
||||
|
||||
or directly by using its geocoded position:
|
||||
|
||||
|
||||
```
|
||||
```http
|
||||
GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
|
||||
```
|
||||
|
||||
|
@ -70,12 +77,13 @@ GET /forecast?lat=52.0902&lon=5.1114&metrics[]=all
|
|||
When querying, the metrics need to be selected. It can be one of: `AQI`, `NO2`,
|
||||
`O3`, `PAQI`, `PM10`, `pollen`, `precipitation` or `UVI`. If you use metric `all`, or
|
||||
`all` is part of the selected metrics, all metrics will be retrieved.
|
||||
Note that the parameter "array" as well as the repeated parameter notations are supported. For example:
|
||||
Note that the parameter "array" notation as well as the repeated parameter
|
||||
notation are supported. For example:
|
||||
|
||||
```
|
||||
GET /address=Stationsplein,Utrecht&metrics[]=AQI&metrics[]=pollen
|
||||
GET /address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen
|
||||
GET /address=Stationsplein,Utrecht&metrics=all
|
||||
```http
|
||||
GET /forecast?address=Stationsplein,Utrecht&metrics[]=AQI&metrics[]=pollen
|
||||
GET /forecast?address=Stationsplein,Utrecht&metrics=AQI&metrics=pollen
|
||||
GET /forecast?address=Stationsplein,Utrecht&metrics=all
|
||||
```
|
||||
|
||||
### Response
|
||||
|
@ -97,8 +105,8 @@ position:
|
|||
|
||||
```json
|
||||
{
|
||||
"lat": 34.567890,
|
||||
"lon": 1.234567,
|
||||
"lat": 52.0905169,
|
||||
"lon": 5.1109709,
|
||||
"time": 1645800043,
|
||||
"UVI": [
|
||||
{
|
||||
|
@ -125,6 +133,67 @@ position:
|
|||
}
|
||||
```
|
||||
|
||||
#### Combined metric PAQI
|
||||
|
||||
The PAQI (pollen/air quality index) metric is a special combined metric.
|
||||
If selected, it not only merges items from the AQI and pollen metric into
|
||||
`PAQI` by selecting the maximum value for each hour, but it also yields the
|
||||
24-hour maximum forecast item for air quality index in `AQI_max` and for
|
||||
pollen in `pollen_max` seperately:
|
||||
|
||||
``` json
|
||||
{
|
||||
"lat": 52.0905169,
|
||||
"lon": 5.1109709,
|
||||
"time": 1645800043,
|
||||
"AQI_max": {
|
||||
"time": 1652022000,
|
||||
"value": 6.65
|
||||
},
|
||||
"PAQI": [
|
||||
{
|
||||
"time": 1651951457,
|
||||
"value": 6.04
|
||||
},
|
||||
{
|
||||
"time": 1651955057,
|
||||
"value": 6.04
|
||||
},
|
||||
...
|
||||
],
|
||||
"pollen_max": {
|
||||
"time": 1652034257,
|
||||
"value": 6
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Map API endpoint
|
||||
|
||||
The `/map` API endpoint basically only exists for debugging purposes. Given an
|
||||
address or geocoded position, it shows the current map for the provided metric
|
||||
and draws a crosshair on the position.
|
||||
Currently, only the `PAQI`, `pollen` and `UVI` metrics are backed by a map.
|
||||
|
||||
For example, to get the current pollen map with a crosshair on Stationsplein in
|
||||
Utrecht, use:
|
||||
|
||||
```http
|
||||
GET /map?address=Stationsplein,Utrecht&metric=pollen
|
||||
```
|
||||
|
||||
or directly by using its geocoded position:
|
||||
|
||||
```http
|
||||
GET /map?lat=52.0902&lon=5.1114&metric=pollen
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The response is a PNG image with a crosshair drawn on the map. If geocoding of
|
||||
an address fails or if the position is out of bounds of the map, nothing is
|
||||
returned (HTTP 404).
|
||||
|
||||
## License
|
||||
|
||||
Sinoptik is licensed under the MIT license (see the `LICENSE` file or
|
||||
|
|
|
@ -33,6 +33,10 @@ pub(crate) struct Forecast {
|
|||
#[serde(rename = "AQI", skip_serializing_if = "Option::is_none")]
|
||||
aqi: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
||||
/// The maximum air quality index value (when asked for PAQI).
|
||||
#[serde(rename = "AQI_max", skip_serializing_if = "Option::is_none")]
|
||||
aqi_max: Option<LuchtmeetnetItem>,
|
||||
|
||||
/// The NO₂ concentration (when asked for).
|
||||
#[serde(rename = "NO2", skip_serializing_if = "Option::is_none")]
|
||||
no2: Option<Vec<LuchtmeetnetItem>>,
|
||||
|
@ -53,6 +57,10 @@ pub(crate) struct Forecast {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pollen: Option<Vec<BuienradarSample>>,
|
||||
|
||||
/// The maximum pollen in the air (when asked for PAQI).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pollen_max: Option<BuienradarSample>,
|
||||
|
||||
/// The precipitation (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
precipitation: Option<Vec<BuienradarItem>>,
|
||||
|
@ -136,7 +144,13 @@ pub(crate) async fn forecast(
|
|||
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(position, metric).await,
|
||||
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(position, metric).await,
|
||||
Metric::PAQI => {
|
||||
forecast.paqi = providers::combined::get(position, metric, maps_handle).await
|
||||
if let Some((paqi, pollen_max, aqi_max)) =
|
||||
providers::combined::get(position, metric, maps_handle).await
|
||||
{
|
||||
forecast.paqi = Some(paqi);
|
||||
forecast.aqi_max = aqi_max;
|
||||
forecast.pollen_max = pollen_max;
|
||||
}
|
||||
}
|
||||
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
|
||||
Metric::Pollen => {
|
||||
|
|
|
@ -0,0 +1,283 @@
|
|||
#![doc = include_str!("../README.md")]
|
||||
#![warn(
|
||||
clippy::all,
|
||||
missing_debug_implementations,
|
||||
rust_2018_idioms,
|
||||
rustdoc::broken_intra_doc_links
|
||||
)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::content::Custom;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::{get, routes, Build, Rocket, State};
|
||||
|
||||
pub(crate) use self::forecast::Metric;
|
||||
use self::forecast::{forecast, Forecast};
|
||||
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
|
||||
use self::position::{resolve_address, Position};
|
||||
|
||||
pub(crate) mod forecast;
|
||||
pub(crate) mod maps;
|
||||
pub(crate) mod position;
|
||||
pub(crate) mod providers;
|
||||
|
||||
/// Handler for retrieving the forecast for an address.
|
||||
#[get("/forecast?<address>&<metrics>")]
|
||||
async fn forecast_address(
|
||||
address: String,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Json<Forecast>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Some(Json(forecast))
|
||||
}
|
||||
|
||||
/// Handler for retrieving the forecast for a geocoded position.
|
||||
#[get("/forecast?<lat>&<lon>&<metrics>", rank = 2)]
|
||||
async fn forecast_geo(
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Json<Forecast> {
|
||||
let position = Position::new(lat, lon);
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Json(forecast)
|
||||
}
|
||||
|
||||
/// Handler for showing the current map with the geocoded position of an address for a specific
|
||||
/// metric.
|
||||
///
|
||||
/// Note: This handler is mosly used for debugging purposes!
|
||||
#[get("/map?<address>&<metric>")]
|
||||
async fn map_address(
|
||||
address: String,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Custom<Vec<u8>>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let image_data = mark_map(position, metric, maps_handle).await;
|
||||
|
||||
image_data.map(|id| Custom(ContentType::PNG, id))
|
||||
}
|
||||
|
||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||
///
|
||||
/// Note: This handler is mosly used for debugging purposes!
|
||||
#[get("/map?<lat>&<lon>&<metric>", rank = 2)]
|
||||
async fn map_geo(
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Custom<Vec<u8>>> {
|
||||
let position = Position::new(lat, lon);
|
||||
let image_data = mark_map(position, metric, maps_handle).await;
|
||||
|
||||
image_data.map(|id| Custom(ContentType::PNG, id))
|
||||
}
|
||||
|
||||
/// Sets up Rocket.
|
||||
fn rocket(maps_handle: MapsHandle) -> Rocket<Build> {
|
||||
rocket::build().manage(maps_handle).mount(
|
||||
"/",
|
||||
routes![forecast_address, forecast_geo, map_address, map_geo],
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets up Rocket and the maps cache refresher task.
|
||||
pub fn setup() -> (Rocket<Build>, impl Future<Output = ()>) {
|
||||
let maps = Maps::new();
|
||||
let maps_handle = Arc::new(Mutex::new(maps));
|
||||
let maps_refresher = maps::run(Arc::clone(&maps_handle));
|
||||
let rocket = rocket(maps_handle);
|
||||
|
||||
(rocket, maps_refresher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use assert_float_eq::*;
|
||||
use assert_matches::assert_matches;
|
||||
use image::{DynamicImage, Rgba, RgbaImage};
|
||||
use rocket::http::Status;
|
||||
use rocket::local::blocking::Client;
|
||||
use rocket::serde::json::Value as JsonValue;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn maps_stub(map_count: u32) -> DynamicImage {
|
||||
let map_color = Rgba::from([73, 218, 33, 255]); // First color from map key.
|
||||
|
||||
DynamicImage::ImageRgba8(RgbaImage::from_pixel(820 * map_count, 988, map_color))
|
||||
}
|
||||
|
||||
fn maps_handle_stub() -> MapsHandle {
|
||||
let mut maps = Maps::new();
|
||||
maps.pollen = Some(maps_stub(24));
|
||||
maps.uvi = Some(maps_stub(5));
|
||||
|
||||
Arc::new(Mutex::new(maps))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_address() {
|
||||
let maps_handle = maps_handle_stub();
|
||||
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
|
||||
|
||||
// Get an empty forecast for the provided address.
|
||||
let response = client.get("/forecast?address=eindhoven").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
|
||||
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648);
|
||||
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633);
|
||||
assert_matches!(json["time"], JsonValue::Number(_));
|
||||
assert_matches!(json.get("AQI"), None);
|
||||
assert_matches!(json.get("AQI_max"), None);
|
||||
assert_matches!(json.get("NO2"), None);
|
||||
assert_matches!(json.get("O3"), None);
|
||||
assert_matches!(json.get("PAQI"), None);
|
||||
assert_matches!(json.get("PM10"), None);
|
||||
assert_matches!(json.get("pollen"), None);
|
||||
assert_matches!(json.get("pollen_max"), None);
|
||||
assert_matches!(json.get("precipitation"), None);
|
||||
assert_matches!(json.get("UVI"), None);
|
||||
|
||||
// Get a forecast with all metrics for the provided address.
|
||||
let response = client
|
||||
.get("/forecast?address=eindhoven&metrics=all")
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
|
||||
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4392648);
|
||||
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.478633);
|
||||
assert_matches!(json["time"], JsonValue::Number(_));
|
||||
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
|
||||
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
|
||||
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_geo() {
|
||||
let maps_handle = maps_handle_stub();
|
||||
let client = Client::tracked(rocket(maps_handle)).expect("valid Rocket instance");
|
||||
|
||||
// Get an empty forecast for the geocoded location.
|
||||
let response = client.get("/forecast?lat=51.4&lon=5.5").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
|
||||
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4);
|
||||
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
|
||||
assert_matches!(json["time"], JsonValue::Number(_));
|
||||
assert_matches!(json.get("AQI"), None);
|
||||
assert_matches!(json.get("AQI_max"), None);
|
||||
assert_matches!(json.get("NO2"), None);
|
||||
assert_matches!(json.get("O3"), None);
|
||||
assert_matches!(json.get("PAQI"), None);
|
||||
assert_matches!(json.get("PM10"), None);
|
||||
assert_matches!(json.get("pollen"), None);
|
||||
assert_matches!(json.get("pollen_max"), None);
|
||||
assert_matches!(json.get("precipitation"), None);
|
||||
assert_matches!(json.get("UVI"), None);
|
||||
|
||||
// Get a forecast with all metrics for the geocoded location.
|
||||
let response = client
|
||||
.get("/forecast?lat=51.4&lon=5.5&metrics=all")
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
let json = response.into_json::<JsonValue>().expect("Not valid JSON");
|
||||
assert_f64_near!(json["lat"].as_f64().unwrap(), 51.4);
|
||||
assert_f64_near!(json["lon"].as_f64().unwrap(), 5.5);
|
||||
assert_matches!(json["time"], JsonValue::Number(_));
|
||||
assert_matches!(json.get("AQI"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("AQI_max"), Some(JsonValue::Object(_)));
|
||||
assert_matches!(json.get("NO2"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("O3"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("PAQI"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("PM10"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("pollen"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("pollen_max"), Some(JsonValue::Object(_)));
|
||||
assert_matches!(json.get("precipitation"), Some(JsonValue::Array(_)));
|
||||
assert_matches!(json.get("UVI"), Some(JsonValue::Array(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_address() {
|
||||
let maps_handle = Arc::new(Mutex::new(Maps::new()));
|
||||
let maps_handle_clone = Arc::clone(&maps_handle);
|
||||
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
|
||||
|
||||
// No maps available yet.
|
||||
let response = client
|
||||
.get("/map?address=eindhoven&metric=pollen")
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
|
||||
// Load some dummy map.
|
||||
let mut maps = maps_handle_clone
|
||||
.lock()
|
||||
.expect("Maps handle mutex was poisoned");
|
||||
maps.pollen = Some(maps_stub(24));
|
||||
drop(maps);
|
||||
|
||||
// There should be a map now.
|
||||
let response = client
|
||||
.get("/map?address=eindhoven&metric=pollen")
|
||||
.dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
assert_eq!(response.content_type(), Some(ContentType::PNG));
|
||||
|
||||
// ... but not if it is out of bounds.
|
||||
let response = client.get("/map?address=berlin&metric=pollen").dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
|
||||
// No metric selected, don't know which map to show?
|
||||
let response = client.get("/map?address=eindhoven").dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_geo() {
|
||||
let maps_handle = Arc::new(Mutex::new(Maps::new()));
|
||||
let maps_handle_clone = Arc::clone(&maps_handle);
|
||||
let client = Client::tracked(rocket(maps_handle)).expect("Not a valid Rocket instance");
|
||||
|
||||
// No metric passed, don't know which map to show?
|
||||
let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
|
||||
// No maps available yet.
|
||||
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
|
||||
// Load some dummy map.
|
||||
let mut maps = maps_handle_clone
|
||||
.lock()
|
||||
.expect("Maps handle mutex was poisoned");
|
||||
maps.pollen = Some(maps_stub(24));
|
||||
drop(maps);
|
||||
|
||||
// There should be a map now.
|
||||
let response = client.get("/map?lat=51.4&lon=5.5&metric=pollen").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
assert_eq!(response.content_type(), Some(ContentType::PNG));
|
||||
|
||||
// No metric passed, don't know which map to show?
|
||||
let response = client.get("/map?lat=51.4&lon=5.5").dispatch();
|
||||
assert_eq!(response.status(), Status::NotFound);
|
||||
}
|
||||
}
|
96
src/main.rs
96
src/main.rs
|
@ -7,110 +7,24 @@
|
|||
)]
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use color_eyre::Result;
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::content::Custom;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::tokio::{self, select};
|
||||
use rocket::{get, routes, State};
|
||||
|
||||
pub(crate) use self::forecast::Metric;
|
||||
use self::forecast::{forecast, Forecast};
|
||||
pub(crate) use self::maps::{mark_map, Maps, MapsHandle};
|
||||
use self::position::{resolve_address, Position};
|
||||
|
||||
pub(crate) mod forecast;
|
||||
pub(crate) mod maps;
|
||||
pub(crate) mod position;
|
||||
pub(crate) mod providers;
|
||||
|
||||
/// Handler for retrieving the forecast for an address.
|
||||
#[get("/forecast?<address>&<metrics>")]
|
||||
async fn forecast_address(
|
||||
address: String,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Json<Forecast>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Some(Json(forecast))
|
||||
}
|
||||
|
||||
/// Handler for retrieving the forecast for a geocoded position.
|
||||
#[get("/forecast?<lat>&<lon>&<metrics>", rank = 2)]
|
||||
async fn forecast_geo(
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
metrics: Vec<Metric>,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Json<Forecast> {
|
||||
let position = Position::new(lat, lon);
|
||||
let forecast = forecast(position, metrics, maps_handle).await;
|
||||
|
||||
Json(forecast)
|
||||
}
|
||||
|
||||
/// Handler for showing the current map with the geocoded position of an address for a specific
|
||||
/// metric.
|
||||
///
|
||||
/// Note: This handler is mosly used for debugging purposes!
|
||||
#[get("/map?<address>&<metric>")]
|
||||
async fn map_address(
|
||||
address: String,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Custom<Vec<u8>>> {
|
||||
let position = resolve_address(address).await?;
|
||||
let image_data = mark_map(position, metric, maps_handle).await;
|
||||
|
||||
image_data.map(|id| Custom(ContentType::PNG, id))
|
||||
}
|
||||
|
||||
/// Handler for showing the current map with the geocoded position for a specific metric.
|
||||
///
|
||||
/// Note: This handler is mosly used for debugging purposes!
|
||||
#[get("/map?<lat>&<lon>&<metric>", rank = 2)]
|
||||
async fn map_geo(
|
||||
lat: f64,
|
||||
lon: f64,
|
||||
metric: Metric,
|
||||
maps_handle: &State<MapsHandle>,
|
||||
) -> Option<Custom<Vec<u8>>> {
|
||||
let position = Position::new(lat, lon);
|
||||
let image_data = mark_map(position, metric, maps_handle).await;
|
||||
|
||||
image_data.map(|id| Custom(ContentType::PNG, id))
|
||||
}
|
||||
|
||||
/// Starts the main maps refresh loop and sets up and launches Rocket.
|
||||
///
|
||||
/// See [`maps::run`] for the maps refresh loop.
|
||||
/// Starts the main maps refresh task and sets up and launches Rocket.
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let maps = Maps::new();
|
||||
let maps_handle = Arc::new(Mutex::new(maps));
|
||||
let maps_updater = tokio::spawn(maps::run(Arc::clone(&maps_handle)));
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(maps_handle)
|
||||
.mount(
|
||||
"/",
|
||||
routes![forecast_address, forecast_geo, map_address, map_geo],
|
||||
)
|
||||
.ignite()
|
||||
.await?;
|
||||
let (rocket, maps_refresher) = sinoptik::setup();
|
||||
let rocket = rocket.ignite().await?;
|
||||
let shutdown = rocket.shutdown();
|
||||
let maps_refresher = tokio::spawn(maps_refresher);
|
||||
|
||||
select! {
|
||||
result = rocket.launch() => {
|
||||
result?
|
||||
}
|
||||
result = maps_updater => {
|
||||
result = maps_refresher => {
|
||||
shutdown.notify();
|
||||
result?
|
||||
}
|
||||
|
|
|
@ -536,7 +536,7 @@ pub(crate) async fn mark_map(
|
|||
///
|
||||
/// Use [`MapsRefresh`] trait methods on `maps_handle` to check whether each maps type needs to be
|
||||
/// refreshed and uses its retrieval function to update it if necessary.
|
||||
pub(crate) async fn run(maps_handle: MapsHandle) -> ! {
|
||||
pub(crate) async fn run(maps_handle: MapsHandle) {
|
||||
loop {
|
||||
println!("🕔 Refreshing the maps (if necessary)...");
|
||||
|
||||
|
|
|
@ -27,16 +27,23 @@ pub(crate) struct Item {
|
|||
|
||||
/// Merges pollen samples and AQI items into combined items.
|
||||
///
|
||||
/// This drops items from either the pollen samples or from the AQI items if they are not stamped
|
||||
/// with half an hour of the first item of the latest stating series, thus lining them before they
|
||||
/// are combined.
|
||||
/// The merging drops items from either the pollen samples or from the AQI items if they are not
|
||||
/// stamped with half an hour of the first item of the latest starting series, thus lining them
|
||||
/// before they are combined.
|
||||
///
|
||||
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
|
||||
/// lining them up fails.
|
||||
/// This function also finds the maximum pollen sample and AQI item.
|
||||
///
|
||||
/// Returns [`None`] if there are no pollen samples, if there are no AQI items, or if
|
||||
/// lining them up fails. Returns [`None`] for the maximum pollen sample or maximum AQI item
|
||||
/// if there are no samples or items.
|
||||
fn merge(
|
||||
pollen_samples: Vec<BuienradarSample>,
|
||||
aqi_items: Vec<LuchtmeetnetItem>,
|
||||
) -> Option<Vec<Item>> {
|
||||
) -> Option<(
|
||||
Vec<Item>,
|
||||
Option<BuienradarSample>,
|
||||
Option<LuchtmeetnetItem>,
|
||||
)> {
|
||||
let mut pollen_samples = pollen_samples;
|
||||
let mut aqi_items = aqi_items;
|
||||
|
||||
|
@ -65,6 +72,16 @@ fn merge(
|
|||
aqi_items.drain(..idx);
|
||||
}
|
||||
|
||||
// Find the maximum sample/item of each series.
|
||||
let pollen_max = pollen_samples
|
||||
.iter()
|
||||
.max_by_key(|sample| sample.score)
|
||||
.cloned();
|
||||
let aqi_max = aqi_items
|
||||
.iter()
|
||||
.max_by_key(|item| (item.value * 1_000.0) as u32)
|
||||
.cloned();
|
||||
|
||||
// Combine the samples with items by taking the maximum of pollen sample score and AQI item
|
||||
// value.
|
||||
let items = pollen_samples
|
||||
|
@ -78,18 +95,23 @@ fn merge(
|
|||
})
|
||||
.collect();
|
||||
|
||||
Some(items)
|
||||
Some((items, pollen_max, aqi_max))
|
||||
}
|
||||
|
||||
/// Retrieves the combined forecasted items for the provided position and metric.
|
||||
///
|
||||
/// Besides the combined items, it also yields the maxium pollen sample and AQI item.
|
||||
/// Note that the maximum values are calculated before combining them, so the time stamp
|
||||
/// corresponds to the one in the original series, not to a timestamp of an item after merging.
|
||||
///
|
||||
/// It supports the following metric:
|
||||
/// * [`Metric::PAQI`]
|
||||
///
|
||||
/// Returns [`None`] if retrieving data from either the Buienradar or the Luchtmeetnet provider
|
||||
/// fails or if they cannot be combined.
|
||||
/// Returns [`None`] for the combined items if retrieving data from either the Buienradar or the
|
||||
/// Luchtmeetnet provider fails or if they cannot be combined. Returns [`None`] for the maxiumum
|
||||
/// pollen sample or AQI item if there are no samples or items.
|
||||
///
|
||||
/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and
|
||||
/// If the result is [`Some`], it will be cached for 30 minutes for the the given position and
|
||||
/// metric.
|
||||
#[cached(
|
||||
time = 1800,
|
||||
|
@ -101,7 +123,11 @@ pub(crate) async fn get(
|
|||
position: Position,
|
||||
metric: Metric,
|
||||
maps_handle: &MapsHandle,
|
||||
) -> Option<Vec<Item>> {
|
||||
) -> Option<(
|
||||
Vec<Item>,
|
||||
Option<BuienradarSample>,
|
||||
Option<LuchtmeetnetItem>,
|
||||
)> {
|
||||
if metric != Metric::PAQI {
|
||||
return None;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue