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}