Skip to content

Commit

Permalink
Add weather forecast
Browse files Browse the repository at this point in the history
Only include forecast if needed

Forecasts are only fetched if forecast_hours > 0 and the format has keys
related to forecast
  • Loading branch information
bim9262 committed Oct 1, 2023
1 parent 40bc766 commit b5a62d5
Show file tree
Hide file tree
Showing 4 changed files with 415 additions and 57 deletions.
1 change: 1 addition & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ words:
- gsettings
- HHMM
- horiz
- hypot
- ibus
- iface
- ifaces
Expand Down
255 changes: 228 additions & 27 deletions src/blocks/weather.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//! ----|--------|--------
//! `service` | The configuration of a weather service (see below). | **Required**
//! `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 "`
//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click | `None`
//! `interval` | Update interval, in seconds. | `600`
//! `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 `city_id` or `place`. | `false`
//! `autolocate_interval` | Update interval for `autolocate` in seconds or "once" | `interval`
Expand All @@ -32,13 +33,16 @@
//! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Yes* | None
//! `units` | Either `"metric"` or `"imperial"`. | No | `"metric"`
//! `lang` | Language code. See [here](https://openweathermap.org/current#multi). Currently only affects `weather_verbose` key. | No | `"en"`
//! `forecast_hours` | How many hours should be forecast (must be increments of 3 hours, max 120 hours) | No | 12
//!
//! One of `city_id`, `place` or `coordinates` is required. If more than one are supplied, `city_id` takes precedence over `place` which takes place over `coordinates`.
//!
//! The options `api_key`, `city_id`, `place` can be omitted from configuration,
//! in which case they must be provided in the environment variables
//! `OPENWEATHERMAP_API_KEY`, `OPENWEATHERMAP_CITY_ID`, `OPENWEATHERMAP_PLACE`.
//!
//! Forecasts are only fetched if forecast_hours > 0 and the format has keys related to forecast.
//!
//! # met.no Options
//!
//! Key | Values | Required | Default
Expand All @@ -47,23 +51,34 @@
//! `coordinates` | GPS latitude longitude coordinates as a tuple, example: `["39.2362","9.3317"]` | Required if `autolocate = false` | None
//! `lang` | Language code: `en`, `nn` or `nb` | No | `en`
//! `altitude` | Meters above sea level of the ground | No | Approximated by server
//! `forecast_hours` | How many hours should be forecast | No | 12
//!
//! Met.no does not support location name.
//!
//! # Available Format Keys
//!
//! Key | Value | Type | Unit
//! ------------------|--------------------------------------------------------------------|--------|-----
//! `icon` | Icon representing the weather | Icon | -
//! `location` | Location name (exact format depends on the service) | Text | -
//! `temp` | Temperature | Number | degrees
//! `apparent` | Australian Apparent Temperature | Number | degrees
//! `humidity` | Humidity | Number | %
//! `weather` | Textual brief description of the weather, e.g. "Raining" | Text | -
//! `weather_verbose` | Textual verbose description of the weather, e.g. "overcast clouds" | Text | -
//! `wind` | Wind speed | Number | -
//! `wind_kmh` | Wind speed. The wind speed in km/h | Number | -
//! `direction` | Wind direction, e.g. "NE" | Text | -
//! Key | Value | Type | Unit
//! --------------------|-------------------------------------------------------------------------------|--------|-----
//! `icon` | Icon representing the weather | Icon | -
//! `location` | Location name (exact format depends on the service) | Text | -
//! `temp` | Temperature | Number | degrees
//! `apparent` | Australian Apparent Temperature | Number | degrees
//! `humidity` | Humidity | Number | %
//! `weather` | Textual brief description of the weather, e.g. "Raining" | Text | -
//! `weather_verbose` | Textual verbose description of the weather, e.g. "overcast clouds" | Text | -
//! `wind` | Wind speed | Number | -
//! `wind_kmh` | Wind speed. The wind speed in km/h | Number | -
//! `direction` | Wind direction, e.g. "NE" | Text | -
//! `temp_forecast` | Temperature forecast (omitted if no forecast available) | Number | degrees
//! `apparent_forecast` | Australian Apparent Temperature forecast (omitted if no forecast available) | Number | degrees
//! `humidity_forecast` | Humidity forecast (omitted if no forecast available) | Number | %
//! `wind_forecast` | Wind speed forecast (omitted if no forecast available) | Number | -
//! `wind_kmh_forecast` | Wind speed forecast. The wind speed in km/h (omitted if no forecast available) | Number | -
//! `direction_forecast` | Wind direction forecast, e.g. "NE" (omitted if no forecast available) | Text | -
//!
//! Action | Description | Default button
//! ----------------|-------------------------------------------|---------------
//! `toggle_format` | Toggles between `format` and `format_alt` | Left
//!
//! # Example
//!
Expand All @@ -73,11 +88,13 @@
//! [[block]]
//! block = "weather"
//! format = " $icon $weather ($location) $temp, $wind m/s $direction "
//! format_alt = " ^icon_weather_default Forecast (9 hour avg) {$temp_forecast $wind_forecast m/s $direction_forecast|Unavailable} "
//! [block.service]
//! name = "openweathermap"
//! api_key = "XXX"
//! city_id = "5398563"
//! units = "metric"
//! forecast_hours = 9
//! ```
//!
//! # Used Icons
Expand All @@ -98,6 +115,8 @@ use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Instant;

