use reqwest::Url;
use time::Date;
use super::url::{
QueryParam, build_endpoint_url, push_api_param, push_api_values, push_float_param,
};
use super::validation::{
validate_coordinates, validate_optional_solar_angle, validate_ordered_range,
};
use crate::response::decode_forecast_json;
use crate::units::{
CellSelection, PrecipitationUnit, TemperatureUnit, TimeFormat, Timezone, WindSpeedUnit,
};
use crate::variables::{CurrentVar, DailyVar, HourlyVar, Minutely15Var, WeatherModel};
use crate::{Client, Error, HistoricalForecastResponse, Result};
#[derive(Debug)]
#[must_use = "historical forecast builders do nothing until `.send().await` is called"]
pub struct HistoricalForecastBuilder<'a> {
client: &'a Client,
latitude: f64,
longitude: f64,
start_date: Date,
end_date: Date,
hourly: Vec<HourlyVar>,
minutely_15: Vec<Minutely15Var>,
daily: Vec<DailyVar>,
current: Vec<CurrentVar>,
temperature_unit: Option<TemperatureUnit>,
wind_speed_unit: Option<WindSpeedUnit>,
precipitation_unit: Option<PrecipitationUnit>,
timeformat: Option<TimeFormat>,
timezone: Option<Timezone>,
cell_selection: Option<CellSelection>,
elevation: Option<f64>,
tilt: Option<f32>,
azimuth: Option<f32>,
models: Vec<WeatherModel>,
}
impl<'a> HistoricalForecastBuilder<'a> {
pub(crate) fn new(
client: &'a Client,
latitude: f64,
longitude: f64,
start_date: Date,
end_date: Date,
) -> Self {
Self {
client,
latitude,
longitude,
start_date,
end_date,
hourly: Vec::new(),
minutely_15: Vec::new(),
daily: Vec::new(),
current: Vec::new(),
temperature_unit: None,
wind_speed_unit: None,
precipitation_unit: None,
timeformat: None,
timezone: None,
cell_selection: None,
elevation: None,
tilt: None,
azimuth: None,
models: Vec::new(),
}
}
pub fn hourly<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = HourlyVar>,
{
self.hourly.extend(variables);
self
}
pub fn minutely_15<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = Minutely15Var>,
{
self.minutely_15.extend(variables);
self
}
pub fn daily<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = DailyVar>,
{
self.daily.extend(variables);
self
}
pub fn current<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = CurrentVar>,
{
self.current.extend(variables);
self
}
pub fn temperature_unit(mut self, unit: TemperatureUnit) -> Self {
self.temperature_unit = Some(unit);
self
}
pub fn wind_speed_unit(mut self, unit: WindSpeedUnit) -> Self {
self.wind_speed_unit = Some(unit);
self
}
pub fn precipitation_unit(mut self, unit: PrecipitationUnit) -> Self {
self.precipitation_unit = Some(unit);
self
}
pub fn timeformat(mut self, format: TimeFormat) -> Self {
self.timeformat = Some(format);
self
}
pub fn timezone(mut self, timezone: Timezone) -> Self {
self.timezone = Some(timezone);
self
}
pub fn cell_selection(mut self, selection: CellSelection) -> Self {
self.cell_selection = Some(selection);
self
}
pub fn elevation(mut self, meters: f64) -> Self {
self.elevation = Some(meters);
self
}
pub fn tilt(mut self, degrees: f32) -> Self {
self.tilt = Some(degrees);
self
}
pub fn azimuth(mut self, degrees: f32) -> Self {
self.azimuth = Some(degrees);
self
}
pub fn models<I>(mut self, models: I) -> Self
where
I: IntoIterator<Item = WeatherModel>,
{
self.models.extend(models);
self
}
pub async fn send(self) -> Result<HistoricalForecastResponse> {
let url = self.build_url()?;
let body = self.client.execute(self.client.http.get(url)).await?;
decode_forecast_json(&body)
}
pub(crate) fn build_url(&self) -> Result<Url> {
self.validate()?;
let mut params: Vec<QueryParam> = vec![
("latitude", self.latitude.to_string()),
("longitude", self.longitude.to_string()),
("start_date", self.start_date.to_string()),
("end_date", self.end_date.to_string()),
];
push_api_values(&mut params, "hourly", &self.hourly);
push_api_values(&mut params, "minutely_15", &self.minutely_15);
push_api_values(&mut params, "daily", &self.daily);
push_api_values(&mut params, "current", &self.current);
push_api_param(
&mut params,
"temperature_unit",
self.temperature_unit.as_ref(),
);
push_api_param(
&mut params,
"wind_speed_unit",
self.wind_speed_unit.as_ref(),
);
push_api_param(
&mut params,
"precipitation_unit",
self.precipitation_unit.as_ref(),
);
push_api_param(&mut params, "timeformat", self.timeformat.as_ref());
push_api_param(&mut params, "timezone", self.timezone.as_ref());
push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());
push_float_param(&mut params, "elevation", self.elevation);
push_float_param(&mut params, "tilt", self.tilt.map(Into::into));
push_float_param(&mut params, "azimuth", self.azimuth.map(Into::into));
push_api_values(&mut params, "models", &self.models);
build_endpoint_url(
&self.client.historical_forecast_base,
"historical_forecast_base_url",
"v1/forecast",
self.client.api_key.as_deref(),
params,
)
}
fn validate(&self) -> Result<()> {
validate_coordinates(self.latitude, self.longitude)?;
if self.hourly.is_empty()
&& self.minutely_15.is_empty()
&& self.daily.is_empty()
&& self.current.is_empty()
{
return Err(Error::InvalidParam {
field: "variables",
reason: "set at least one hourly, minutely_15, daily, or current variable".into(),
});
}
validate_optional_solar_angle("tilt", self.tilt, 0.0..=90.0)?;
validate_optional_solar_angle("azimuth", self.azimuth, -180.0..=180.0)?;
validate_ordered_range(
"date_range",
Some((self.start_date, self.end_date)),
"start date must be before or equal to end date",
)?;
self.validate_variables()
}
fn validate_variables(&self) -> Result<()> {
for var in &self.hourly {
var.validate_weather_request()?;
}
for var in &self.current {
var.validate_weather_request()?;
}
for var in &self.minutely_15 {
var.validate_weather_request()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
CurrentVar, DailyVar, HourlyVar, Minutely15Var, SoilMoistureDepth, TemperatureUnit,
Timezone, TowerLevel, WeatherModel,
};
use time::macros::date;
#[test]
fn build_historical_forecast_url_with_all_groups() {
let client = Client::builder()
.historical_forecast_base_url("https://example.com/historical?token=abc")
.unwrap()
.build()
.unwrap();
let url = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 02),
)
.hourly([HourlyVar::Temperature2m, HourlyVar::Precipitation])
.minutely_15([Minutely15Var::Temperature2m])
.daily([DailyVar::Temperature2mMax, DailyVar::PrecipitationSum])
.current([CurrentVar::Temperature2m])
.timezone(Timezone::Iana("Europe/Zurich".to_owned()))
.temperature_unit(TemperatureUnit::Fahrenheit)
.tilt(30.0)
.azimuth(0.0)
.models([WeatherModel::EcmwfIfs025])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/historical/v1/forecast?token=abc&latitude=47.3769&longitude=8.5417&start_date=2024-01-01&end_date=2024-01-02&hourly=temperature_2m%2Cprecipitation&minutely_15=temperature_2m&daily=temperature_2m_max%2Cprecipitation_sum¤t=temperature_2m&temperature_unit=fahrenheit&timezone=Europe%2FZurich&tilt=30&azimuth=0&models=ecmwf_ifs025"
);
}
#[test]
fn current_only_historical_forecast_request_is_valid() {
let client = Client::builder()
.historical_forecast_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.current([CurrentVar::Temperature2m])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=47.3769&longitude=8.5417&start_date=2024-01-01&end_date=2024-01-01¤t=temperature_2m"
);
}
#[test]
fn rejects_out_of_range_historical_forecast_solar_orientation() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.hourly([HourlyVar::GlobalTiltedIrradiance])
.tilt(91.0)
.build_url()
.unwrap_err();
assert!(matches!(err, Error::InvalidParam { field: "tilt", .. }));
}
#[test]
fn build_historical_forecast_url_with_api_key() {
let client = Client::builder()
.historical_forecast_base_url("https://example.com")
.unwrap()
.api_key("secret")
.build()
.unwrap();
let url = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.hourly([HourlyVar::Temperature2m])
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/forecast?latitude=47.3769&longitude=8.5417&start_date=2024-01-01&end_date=2024-01-01&hourly=temperature_2m&apikey=secret"
);
}
#[test]
fn rejects_empty_historical_forecast_variable_set() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "variables",
..
}
));
}
#[test]
fn rejects_reversed_historical_forecast_date_range() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 02),
date!(2024 - 01 - 01),
)
.hourly([HourlyVar::Temperature2m])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "date_range",
..
}
));
}
#[test]
fn rejects_reversed_historical_forecast_soil_moisture_depth_range() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.hourly([HourlyVar::SoilMoisture {
from: SoilMoistureDepth::Cm27,
to: SoilMoistureDepth::Cm3,
}])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly.soil_moisture",
..
}
));
}
#[test]
fn rejects_unsupported_historical_forecast_minutely_15_tower_level() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.minutely_15([Minutely15Var::WindSpeedAtTower(TowerLevel::M120)])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "minutely_15.tower_level",
..
}
));
}
#[test]
fn rejects_unsupported_historical_forecast_current_tower_level() {
let client = Client::new();
let err = client
.historical_forecast(
47.3769,
8.5417,
date!(2024 - 01 - 01),
date!(2024 - 01 - 01),
)
.current([CurrentVar::TemperatureAtTower(TowerLevel::M10)])
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly.temperature_at_tower",
..
}
));
}
}