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` (`$icon` `$icon_ffin`, when weather is reported as "Clear" during the day)
139//! - `weather_moon` (`$icon` `$icon_ffin`, when weather is reported as "Clear" at night)
140//! - `weather_clouds` (`$icon` `$icon_ffin`, when weather is reported as "Clouds" during the day)
141//! - `weather_clouds_night` (`$icon` `$icon_ffin`, when weather is reported as "Clouds" at night)
142//! - `weather_fog` (`$icon` `$icon_ffin`, when weather is reported as "Fog" or "Mist" during the day)
143//! - `weather_fog_night` (`$icon` `$icon_ffin`, when weather is reported as "Fog" or "Mist" at night)
144//! - `weather_rain` (`$icon` `$icon_ffin`, when weather is reported as "Rain" or "Drizzle" during the day)
145//! - `weather_rain_night` (`$icon` `$icon_ffin`, when weather is reported as "Rain" or "Drizzle" at night)
146//! - `weather_snow` (`$icon` `$icon_ffin`, when weather is reported as "Snow")
147//! - `weather_thunder` (`$icon` `$icon_ffin`, when weather is reported as "Thunderstorm" during the day)
148//! - `weather_thunder_night` (`$icon` `$icon_ffin`, when weather is reported as "Thunderstorm" at night)
149//! - `weather_default` (`$icon` `$icon_ffin`, in all other cases)
150
151use chrono::{DateTime, Utc};
152use sunrise::{SolarDay, SolarEvent};
153
154use super::prelude::*;
155use crate::formatting::Format;
156pub(super) use crate::geolocator::IPAddressInfo;
157use crate::util::{celsius_to_fahrenheit, kmh_to_mph, kmh_to_mps};
158
159pub mod met_no;
160pub mod nws;
161pub mod open_weather_map;
162
163#[derive(Deserialize, Debug)]
164pub struct Config {
165    #[serde(default = "default_interval")]
166    pub interval: Seconds,
167    #[serde(flatten)]
168    pub formats: MaybeMultiFormatConfig,
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    /// Every icon name [`Self::to_icon_str`] can return. The weather
221    /// condition is externally selected but finite, so the block plan
222    /// declares the full set.
223    const ALL_NAMES: [&'static str; 12] = [
224        icons::WEATHER_SUN,
225        icons::WEATHER_MOON,
226        icons::WEATHER_CLOUDS,
227        icons::WEATHER_CLOUDS_NIGHT,
228        icons::WEATHER_FOG,
229        icons::WEATHER_FOG_NIGHT,
230        icons::WEATHER_RAIN,
231        icons::WEATHER_RAIN_NIGHT,
232        icons::WEATHER_SNOW,
233        icons::WEATHER_THUNDER,
234        icons::WEATHER_THUNDER_NIGHT,
235        icons::WEATHER_DEFAULT,
236    ];
237
238    fn to_icon_str(self) -> &'static str {
239        match self {
240            Self::Clear { is_night: false } => icons::WEATHER_SUN,
241            Self::Clear { is_night: true } => icons::WEATHER_MOON,
242            Self::Clouds { is_night: false } => icons::WEATHER_CLOUDS,
243            Self::Clouds { is_night: true } => icons::WEATHER_CLOUDS_NIGHT,
244            Self::Fog { is_night: false } => icons::WEATHER_FOG,
245            Self::Fog { is_night: true } => icons::WEATHER_FOG_NIGHT,
246            Self::Rain { is_night: false } => icons::WEATHER_RAIN,
247            Self::Rain { is_night: true } => icons::WEATHER_RAIN_NIGHT,
248            Self::Snow => icons::WEATHER_SNOW,
249            Self::Thunder { is_night: false } => icons::WEATHER_THUNDER,
250            Self::Thunder { is_night: true } => icons::WEATHER_THUNDER_NIGHT,
251            Self::Default => icons::WEATHER_DEFAULT,
252        }
253    }
254}
255
256#[derive(Default)]
257struct WeatherMoment {
258    icon: WeatherIcon,
259    weather: String,
260    weather_verbose: String,
261    temp: f64,
262    apparent: f64,
263    humidity: f64,
264    wind_kmh: f64,
265    wind_direction: Option<f64>,
266}
267
268struct ForecastAggregate {
269    temp: f64,
270    apparent: f64,
271    humidity: f64,
272    wind_kmh: f64,
273    wind_direction: Option<f64>,
274}
275
276struct ForecastAggregateSegment {
277    temp: Option<f64>,
278    apparent: Option<f64>,
279    humidity: Option<f64>,
280    wind_kmh: Option<f64>,
281    wind_direction: Option<f64>,
282}
283
284struct WeatherResult {
285    location: String,
286    current_weather: WeatherMoment,
287    forecast: Option<Forecast>,
288    sunrise: Option<DateTime<Utc>>,
289    sunset: Option<DateTime<Utc>>,
290}
291
292impl WeatherResult {
293    fn into_values(self, unit_system: &UnitSystem) -> Values {
294        let mut values = map! {
295            "location" => Value::text(self.location),
296            //current_weather
297            "icon" => Value::icon(self.current_weather.icon.to_icon_str()),
298            "temp" => unit_system.temperature_value(self.current_weather.temp),
299            "apparent" => unit_system.temperature_value(self.current_weather.apparent),
300            "humidity" => Value::percents(self.current_weather.humidity),
301            "weather" => Value::text(self.current_weather.weather),
302            "weather_verbose" => Value::text(self.current_weather.weather_verbose),
303            "wind" => unit_system.wind_speed_value(self.current_weather.wind_kmh),
304            "wind_kmh" => Value::number(self.current_weather.wind_kmh),
305            "direction" => Value::text(convert_wind_direction(self.current_weather.wind_direction).into()),
306            [if let Some(sunrise) = self.sunrise] "sunrise" => Value::datetime(sunrise, None),
307            [if let Some(sunset) = self.sunset] "sunset" => Value::datetime(sunset, None),
308        };
309
310        if let Some(forecast) = self.forecast {
311            macro_rules! map_forecasts {
312                ({$($suffix: literal => $src: expr),* $(,)?}) => {
313                    map!{ @extend values
314                        $(
315                            concat!("temp_f", $suffix) => unit_system.temperature_value($src.temp),
316                            concat!("apparent_f", $suffix) => unit_system.temperature_value($src.apparent),
317                            concat!("humidity_f", $suffix) => Value::percents($src.humidity),
318                            concat!("wind_f", $suffix) => unit_system.wind_speed_value($src.wind_kmh),
319                            concat!("wind_kmh_f", $suffix) => Value::number($src.wind_kmh),
320                            concat!("direction_f", $suffix) => Value::text(convert_wind_direction($src.wind_direction).into()),
321                        )*
322                    }
323                };
324            }
325            map_forecasts!({
326                "avg" => forecast.avg,
327                "min" => forecast.min,
328                "max" => forecast.max,
329                "fin" => forecast.fin,
330            });
331
332            map! { @extend values
333                "icon_ffin" => Value::icon(forecast.fin.icon.to_icon_str()),
334                "weather_ffin" => Value::text(forecast.fin.weather.clone()),
335                "weather_verbose_ffin" => Value::text(forecast.fin.weather_verbose.clone()),
336            }
337        }
338
339        values
340    }
341}
342
343struct Forecast {
344    avg: ForecastAggregate,
345    min: ForecastAggregate,
346    max: ForecastAggregate,
347    fin: WeatherMoment,
348}
349
350impl Forecast {
351    fn new(data: &[ForecastAggregateSegment], fin: WeatherMoment) -> Self {
352        let mut temp_avg = 0.0;
353        let mut temp_count = 0.0;
354        let mut apparent_avg = 0.0;
355        let mut apparent_count = 0.0;
356        let mut humidity_avg = 0.0;
357        let mut humidity_count = 0.0;
358        let mut wind_kmh_north_avg = 0.0;
359        let mut wind_kmh_east_avg = 0.0;
360        let mut wind_count = 0.0;
361        let mut max = ForecastAggregate {
362            temp: f64::MIN,
363            apparent: f64::MIN,
364            humidity: f64::MIN,
365            wind_kmh: f64::MIN,
366            wind_direction: None,
367        };
368        let mut min = ForecastAggregate {
369            temp: f64::MAX,
370            apparent: f64::MAX,
371            humidity: f64::MAX,
372            wind_kmh: f64::MAX,
373            wind_direction: None,
374        };
375        for val in data {
376            if let Some(temp) = val.temp {
377                temp_avg += temp;
378                max.temp = max.temp.max(temp);
379                min.temp = min.temp.min(temp);
380                temp_count += 1.0;
381            }
382            if let Some(apparent) = val.apparent {
383                apparent_avg += apparent;
384                max.apparent = max.apparent.max(apparent);
385                min.apparent = min.apparent.min(apparent);
386                apparent_count += 1.0;
387            }
388            if let Some(humidity) = val.humidity {
389                humidity_avg += humidity;
390                max.humidity = max.humidity.max(humidity);
391                min.humidity = min.humidity.min(humidity);
392                humidity_count += 1.0;
393            }
394
395            if let Some(wind_kmh) = val.wind_kmh {
396                if let Some(degrees) = val.wind_direction {
397                    let (sin, cos) = degrees.to_radians().sin_cos();
398                    wind_kmh_north_avg += wind_kmh * cos;
399                    wind_kmh_east_avg += wind_kmh * sin;
400                    wind_count += 1.0;
401                }
402
403                if wind_kmh > max.wind_kmh {
404                    max.wind_direction = val.wind_direction;
405                    max.wind_kmh = wind_kmh;
406                }
407
408                if wind_kmh < min.wind_kmh {
409                    min.wind_direction = val.wind_direction;
410                    min.wind_kmh = wind_kmh;
411                }
412            }
413        }
414
415        temp_avg /= temp_count;
416        humidity_avg /= humidity_count;
417        apparent_avg /= apparent_count;
418
419        // Calculate the wind results separately, discarding invalid wind values
420        let (wind_kmh_avg, wind_direction_avg) = if wind_count == 0.0 {
421            (0.0, None)
422        } else {
423            (
424                wind_kmh_east_avg.hypot(wind_kmh_north_avg) / wind_count,
425                Some(
426                    wind_kmh_east_avg
427                        .atan2(wind_kmh_north_avg)
428                        .to_degrees()
429                        .rem_euclid(360.0),
430                ),
431            )
432        };
433
434        let avg = ForecastAggregate {
435            temp: temp_avg,
436            apparent: apparent_avg,
437            humidity: humidity_avg,
438            wind_kmh: wind_kmh_avg,
439            wind_direction: wind_direction_avg,
440        };
441        Self { avg, min, max, fin }
442    }
443}
444
445pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
446    let weather_icons = || IconChoices::fixed(WeatherIcon::ALL_NAMES);
447    let declare = |output: OutputPlan| {
448        output
449            .icon("icon", weather_icons())
450            .icon("icon_ffin", weather_icons())
451    };
452    let formats = config.formats.with_default(" $icon $weather $temp ")?;
453    BlockPlan::new(format_outputs(formats, declare))
454}
455
456pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
457    let mut actions = api.get_actions()?;
458    api.set_default_actions(&[
459        (MouseButton::Left, None, "next_format"),
460        (MouseButton::Right, None, "prev_format"),
461    ])?;
462
463    let mut formats = FormatRotation::new(plan)?;
464
465    let (provider, service_units): (Box<dyn WeatherProvider + Send + Sync>, UnitSystem) =
466        match &config.service {
467            WeatherService::MetNo(service_config) => (
468                Box::new(met_no::Service::new(service_config)?),
469                UnitSystem::default(),
470            ),
471            WeatherService::OpenWeatherMap(service_config) => (
472                Box::new(open_weather_map::Service::new(config.autolocate, service_config).await?),
473                service_config.units,
474            ),
475            WeatherService::Nws(service_config) => (
476                Box::new(nws::Service::new(config.autolocate, service_config).await?),
477                service_config.units,
478            ),
479        };
480    let units = config.units.unwrap_or(service_units);
481
482    let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval).0;
483    // The plan holds one output per configured format, so the formats the
484    // user can rotate to are exactly the ones a forecast may be needed for.
485    let declared_formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
486    let need_forecast = need_forecast(&declared_formats);
487
488    let mut timer = config.interval.timer();
489
490    loop {
491        let location = if config.autolocate {
492            Some(
493                api.find_ip_location(&REQWEST_CLIENT, autolocate_interval)
494                    .await?,
495            )
496        } else {
497            None
498        };
499
500        let fetch = || provider.get_weather(location.as_ref(), need_forecast);
501        let data = fetch.retry(ExponentialBuilder::default()).await?;
502        // Every output declares the same icon choices, so minting against the
503        // currently selected one stays valid after a format rotation.
504        let data_values = data.into_values(&units);
505
506        loop {
507            let output = formats.current();
508            let mut widget = output.new_widget();
509            widget.set_values(data_values.clone());
510            api.set_widget(widget)?;
511
512            select! {
513                _ = timer.tick() => break,
514                _ = api.wait_for_update_request() => break,
515                Some(action) = actions.recv() => match action.as_ref() {
516                        "next_format" | "toggle_format" => {
517                            formats.next();
518                        }
519                        "prev_format" => {
520                            formats.prev();
521                        }
522                        _ => (),
523                    }
524            }
525        }
526    }
527}
528
529fn need_forecast(formats: &[Format]) -> bool {
530    fn has_forecast_key(format: &Format) -> bool {
531        macro_rules! format_suffix {
532            ($($suffix: literal),* $(,)?) => {
533                false
534                $(
535                    || format.contains_key(concat!("temp_f", $suffix))
536                    || format.contains_key(concat!("apparent_f", $suffix))
537                    || format.contains_key(concat!("humidity_f", $suffix))
538                    || format.contains_key(concat!("wind_f", $suffix))
539                    || format.contains_key(concat!("wind_kmh_f", $suffix))
540                    || format.contains_key(concat!("direction_f", $suffix))
541                )*
542            };
543        }
544
545        format_suffix!("avg", "min", "max", "fin")
546            || format.contains_key("icon_ffin")
547            || format.contains_key("weather_ffin")
548            || format.contains_key("weather_verbose_ffin")
549    }
550    formats.iter().any(has_forecast_key)
551}
552
553fn calculate_sunrise_sunset(
554    lat: f64,
555    lon: f64,
556    altitude: Option<f64>,
557) -> Result<(Option<DateTime<Utc>>, Option<DateTime<Utc>>)> {
558    let date = Utc::now().date_naive();
559    let coordinates = sunrise::Coordinates::new(lat, lon).error("Invalid coordinates")?;
560    let solar_day = SolarDay::new(coordinates, date).with_altitude(altitude.unwrap_or_default());
561
562    Ok((
563        solar_day.event_time(SolarEvent::Sunrise),
564        solar_day.event_time(SolarEvent::Sunset),
565    ))
566}
567
568#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)]
569#[serde(rename_all = "lowercase")]
570pub enum UnitSystem {
571    #[default]
572    Metric,
573    Imperial,
574}
575
576impl AsRef<str> for UnitSystem {
577    fn as_ref(&self) -> &str {
578        match self {
579            UnitSystem::Metric => "metric",
580            UnitSystem::Imperial => "imperial",
581        }
582    }
583}
584
585impl UnitSystem {
586    fn temperature_value(&self, temp_celsius: f64) -> Value {
587        match self {
588            UnitSystem::Metric => Value::degrees_c(temp_celsius),
589            UnitSystem::Imperial => Value::degrees_f(celsius_to_fahrenheit(temp_celsius)),
590        }
591    }
592
593    fn wind_speed_value(&self, speed_kmh: f64) -> Value {
594        match self {
595            UnitSystem::Metric => Value::number(kmh_to_mps(speed_kmh)),
596            UnitSystem::Imperial => Value::number(kmh_to_mph(speed_kmh)),
597        }
598    }
599}
600
601// Convert wind direction in azimuth degrees to abbreviation names
602fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
603    match direction_opt {
604        Some(direction) => match direction.round() as i64 {
605            24..=68 => "NE",
606            69..=113 => "E",
607            114..=158 => "SE",
608            159..=203 => "S",
609            204..=248 => "SW",
610            249..=293 => "W",
611            294..=338 => "NW",
612            _ => "N",
613        },
614        None => "-",
615    }
616}
617
618/// Compute the Australian Apparent Temperature from metric units
619fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 {
620    let exponent = 17.27 * temp / (237.7 + temp);
621    let water_vapor_pressure = humidity * 0.06105 * exponent.exp();
622    temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn test_new_forecast_average_wind_speed() {
631        let mut degrees = 0.0;
632        while degrees < 360.0 {
633            let forecast = Forecast::new(
634                &[
635                    ForecastAggregateSegment {
636                        temp: None,
637                        apparent: None,
638                        humidity: None,
639                        wind_kmh: Some(3.6),
640                        wind_direction: Some(degrees),
641                    },
642                    ForecastAggregateSegment {
643                        temp: None,
644                        apparent: None,
645                        humidity: None,
646                        wind_kmh: Some(7.2),
647                        wind_direction: Some(degrees),
648                    },
649                ],
650                WeatherMoment::default(),
651            );
652            assert!((forecast.avg.wind_kmh - 5.4).abs() < 0.1);
653            assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
654
655            degrees += 15.0;
656        }
657    }
658
659    #[test]
660    fn test_new_forecast_average_wind_degrees() {
661        let mut degrees = 0.0;
662        while degrees < 360.0 {
663            let low = degrees - 1.0;
664            let high = degrees + 1.0;
665            let forecast = Forecast::new(
666                &[
667                    ForecastAggregateSegment {
668                        temp: None,
669                        apparent: None,
670                        humidity: None,
671                        wind_kmh: Some(3.6),
672                        wind_direction: Some(low),
673                    },
674                    ForecastAggregateSegment {
675                        temp: None,
676                        apparent: None,
677                        humidity: None,
678                        wind_kmh: Some(3.6),
679                        wind_direction: Some(high),
680                    },
681                ],
682                WeatherMoment::default(),
683            );
684            // For winds of equal strength the direction should will be the
685            // average of the low and high degrees
686            assert!((forecast.avg.wind_direction.unwrap() - degrees).abs() < 0.1);
687
688            degrees += 15.0;
689        }
690    }
691
692    #[test]
693    fn test_new_forecast_average_wind_speed_and_degrees() {
694        let mut degrees = 0.0;
695        while degrees < 360.0 {
696            let low = degrees - 1.0;
697            let high = degrees + 1.0;
698            let forecast = Forecast::new(
699                &[
700                    ForecastAggregateSegment {
701                        temp: None,
702                        apparent: None,
703                        humidity: None,
704                        wind_kmh: Some(3.6),
705                        wind_direction: Some(low),
706                    },
707                    ForecastAggregateSegment {
708                        temp: None,
709                        apparent: None,
710                        humidity: None,
711                        wind_kmh: Some(7.2),
712                        wind_direction: Some(high),
713                    },
714                ],
715                WeatherMoment::default(),
716            );
717            // Wind degree will be higher than the centerpoint of the low
718            // and high winds since the high wind is stronger and will be
719            // less than high
720            // (low+high)/2 < average.degrees < high
721            assert!((low + high) / 2.0 < forecast.avg.wind_direction.unwrap());
722            assert!(forecast.avg.wind_direction.unwrap() < high);
723            degrees += 15.0;
724        }
725    }
726
727    fn config(toml_str: &str) -> Config {
728        toml::from_str(toml_str).unwrap()
729    }
730
731    #[test]
732    fn every_condition_icon_is_declared() {
733        let all_variants = [
734            WeatherIcon::Clear { is_night: false },
735            WeatherIcon::Clear { is_night: true },
736            WeatherIcon::Clouds { is_night: false },
737            WeatherIcon::Clouds { is_night: true },
738            WeatherIcon::Fog { is_night: false },
739            WeatherIcon::Fog { is_night: true },
740            WeatherIcon::Rain { is_night: false },
741            WeatherIcon::Rain { is_night: true },
742            WeatherIcon::Snow,
743            WeatherIcon::Thunder { is_night: false },
744            WeatherIcon::Thunder { is_night: true },
745            WeatherIcon::Default,
746        ];
747        assert_eq!(all_variants.len(), WeatherIcon::ALL_NAMES.len());
748        for variant in all_variants {
749            assert!(WeatherIcon::ALL_NAMES.contains(&variant.to_icon_str()));
750        }
751    }
752
753    #[test]
754    fn every_configured_format_gets_an_output() {
755        let plan = prepare(&config("service = { name = \"metno\" }")).unwrap();
756        assert!(plan.output("format").is_ok());
757        assert!(plan.output("format2").is_err());
758
759        let plan = prepare(&config(
760            "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $humidity \"]",
761        ))
762        .unwrap();
763        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
764        assert_eq!(ids, ["format", "format2"]);
765        let alt = plan.output("format2").unwrap();
766        assert!(alt.format().contains_key("humidity"));
767        let choices = alt.output().choices_for("icon").unwrap();
768        assert!(choices.permits("weather_snow"));
769        assert!(!choices.permits("bat"));
770    }
771
772    #[test]
773    fn format_alt_still_produces_a_second_output() {
774        let plan = prepare(&config(
775            "service = { name = \"metno\" }\nformat_alt = \" $humidity \"",
776        ))
777        .unwrap();
778        let alt = plan.output("format2").unwrap();
779        assert!(alt.format().contains_key("humidity"));
780        assert!(
781            alt.output()
782                .choices_for("icon_ffin")
783                .unwrap()
784                .permits("weather_default")
785        );
786    }
787
788    #[test]
789    fn forecast_is_needed_when_any_rotated_format_asks_for_it() {
790        let plan = prepare(&config(
791            "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $temp_favg \"]",
792        ))
793        .unwrap();
794        let formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
795        assert!(need_forecast(&formats));
796
797        let plan = prepare(&config(
798            "service = { name = \"metno\" }\nformat = [\" $icon $temp \", \" $humidity \"]",
799        ))
800        .unwrap();
801        let formats: Vec<Format> = plan.outputs().map(|o| o.format().clone()).collect();
802        assert!(!need_forecast(&formats));
803    }
804}