forked from paul/sinoptik
Merge pull request 'Implment the pollen and UV index metrics' (#13) from 4-implement-pollen-uvi into main
Reviewed-on: paul/sinoptik#13
This commit is contained in:
commit
a5ca1f02ff
|
@ -8,7 +8,7 @@ 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::buienradar::{Item as BuienradarItem, Sample as BuienradarSample};
|
||||
use crate::providers::luchtmeetnet::Item as LuchtmeetnetItem;
|
||||
|
||||
/// The current forecast for a specific location.
|
||||
|
@ -50,7 +50,7 @@ pub(crate) struct Forecast {
|
|||
|
||||
/// The pollen in the air (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pollen: Option<()>,
|
||||
pollen: Option<Vec<BuienradarSample>>,
|
||||
|
||||
/// The precipitation (when asked for).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
@ -58,7 +58,7 @@ pub(crate) struct Forecast {
|
|||
|
||||
/// The UV index (when asked for).
|
||||
#[serde(rename = "UVI", skip_serializing_if = "Option::is_none")]
|
||||
uvi: Option<()>,
|
||||
uvi: Option<Vec<BuienradarSample>>,
|
||||
}
|
||||
|
||||
impl Forecast {
|
||||
|
@ -115,7 +115,7 @@ impl Metric {
|
|||
pub(crate) async fn forecast(
|
||||
position: Position,
|
||||
metrics: Vec<Metric>,
|
||||
_maps_handle: &MapsHandle,
|
||||
maps_handle: &MapsHandle,
|
||||
) -> Forecast {
|
||||
let mut forecast = Forecast::new(position);
|
||||
|
||||
|
@ -136,11 +136,17 @@ pub(crate) async fn forecast(
|
|||
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::Pollen => {
|
||||
forecast.pollen =
|
||||
providers::buienradar::get_samples(position, metric, maps_handle).await
|
||||
}
|
||||
Metric::Precipitation => {
|
||||
forecast.precipitation = providers::buienradar::get_items(position, metric).await
|
||||
}
|
||||
Metric::UVI => {
|
||||
forecast.uvi =
|
||||
providers::buienradar::get_samples(position, metric, maps_handle).await
|
||||
}
|
||||
Metric::UVI => forecast.uvi = Some(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
136
src/maps.rs
136
src/maps.rs
|
@ -3,15 +3,13 @@
|
|||
//! 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.
|
||||
|
||||
// TODO: Allow dead code until #9 is implemented.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::f64::consts::PI;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Utc};
|
||||
use image::{DynamicImage, GenericImageView, ImageFormat};
|
||||
use image::{DynamicImage, GenericImageView, ImageFormat, Pixel, Rgb, Rgba};
|
||||
use reqwest::Url;
|
||||
use rocket::serde::Serialize;
|
||||
use rocket::tokio;
|
||||
|
@ -22,6 +20,31 @@ use crate::position::Position;
|
|||
/// A handle to access the in-memory cached maps.
|
||||
pub(crate) type MapsHandle = Arc<Mutex<Maps>>;
|
||||
|
||||
/// A histogram mapping map key colors to occurences/counts.
|
||||
type MapKeyHistogram = HashMap<Rgb<u8>, u32>;
|
||||
|
||||
/// The Buienradar map key used for determining the score of a coordinate by mapping its color.
|
||||
///
|
||||
/// Note that the actual score starts from 1, not 0 as per this array.
|
||||
#[rustfmt::skip]
|
||||
const MAP_KEY: [[u8; 3]; 10] = [
|
||||
[ 73, 218, 33],
|
||||
[ 48, 210, 0],
|
||||
[255, 248, 139],
|
||||
[255, 246, 66],
|
||||
[253, 187, 49],
|
||||
[253, 142, 36],
|
||||
[252, 16, 62],
|
||||
[150, 10, 51],
|
||||
[166, 109, 188],
|
||||
[179, 48, 161],
|
||||
];
|
||||
|
||||
/// The Buienradar map sample size.
|
||||
///
|
||||
/// Determiess the number of pixels in width/height that is samples around the sampling coordinate.
|
||||
const MAP_SAMPLE_SIZE: [u32; 2] = [11, 11];
|
||||
|
||||
/// The interval between map refreshes (in seconds).
|
||||
const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
|
@ -161,11 +184,12 @@ impl Maps {
|
|||
/// This returns [`None`] if the maps are not in the cache yet.
|
||||
/// Otherwise, it returns [`Some`] with a list of pollen sample, one for each map
|
||||
/// in the series of maps.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn pollen_sample(&self, _position: Position) -> Option<Vec<PollenSample>> {
|
||||
// TODO: Sample each map using the projected coordinates from the pollen map
|
||||
// timestamp, yielding it for each `POLLEN_MAP_INTERVAL`.
|
||||
todo!()
|
||||
pub(crate) fn pollen_sample(&self, position: Position) -> Option<Vec<Sample>> {
|
||||
self.pollen.as_ref().and_then(|maps| {
|
||||
let coords = self.pollen_project(position)?;
|
||||
|
||||
sample(maps, POLLEN_MAP_INTERVAL, POLLEN_MAP_COUNT, coords)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the UV index map for the given instant.
|
||||
|
@ -204,10 +228,12 @@ impl Maps {
|
|||
/// Otherwise, it returns [`Some`] with a list of UV index sample, one for each map
|
||||
/// in the series of maps.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn uvi_sample(&self, _position: Position) -> Option<Vec<UviSample>> {
|
||||
// TODO: Sample each map using the projected coordinates from the UV index map
|
||||
// timestamp, yielding it for each `UVI_MAP_INTERVAL`.
|
||||
todo!()
|
||||
pub(crate) fn uvi_sample(&self, position: Position) -> Option<Vec<Sample>> {
|
||||
self.uvi.as_ref().and_then(|maps| {
|
||||
let coords = self.uvi_project(position)?;
|
||||
|
||||
sample(maps, UVI_MAP_INTERVAL, UVI_MAP_COUNT, coords)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -253,36 +279,86 @@ impl MapsRefresh for MapsHandle {
|
|||
}
|
||||
}
|
||||
|
||||
/// A Buienradar pollen map sample.
|
||||
/// A Buienradar map sample.
|
||||
///
|
||||
/// This represents a value at a given time.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct PollenSample {
|
||||
pub(crate) struct Sample {
|
||||
/// The time(stamp) of the forecast.
|
||||
#[serde(serialize_with = "ts_seconds::serialize")]
|
||||
time: DateTime<Utc>,
|
||||
|
||||
/// The forecasted value.
|
||||
/// The forecasted score.
|
||||
///
|
||||
/// A value in the range `1..=10`.
|
||||
value: u8,
|
||||
#[serde(rename(serialize = "value"))]
|
||||
score: u8,
|
||||
}
|
||||
|
||||
/// A Buienradar UV index map sample.
|
||||
///
|
||||
/// This represents a value at a given time.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct UviSample {
|
||||
/// The time(stamp) of the forecast.
|
||||
#[serde(serialize_with = "ts_seconds::serialize")]
|
||||
time: DateTime<Utc>,
|
||||
/// Builds a scoring histogram for the map key.
|
||||
fn map_key_histogram() -> MapKeyHistogram {
|
||||
MAP_KEY
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut hm, channels| {
|
||||
hm.insert(Rgb::from(channels), 0);
|
||||
hm
|
||||
})
|
||||
}
|
||||
|
||||
/// The forecasted value.
|
||||
///
|
||||
/// A value in the range `1..=10`.
|
||||
value: u8,
|
||||
/// Samples the provided maps at the given (map-relative) coordinates and starting timestamp.
|
||||
/// It assumes the provided coordinates are within bounds of at least one map.
|
||||
/// The interval is the number of seconds the timestamp is bumped for each map.
|
||||
///
|
||||
/// Returns [`None`] if it encounters no known colors in any of the samples.
|
||||
fn sample<I: GenericImageView<Pixel = Rgba<u8>>>(
|
||||
maps: &I,
|
||||
interval: u64,
|
||||
count: u32,
|
||||
coords: (u32, u32),
|
||||
) -> Option<Vec<Sample>> {
|
||||
let (x, y) = coords;
|
||||
let width = maps.width() / count;
|
||||
let height = maps.height();
|
||||
let max_sample_width = (width - x).min(MAP_SAMPLE_SIZE[0]);
|
||||
let max_sample_height = (height - y).min(MAP_SAMPLE_SIZE[1]);
|
||||
let mut samples = Vec::with_capacity(count as usize);
|
||||
let mut time = Utc::now(); // TODO: Should be the timestamp of the map!
|
||||
let mut offset = 0;
|
||||
|
||||
while offset < maps.width() {
|
||||
let map = maps.view(
|
||||
x.saturating_sub(MAP_SAMPLE_SIZE[0] / 2) + offset,
|
||||
y.saturating_sub(MAP_SAMPLE_SIZE[1] / 2),
|
||||
max_sample_width,
|
||||
max_sample_height,
|
||||
);
|
||||
let histogram = map
|
||||
.pixels()
|
||||
.fold(map_key_histogram(), |mut h, (_px, _py, color)| {
|
||||
h.entry(color.to_rgb()).and_modify(|count| *count += 1);
|
||||
h
|
||||
});
|
||||
let (max_color, &count) = histogram
|
||||
.iter()
|
||||
.max_by_key(|(_color, count)| *count)
|
||||
.expect("Map key is never empty");
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let score = MAP_KEY
|
||||
.iter()
|
||||
.position(|&color| &Rgb::from(color) == max_color)
|
||||
.map(|score| score + 1) // Scores go from 1..=10, not 0..=9!
|
||||
.expect("Maximum color is always a map key color") as u8;
|
||||
|
||||
samples.push(Sample { time, score });
|
||||
time = time + chrono::Duration::seconds(interval as i64);
|
||||
offset += width;
|
||||
}
|
||||
|
||||
Some(samples)
|
||||
}
|
||||
|
||||
/// Retrieves an image from the provided URL.
|
||||
|
|
|
@ -22,7 +22,10 @@ use std::f64::consts::PI;
|
|||
/// [`Position::lon_as_i32`]).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Position {
|
||||
/// The latitude of the position.
|
||||
pub(crate) lat: f64,
|
||||
|
||||
/// The longitude of the position.
|
||||
pub(crate) lon: f64,
|
||||
}
|
||||
|
||||
|
|
|
@ -12,12 +12,16 @@ use csv::ReaderBuilder;
|
|||
use reqwest::Url;
|
||||
use rocket::serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::maps::MapsHandle;
|
||||
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";
|
||||
|
||||
/// The Buienradar pollen/UV index map sample.
|
||||
pub(crate) type Sample = crate::maps::Sample;
|
||||
|
||||
/// A row in the precipitation text output.
|
||||
///
|
||||
/// This is an intermediate type used to represent rows of the output.
|
||||
|
@ -110,6 +114,62 @@ async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
|
|||
rdr.deserialize().collect::<Result<_, _>>().ok()
|
||||
}
|
||||
|
||||
/// Retrieves the Buienradar forecasted pollen samples for the provided position.
|
||||
///
|
||||
/// Returns [`None`] if the sampling fails.
|
||||
///
|
||||
/// If the result is [`Some`] if will be cached for 1 hour for the given position.
|
||||
#[cached(
|
||||
time = 3_600,
|
||||
key = "Position",
|
||||
convert = r#"{ position }"#,
|
||||
option = true
|
||||
)]
|
||||
async fn get_pollen(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> {
|
||||
maps_handle
|
||||
.lock()
|
||||
.expect("Maps handle mutex was poisoned")
|
||||
.pollen_sample(position)
|
||||
}
|
||||
|
||||
/// Retrieves the Buienradar forecasted UV index samples for the provided position.
|
||||
///
|
||||
/// Returns [`None`] if the sampling fails.
|
||||
///
|
||||
/// If the result is [`Some`] if will be cached for 1 day for the given position.
|
||||
#[cached(
|
||||
time = 86_400,
|
||||
key = "Position",
|
||||
convert = r#"{ position }"#,
|
||||
option = true
|
||||
)]
|
||||
async fn get_uvi(position: Position, maps_handle: &MapsHandle) -> Option<Vec<Sample>> {
|
||||
maps_handle
|
||||
.lock()
|
||||
.expect("Maps handle mutex was poisoned")
|
||||
.uvi_sample(position)
|
||||
}
|
||||
|
||||
/// Retrieves the Buienradar forecasted map samples for the provided position.
|
||||
///
|
||||
/// It only supports the following metric:
|
||||
/// * [`Metric::Pollen`]
|
||||
/// * [`Metric::UVI`]
|
||||
///
|
||||
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
|
||||
/// this provider.
|
||||
pub(crate) async fn get_samples(
|
||||
position: Position,
|
||||
metric: Metric,
|
||||
maps_handle: &MapsHandle,
|
||||
) -> Option<Vec<Sample>> {
|
||||
match metric {
|
||||
Metric::Pollen => get_pollen(position, maps_handle).await,
|
||||
Metric::UVI => get_uvi(position, maps_handle).await,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the Buienradar forecasted items for the provided position.
|
||||
///
|
||||
/// It only supports the following metric:
|
||||
|
@ -117,7 +177,7 @@ async fn get_precipitation(position: Position) -> Option<Vec<Item>> {
|
|||
///
|
||||
/// Returns [`None`] if retrieval or deserialization fails, or if the metric is not supported by
|
||||
/// this provider.
|
||||
pub(crate) async fn get(position: Position, metric: Metric) -> Option<Vec<Item>> {
|
||||
pub(crate) async fn get_items(position: Position, metric: Metric) -> Option<Vec<Item>> {
|
||||
match metric {
|
||||
Metric::Precipitation => get_precipitation(position).await,
|
||||
_ => None,
|
||||
|
|
Loading…
Reference in New Issue