15
crates/adapters/weather/Cargo.toml
Normal file
15
crates/adapters/weather/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "weather"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
domain.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
5
crates/adapters/weather/src/lib.rs
Normal file
5
crates/adapters/weather/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod open_meteo;
|
||||
mod wmo;
|
||||
|
||||
pub use open_meteo::OpenMeteoWeatherLookup;
|
||||
pub use wmo::condition_for;
|
||||
122
crates/adapters/weather/src/open_meteo.rs
Normal file
122
crates/adapters/weather/src/open_meteo.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::location::Coordinates;
|
||||
use domain::provider::ProviderName;
|
||||
use domain::weather::{Celsius, Weather};
|
||||
|
||||
use super::wmo::condition_for;
|
||||
|
||||
const ARCHIVE_URL: &str = "https://archive-api.open-meteo.com/v1/archive";
|
||||
const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast";
|
||||
const PROVIDER: &str = "open-meteo";
|
||||
const HOURLY_FIELDS: &str = "temperature_2m,weather_code";
|
||||
const DAYS_THE_ARCHIVE_LAGS_BEHIND: i64 = 6;
|
||||
|
||||
pub struct OpenMeteoWeatherLookup {
|
||||
http: reqwest::Client,
|
||||
provider: ProviderName,
|
||||
}
|
||||
|
||||
impl OpenMeteoWeatherLookup {
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
Self {
|
||||
http,
|
||||
provider: ProviderName::from_persistence(PROVIDER.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct HourlyResponse {
|
||||
hourly: Option<Hourly>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct Hourly {
|
||||
time: Vec<String>,
|
||||
#[serde(rename = "temperature_2m")]
|
||||
temperature: Vec<Option<f64>>,
|
||||
#[serde(rename = "weather_code")]
|
||||
code: Vec<Option<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::WeatherLookupPort for OpenMeteoWeatherLookup {
|
||||
fn provider(&self) -> &ProviderName {
|
||||
&self.provider
|
||||
}
|
||||
|
||||
async fn observed_at(
|
||||
&self,
|
||||
coordinates: &Coordinates,
|
||||
instant: &DateTime<FixedOffset>,
|
||||
) -> Result<Option<Weather>, DomainError> {
|
||||
let day = instant.date_naive().to_string();
|
||||
let url = url_for(instant);
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[
|
||||
("latitude", coordinates.latitude().value().to_string()),
|
||||
("longitude", coordinates.longitude().value().to_string()),
|
||||
("start_date", day.clone()),
|
||||
("end_date", day),
|
||||
("hourly", HOURLY_FIELDS.to_string()),
|
||||
("timezone", "UTC".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
DomainError::InvalidInput(format!("weather lookup failed: {error}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DomainError::InvalidInput(format!(
|
||||
"the weather provider answered {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let hourly: HourlyResponse = response.json().await.map_err(|error| {
|
||||
DomainError::InvalidInput(format!(
|
||||
"the weather provider sent something unreadable: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(self.nearest_hour(hourly.hourly, instant))
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenMeteoWeatherLookup {
|
||||
fn nearest_hour(
|
||||
&self,
|
||||
hourly: Option<Hourly>,
|
||||
instant: &DateTime<FixedOffset>,
|
||||
) -> Option<Weather> {
|
||||
let hourly = hourly?;
|
||||
let wanted = instant.naive_utc().format("%Y-%m-%dT%H:00").to_string();
|
||||
|
||||
let at = hourly.time.iter().position(|hour| hour == &wanted)?;
|
||||
|
||||
let code = (*hourly.code.get(at)?)?;
|
||||
let degrees = (*hourly.temperature.get(at)?)?;
|
||||
|
||||
Some(Weather::new(
|
||||
condition_for(code)?,
|
||||
Celsius::new(degrees).ok()?,
|
||||
self.provider.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn url_for(instant: &DateTime<FixedOffset>) -> &'static str {
|
||||
let lag = chrono::Utc::now() - instant.with_timezone(&chrono::Utc);
|
||||
|
||||
if lag.num_days() >= DAYS_THE_ARCHIVE_LAGS_BEHIND {
|
||||
return ARCHIVE_URL;
|
||||
}
|
||||
|
||||
FORECAST_URL
|
||||
}
|
||||
14
crates/adapters/weather/src/wmo.rs
Normal file
14
crates/adapters/weather/src/wmo.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use domain::weather::Condition;
|
||||
|
||||
pub fn condition_for(code: u8) -> Option<Condition> {
|
||||
match code {
|
||||
0..=1 => Some(Condition::Clear),
|
||||
2..=3 => Some(Condition::Cloudy),
|
||||
45..=48 => Some(Condition::Fog),
|
||||
51..=57 => Some(Condition::Drizzle),
|
||||
61..=67 | 80..=82 => Some(Condition::Rain),
|
||||
71..=77 | 85..=86 => Some(Condition::Snow),
|
||||
95..=99 => Some(Condition::Thunderstorm),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
60
crates/adapters/weather/tests/wmo_test.rs
Normal file
60
crates/adapters/weather/tests/wmo_test.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use domain::weather::Condition;
|
||||
|
||||
use weather::condition_for;
|
||||
|
||||
#[test]
|
||||
fn the_documented_wmo_codes_map_onto_the_domains_vocabulary() {
|
||||
let expected = [
|
||||
(0, Condition::Clear),
|
||||
(1, Condition::Clear),
|
||||
(2, Condition::Cloudy),
|
||||
(3, Condition::Cloudy),
|
||||
(45, Condition::Fog),
|
||||
(48, Condition::Fog),
|
||||
(51, Condition::Drizzle),
|
||||
(55, Condition::Drizzle),
|
||||
(61, Condition::Rain),
|
||||
(65, Condition::Rain),
|
||||
(66, Condition::Rain),
|
||||
(80, Condition::Rain),
|
||||
(82, Condition::Rain),
|
||||
(71, Condition::Snow),
|
||||
(75, Condition::Snow),
|
||||
(77, Condition::Snow),
|
||||
(85, Condition::Snow),
|
||||
(86, Condition::Snow),
|
||||
(95, Condition::Thunderstorm),
|
||||
(99, Condition::Thunderstorm),
|
||||
];
|
||||
|
||||
for (code, condition) in expected {
|
||||
assert_eq!(
|
||||
condition_for(code),
|
||||
Some(condition),
|
||||
"wmo code {code} should be {condition:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_the_provider_never_documents_maps_to_nothing() {
|
||||
for code in [4, 20, 44, 50, 60, 70, 90, 100, 255] {
|
||||
assert_eq!(
|
||||
condition_for(code),
|
||||
None,
|
||||
"wmo code {code} is not documented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_condition_in_the_vocabulary_is_reachable_from_some_code() {
|
||||
let reached: Vec<Condition> = (0..=99).filter_map(condition_for).collect();
|
||||
|
||||
for condition in Condition::ALL {
|
||||
assert!(
|
||||
reached.contains(&condition),
|
||||
"{condition:?} cannot be produced by any wmo code"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user