Introduce the Position struct; add position module

* Use `Position` everywhere instead of latitude/longitude float values
* Implement `Partial`, `Eq` and `Hash` for `Position` so it can
  part of a cache key
* Drop the `cache_key` helper function
* Rename the `address_position` function to `resolve_address`
* Add methods on `Position` for formatting latitude/longitude with
  a given precision (used for URL parameters in providers)
This commit is contained in:
Paul van Tilburg 2022-02-15 14:15:59 +01:00
parent b2f63db6b4
commit 3a48f234e9
5 changed files with 131 additions and 67 deletions

View File

@ -6,6 +6,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::luchtmeetnet::Item as LuchtmeetnetItem;
@ -112,12 +113,11 @@ impl Metric {
///
/// The provided list `metrics` determines what will be included in the forecast.
pub(crate) async fn forecast(
lat: f64,
lon: f64,
position: Position,
metrics: Vec<Metric>,
_maps_handle: &MapsHandle,
) -> Forecast {
let mut forecast = Forecast::new(lat, lon);
let mut forecast = Forecast::new(position);
// Expand the `All` metric if present, deduplicate otherwise.
let mut metrics = metrics;
@ -131,14 +131,14 @@ pub(crate) async fn forecast(
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(lat, lon, metric).await,
Metric::NO2 => forecast.no2 = providers::luchtmeetnet::get(lat, lon, metric).await,
Metric::O3 => forecast.o3 = providers::luchtmeetnet::get(lat, lon, metric).await,
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(lat, lon, metric).await,
Metric::PM10 => forecast.pm10 = providers::luchtmeetnet::get(position, metric).await,
Metric::Pollen => forecast.pollen = Some(()),
Metric::Precipitation => {
forecast.precipitation = providers::buienradar::get(lat, lon, metric).await
forecast.precipitation = providers::buienradar::get(position, metric).await
}
Metric::UVI => forecast.uvi = Some(()),
}

View File

@ -13,53 +13,21 @@
use std::sync::{Arc, Mutex};
use cached::proc_macro::cached;
use color_eyre::Result;
use geocoding::{Forward, Openstreetmap, Point};
use rocket::serde::json::Json;
use rocket::tokio::{self, select};
use rocket::{get, routes, State};
pub(crate) use self::forecast::{forecast, Forecast, Metric};
pub(crate) use self::forecast::Metric;
use self::forecast::{forecast, Forecast};
pub(crate) use self::maps::{Maps, MapsHandle};
use self::position::{resolve_address, Position};
pub(crate) mod forecast;
pub(crate) mod maps;
pub(crate) mod position;
pub(crate) mod providers;
/// Caching key helper function that can be used by providers.
///
/// This is necessary because `f64` does not implement `Eq` nor `Hash`, which is required by
/// the caching implementation.
fn cache_key(lat: f64, lon: f64, metric: Metric) -> (i32, i32, Metric) {
let lat_key = (lat * 10_000.0) as i32;
let lon_key = (lon * 10_000.0) as i32;
(lat_key, lon_key, metric)
}
/// Retrieves the geocoded position for the given address.
///
/// Returns [`Some`] with tuple of latitude and longitude. 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. Only the 100 least-recently used address
/// will be cached.
#[cached(size = 100)]
async fn address_position(address: String) -> Option<(f64, f64)> {
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()?;
// The `geocoding` API always returns (longitude, latitude) as (x, y).
points.get(0).map(|point| (point.y(), point.x()))
})
.await
.ok()
.flatten()
}
/// Handler for retrieving the forecast for an address.
#[get("/forecast?<address>&<metrics>")]
async fn forecast_address(
@ -67,8 +35,8 @@ async fn forecast_address(
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Option<Json<Forecast>> {
let (lat, lon) = address_position(address).await?;
let forecast = forecast(lat, lon, metrics, maps_handle).await;
let position = resolve_address(address).await?;
let forecast = forecast(position, metrics, maps_handle).await;
Some(Json(forecast))
}
@ -81,7 +49,8 @@ async fn forecast_geo(
metrics: Vec<Metric>,
maps_handle: &State<MapsHandle>,
) -> Json<Forecast> {
let forecast = forecast(lat, lon, metrics, maps_handle).await;
let position = Position::new(lat, lon);
let forecast = forecast(position, metrics, maps_handle).await;
Json(forecast)
}

103
src/position.rs Normal file
View File

@ -0,0 +1,103 @@
//! 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;
/// 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)
}
}
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()
}

View File

@ -12,7 +12,8 @@ use csv::ReaderBuilder;
use reqwest::Url;
use rocket::serde::{Deserialize, Serialize};
use crate::{cache_key, Metric};
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";
@ -93,20 +94,15 @@ fn convert_value(v: u16) -> f32 {
///
/// If the result is [`Some`] it will be cached for 5 minutes for the the given position and
/// metric.
#[cached(
time = 300,
convert = "{ cache_key(lat, lon, metric) }",
key = "(i32, i32, Metric)",
option = true
)]
pub(crate) async fn get(lat: f64, lon: f64, metric: Metric) -> Option<Vec<Item>> {
#[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", &format!("{:.02}", lat))
.append_pair("lon", &format!("{:.02}", lon));
.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()?;

View File

@ -8,7 +8,8 @@ use chrono::{DateTime, Utc};
use reqwest::Url;
use rocket::serde::{Deserialize, Serialize};
use crate::{cache_key, Metric};
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";
@ -52,13 +53,8 @@ pub(crate) struct Item {
///
/// If the result is [`Some`] it will be cached for 30 minutes for the the given position and
/// metric.
#[cached(
time = 1800,
convert = "{ cache_key(lat, lon, metric) }",
key = "(i32, i32, Metric)",
option = true
)]
pub(crate) async fn get(lat: f64, lon: f64, metric: Metric) -> Option<Vec<Item>> {
#[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",
@ -69,8 +65,8 @@ pub(crate) async fn get(lat: f64, lon: f64, metric: Metric) -> Option<Vec<Item>>
let mut url = Url::parse(LUCHTMEETNET_BASE_URL).unwrap();
url.query_pairs_mut()
.append_pair("formula", formula)
.append_pair("latitude", &format!("{:.05}", lat))
.append_pair("longitude", &format!("{:.05}", lon));
.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()?;