changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View 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"] }

View File

@@ -0,0 +1,5 @@
mod open_meteo;
mod wmo;
pub use open_meteo::OpenMeteoWeatherLookup;
pub use wmo::condition_for;

View 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
}

View 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,
}
}

View 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"
);
}
}