diff --git a/src/forecast.rs b/src/forecast.rs index 3bb8a88..0b6fd7f 100644 --- a/src/forecast.rs +++ b/src/forecast.rs @@ -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>, /// 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>, } impl Forecast { @@ -115,7 +115,7 @@ impl Metric { pub(crate) async fn forecast( position: Position, metrics: Vec, - _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(()), } } diff --git a/src/maps.rs b/src/maps.rs index 3ac88cf..31d734a 100644 --- a/src/maps.rs +++ b/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>; +/// A histogram mapping map key colors to occurences/counts. +type MapKeyHistogram = HashMap, 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> { - // 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> { + 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> { - // 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> { + 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, - /// 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, +/// 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>>( + maps: &I, + interval: u64, + count: u32, + coords: (u32, u32), +) -> Option> { + 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. diff --git a/src/position.rs b/src/position.rs index f3b5569..b098c4f 100644 --- a/src/position.rs +++ b/src/position.rs @@ -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, } diff --git a/src/providers/buienradar.rs b/src/providers/buienradar.rs index f1b9c3f..81614cb 100644 --- a/src/providers/buienradar.rs +++ b/src/providers/buienradar.rs @@ -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> { rdr.deserialize().collect::>().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> { + 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> { + 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> { + 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> { /// /// 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> { +pub(crate) async fn get_items(position: Position, metric: Metric) -> Option> { match metric { Metric::Precipitation => get_precipitation(position).await, _ => None,