i3status_rs/blocks/
weather.rs

1//! Current weather
2//!
3//! This block displays local weather and temperature information. In order to use this block, you
4//! will need access to a supported weather API service. At the time of writing, OpenWeatherMap,
5//! met.no, and the US National Weather Service are supported.
6//!
7//! Configuring this block requires configuring a weather service, which may require API keys and
8//! other parameters.
9//!
10//! If using the `autolocate` feature, set the autolocate update interval such that you do not exceed ipapi.co's free daily limit of 1000 hits. Or use `autolocate_interval = "once"` to only run on initialization.
11//!
12//! # Configuration
13//!
14//! Key | Values | Default
15//! ----|--------|--------
16//! `service` | The configuration of a weather service (see below). | **Required**
17//! `format` | A string to customise the output of this block. See below for available placeholders. Text may need to be escaped, refer to [Escaping Text](#escaping-text). | `" $icon $weather $temp "`
18//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None`
19//! `interval` | Update interval, in seconds. | `600`
20//! `autolocate` | Gets your location using the ipapi.co IP location service (no API key required). If the API call fails then the block will fallback to service specific location config. | `false`
21//! `autolocate_interval` | Update interval for `autolocate` in seconds or "once" | `interval`
22//!
23//! # OpenWeatherMap Options
24//!
25//! To use the service you will need a (free) API key.
26//!
27//! Key | Values | Required | Default
28//! ----|--------|----------|--------
29//! `name` | `openweathermap`. | Yes | None
30//! `api_key` | Your OpenWeatherMap API key. | Yes | None
31//! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Yes* | None
32//! `city_id` | OpenWeatherMap's ID for the city. (Deprecated) | Yes* | None
33//! `place` | OpenWeatherMap 'By {city name},{state code},{country code}' search query. See [here](https://openweathermap.org/api/geocoding-api#direct_name). Consumes an additional API call | Yes* | None
34//! `zip` | OpenWeatherMap 'By {zip code},{country code}' search query. See [here](https://openweathermap.org/api/geocoding-api#direct_zip). Consumes an additional API call | Yes* | None
35//! `units` | Either `"metric"` or `"imperial"`. | No | `"metric"`
36//! `lang` | Language code. See [here](https://openweathermap.org/current#multi). Currently only affects `weather_verbose` key. | No | `"en"`
37//! `forecast_hours` | How many hours should be forecast (must be increments of 3 hours, max 120 hours) | No | 12
38//!
39//! One of `coordinates`, `city_id`, `place`, or `zip` is required. If more than one are supplied, `coordinates` takes precedence over `city_id` which takes precedence over `place` which takes precedence over `zip`.
40//!
41//! The options `api_key`, `city_id`, `place`, `zip`, can be omitted from configuration,
42//! in which case they must be provided in the environment variables
43//! `OPENWEATHERMAP_API_KEY`, `OPENWEATHERMAP_CITY_ID`, `OPENWEATHERMAP_PLACE`, `OPENWEATHERMAP_ZIP`.
44//!
45//! Forecasts are only fetched if forecast_hours > 0 and the format has keys related to forecast.
46//!
47//! # met.no Options
48//!
49//! Key | Values | Required | Default
50//! ----|--------|----------|--------
51//! `name` | `metno`. | Yes | None
52//! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Required if `autolocate = false` | None
53//! `lang` | Language code: `en`, `nn` or `nb` | No | `en`
54//! `altitude` | Meters above sea level of the ground | No | Approximated by server
55//! `forecast_hours` | How many hours should be forecast | No | 12
56//!
57//! Met.no does not support location name, but if autolocate is enabled then autolocate's city value is used.
58//!
59//! # US National Weather Service Options
60//!
61//! Key | Values | Required | Default
62//! ----|--------|----------|--------
63//! `name` | `nws`. | Yes | None
64//! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Required if `autolocate = false` | None
65//! `forecast_hours` | How many hours should be forecast | No | 12
66//! `units` | Either `"metric"` or `"imperial"`. | No | `"metric"`
67//!
68//! Forecasts gather statistics from each hour between now and the `forecast_hours` value, and
69//! provide predicted weather at the set number of hours into the future.
70//!
71//! # Available Format Keys
72//!
73//!  Key                                         | Value                                                                         | Type     | Unit
74//! ---------------------------------------------|-------------------------------------------------------------------------------|----------|-----
75//! `location`                                   | Location name (exact format depends on the service)                           | Text     | -
76//! `icon{,_ffin}`                               | Icon representing the weather                                                 | Icon     | -
77//! `weather{,_ffin}`                            | Textual brief description of the weather, e.g. "Raining"                      | Text     | -
78//! `weather_verbose{,_ffin}`                    | Textual verbose description of the weather, e.g. "overcast clouds"            | Text     | -
79//! `temp{,_{favg,fmin,fmax,ffin}}`              | Temperature                                                                   | Number   | degrees
80//! `apparent{,_{favg,fmin,fmax,ffin}}`          | Australian Apparent Temperature                                               | Number   | degrees
81//! `humidity{,_{favg,fmin,fmax,ffin}}`          | Humidity                                                                      | Number   | %
82//! `wind{,_{favg,fmin,fmax,ffin}}`              | Wind speed                                                                    | Number   | -
83//! `wind_kmh{,_{favg,fmin,fmax,ffin}}`          | Wind speed. The wind speed in km/h                                            | Number   | -
84//! `direction{,_{favg,fmin,fmax,ffin}}`         | Wind direction, e.g. "NE"                                                     | Text     | -
85//! `sunrise`                                    | Time of sunrise                                                               | DateTime | -
86//! `sunset`                                     | Time of sunset                                                                | DateTime | -
87//!
88//! You can use the suffixes noted above to get the following:
89//!
90//! Suffix    | Description
91//! ----------|------------
92//! None      | Current weather
93//! `_favg`   | Average forecast value
94//! `_fmin`   | Minimum forecast value
95//! `_fmax`   | Maximum forecast value
96//! `_ffin`   | Final forecast value
97//!
98//! Action          | Description                               | Default button
99//! ----------------|-------------------------------------------|---------------
100//! `toggle_format` | Toggles between `format` and `format_alt` | Left
101//!
102//! # Examples
103//!
104//! Show detailed weather in San Francisco through the OpenWeatherMap service:
105//!
106//! ```toml
107//! [[block]]
108//! block = "weather"
109//! format = " $icon $weather ($location) $temp, $wind m/s $direction "
110//! format_alt = " $icon_ffin Forecast (9 hour avg) {$temp_favg ({$temp_fmin}-{$temp_fmax})|Unavailable} "
111//! [block.service]
112//! name = "openweathermap"
113//! api_key = "XXX"
114//! city_id = "5398563"
115//! units = "metric"
116//! forecast_hours = 9
117//! ```
118//!
119//! Show sunrise and sunset times in null island
120//!
121//! ```toml
122//! [[block]]
123//! block = "weather"
124//! format = "up $sunrise.datetime(f:'%R') down $sunset.datetime(f:'%R')"
125//! [block.service]
126//! name = "metno"
127//! coordinates = ["0", "0"]
128//! ```
129//!
130//! # Used Icons
131//!
132//! - `weather_sun` (when weather is reported as "Clear" during the day)
133//! - `weather_moon` (when weather is reported as "Clear" at night)
134//! - `weather_clouds` (when weather is reported as "Clouds" during the day)
135//! - `weather_clouds_night` (when weather is reported as "Clouds" at night)
136//! - `weather_fog` (when weather is reported as "Fog" or "Mist" during the day)
137//! - `weather_fog_night` (when weather is reported as "Fog" or "Mist" at night)
138//! - `weather_rain` (when weather is reported as "Rain" or "Drizzle" during the day)
139//! - `weather_rain_night` (when weather is reported as "Rain" or "Drizzle" at night)
140//! - `weather_snow` (when weather is reported as "Snow")
141//! - `weather_thunder` (when weather is reported as "Thunderstorm" during the day)
142//! - `weather_thunder_night` (when weather is reported as "Thunderstorm" at night)
143
144use std::fmt;
145use std::sync::{Arc, Mutex};
146use std::time::Instant;
147
148use chrono::{DateTime, Utc};
149use sunrise::{SolarDay, SolarEvent};
150
151use crate::formatting::Format;
152
153use super::prelude::*;
154
155pub mod met_no;
156pub mod nws;
157pub mod open_weather_map;
158
159const IP_API_URL: &str = "https://ipapi.co/json";
160
161static LAST_AUTOLOCATE: Mutex<Option<AutolocateResult>> = Mutex::new(None);
162
163#[derive(Deserialize, Debug)]
164#[serde(deny_unknown_fields)]
165pub struct Config {
166    #[serde(default = "default_interval")]
167    pub interval: Seconds,
168    #[serde(default)]
169    pub format: FormatConfig,
170    pub format_alt: Option<FormatConfig>,
171    pub service: WeatherService,
172    #[serde(default)]
173    pub autolocate: bool,
174    pub autolocate_interval: Option<Seconds>,
175}
176
177fn default_interval() -> Seconds {
178    Seconds::new(600)
179}
180
181#[async_trait]
182trait WeatherProvider {
183    async fn get_weather(
184        &self,
185        autolocated_location: Option<&Coordinates>,
186        need_forecast: bool,
187    ) -> Result<WeatherResult>;
188}
189
190#[derive(Deserialize, Debug)]
191#[serde(tag = "name", rename_all = "lowercase")]
192pub enum WeatherService {
193    OpenWeatherMap(open_weather_map::Config),
194    MetNo(met_no::Config),
195    Nws(nws::Config),
196}
197
198#[derive(Clone, Copy, Default)]
199enum WeatherIcon {
200    Clear {
201        is_night: bool,
202    },
203    Clouds {
204        is_night: bool,
205    },
206    Fog {
207        is_night: bool,
208    },
209    Rain {
210        is_night: bool,
211    },
212    Snow,
213    Thunder {
214        is_night: bool,
215    },
216    #[default]
217    Default,
218}
219
220impl WeatherIcon {
221    fn to_icon_str(self) -> &'static str {
222        match self {
223            Self::Clear { is_night: false } => "weather_sun",
224            Self::Clear { is_night: true } => "weather_moon",
225            Self::Clouds { is_night: false } => "weather_clouds",
226            Self::Clouds { is_night: true } => "weather_clouds_night",
227            Self::Fog { is_night: false } => "weather_fog",
228            Self::Fog { is_night: true } => "weather_fog_night",
229            Self::Rain { is_night: false } => "weather_rain",
230            Self::Rain { is_night: true } => "weather_rain_night",
231            Self::Snow => "weather_snow",
232            Self::Thunder { is_night: false } => "weather_thunder",
233            Self::Thunder { is_night: true } => "weather_thunder_night",
234            Self::Default => "weather_default",
235        }
236    }
237}
238
239#[derive(Default)]
240struct WeatherMoment {
241    icon: WeatherIcon,
242    weather: String,
243    weather_verbose: String,
244    temp: f64,
245    apparent: f64,
246    humidity: f64,
247    wind: f64,
248    wind_kmh: f64,
249    wind_direction: Option<f64>,
250}
251
252struct ForecastAggregate {
253    temp: f64,
254    apparent: f64,
255    humidity: f64,
256    wind: f64,
257    wind_kmh: f64,
258    wind_direction: Option<f64>,
259}
260
261struct ForecastAggregateSegment {
262    temp: Option<f64>,
263    apparent: Option<f64>,
264    humidity: Option<f64>,
265    wind: Option<f64>,
266    wind_kmh: Option<f64>,
267    wind_direction: Option<f64>,
268}
269
270struct WeatherResult {
271    location: String,
272    current_weather: WeatherMoment,
273    forecast: Option<Forecast>,
274    sunrise: DateTime<Utc>,
275    sunset: DateTime<Utc>,
276}
277
278impl WeatherResult {
279    fn into_values(self) -> Values {
280        let mut values = map! {
281            "location" => Value::text(self.location),
282            //current_weather
283            "icon" => Value::icon(self.current_weather.icon.to_icon_str()),
284            "temp" => Value::degrees(self.current_weather.temp),
285            "apparent" => Value::degrees(self.current_weather.apparent),
286            "humidity" => Value::percents(self.current_weather.humidity),
287            "weather" => Value::text(self.current_weather.weather),
288            "weather_verbose" => Value::text(self.current_weather.weather_verbose),
289            "wind" => Value::number(self.current_weather.wind),
290            "wind_kmh" => Value::number(self.current_weather.wind_kmh),
291            "direction" => Value::text(convert_wind_direction(self.current_weather.wind_direction).into()),
292            "sunrise" => Value::datetime(self.sunrise, None),
293            "sunset" => Value::datetime(self.sunset, None),
294        };
295
296        if let Some(forecast) = self.forecast {
297            macro_rules! map_forecasts {
298                ({$($suffix: literal => $src: expr),* $(,)?}) => {
299                    map!{ @extend values
300                        $(
301                            concat!("temp_f", $suffix) => Value::degrees($src.temp),
302                            concat!("apparent_f", $suffix) => Value::degrees($src.apparent),
303                            concat!("humidity_f", $suffix) => Value::percents($src.humidity),
304                            concat!("wind_f", $suffix) => Value::number($src.wind),
305                            concat!("wind_kmh_f", $suffix) => Value::number($src.wind_kmh),
306                            concat!("direction_f", $suffix) => Value::text(convert_wind_direction($src.wind_direction).into()),
307                        )*
308                    }
309                };
310            }
311            map_forecasts!({
312                "avg" => forecast.avg,
313                "min" => forecast.min,
314                "max" => forecast.max,
315                "fin" => forecast.fin,
316            });
317
318            map! { @extend values
319                "icon_ffin" => Value::icon(forecast.fin.icon.to_icon_str()),
320                "weather_ffin" => Value::text(forecast.fin.weather.clone()),
321                "weather_verbose_ffin" => Value::text(forecast.fin.weather_verbose.clone()),
322            }
323        }
324
325        values
326    }
327}
328
329struct Forecast {
330    avg: ForecastAggregate,
331    min: ForecastAggregate,
332    max: ForecastAggregate,
333    fin: WeatherMoment,
334}
335
336impl Forecast {
337    fn new(data: &[ForecastAggregateSegment], fin: WeatherMoment) -> Self {
338        let mut temp_avg = 0.0;
339        let mut temp_count = 0.0;
340        let mut apparent_avg = 0.0;
341        let mut apparent_count = 0.0;
342        let mut humidity_avg = 0.0;
343        let mut humidity_count = 0.0;
344        let mut wind_north_avg = 0.0;
345        let mut wind_east_avg = 0.0;
346        let mut wind_kmh_north_avg = 0.0;
347        let mut wind_kmh_east_avg = 0.0;
348        let mut wind_count = 0.0;
349        let mut max = ForecastAggregate {
350            temp: f64::MIN,
351            apparent: f64::MIN,
352            humidity: f64::MIN,
353            wind: f64::MIN,
354            wind_kmh: f64::MIN,
355            wind_direction: None,
356        };
357        let mut min = ForecastAggregate {
358            temp: f64::MAX,
359            apparent: f64::MAX,
360            humidity: f64::MAX,
361            wind: f64::MAX,
362            wind_kmh: f64::MAX,
363            wind_direction: None,
364        };
365        for val in data {
366            if let Some(temp) = val.temp {
367                temp_avg += temp;
368                max.temp = max.temp.max(temp);
369                min.temp = min.temp.min(temp);
370                temp_count += 1.0;
371            }
372            if let Some(apparent) = val.apparent {
373                apparent_avg += apparent;
374                max.apparent = max.apparent.max(apparent);
375                min.apparent = min.apparent.min(apparent);
376                apparent_count += 1.0;
377            }
378            if let Some(humidity) = val.humidity {
379                humidity_avg += humidity;
380                max.humidity = max.humidity.max(humidity);
381                min.humidity = min.humidity.min(humidity);
382                humidity_count += 1.0;
383            }
384
385            if let (Some(wind), Some(wind_kmh)) = (val.wind, val.wind_kmh) {
386                if let Some(degrees) = val.wind_direction {
387                    let (sin, cos) = degrees.to_radians().sin_cos();
388                    wind_north_avg += wind * cos;
389                    wind_east_avg += wind * sin;
390                    wind_kmh_north_avg += wind_kmh * cos;
391                    wind_kmh_east_avg += wind_kmh * sin;
392                    wind_count += 1.0;
393                }
394
395                if wind > max.wind {
396                    max.wind_direction = val.wind_direction;
397                    max.wind = wind;
398                    max.wind_kmh = wind_kmh;
399                }
400
401                if wind < min.wind {
402                    min.wind_direction = val.wind_direction;
403                    min.wind = wind;
404                    min.wind_kmh = wind_kmh;
405                }
406            }
407        }
408
409        temp_avg /= temp_count;
410        humidity_avg /= humidity_count;
411        apparent_avg /= apparent_count;
412
413        // Calculate the wind results separately, discarding invalid wind values
414        let (wind_avg, wind_kmh_avg, wind_direction_avg) = if wind_count == 0.0 {
415            (0.0, 0.0, None)
416        } else {
417            (
418                wind_east_avg.hypot(wind_north_avg) / wind_count,
419                wind_kmh_east_avg.hypot(wind_kmh_north_avg) / wind_count,
420                Some(
421                    wind_east_avg
422                        .atan2(wind_north_avg)
423                        .to_degrees()
424                        .rem_euclid(360.0),
425                ),
426            )
427        };
428
429        let avg = ForecastAggregate {
430            temp: temp_avg,
431            apparent: apparent_avg,
432            humidity: humidity_avg,
433            wind: wind_avg,
434            wind_kmh: wind_kmh_avg,
435            wind_direction: wind_direction_avg,
436        };
437        Self { avg, min, max, fin }
438    }
439}
440
441pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
442    let mut actions = api.get_actions()?;
443    api.set_default_actions(&[(MouseButton::Left, None, "toggle_format")])?;
444
445    let mut format = config.format.with_default(" $icon $weather $temp ")?;
446    let mut format_alt = match &config.format_alt {
447        Some(f) => Some(f.with_default("")?),
448        None => None,
449    };
450
451    let provider: Box<dyn WeatherProvider + Send + Sync> = match &config.service {
452        WeatherService::MetNo(service_config) => Box::new(met_no::Service::new(service_config)?),
453        WeatherService::OpenWeatherMap(service_config) => {
454            Box::new(open_weather_map::Service::new(config.autolocate, service_config).await?)
455        }
456        WeatherService::Nws(service_config) => {
457            Box::new(nws::Service::new(config.autolocate, service_config).await?)
458        }
459    };
460
461    let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval);
462    let need_forecast = need_forecast(&format, format_alt.as_ref());
463
464    let mut timer = config.interval.timer();
465
466    loop {
467        let location = if config.autolocate {
468            let fetch = || find_ip_location(autolocate_interval.0);
469            Some(fetch.retry(ExponentialBuilder::default()).await?)
470        } else {
471            None
472        };
473
474        let fetch = || provider.get_weather(location.as_ref(), need_forecast);
475        let data = fetch.retry(ExponentialBuilder::default()).await?;
476        let data_values = data.into_values();
477
478        loop {
479            let mut widget = Widget::new().with_format(format.clone());
480            widget.set_values(data_values.clone());
481            api.set_widget(widget)?;
482
483            select! {
484                _ = timer.tick() => break,
485                _ = api.wait_for_update_request() => break,
486                Some(action) = actions.recv() => match action.as_ref() {
487                        "toggle_format" => {
488                            if let Some(ref mut format_alt) = format_alt {
489                                std::mem::swap(format_alt, &mut format);
490                            }
491                        }
492                        _ => (),
493                    }
494            }
495        }
496    }
497}
498
499fn need_forecast(format: &Format, format_alt: Option<&Format>) -> bool {
500    fn has_forecast_key(format: &Format) -> bool {
501        macro_rules! format_suffix {
502            ($($suffix: literal),* $(,)?) => {
503                false
504                $(
505                    || format.contains_key(concat!("temp_f", $suffix))
506                    || format.contains_key(concat!("apparent_f", $suffix))
507                    || format.contains_key(concat!("humidity_f", $suffix))
508                    || format.contains_key(concat!("wind_f", $suffix))
509                    || format.contains_key(concat!("wind_kmh_f", $suffix))
510                    || format.contains_key(concat!("direction_f", $suffix))
511                )*
512            };
513        }
514
515        format_suffix!("avg", "min", "max", "fin")
516            || format.contains_key("icon_ffin")
517            || format.contains_key("weather_ffin")
518            || format.contains_key("weather_verbose_ffin")
519    }
520    has_forecast_key(format) || format_alt.is_some_and(has_forecast_key)
521}
522
523fn calculate_sunrise_sunset(
524    lat: f64,
525    lon: f64,
526    altitude: Option<f64>,
527) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
528    let date = Utc::now().date_naive();
529    let coordinates = sunrise::Coordinates::new(lat, lon).error("Invalid coordinates")?;
530    let solar_day = SolarDay::new(coordinates, date).with_altitude(altitude.unwrap_or_default());
531
532    Ok((
533        solar_day.event_time(SolarEvent::Sunrise),
534        solar_day.event_time(SolarEvent::Sunset),
535    ))
536}
537
538#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)]
539#[serde(rename_all = "lowercase")]
540enum UnitSystem {
541    #[default]
542    Metric,
543    Imperial,
544}
545
546#[derive(Deserialize, Clone)]
547struct Coordinates {
548    latitude: f64,
549    longitude: f64,
550    city: String,
551}
552
553struct AutolocateResult {
554    location: Coordinates,
555    timestamp: Instant,
556}
557
558// TODO: might be good to allow for different geolocation services to be used, similar to how we have `service` for the weather API
559/// No-op if last API call was made in the last `interval` seconds.
560async fn find_ip_location(interval: Duration) -> Result<Coordinates> {
561    {
562        let guard = LAST_AUTOLOCATE.lock().unwrap();
563        if let Some(cached) = &*guard {
564            if cached.timestamp.elapsed() < interval {
565                return Ok(cached.location.clone());
566            }
567        }
568    }
569
570    #[derive(Deserialize)]
571    struct ApiResponse {
572        #[serde(flatten)]
573        location: Option<Coordinates>,
574        #[serde(default)]
575        error: bool,
576        #[serde(default)]
577        reason: ApiError,
578    }
579
580    #[derive(Deserialize, Default, Debug)]
581    #[serde(transparent)]
582    struct ApiError(Option<String>);
583
584    impl fmt::Display for ApiError {
585        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
586            f.write_str(self.0.as_deref().unwrap_or("Unknown Error"))
587        }
588    }
589    impl StdError for ApiError {}
590
591    let response: ApiResponse = REQWEST_CLIENT
592        .get(IP_API_URL)
593        .send()
594        .await
595        .error("Failed during request for current location")?
596        .json()
597        .await
598        .error("Failed while parsing location API result")?;
599
600    let location = if response.error {
601        return Err(Error {
602            message: Some("ipapi.co error".into()),
603            cause: Some(Arc::new(response.reason)),
604        });
605    } else {
606        response
607            .location
608            .error("Failed while parsing location API result")?
609    };
610
611    {
612        let mut guard = LAST_AUTOLOCATE.lock().unwrap();
613        *guard = Some(AutolocateResult {
614            location: location.clone(),
615            timestamp: Instant::now(),
616        });
617    }
618
619    Ok(location)
620}
621
622// Convert wind direction in azimuth degrees to abbreviation names
623fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
624    match direction_opt {
625        Some(direction) => match direction.round() as i64 {
626            24..=68 => "NE",
627            69..=113 => "E",
628            114..=158 => "SE",
629            159..=203 => "S",
630            204..=248 => "SW",
631            249..=293 => "W",
632            294..=338 => "NW",
633            _ => "N",
634        },
635        None => "-",
636    }
637}
638
639/// Compute the Australian Apparent Temperature from metric units
640fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 {
641    let exponent = 17.27 * temp / (237.7 + temp);
642    let water_vapor_pressure = humidity * 0.06105 * exponent.exp();
643    temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_new_forecast_average_wind_speed() {
652        let mut degrees = 0.0;
653        while degrees < 360.0 {
654            let forecast = Forecast::new(
655                &[
656                    ForecastAggregateSegment {
657                        temp: None,
658                        apparent: None,
659                        humidity: None,
660                        wind: Some(1.0),
661                        wind_kmh: Some(3.6),
662                        wind_direction: Some(degrees),
663                    },
664                    ForecastAggregateSegment {
665                        temp: None,
666                        apparent: None,
667                        humidity: None,
668                        wind: Some(2.0),
669                        wind_kmh: Some(7.2),
670                        wind_direction: Some(degrees),
671                    },
672                ],
673                WeatherMoment::default(),
674            );
675            assert!((forecast.avg.wind - 1.5).abs() < 0.1);
676            assert!((forecast.avg.wind_kmh - 5.4).abs() < 0.1);
677            assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
678
679            degrees += 15.0;
680        }
681    }
682
683    #[test]
684    fn test_new_forecast_average_wind_degrees() {
685        let mut degrees = 0.0;
686        while degrees < 360.0 {
687            let low = degrees - 1.0;
688            let high = degrees + 1.0;
689            let forecast = Forecast::new(
690                &[
691                    ForecastAggregateSegment {
692                        temp: None,
693                        apparent: None,
694                        humidity: None,
695                        wind: Some(1.0),
696                        wind_kmh: Some(3.6),
697                        wind_direction: Some(low),
698                    },
699                    ForecastAggregateSegment {
700                        temp: None,
701                        apparent: None,
702                        humidity: None,
703                        wind: Some(1.0),
704                        wind_kmh: Some(3.6),
705                        wind_direction: Some(high),
706                    },
707                ],
708                WeatherMoment::default(),
709            );
710            // For winds of equal strength the direction should will be the
711            // average of the low and high degrees
712            assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
713
714            degrees += 15.0;
715        }
716    }
717
718    #[test]
719    fn test_new_forecast_average_wind_speed_and_degrees() {
720        let mut degrees = 0.0;
721        while degrees < 360.0 {
722            let low = degrees - 1.0;
723            let high = degrees + 1.0;
724            let forecast = Forecast::new(
725                &[
726                    ForecastAggregateSegment {
727                        temp: None,
728                        apparent: None,
729                        humidity: None,
730                        wind: Some(1.0),
731                        wind_kmh: Some(3.6),
732                        wind_direction: Some(low),
733                    },
734                    ForecastAggregateSegment {
735                        temp: None,
736                        apparent: None,
737                        humidity: None,
738                        wind: Some(2.0),
739                        wind_kmh: Some(7.2),
740                        wind_direction: Some(high),
741                    },
742                ],
743                WeatherMoment::default(),
744            );
745            // Wind degree will be higher than the centerpoint of the low
746            // and high winds since the high wind is stronger and will be
747            // less than high
748            // (low+high)/2 < average.degrees < high
749            assert!((low + high) / 2.0 < forecast.avg.wind_direction.unwrap());
750            assert!(forecast.avg.wind_direction.unwrap() < high);
751            degrees += 15.0;
752        }
753    }
754}