Skip to main content

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