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