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