use crate::formatting::Format;

use super::prelude::*;

pub mod met_no;
Expand All @@ -114,6 +133,7 @@ pub struct Config {
pub interval: Seconds,
#[serde(default)]
pub format: FormatConfig,
pub format_alt: Option<FormatConfig>,
pub service: WeatherService,
#[serde(default)]
pub autolocate: bool,
Expand All @@ -126,8 +146,11 @@ fn default_interval() -> Seconds {

#[async_trait]
trait WeatherProvider {
async fn get_weather(&self, autolocated_location: Option<Coordinates>)
-> Result<WeatherResult>;
async fn get_weather(
&self,
autolocated_location: Option<Coordinates>,
need_forecast: bool,
) -> Result<WeatherResult>;
}

#[derive(Deserialize, Debug)]
Expand Down Expand Up @@ -167,6 +190,32 @@ impl WeatherIcon {
}
}

#[derive(Debug)]
struct Wind {
speed: f64,
degrees: Option<f64>,
}

impl PartialEq for Wind {
fn eq(&self, other: &Self) -> bool {
(self.speed - other.speed).abs() < 0.001
&& match (self.degrees, other.degrees) {
(Some(degrees0), Some(degrees1)) => (degrees0 - degrees1).abs() < 0.001,
(None, None) => true,
_ => false,
}
}
}

struct Forecast {
temp: f64,
apparent: f64,
humidity: f64,
wind: f64,
wind_kmh: f64,
wind_direction: Option<f64>,
}

struct WeatherResult {
location: String,
temp: f64,
Expand All @@ -176,13 +225,14 @@ struct WeatherResult {
weather_verbose: String,
wind: f64,
wind_kmh: f64,
wind_direction: String,
wind_direction: Option<f64>,
forecast: Option<Forecast>,
icon: WeatherIcon,
}

impl WeatherResult {
fn into_values(self) -> Values {
map! {
let mut values = map! {
"icon" => Value::icon(self.icon.to_icon_str()),
"location" => Value::text(self.location),
"temp" => Value::degrees(self.temp),
Expand All @@ -192,20 +242,41 @@ impl WeatherResult {
"weather_verbose" => Value::text(self.weather_verbose),
"wind" => Value::number(self.wind),
"wind_kmh" => Value::number(self.wind_kmh),
"direction" => Value::text(self.wind_direction),
"direction" => Value::text(convert_wind_direction(self.wind_direction).into()),
};
if let Some(forecast) = &self.forecast {
values.extend(map! {
"temp_forecast" => Value::degrees(forecast.temp),
"apparent_forecast" => Value::degrees(forecast.apparent),
"humidity_forecast" => Value::percents(forecast.humidity),
"wind_forecast" => Value::number(forecast.wind),
"wind_kmh_forecast" => Value::number(forecast.wind_kmh),
"direction_forecast" => Value::text(convert_wind_direction(forecast.wind_direction).into()),
});
}
values
}
}

pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
let format = config.format.with_default(" $icon $weather $temp ")?;
let mut actions = api.get_actions()?;
api.set_default_actions(&[(MouseButton::Left, None, "toggle_format")])?;

let mut format = config.format.with_default(" $icon $weather $temp ")?;
let mut format_alt = match &config.format_alt {
Some(f) => Some(f.with_default("")?),
None => None,
};

let provider: Box<dyn WeatherProvider + Send + Sync> = match &config.service {
WeatherService::MetNo(config) => Box::new(met_no::Service::new(config)?),
WeatherService::OpenWeatherMap(config) => Box::new(open_weather_map::Service::new(config)),
};

let autolocate_interval = config.autolocate_interval.unwrap_or(config.interval);
let need_forecast = need_forecast(&format, &format_alt);

let mut timer = config.interval.timer();

loop {
let location = if config.autolocate {
Expand All @@ -215,20 +286,43 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
None
};

let fetch = || provider.get_weather(location);
let fetch = || provider.get_weather(location, need_forecast);
let data = fetch.retry(&ExponentialBuilder::default()).await?;

let mut widget = Widget::new().with_format(format.clone());
widget.set_values(data.into_values());
api.set_widget(widget)?;

select! {
_ = sleep(config.interval.0) => (),
_ = api.wait_for_update_request() => ()
let data_values = data.into_values();

loop {
let mut widget = Widget::new().with_format(format.clone());
widget.set_values(data_values.clone());
api.set_widget(widget)?;

select! {
_ = timer.tick() => break,
_ = api.wait_for_update_request() => break,
Some(action) = actions.recv() => match action.as_ref() {
"toggle_format" => {
if let Some(ref mut format_alt) = format_alt {
std::mem::swap(format_alt, &mut format);
}
}
_ => (),
}
}
}
}
}

fn need_forecast(format: &Format, format_alt: &Option<Format>) -> bool {
fn has_forecast_key(format: &Format) -> bool {
format.contains_key("temp_forecast")
|| format.contains_key("apparent_forecast")
|| format.contains_key("humidity_forecast")
|| format.contains_key("wind_forecast")
|| format.contains_key("wind_kmh_forecast")
|| format.contains_key("direction_forecast")
}
has_forecast_key(format) || format_alt.as_ref().is_some_and(has_forecast_key)
}

#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, SmartDefault)]
#[serde(rename_all = "lowercase")]
enum UnitSystem {
Expand Down Expand Up @@ -331,9 +425,116 @@ fn convert_wind_direction(direction_opt: Option<f64>) -> &'static str {
}
}

// Compute the average wind speed and direction
fn average_wind(winds: &[Wind]) -> Wind {
let mut north = 0.0;
let mut east = 0.0;
let mut count = 0.0;
for wind in winds {
if let Some(degrees) = wind.degrees {
let (sin, cos) = degrees.to_radians().sin_cos();
north += wind.speed * cos;
east += wind.speed * sin;
count += 1.0;
}
}
if count == 0.0 {
Wind {
speed: 0.0,
degrees: None,
}
} else {
Wind {
speed: east.hypot(north) / count,
degrees: Some(east.atan2(north).to_degrees().rem_euclid(360.0)),
}
}
}

/// Compute the Australian Apparent Temperature from metric units
fn australian_apparent_temp(temp: f64, humidity: f64, wind_speed: f64) -> f64 {
let exponent = 17.27 * temp / (237.7 + temp);
let water_vapor_pressure = humidity * 0.06105 * exponent.exp();
temp + 0.33 * water_vapor_pressure - 0.7 * wind_speed - 4.0
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_average_wind_speed() {
let mut degrees = 0.0;
while degrees < 360.0 {
let averaged = average_wind(&[
Wind {
speed: 1.0,
degrees: Some(degrees),
},
Wind {
speed: 2.0,
degrees: Some(degrees),
},
]);
assert_eq!(
averaged,
Wind {
speed: 1.5,
degrees: Some(degrees)
}
);

degrees += 15.0;
}
}

#[test]
fn test_average_wind_degrees() {
let mut degrees = 0.0;
while degrees < 360.0 {
let low = degrees - 1.0;
let high = degrees + 1.0;
let averaged = average_wind(&[
Wind {
speed: 1.0,
degrees: Some(low),
},
Wind {
speed: 1.0,
degrees: Some(high),
},
]);
// For winds of equal strength the direction should will be the
// average of the low and high degrees
assert!((averaged.degrees.unwrap() - degrees).abs() < 0.1);

degrees += 15.0;
}
}

#[test]
fn test_average_wind_speed_and_degrees() {
let mut degrees = 0.0;
while degrees < 360.0 {
let low = degrees - 1.0;
let high = degrees + 1.0;
let averaged = average_wind(&[
Wind {
speed: 1.0,
degrees: Some(low),
},
Wind {
speed: 2.0,
degrees: Some(high),
},
]);
// Wind degree will be higher than the centerpoint of the low
// and high winds since the high wind is stronger and will be
// less than high
// (low+high)/2 < average.degrees < high
assert!((low + high) / 2.0 < averaged.degrees.unwrap());
assert!(averaged.degrees.unwrap() < high);
degrees += 15.0;
}
}
}
Loading

0 comments on commit b5a62d5

Please sign in to comment.