openmeteo-rs 1.0.0

Rust client for the Open-Meteo weather API.
Documentation
//! Satellite radiation endpoint builder.

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

/// Builder for the satellite-radiation `/v1/archive` endpoint.
#[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,
        }
    }

    /// Adds hourly satellite-radiation variables to the request.
    pub fn hourly<I>(mut self, variables: I) -> Self
    where
        I: IntoIterator<Item = SatelliteRadiationHourlyVar>,
    {
        self.hourly.extend(variables);
        self
    }

    /// Pins one or more satellite-radiation models.
    pub fn models<I>(mut self, models: I) -> Self
    where
        I: IntoIterator<Item = SatelliteRadiationModel>,
    {
        self.models.extend(models);
        self
    }

    /// Sets native or hourly satellite temporal resolution.
    pub fn temporal_resolution(mut self, resolution: SatelliteRadiationTemporalResolution) -> Self {
        self.temporal_resolution = Some(resolution);
        self
    }

    /// Sets the response timestamp format.
    pub fn timeformat(mut self, format: TimeFormat) -> Self {
        self.timeformat = Some(format);
        self
    }

    /// Sets the response timezone.
    pub fn timezone(mut self, timezone: Timezone) -> Self {
        self.timezone = Some(timezone);
        self
    }

    /// Requests a number of past days.
    pub fn past_days(mut self, days: u16) -> Self {
        self.past_days = Some(days);
        self
    }

    /// Requests a number of forecast days.
    pub fn forecast_days(mut self, days: u16) -> Self {
        self.forecast_days = Some(days);
        self
    }

    /// Requests a number of past hourly time steps.
    pub fn past_hours(mut self, hours: u16) -> Self {
        self.past_hours = Some(hours);
        self
    }

    /// Requests a number of forecast hourly time steps.
    pub fn forecast_hours(mut self, hours: u16) -> Self {
        self.forecast_hours = Some(hours);
        self
    }

    /// Sets an inclusive date range using Open-Meteo's `start_date` and `end_date`.
    pub fn date_range(mut self, start: Date, end: Date) -> Self {
        self.date_range = Some((start, end));
        self
    }

    /// Sets grid-cell selection.
    pub fn cell_selection(mut self, selection: CellSelection) -> Self {
        self.cell_selection = Some(selection);
        self
    }

    /// Sets panel tilt for tilted irradiance variables.
    pub fn tilt(mut self, degrees: f32) -> Self {
        self.tilt = Some(degrees);
        self
    }

    /// Sets panel azimuth for tilted irradiance variables.
    pub fn azimuth(mut self, degrees: f32) -> Self {
        self.azimuth = Some(degrees);
        self
    }

    /// Sends the request and decodes the JSON satellite-radiation response.
    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"
            );
        }
    }
}