use reqwest::Url;
use time::Date;
use super::url::{
QueryParam, build_endpoint_url, push_api_param, push_api_values, push_display_param,
push_float_param,
};
use super::validation::{
is_non_zero_u16, validate_coordinates, validate_fixed_range_windows,
validate_optional_solar_angle,
};
use crate::response::decode_forecast_json;
use crate::units::{CellSelection, TimeFormat, Timezone};
use crate::variables::{
SatelliteRadiationHourlyVar, SatelliteRadiationModel, SatelliteRadiationTemporalResolution,
};
use crate::{Client, Error, Result, SatelliteRadiationResponse};
#[derive(Debug)]
#[must_use = "satellite radiation builders do nothing until `.send().await` is called"]
pub struct SatelliteRadiationBuilder<'a> {
client: &'a Client,
latitude: f64,
longitude: f64,
hourly: Vec<SatelliteRadiationHourlyVar>,
models: Vec<SatelliteRadiationModel>,
temporal_resolution: Option<SatelliteRadiationTemporalResolution>,
timeformat: Option<TimeFormat>,
timezone: Option<Timezone>,
past_days: Option<u16>,
forecast_days: Option<u16>,
past_hours: Option<u16>,
forecast_hours: Option<u16>,
date_range: Option<(Date, Date)>,
cell_selection: Option<CellSelection>,
tilt: Option<f32>,
azimuth: Option<f32>,
}
impl<'a> SatelliteRadiationBuilder<'a> {
pub(crate) fn new(client: &'a Client, latitude: f64, longitude: f64) -> Self {
Self {
client,
latitude,
longitude,
hourly: Vec::new(),
models: Vec::new(),
temporal_resolution: None,
timeformat: None,
timezone: None,
past_days: None,
forecast_days: None,
past_hours: None,
forecast_hours: None,
date_range: None,
cell_selection: None,
tilt: None,
azimuth: None,
}
}
pub fn hourly<I>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = SatelliteRadiationHourlyVar>,
{
self.hourly.extend(variables);
self
}
pub fn models<I>(mut self, models: I) -> Self
where
I: IntoIterator<Item = SatelliteRadiationModel>,
{
self.models.extend(models);
self
}
pub fn temporal_resolution(mut self, resolution: SatelliteRadiationTemporalResolution) -> Self {
self.temporal_resolution = Some(resolution);
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 past_days(mut self, days: u16) -> Self {
self.past_days = Some(days);
self
}
pub fn forecast_days(mut self, days: u16) -> Self {
self.forecast_days = Some(days);
self
}
pub fn past_hours(mut self, hours: u16) -> Self {
self.past_hours = Some(hours);
self
}
pub fn forecast_hours(mut self, hours: u16) -> Self {
self.forecast_hours = Some(hours);
self
}
pub fn date_range(mut self, start: Date, end: Date) -> Self {
self.date_range = Some((start, end));
self
}
pub fn cell_selection(mut self, selection: CellSelection) -> Self {
self.cell_selection = Some(selection);
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 async fn send(self) -> Result<SatelliteRadiationResponse> {
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()),
];
push_api_values(&mut params, "hourly", &self.hourly);
push_api_values(&mut params, "models", &self.models);
push_api_param(
&mut params,
"temporal_resolution",
self.temporal_resolution.as_ref(),
);
push_api_param(&mut params, "timeformat", self.timeformat.as_ref());
push_api_param(&mut params, "timezone", self.timezone.as_ref());
push_display_param(
&mut params,
"past_days",
self.past_days.filter(|days| *days > 0),
);
push_display_param(&mut params, "forecast_days", self.forecast_days);
push_display_param(
&mut params,
"past_hours",
self.past_hours.filter(|hours| *hours > 0),
);
push_display_param(
&mut params,
"forecast_hours",
self.forecast_hours.filter(|hours| *hours > 0),
);
if let Some((start, end)) = self.date_range {
params.push(("start_date", start.to_string()));
params.push(("end_date", end.to_string()));
}
push_api_param(&mut params, "cell_selection", self.cell_selection.as_ref());
push_float_param(&mut params, "tilt", self.tilt.map(f64::from));
push_float_param(&mut params, "azimuth", self.azimuth.map(f64::from));
build_endpoint_url(
&self.client.satellite_radiation_base,
"satellite_radiation_base_url",
"v1/archive",
self.client.api_key.as_deref(),
params,
)
}
fn validate(&self) -> Result<()> {
validate_coordinates(self.latitude, self.longitude)?;
if self.hourly.is_empty() {
return Err(Error::InvalidParam {
field: "hourly",
reason: "set at least one hourly variable".into(),
});
}
validate_fixed_range_windows(
self.date_range,
None::<(Date, Date)>,
&[
("past_days", is_non_zero_u16(self.past_days)),
("forecast_days", self.forecast_days.is_some()),
("past_hours", is_non_zero_u16(self.past_hours)),
("forecast_hours", is_non_zero_u16(self.forecast_hours)),
],
)?;
validate_optional_solar_angle("tilt", self.tilt, 0.0..=90.0)?;
validate_optional_solar_angle("azimuth", self.azimuth, -180.0..=180.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
SatelliteRadiationHourlyVar, SatelliteRadiationModel, SatelliteRadiationTemporalResolution,
Timezone,
};
use time::macros::date;
#[test]
fn build_satellite_radiation_url_with_hourly_variables() {
let client = Client::builder()
.satellite_radiation_base_url("https://example.com/satellite?token=abc")
.unwrap()
.build()
.unwrap();
let url = client
.satellite_radiation(47.3769, 8.5417)
.hourly([
SatelliteRadiationHourlyVar::ShortwaveRadiation,
SatelliteRadiationHourlyVar::DirectRadiation,
])
.models([SatelliteRadiationModel::SatelliteRadiationSeamless])
.temporal_resolution(SatelliteRadiationTemporalResolution::Hourly)
.timezone(Timezone::Iana("Europe/Zurich".to_owned()))
.date_range(date!(2026 - 04 - 30), date!(2026 - 04 - 30))
.tilt(45.0)
.azimuth(0.0)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/satellite/v1/archive?token=abc&latitude=47.3769&longitude=8.5417&hourly=shortwave_radiation%2Cdirect_radiation&models=satellite_radiation_seamless&temporal_resolution=hourly&timezone=Europe%2FZurich&start_date=2026-04-30&end_date=2026-04-30&tilt=45&azimuth=0"
);
}
#[test]
fn build_satellite_radiation_url_with_relative_windows() {
let client = Client::builder()
.satellite_radiation_base_url("https://example.com")
.unwrap()
.build()
.unwrap();
let url = client
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.models([SatelliteRadiationModel::EumetsatSarah3])
.past_days(1)
.forecast_days(2)
.past_hours(3)
.forecast_hours(4)
.build_url()
.unwrap();
assert_eq!(
url.as_str(),
"https://example.com/v1/archive?latitude=47.3769&longitude=8.5417&hourly=shortwave_radiation&models=eumetsat_sarah3&past_days=1&forecast_days=2&past_hours=3&forecast_hours=4"
);
}
#[test]
fn rejects_empty_satellite_radiation_variable_set() {
let client = Client::new();
let err = client
.satellite_radiation(47.3769, 8.5417)
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "hourly",
..
}
));
}
#[test]
fn rejects_reversed_satellite_radiation_date_range() {
let client = Client::new();
let err = client
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.date_range(date!(2026 - 05 - 01), date!(2026 - 04 - 30))
.build_url()
.unwrap_err();
assert!(matches!(
err,
Error::InvalidParam {
field: "date_range",
..
}
));
}
#[test]
fn rejects_satellite_radiation_date_range_with_relative_window() {
for (field, err) in [
(
"past_days",
Client::new()
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.past_days(1)
.date_range(date!(2026 - 04 - 30), date!(2026 - 04 - 30))
.build_url()
.unwrap_err(),
),
(
"forecast_days",
Client::new()
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.forecast_days(2)
.date_range(date!(2026 - 04 - 30), date!(2026 - 04 - 30))
.build_url()
.unwrap_err(),
),
(
"past_hours",
Client::new()
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.past_hours(1)
.date_range(date!(2026 - 04 - 30), date!(2026 - 04 - 30))
.build_url()
.unwrap_err(),
),
(
"forecast_hours",
Client::new()
.satellite_radiation(47.3769, 8.5417)
.hourly([SatelliteRadiationHourlyVar::ShortwaveRadiation])
.forecast_hours(1)
.date_range(date!(2026 - 04 - 30), date!(2026 - 04 - 30))
.build_url()
.unwrap_err(),
),
] {
assert!(
matches!(
err,
Error::MutuallyExclusive {
first: "date_range",
second,
} if second == field
),
"{field} should be mutually exclusive with date_range"
);
}
}
}