diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2c38942 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + with: + otp-version: ">=27" + gleam-version: "1.3.2" + rebar3-version: "3" + # elixir-version: "1.15.4" + - run: gleam deps download + - run: gleam test + - run: gleam format --check src test diff --git a/README.md b/README.md index 7812678..dc861fc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🌤️ Sunny (WIP) +# 🌤️ Sunny An [Open-meteo API](https://open-meteo.com/) client written in Gleam. @@ -12,44 +12,64 @@ Makes it easier to get weather forecasts, current and past weather data with dif Add this package to your gleam project (not online yet) ```sh -gleam add sunny@1 +gleam add sunny ``` -### Getting the coordinates of a city +### Getting the current temperature in a city +
+ Example code + ```gleam +import gleam/dict +import gleam/io +import gleam/option + import sunny +import sunny/api/forecast +import sunny/api/forecast/instant import sunny/api/geocoding +import sunny/measurement pub fn main() { - // Use `new_commercial("")` if you have a commercial Open-meteo - // API access let sunny = sunny.new() let assert Ok(location) = - geocoding.get_first_location(sunny, { + sunny + |> geocoding.get_first_location( geocoding.params("marseille") - |> geocoding.set_language(geocoding.French) - }) + |> geocoding.set_language(geocoding.French), + ) + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(geocoding.location_to_position(location)) + |> forecast.set_current([instant.Temperature2m]), + ) + + let assert option.Some(current_data) = forecast_result.current + + let assert Ok(temperature) = + current_data.data |> dict.get(instant.Temperature2m) io.println( location.name - <> " is located at :\n" - <> float.to_string(location.latitude) - <> "\n" - <> float.to_string(location.longitude), + <> "'s current temperature is : " + <> measurement.to_string(temperature), ) } ``` +
+ +More examples in the `test/examples` directory Further documentation can be found at . ## Contributing -The project is open for contributions ! Make a fork, and once you made the changes you wanted, make a PR. +Contributions are very welcome ! Make a fork, and once you made the changes you wanted, make a PR. ### Todo -- Weather forecast API - Historical forecast API - Air quality API -- Make tests to make sure nothing is breaking diff --git a/examples/city_info.gleam b/examples/city_info.gleam deleted file mode 100644 index ca075e0..0000000 --- a/examples/city_info.gleam +++ /dev/null @@ -1,22 +0,0 @@ -import sunny -import sunny/api/geocoding - -pub fn main() { - // Use `new_commercial("")` if you have a commercial Open-meteo - // API access - let sunny = sunny.new() - - let assert Ok(location) = - geocoding.get_first_location(sunny, { - geocoding.params("marseille") - |> geocoding.set_language(geocoding.French) - }) - - io.println( - location.name - <> " is located at :\n" - <> float.to_string(location.latitude) - <> "\n" - <> float.to_string(location.longitude), - ) -} diff --git a/gleam.toml b/gleam.toml index e9c5df0..80baa59 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,6 @@ name = "sunny" version = "0.2.0" +gleam = ">= 0.32.0" # Fill out these fields if you intend to generate HTML documentation or publish # your project to the Hex package manager. @@ -14,9 +15,10 @@ version = "0.2.0" [dependencies] gleam_stdlib = ">= 0.34.0 and < 2.0.0" -gleam_json = ">= 2.0.0 and < 3.0.0" gleam_http = ">= 3.6.0 and < 4.0.0" efetch = ">= 2.0.1 and < 3.0.0" +birl = ">= 1.7.1 and < 2.0.0" +gleam_json = ">= 1.0.1 and < 2.0.0" [dev-dependencies] -gleeunit = ">= 1.0.0 and < 2.0.0" +glacier = ">= 1.1.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 13f17fd..8dfae2a 100644 --- a/manifest.toml +++ b/manifest.toml @@ -2,20 +2,32 @@ # You typically do not need to edit this file packages = [ + { name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" }, + { name = "birl", version = "1.7.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "5C66647D62BCB11FE327E7A6024907C4A17954EF22865FE0940B54A852446D01" }, { name = "efetch", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_stdlib"], otp_app = "efetch", source = "hex", outer_checksum = "849E38898C13436517C31A16D94C4642B3FFC444E26E293A14A1E3A8B7081C8A" }, + { name = "filepath", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "EFB6FF65C98B2A16378ABC3EE2B14124168C0CE5201553DE652E2644DCFDB594" }, + { name = "fs", version = "8.6.1", build_tools = ["rebar3"], requirements = [], otp_app = "fs", source = "hex", outer_checksum = "61EA2BDAEDAE4E2024D0D25C63E44DCCF65622D4402DB4A2DF12868D1546503F" }, + { name = "glacier", version = "1.1.0", build_tools = ["gleam"], requirements = ["argv", "fs", "glacier_gleeunit", "gleam_community_ansi", "gleam_community_colour", "gleam_stdlib", "shellout", "simplifile"], otp_app = "glacier", source = "hex", outer_checksum = "FC194FE584147BA997BAA1D33305A64BFE3F01112DBC70D27A3AC491A4338CC7" }, + { name = "glacier_gleeunit", version = "1.2.1001", build_tools = ["gleam"], requirements = ["argv", "gleam_stdlib"], otp_app = "glacier_gleeunit", source = "hex", outer_checksum = "F63ABBCE21DDBB0410B1365756BA4F897B504EA567A24462A1BC3291972FC981" }, + { name = "gleam_community_ansi", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "FE79E08BF97009729259B6357EC058315B6FBB916FAD1C2FF9355115FEB0D3A4" }, + { name = "gleam_community_colour", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "795964217EBEDB3DA656F5EB8F67D7AD22872EB95182042D3E7AFEF32D3FD2FE" }, { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, { name = "gleam_fetch", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "4AE60B21A9A664137A79B1BEB93F751CB27F1DDED4086CA00C0260F5FFACBD80" }, { name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" }, { name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" }, { name = "gleam_javascript", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "483631D3001FCE8EB12ADEAD5E1B808440038E96F93DA7A32D326C82F480C0B2" }, - { name = "gleam_json", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "CB10B0E7BF44282FB25162F1A24C1A025F6B93E777CCF238C4017E4EEF2CDE97" }, + { name = "gleam_json", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "thoas"], otp_app = "gleam_json", source = "hex", outer_checksum = "9063D14D25406326C0255BDA0021541E797D8A7A12573D849462CAFED459F6EB" }, { name = "gleam_stdlib", version = "0.39.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "2D7DE885A6EA7F1D5015D1698920C9BAF7241102836CE0C3837A4F160128A9C4" }, - { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "ranger", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "ranger", source = "hex", outer_checksum = "1566C272B1D141B3BBA38B25CB761EF56E312E79EC0E2DFD4D3C19FB0CC1F98C" }, + { name = "shellout", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "E2FCD18957F0E9F67E1F497FC9FF57393392F8A9BAEAEA4779541DE7A68DD7E0" }, + { name = "simplifile", version = "2.0.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "5FFEBD0CAB39BDD343C3E1CCA6438B2848847DC170BA2386DF9D7064F34DF000" }, + { name = "thoas", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "E38697EDFFD6E91BD12CEA41B155115282630075C2A727E7A6B2947F5408B86A" }, ] [requirements] +birl = { version = ">= 1.7.1 and < 2.0.0" } efetch = { version = ">= 2.0.1 and < 3.0.0" } +glacier = { version = ">= 1.1.0 and < 2.0.0" } gleam_http = { version = ">= 3.6.0 and < 4.0.0" } -gleam_json = { version = ">= 2.0.0 and < 3.0.0" } +gleam_json = { version = ">= 1.0.1 and < 2.0.0"} gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } -gleeunit = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/sunny.gleam b/src/sunny.gleam index e92d2c6..57bf8b0 100644 --- a/src/sunny.gleam +++ b/src/sunny.gleam @@ -5,15 +5,17 @@ import sunny/internal/defaults /// need anything more). /// /// If you have a commercial Open-meteo API acess, check out `new_commercial`. -/// -/// To change some client parameters (such as the base url), check out the -/// `sunny/client` module. pub fn new() -> Client { Client(defaults.base_url, False, "") } -/// Creates a new commercial Open-meteo client with the default values +/// Creates a new commercial Open-meteo client with the default values. /// Takes your Open-meteo api key as an argument. pub fn new_commercial(key: String) -> Client { Client(defaults.base_url, True, key) } + +/// Takes a Client and returns a new one with a custom base url. +pub fn set_base_url(client: Client, url: String) -> Client { + Client(..client, base_url: url) +} diff --git a/src/sunny/api/forecast.gleam b/src/sunny/api/forecast.gleam new file mode 100644 index 0000000..a8a24da --- /dev/null +++ b/src/sunny/api/forecast.gleam @@ -0,0 +1,687 @@ +//// The module for interactiong with the Forecast API. +//// +//// ## Example +//// +//// Get the hourly forecast of a city +//// +//// ```gleam +//// import birl +//// +//// import gleam/float +//// import gleam/io +//// import gleam/list +//// +//// import sunny +//// import sunny/api/forecast +//// import sunny/api/forecast/data +//// import sunny/api/forecast/instant +//// import sunny/position +//// import sunny/wmo_code +//// +//// pub fn main() { +//// // Use `new_commercial("")` if you have a commercial Open-meteo +//// // API access. +//// let sunny = sunny.new() +//// +//// // You can get the coordinates of a place using the Geocoding API. See +//// // `sunny/api/geocoding`, or the `city_info` example. +//// // +//// // Once you have a `Location`, use `geocoding.location_to_position()` to +//// // convert it to a position. +//// let position = position.Position(43.0, 5.0) +//// +//// let assert Ok(forecast_result) = +//// sunny +//// |> forecast.get_forecast( +//// forecast.params(position) +//// // All available variables are listed in the `sunny/api/forecast/instant` +//// // module. +//// // Daily variables are in `sunny/api/forecast/daily`. +//// |> forecast.set_hourly([instant.WeatherCode]) +//// |> forecast.set_forecast_days(1), +//// ) +//// +//// let assert Ok(hourly_weather) = +//// forecast_result.hourly +//// |> data.range_to_data_list(instant.WeatherCode) +//// +//// hourly_weather +//// |> list.each(fn(timed_data) { +//// io.debug( +//// birl.to_time_string(timed_data.time) +//// <> " : " +//// // `wmo_code.to_string` translates the `Int` WMOCode to a human-readable +//// // `String`. +//// <> wmo_code.to_string(float.round(timed_data.data.value)), +//// ) +//// }) +//// } +//// ``` + +import birl + +import gleam/float +import gleam/int +import gleam/list +import gleam/option +import gleam/result + +import sunny/api/forecast/daily +import sunny/api/forecast/data +import sunny/api/forecast/instant +import sunny/errors +import sunny/internal/api/forecast +import sunny/internal/client +import sunny/internal/utils +import sunny/position + +pub type TemperatureUnit { + Celsius + Fahrenheit +} + +fn temp_unit_to_string(u: TemperatureUnit) -> String { + case u { + Celsius -> "celsius" + Fahrenheit -> "fahrenheit" + } +} + +pub type WindSpeedUnit { + KilometersPerHour + MetersPerSecond + MilesPerHour + Knots +} + +fn wind_unit_to_string(u: WindSpeedUnit) -> String { + case u { + KilometersPerHour -> "kmh" + MetersPerSecond -> "ms" + MilesPerHour -> "mph" + Knots -> "kn" + } +} + +pub type PrecipitationUnit { + Millimeters + Inches +} + +fn precipitation_unit_to_string(u: PrecipitationUnit) -> String { + case u { + Millimeters -> "mm" + Inches -> "inch" + } +} + +pub type CellSelection { + Land + Sea + Nearest +} + +fn cell_select_to_string(c: CellSelection) -> String { + case c { + Land -> "land" + Sea -> "sea" + Nearest -> "nearest" + } +} + +/// The result of a request to the Forecast API. +pub type ForecastResult { + ForecastResult( + position: position.Position, + elevation: Float, + utc_offset_seconds: Int, + timezone: String, + timezone_abbreviation: String, + hourly: data.TimeRangedData(instant.InstantVariable), + daily: data.TimeRangedData(daily.DailyVariable), + minutely: data.TimeRangedData(instant.InstantVariable), + current: option.Option(data.CurrentData(instant.InstantVariable)), + ) +} + +/// The different parameters available on the Forecast API. +/// +/// See for further reference. +pub type ForecastParams { + ForecastParams( + // TODO: Support multiple positions in one call + position: position.Position, + hourly: List(instant.InstantVariable), + daily: List(daily.DailyVariable), + /// Get data every 15 minutes. Some data can't be optained every 15 + /// minutes, so it will be interpolated over the hour (or more if needed) + minutely: List(instant.InstantVariable), + current: List(instant.InstantVariable), + temperature_unit: TemperatureUnit, + wind_speed_unit: WindSpeedUnit, + precipitation_unit: PrecipitationUnit, + /// Full list here : + /// + /// You can set `timezone` to `auto`, and the API will deduce the local + /// timezone from the coordinates. + /// + /// For abbreviations, in my experience only three letter ones work + /// (e.g. `CEST` returns an error). + timezone: String, + /// Will be clamped between 0 and 92. + past_days: Int, + /// Will be clamped between 0 and 16. + forecast_days: Int, + /// If `forecast_hours` is negative or null, it will be set to `option.None`. + forecast_hours: option.Option(Int), + /// If `forecast_minutely_15` is negative or null, it will be set to `option.None`. + forecast_minutely_15: option.Option(Int), + /// If `past_hours` is negative or null, it will be set to `option.None`. + past_hours: option.Option(Int), + /// If `past_minutely_15` is negative or null, it will be set to `option.None`. + past_minutely_15: option.Option(Int), + start_date: option.Option(birl.Time), + end_date: option.Option(birl.Time), + start_hour: option.Option(birl.Time), + end_hour: option.Option(birl.Time), + start_minutely_15: option.Option(birl.Time), + end_minutely_15: option.Option(birl.Time), + // TODO: support multiple models + cell_selection: CellSelection, + ) +} + +/// Creates a new ForecastParams with the default values +pub fn params(position: position.Position) -> ForecastParams { + ForecastParams( + position, + [], + [], + [], + [], + Celsius, + KilometersPerHour, + Millimeters, + "GMT", + 0, + 7, + option.None, + option.None, + option.None, + option.None, + option.None, + option.None, + option.None, + option.None, + option.None, + option.None, + Land, + ) +} + +/// Returns a new ForecastParams with the specified temperature unit. +pub fn set_temperature_unit( + params: ForecastParams, + unit: TemperatureUnit, +) -> ForecastParams { + ForecastParams(..params, temperature_unit: unit) +} + +/// Returns a new ForecastParams with the specified temperature unit. +pub fn set_wind_speed_unit( + params: ForecastParams, + unit: WindSpeedUnit, +) -> ForecastParams { + ForecastParams(..params, wind_speed_unit: unit) +} + +/// Returns a new ForecastParams with the specified temperature unit. +pub fn set_precipitation_unit( + params: ForecastParams, + unit: PrecipitationUnit, +) -> ForecastParams { + ForecastParams(..params, precipitation_unit: unit) +} + +/// Returns a new ForecastParams with the specified cell selection. +pub fn set_cell_selection( + params: ForecastParams, + cell_selection: CellSelection, +) -> ForecastParams { + ForecastParams(..params, cell_selection: cell_selection) +} + +/// Returns a new ForecastParams with the specified timezone. +pub fn set_timezone(params: ForecastParams, timezone: String) -> ForecastParams { + ForecastParams(..params, timezone: timezone) +} + +/// Returns a new ForecastParams with the specified forecast days. +/// +/// `forecast_days` will be clamped between 0 and 16. +pub fn set_forecast_days( + params: ForecastParams, + forecast_days: Int, +) -> ForecastParams { + ForecastParams(..params, forecast_days: int.clamp(forecast_days, 0, 16)) +} + +/// Returns a new ForecastParams with the specified past days. +/// +/// `past_days` will be clamped between 0 and 92. +pub fn set_past_days(params: ForecastParams, past_days: Int) -> ForecastParams { + ForecastParams(..params, past_days: int.clamp(past_days, 0, 95)) +} + +/// Returns a new ForecastParams with the specified forecast hours. +/// +/// If `forecast_hours` is negative or null, it will be set to `option.None`. +pub fn set_forecast_hours( + params: ForecastParams, + forecast_hours: Int, +) -> ForecastParams { + case forecast_hours { + _ if forecast_hours <= 0 -> + ForecastParams(..params, forecast_hours: option.None) + _ -> ForecastParams(..params, forecast_hours: option.Some(forecast_hours)) + } +} + +/// Returns a new ForecastParams with the specified forecast minutely 15. +/// +/// If `forecast_minutely_15` is negative or null, it will be set to `option.None`. +pub fn set_forecast_minutely_15( + params: ForecastParams, + forecast_minutely_15: Int, +) -> ForecastParams { + case forecast_minutely_15 { + _ if forecast_minutely_15 <= 0 -> + ForecastParams(..params, forecast_minutely_15: option.None) + _ -> + ForecastParams( + ..params, + forecast_minutely_15: option.Some(forecast_minutely_15), + ) + } +} + +/// Returns a new ForecastParams with the specified past hours. +/// +/// If `past_hours` is negative or null, it will be set to `option.None`. +pub fn set_past_hours(params: ForecastParams, past_hours: Int) -> ForecastParams { + case past_hours { + _ if past_hours <= 0 -> ForecastParams(..params, past_hours: option.None) + _ -> ForecastParams(..params, past_hours: option.Some(past_hours)) + } +} + +/// Returns a new ForecastParams with the specified past minutely 15. +/// +/// If `past_minutely_15` is negative or null, it will be set to `option.None`. +pub fn set_past_minutely_15( + params: ForecastParams, + past_minutely_15: Int, +) -> ForecastParams { + case past_minutely_15 { + _ if past_minutely_15 <= 0 -> + ForecastParams(..params, past_minutely_15: option.None) + _ -> + ForecastParams(..params, past_minutely_15: option.Some(past_minutely_15)) + } +} + +/// Returns a new ForecastParams with the specified start date. +pub fn set_start_date( + params: ForecastParams, + start_date: birl.Time, +) -> ForecastParams { + ForecastParams(..params, start_date: option.Some(start_date)) +} + +/// Returns a new ForecastParams with the specified end date. +pub fn set_end_date( + params: ForecastParams, + end_date: birl.Time, +) -> ForecastParams { + ForecastParams(..params, end_date: option.Some(end_date)) +} + +/// Returns a new ForecastParams with the specified start hour. +pub fn set_start_hour( + params: ForecastParams, + start_hour: birl.Time, +) -> ForecastParams { + ForecastParams(..params, start_hour: option.Some(start_hour)) +} + +/// Returns a new ForecastParams with the specified end hour. +pub fn set_end_hour( + params: ForecastParams, + end_hour: birl.Time, +) -> ForecastParams { + ForecastParams(..params, end_hour: option.Some(end_hour)) +} + +/// Returns a new ForecastParams with the specified start minutely 15. +pub fn set_start_minutely_15( + params: ForecastParams, + start_minutely_15: birl.Time, +) -> ForecastParams { + ForecastParams(..params, start_minutely_15: option.Some(start_minutely_15)) +} + +/// Returns a new ForecastParams with the specified end minutely 15. +pub fn set_end_minutely_15( + params: ForecastParams, + end_minutely_15: birl.Time, +) -> ForecastParams { + ForecastParams(..params, end_minutely_15: option.Some(end_minutely_15)) +} + +/// Returns a new ForecastParams with the specified hourly list. +pub fn set_hourly( + params: ForecastParams, + hourly_list: List(instant.InstantVariable), +) -> ForecastParams { + ForecastParams(..params, hourly: hourly_list) +} + +/// Returns a new `ForecastParams` with all the hourly variables except the +/// ones in the `except` argument. +pub fn set_all_hourly( + params: ForecastParams, + except: List(instant.InstantVariable), +) -> ForecastParams { + set_all(params, except, instant.all, set_hourly) +} + +/// Returns a new ForecastParams with the specified daily list +pub fn set_daily( + params: ForecastParams, + daily_list: List(daily.DailyVariable), +) -> ForecastParams { + ForecastParams(..params, daily: daily_list) +} + +/// Returns a new `ForecastParams` with all the daily variables except the +/// ones in the `except` argument +pub fn set_all_daily( + params: ForecastParams, + except: List(daily.DailyVariable), +) -> ForecastParams { + set_all(params, except, daily.all, set_daily) +} + +/// Returns a new ForecastParams with the specified 15-minutely list +pub fn set_minutely( + params: ForecastParams, + minutely_list: List(instant.InstantVariable), +) -> ForecastParams { + ForecastParams(..params, minutely: minutely_list) +} + +/// Returns a new `ForecastParams` with all the minutely variables except the +/// ones in the `except` argument. +pub fn set_all_minutely( + params: ForecastParams, + except: List(instant.InstantVariable), +) -> ForecastParams { + set_all(params, except, instant.all, set_minutely) +} + +/// Returns a new ForecastParams with the specified hourly list +pub fn set_current( + params: ForecastParams, + current_list: List(instant.InstantVariable), +) -> ForecastParams { + ForecastParams(..params, current: current_list) +} + +/// Returns a new `ForecastParams` with all the current variables except the +/// ones in the `except` argument. +pub fn set_all_current( + params: ForecastParams, + except: List(instant.InstantVariable), +) -> ForecastParams { + set_all(params, except, instant.all, set_current) +} + +fn set_all( + params: ForecastParams, + except: List(a), + all: List(a), + set_fn: fn(ForecastParams, List(a)) -> ForecastParams, +) -> ForecastParams { + case except { + [] -> params |> set_fn(all) + [_, ..] -> + params + |> set_fn(list.filter(all, fn(x) { list.contains(except, x) })) + } +} + +/// Get a `ForecastResult` according to the specified `ForecastParams`. +pub fn get_forecast( + client: client.Client, + params: ForecastParams, +) -> Result(ForecastResult, errors.SunnyError) { + make_request(client, params) +} + +fn make_request( + client: client.Client, + params: ForecastParams, +) -> Result(ForecastResult, errors.SunnyError) { + let params = verify_params(params) + + use json_string <- result.try( + utils.get_final_url( + client.base_url, + "", + client.commercial, + "/forecast", + client.key, + params |> forecast_params_to_params_list, + ) + |> utils.make_request + |> result.map_error(fn(x) { errors.HttpError(x) }), + ) + use raw_result <- result.try(forecast.raw_forecast_result_from_json( + json_string, + )) + raw_result + |> refine_raw_result() + |> result.map_error(fn(e) { errors.SunnyInternalError(e) }) +} + +fn verify_params(params: ForecastParams) -> ForecastParams { + ForecastParams( + ..params, + hourly: list.unique(params.hourly), + daily: list.unique(params.daily), + minutely: list.unique(params.minutely), + current: list.unique(params.current), + ) + |> set_past_days(params.past_days) + |> set_forecast_days(params.forecast_days) + |> set_if_some(params.forecast_hours, set_forecast_hours) + |> set_if_some(params.forecast_minutely_15, set_forecast_minutely_15) + |> set_if_some(params.past_hours, set_past_hours) + |> set_if_some(params.past_minutely_15, set_past_minutely_15) +} + +fn set_if_some( + params: ForecastParams, + opt: option.Option(a), + func: fn(ForecastParams, a) -> ForecastParams, +) -> ForecastParams { + case opt { + option.Some(x) -> func(params, x) + option.None -> params + } +} + +fn forecast_params_to_params_list( + params: ForecastParams, +) -> List(utils.RequestParameter) { + option_to_param_list(params.forecast_hours, "forecast_hours", int.to_string) + |> list.append(list_to_param_list( + params.hourly, + "hourly", + forecast.instant_to_string, + )) + |> list.append(list_to_param_list( + params.daily, + "daily", + forecast.daily_to_string, + )) + |> list.append(list_to_param_list( + params.minutely, + "minutely_15", + forecast.instant_to_string, + )) + |> list.append(list_to_param_list( + params.current, + "current", + forecast.instant_to_string, + )) + |> list.append(option_to_param_list( + params.forecast_minutely_15, + "forecast_minutely_15", + int.to_string, + )) + |> list.append(option_to_param_list( + params.past_hours, + "past_hours", + int.to_string, + )) + |> list.append(option_to_param_list( + params.past_minutely_15, + "past_minutely_15", + int.to_string, + )) + |> list.append(option_to_param_list( + params.start_date, + "start_date", + birl.to_naive_date_string, + )) + |> list.append(option_to_param_list( + params.end_date, + "end_date", + birl.to_naive_date_string, + )) + |> list.append(option_to_param_list( + params.start_hour, + "start_hour", + birl.to_naive_time_string, + )) + |> list.append(option_to_param_list( + params.end_hour, + "end_hour", + birl.to_naive_date_string, + )) + |> list.append(option_to_param_list( + params.start_minutely_15, + "start_minutely_15", + birl.to_naive_date_string, + )) + |> list.append(option_to_param_list( + params.end_minutely_15, + "end_minutely_15", + birl.to_naive_date_string, + )) + |> list.append([ + utils.RequestParameter( + "latitude", + params.position.latitude |> float.to_string, + ), + utils.RequestParameter( + "longitude", + params.position.longitude |> float.to_string, + ), + utils.RequestParameter( + "temperature_unit", + params.temperature_unit |> temp_unit_to_string, + ), + utils.RequestParameter( + "wind_speed_unit", + params.wind_speed_unit |> wind_unit_to_string, + ), + utils.RequestParameter( + "precipitation_unit", + params.precipitation_unit |> precipitation_unit_to_string, + ), + utils.RequestParameter("timezone", params.timezone), + utils.RequestParameter("past_days", params.past_days |> int.to_string), + utils.RequestParameter( + "forecast_days", + params.forecast_days |> int.to_string, + ), + utils.RequestParameter( + "cell_selection", + params.cell_selection |> cell_select_to_string, + ), + ]) +} + +fn option_to_param_list( + opt: option.Option(a), + opt_string: String, + to_string_fn: fn(a) -> String, +) -> List(utils.RequestParameter) { + case opt { + option.Some(x) -> [utils.RequestParameter(opt_string, x |> to_string_fn)] + option.None -> [] + } +} + +fn list_to_param_list( + l: List(a), + l_string: String, + to_string_fn: fn(a) -> String, +) -> List(utils.RequestParameter) { + case l { + [_, ..] -> [ + l + |> utils.param_list_to_string(to_string_fn) + |> utils.RequestParameter(l_string, _), + ] + [] -> [] + } +} + +fn refine_raw_result( + raw: forecast.RawForecastResult, +) -> Result(ForecastResult, errors.InternalError) { + use hourly <- result.try(forecast.refine_raw_time_ranged_data( + raw.hourly, + raw.hourly_units, + forecast.string_to_instant, + )) + use daily <- result.try(forecast.refine_raw_time_ranged_data( + raw.daily, + raw.daily_units, + forecast.string_to_daily, + )) + use minutely <- result.try(forecast.refine_raw_time_ranged_data( + raw.minutely, + raw.minutely_units, + forecast.string_to_instant, + )) + use current <- result.try(forecast.refine_raw_current_data( + raw.current, + raw.current_units, + forecast.string_to_instant, + )) + Ok(ForecastResult( + position.Position(raw.latitude, raw.longitude), + raw.elevation, + raw.utc_offset_seconds, + raw.timezone, + raw.timezone_abbreviation, + hourly, + daily, + minutely, + current, + )) +} diff --git a/src/sunny/api/forecast/daily.gleam b/src/sunny/api/forecast/daily.gleam new file mode 100644 index 0000000..61d0f7e --- /dev/null +++ b/src/sunny/api/forecast/daily.gleam @@ -0,0 +1,35 @@ +/// Variables that can be obtained at for a specific day (for the `daily` field) +/// +/// See +pub type DailyVariable { + MaximumTemperature2m + MinimumTemperature2m + ApparentTemperatureMax + ApparentTemperatureMin + PrecipitationSum + RainSum + ShowersSum + SnowfallSum + PrecipitationHours + PrecipitationProbabilityMax + PrecipitationProbabilityMin + PrecipitationProbabilityMean + WorstWeatherCode + // TODO: Implemeny `Sunrise` and `Sunset` variables (they're problematic because they're not integers nor floats, but dates.) + // Sunrise + // Sunset + SunshineSuration + DaylightDuration + WindSpeed10mMax + WindGusts10mMax + WindDirection10mDominant +} + +/// A list of all available daily variables. +pub const all = [ + MaximumTemperature2m, MinimumTemperature2m, ApparentTemperatureMax, + ApparentTemperatureMin, PrecipitationSum, RainSum, ShowersSum, SnowfallSum, + PrecipitationHours, PrecipitationProbabilityMax, PrecipitationProbabilityMin, + PrecipitationProbabilityMean, WorstWeatherCode, SunshineSuration, + DaylightDuration, WindSpeed10mMax, WindGusts10mMax, WindDirection10mDominant, +] diff --git a/src/sunny/api/forecast/data.gleam b/src/sunny/api/forecast/data.gleam new file mode 100644 index 0000000..122b657 --- /dev/null +++ b/src/sunny/api/forecast/data.gleam @@ -0,0 +1,121 @@ +//// A module containing useful functions to handle API results. + +import birl +import gleam/dict +import gleam/list +import gleam/result +import sunny/errors +import sunny/measurement + +/// Data over a time range. +pub type TimeRangedData(data_type) { + TimeRangedData( + /// A list of `birl.Time`, with each index corresponding to the index of + /// the `data` argument. + /// + /// For example, `list.first(time)` is the time when the first measurement + /// was taken + time: List(birl.Time), + data: dict.Dict(data_type, List(measurement.Measurement)), + ) +} + +/// Data at a specific time. +pub type CurrentData(data_type) { + CurrentData( + time: birl.Time, + data: dict.Dict(data_type, measurement.Measurement), + ) +} + +/// A measurement at a specific time. +/// +/// Similar to `CurrentData` but with only one `Measurement`. +pub type Data { + Data(time: birl.Time, data: measurement.Measurement) +} + +/// Converts a `TimeRangedData` to a `CurrentData`, given a specific `time`. +pub fn range_to_current( + from data: TimeRangedData(a), + at time: birl.Time, +) -> Result(CurrentData(a), errors.SunnyError) { + case data.time { + [] -> + Error( + errors.DataError(errors.DataNotFoundError( + "Invalid time for provided data.", + )), + ) + [head, ..tail] -> + case head == time { + False -> { + let new_data = + data.data + |> dict.map_values(fn(_, v) { + // Should be ok, because the `time` length is the same as `v` + // length. + let assert [_, ..new] = v + new + }) + range_to_current(TimeRangedData(time: tail, data: new_data), time) + } + True -> + Ok(CurrentData( + time: time, + data: dict.fold(data.data, dict.new(), fn(d, k, v) { + dict.insert(d, k, { + let assert [head, ..] = v + head + }) + }), + )) + } + } +} + +/// Converts a `TimeRangedData` to a list of `Data` for a specific `InstantVariable` +/// or `DailyVariable`. +/// +/// Used in the `hourly_forecast` example. +pub fn range_to_data_list( + from data: TimeRangedData(a), + get var: a, +) -> Result(List(Data), errors.SunnyError) { + use l <- result.try( + get_range_var(data, var) |> result.map_error(fn(e) { errors.DataError(e) }), + ) + do_range_to_data_list(data.time, l, []) + |> result.map_error(fn(e) { errors.SunnyInternalError(e) }) +} + +fn do_range_to_data_list( + time: List(birl.Time), + l: List(measurement.Measurement), + result: List(Data), +) -> Result(List(Data), errors.InternalError) { + case time, l { + [], [] -> Ok(result) + [t, ..t_tail], [m, ..m_tail] -> + result + // This could, maybe, be optimized, by appending in the other way. + |> list.append([Data(t, m)]) + |> do_range_to_data_list(t_tail, m_tail, _) + _, _ -> + // Should not happen because the two lists should have the same length. + Error(errors.InternalError( + "Please open an issue on Github if you encountered this error.", + )) + } +} + +fn get_range_var( + from data: TimeRangedData(a), + get var: a, +) -> Result(List(measurement.Measurement), errors.DataError) { + data.data + |> dict.get(var) + |> result.map_error(fn(_) { + errors.DataNotFoundError("Could not find variable in data.") + }) +} diff --git a/src/sunny/api/forecast/instant.gleam b/src/sunny/api/forecast/instant.gleam new file mode 100644 index 0000000..0eb93b6 --- /dev/null +++ b/src/sunny/api/forecast/instant.gleam @@ -0,0 +1,49 @@ +/// Variables that can be obtained at a specific time (for `hourly`, `minutely` +/// and `current` fields) +/// +/// See +pub type InstantVariable { + Temperature2m + Temperature80m + Temperature120m + Temperature180m + RelativeHumidity2m + DewPoint2m + ApparentTemperature + PressureMsl + SurfacePressure + CloudCover + CloudCoverLow + CloudCoverMid + CloudCoverHigh + WindSpeed10m + WindSpeed80m + WindSpeed120m + WindSpeed180m + WindDirection10m + WindDirection80m + WindDirection120m + WindDirection180m + WindGusts10m + Precipitation + Snowfall + PrecipitationProbability + Rain + Showers + WeatherCode + SnowDepth + FreezingLevelHeight + Visibility + IsDay +} + +/// A list of all available instant variables. +pub const all = [ + Temperature2m, Temperature80m, Temperature120m, Temperature180m, + RelativeHumidity2m, DewPoint2m, ApparentTemperature, PressureMsl, + SurfacePressure, CloudCover, CloudCoverLow, CloudCoverMid, CloudCoverHigh, + WindSpeed10m, WindSpeed80m, WindSpeed120m, WindSpeed180m, WindDirection10m, + WindDirection80m, WindDirection120m, WindDirection180m, WindGusts10m, + Precipitation, Snowfall, PrecipitationProbability, Rain, Showers, WeatherCode, + SnowDepth, FreezingLevelHeight, Visibility, IsDay, +] diff --git a/src/sunny/api/geocoding.gleam b/src/sunny/api/geocoding.gleam index 95fd90e..16cc20e 100644 --- a/src/sunny/api/geocoding.gleam +++ b/src/sunny/api/geocoding.gleam @@ -1,8 +1,8 @@ -//// The module for interacting with the Geocoding API. -//// Useful for getting the coordinates of a city to then get the weather -//// forecast. +//// The module for interacting with the Geocoding API. Useful for getting the +//// coordinates of a city to then get the weather forecast. +//// +//// ## Example //// -//// ### Example //// ```gleam //// import sunny //// import sunny/api/geocoding @@ -13,10 +13,11 @@ //// let sunny = sunny.new() //// //// let assert Ok(location) = -//// geocoding.get_first_location(sunny, { +//// sunny +//// |> geocoding.get_first_location( //// geocoding.params("marseille") //// |> geocoding.set_language(geocoding.French) -//// }) +//// ) //// //// io.println( //// location.name @@ -26,7 +27,7 @@ //// <> float.to_string(location.longitude), //// ) //// } -//// ```gleam +//// ``` import gleam/dict import gleam/dynamic.{dict, field, float, int, list, optional_field, string} @@ -35,10 +36,10 @@ import gleam/json import gleam/list import gleam/option import gleam/result -import sunny/client import sunny/errors import sunny/internal/client.{type Client, Client} as _ import sunny/internal/utils +import sunny/position /// Enumeration of the available languages for the geocoding API. /// Changing the language will impact the search results. @@ -56,12 +57,9 @@ pub type Language { /// Represents a location on good old earth. Can be obtained with the geocoding /// API. +/// +/// See pub type Location { - /// See https://open-meteo.com/en/docs/geocoding-api for more information - /// on the fields. - /// - /// If you want to use specific coordinates, use the `Coordinates` - /// constructor. Location( latitude: Float, longitude: Float, @@ -71,8 +69,8 @@ pub type Location { feature_code: String, country_code: String, country_id: Int, - population: Int, - post_codes: List(String), + population: option.Option(Int), + post_codes: option.Option(List(String)), admin1: option.Option(String), admin2: option.Option(String), admin3: option.Option(String), @@ -84,6 +82,11 @@ pub type Location { ) } +/// Converts a location to a position. +pub fn location_to_position(location: Location) -> position.Position { + position.Position(location.latitude, location.longitude) +} + /// The different parameters needed to make a request to the geocoding API pub type GeocodingParams { GeocodingParams(name: String, count: Int, language: Language) @@ -93,7 +96,7 @@ pub type GeocodingParams { pub fn get_locations( client: Client, params: GeocodingParams, -) -> Result(List(Location), errors.OMApiError) { +) -> Result(List(Location), errors.SunnyError) { make_request(client, params) } @@ -102,12 +105,17 @@ pub fn get_locations( pub fn get_first_location( client: Client, params: GeocodingParams, -) -> Result(Location, errors.OMApiError) { +) -> Result(Location, errors.SunnyError) { use locations <- result.try(get_locations(client, set_count(params, 1))) case locations { [head, ..] -> Ok(head) // Shouldn't happen because an error would be returned by `get_locations` - [] -> panic as "`get_locations` gave empty list instead of error." + [] -> + Error( + errors.SunnyInternalError(errors.InternalError( + "`get_locations` gave empty list instead of error.", + )), + ) } } @@ -122,12 +130,10 @@ pub fn params(name: String) -> GeocodingParams { /// Creates a new GeocodingParams from the one specified, changing its count /// field. +/// +/// The count will be clamped between 1 and 100. pub fn set_count(params: GeocodingParams, count: Int) -> GeocodingParams { - case count { - count if count > 100 || count < 1 -> - panic as "Geocoding parameter count must be between 1 and 100." - _ -> GeocodingParams(..params, count: count) - } + GeocodingParams(..params, count: int.clamp(count, 1, 100)) } /// Creates a new GeocodingParams from the one specified, changing its language @@ -142,16 +148,22 @@ pub fn set_language( fn make_request( client: Client, params: GeocodingParams, -) -> Result(List(Location), errors.OMApiError) { +) -> Result(List(Location), errors.SunnyError) { + let params = case params.count { + c if c > 100 || c < 1 -> set_count(params, c) + _ -> params + } + case - utils.make_request(utils.get_final_url( - client.get_base_url(client), + utils.get_final_url( + client.base_url, "geocoding", - client.is_commercial(client), + client.commercial, "/search", - client.get_api_key(client), - geocoding_params_to_params_list(params), - )) + client.key, + params |> geocoding_params_to_params_list, + ) + |> utils.make_request { Ok(body) -> locations_from_json(body) Error(err) -> Error(errors.HttpError(err)) @@ -166,7 +178,7 @@ fn geocoding_params_to_params_list( utils.RequestParameter("count", int.to_string(params.count)), utils.RequestParameter( "language", - language_to_country_code(params.language), + params.language |> language_to_country_code, ), utils.RequestParameter("format", "json"), ] @@ -192,7 +204,7 @@ type LocationList { fn locations_from_json( json_string: String, -) -> Result(List(Location), errors.OMApiError) { +) -> Result(List(Location), errors.SunnyError) { let geocoding_decoder = dynamic.decode1( LocationList, @@ -208,8 +220,8 @@ fn locations_from_json( field("feature_code", of: string), field("country_code", of: string), field("country_id", of: int), - field("population", of: int), - field("postcodes", of: list(string)), + optional_field("population", of: int), + optional_field("postcodes", of: list(string)), optional_field("admin1", of: string), optional_field("admin2", of: string), optional_field("admin3", of: string), @@ -226,7 +238,12 @@ fn locations_from_json( |> result.map(fn(locations_maybe) { case locations_maybe { LocationList(option.Some(locations)) -> Ok(locations) - _ -> Error(errors.NoResults) + _ -> + Error( + errors.ApiError(errors.NoResultsError( + "Geocoding search gave no results", + )), + ) } }) |> result.flatten diff --git a/src/sunny/client.gleam b/src/sunny/client.gleam deleted file mode 100644 index 78aeb4b..0000000 --- a/src/sunny/client.gleam +++ /dev/null @@ -1,23 +0,0 @@ -import sunny/internal/client.{type Client, Client} - -/// Takes a Client and returns a new one with a custom base url. -pub fn set_base_url(client: Client, url: String) -> Client { - Client(..client, base_url: url) -} - -/// Whether the given Client is commercial or not. -pub fn is_commercial(client: Client) -> Bool { - client.commercial -} - -/// Returns the base url of the given Client. -pub fn get_base_url(client: Client) -> String { - client.base_url -} - -/// Gets the api_key of the given client. -/// -/// If the client is not commercial, returns an empty string. -pub fn get_api_key(client: Client) -> String { - client.key -} diff --git a/src/sunny/errors.gleam b/src/sunny/errors.gleam index 1b85bd6..64c6c48 100644 --- a/src/sunny/errors.gleam +++ b/src/sunny/errors.gleam @@ -1,9 +1,31 @@ +//// A module with the errors you could get using Sunny. + import efetch import gleam/json -/// A type describing any error that could occur while using this library -pub type OMApiError { - HttpError(efetch.HttpError) - DecodeError(json.DecodeError) - NoResults +pub type SunnyError { + /// Something went wrong with the HTTP request. + HttpError(err: efetch.HttpError) + /// Something went wrong with decoding the json obtained with the request. + DecodeError(err: json.DecodeError) + /// An API-related error (e.g. wrong arguments) + ApiError(err: ApiError) + /// Something went wrong while handling data. + DataError(err: DataError) + /// Something went wrong internally. If you get this error, please create an + /// issue on Github. + SunnyInternalError(err: InternalError) +} + +pub type ApiError { + NoResultsError(msg: String) + InvalidArgumentError(msg: String) +} + +pub type DataError { + DataNotFoundError(msg: String) +} + +pub type InternalError { + InternalError(msg: String) } diff --git a/src/sunny/internal/api/forecast.gleam b/src/sunny/internal/api/forecast.gleam new file mode 100644 index 0000000..a9a5db1 --- /dev/null +++ b/src/sunny/internal/api/forecast.gleam @@ -0,0 +1,356 @@ +import birl +import gleam/dict +import gleam/dynamic.{dict, field, float, int, list, optional_field, string} +import gleam/float +import gleam/int +import gleam/json +import gleam/list +import gleam/option +import gleam/result +import sunny/api/forecast/daily +import sunny/api/forecast/data +import sunny/api/forecast/instant +import sunny/errors +import sunny/internal/utils +import sunny/measurement + +pub type RawForecastResult { + RawForecastResult( + latitude: Float, + longitude: Float, + elevation: Float, + utc_offset_seconds: Int, + timezone: String, + timezone_abbreviation: String, + hourly: option.Option(dict.Dict(String, List(String))), + hourly_units: option.Option(dict.Dict(String, String)), + daily: option.Option(dict.Dict(String, List(String))), + daily_units: option.Option(dict.Dict(String, String)), + minutely: option.Option(dict.Dict(String, List(String))), + minutely_units: option.Option(dict.Dict(String, String)), + current: option.Option(dict.Dict(String, String)), + current_units: option.Option(dict.Dict(String, String)), + ) +} + +pub fn raw_forecast_result_from_json( + json_string: String, +) -> Result(RawForecastResult, errors.SunnyError) { + let any_of_string_or_int_or_float = + dynamic.any([ + string, + fn(x) { result.map(float(x), fn(f) { float.to_string(f) }) }, + fn(x) { result.map(int(x), fn(n) { int.to_string(n) }) }, + ]) + + let forecast_decoder = + utils.decode14( + RawForecastResult, + field("latitude", float), + field("longitude", float), + field("elevation", float), + field("utc_offset_seconds", int), + field("timezone", string), + field("timezone_abbreviation", string), + optional_field( + "hourly", + dict(string, list(any_of_string_or_int_or_float)), + ), + optional_field("hourly_units", dict(string, string)), + optional_field("daily", dict(string, list(any_of_string_or_int_or_float))), + optional_field("daily_units", dict(string, string)), + optional_field( + "minutely_15", + dict(string, list(any_of_string_or_int_or_float)), + ), + optional_field("minutely_15_units", dict(string, string)), + optional_field("current", dict(string, any_of_string_or_int_or_float)), + optional_field("current_units", dict(string, string)), + ) + json.decode(from: json_string, using: forecast_decoder) + |> result.map_error(fn(e) { errors.DecodeError(e) }) +} + +pub fn instant_to_string(var: instant.InstantVariable) -> String { + case var { + instant.Temperature2m -> "temperature_2m" + instant.Temperature80m -> "temperature_80m" + instant.Temperature120m -> "temperature_120m" + instant.Temperature180m -> "temperature_180m" + instant.RelativeHumidity2m -> "relative_humidity_2m" + instant.DewPoint2m -> "dew_point_2m" + instant.ApparentTemperature -> "apparent_temperature" + instant.PressureMsl -> "pressure_msl" + instant.SurfacePressure -> "surface_pressure" + instant.CloudCover -> "cloud_cover" + instant.CloudCoverLow -> "cloud_cover_low" + instant.CloudCoverMid -> "cloud_cover_mid" + instant.CloudCoverHigh -> "cloud_cover_high" + instant.WindSpeed10m -> "wind_speed_10m" + instant.WindSpeed80m -> "wind_speed_80m" + instant.WindSpeed120m -> "wind_speed_120m" + instant.WindSpeed180m -> "wind_speed_180m" + instant.WindDirection10m -> "wind_direction_10m" + instant.WindDirection80m -> "wind_direction_80m" + instant.WindDirection120m -> "wind_direction_120m" + instant.WindDirection180m -> "wind_direction_180m" + instant.WindGusts10m -> "wind_gusts_10m" + instant.Precipitation -> "precipitation" + instant.Snowfall -> "snowfall" + instant.PrecipitationProbability -> "precipitation_probability" + instant.Rain -> "rain" + instant.Showers -> "showers" + instant.WeatherCode -> "weather_code" + instant.SnowDepth -> "snow_depth" + instant.FreezingLevelHeight -> "freezing_level_height" + instant.Visibility -> "visibility" + instant.IsDay -> "is_day" + } +} + +pub fn string_to_instant( + s: String, +) -> Result(instant.InstantVariable, errors.InternalError) { + case s { + "temperature_2m" -> instant.Temperature2m |> Ok() + "temperature_80m" -> instant.Temperature80m |> Ok() + "temperature_120m" -> instant.Temperature120m |> Ok() + "temperature_180m" -> instant.Temperature180m |> Ok() + "relative_humidity_2m" -> instant.RelativeHumidity2m |> Ok() + "dew_point_2m" -> instant.DewPoint2m |> Ok() + "apparent_temperature" -> instant.ApparentTemperature |> Ok() + "pressure_msl" -> instant.PressureMsl |> Ok() + "surface_pressure" -> instant.SurfacePressure |> Ok() + "cloud_cover" -> instant.CloudCover |> Ok() + "cloud_cover_low" -> instant.CloudCoverLow |> Ok() + "cloud_cover_mid" -> instant.CloudCoverMid |> Ok() + "cloud_cover_high" -> instant.CloudCoverHigh |> Ok() + "wind_speed_10m" -> instant.WindSpeed10m |> Ok() + "wind_speed_80m" -> instant.WindSpeed80m |> Ok() + "wind_speed_120m" -> instant.WindSpeed120m |> Ok() + "wind_speed_180m" -> instant.WindSpeed180m |> Ok() + "wind_direction_10m" -> instant.WindDirection10m |> Ok() + "wind_direction_80m" -> instant.WindDirection80m |> Ok() + "wind_direction_120m" -> instant.WindDirection120m |> Ok() + "wind_direction_180m" -> instant.WindDirection180m |> Ok() + "wind_gusts_10m" -> instant.WindGusts10m |> Ok() + "precipitation" -> instant.Precipitation |> Ok() + "snowfall" -> instant.Snowfall |> Ok() + "precipitation_probability" -> instant.PrecipitationProbability |> Ok() + "rain" -> instant.Rain |> Ok() + "showers" -> instant.Showers |> Ok() + "weather_code" -> instant.WeatherCode |> Ok() + "snow_depth" -> instant.SnowDepth |> Ok() + "freezing_level_height" -> instant.FreezingLevelHeight |> Ok() + "visibility" -> instant.Visibility |> Ok() + "is_day" -> instant.IsDay |> Ok() + _ -> + Error(errors.InternalError( + "Unexpected error : string_to_instant(\"" <> s <> "\")", + )) + } +} + +pub fn daily_to_string(d: daily.DailyVariable) -> String { + case d { + daily.MaximumTemperature2m -> "temperature_2m_max" + daily.MinimumTemperature2m -> "temperature_2m_min" + daily.ApparentTemperatureMax -> "apparent_temperature_max" + daily.ApparentTemperatureMin -> "apparent_temperature_min" + daily.PrecipitationSum -> "precipitation_sum" + daily.RainSum -> "rain_sum" + daily.ShowersSum -> "showers_sum" + daily.SnowfallSum -> "snowfall_sum" + daily.PrecipitationHours -> "precipitation_hours" + daily.PrecipitationProbabilityMax -> "precipitation_probability_max" + daily.PrecipitationProbabilityMin -> "precipitation_probability_min" + daily.PrecipitationProbabilityMean -> "precipitation_probability_mean" + daily.WorstWeatherCode -> "weather_code" + //daily.Sunrise -> "sunrise" + //daily.Sunset -> "sunset" + daily.SunshineSuration -> "sunshine_duration" + daily.DaylightDuration -> "daylight_duration" + daily.WindSpeed10mMax -> "wind_speed_10m_max" + daily.WindGusts10mMax -> "wind_gusts_10m_max" + daily.WindDirection10mDominant -> "wind_direction_10m_dominant" + } +} + +pub fn string_to_daily( + s: String, +) -> Result(daily.DailyVariable, errors.InternalError) { + case s { + "temperature_2m_max" -> daily.MaximumTemperature2m |> Ok() + "temperature_2m_min" -> daily.MinimumTemperature2m |> Ok() + "apparent_temperature_max" -> daily.ApparentTemperatureMax |> Ok() + "apparent_temperature_min" -> daily.ApparentTemperatureMin |> Ok() + "precipitation_sum" -> daily.PrecipitationSum |> Ok() + "rain_sum" -> daily.RainSum |> Ok() + "showers_sum" -> daily.ShowersSum |> Ok() + "snowfall_sum" -> daily.SnowfallSum |> Ok() + "precipitation_hours" -> daily.PrecipitationHours |> Ok() + "precipitation_probability_max" -> daily.PrecipitationProbabilityMax |> Ok() + "precipitation_probability_min" -> daily.PrecipitationProbabilityMin |> Ok() + "precipitation_probability_mean" -> + daily.PrecipitationProbabilityMean |> Ok() + "weather_code" -> daily.WorstWeatherCode |> Ok() + //"sunrise" -> daily.Sunrise |> Ok() + //"sunset" -> daily.Sunset |> Ok() + "sunshine_duration" -> daily.SunshineSuration |> Ok() + "daylight_duration" -> daily.DaylightDuration |> Ok() + "wind_speed_10m_max" -> daily.WindSpeed10mMax |> Ok() + "wind_gusts_10m_max" -> daily.WindGusts10mMax |> Ok() + "wind_direction_10m_dominant" -> daily.WindDirection10mDominant |> Ok() + _ -> + Error(errors.InternalError( + "Unexpected error : string_to_instant(\"" <> s <> "\")", + )) + } +} + +pub fn refine_raw_time_ranged_data( + data_opt: option.Option(dict.Dict(String, List(String))), + data_units_opt: option.Option(dict.Dict(String, String)), + from_string_fn: fn(String) -> Result(a, errors.InternalError), +) -> Result(data.TimeRangedData(a), errors.InternalError) { + case data_opt, data_units_opt { + option.Some(data), option.Some(data_units) -> { + use time <- result.try( + dict.get(data, "time") + // Convert the time strings to `birl.Time` + |> result.map(fn(l) { list.map(l, birl.from_naive) |> result.all }) + |> result.flatten + |> result.map_error(fn(_) { + errors.InternalError( + "Unexpected error : `time` field should be present.", + ) + }), + ) + let data = + data + |> dict.drop(["time"]) + // Check that : + // - all keys correspond to an `instant.InstantVariable` + // - all values are `Float` + // - all keys have a corresponding unit + // Every condition should be ok if everything works as intended + case + list.all(dict.to_list(data), fn(tuple) { + let #(k, v) = tuple + + case from_string_fn(k) { + Ok(_) -> True + Error(_) -> False + } + && list.all(v, fn(value) { + case utils.parse_float_or_int(value) { + Ok(_) -> True + Error(_) -> False + } + }) + && case dict.get(data_units, k) { + Ok(_) -> True + Error(_) -> False + } + }) + { + True -> + data + |> dict.fold( + data.TimeRangedData(time: time, data: dict.new()), + fn(d, k, v) { + // It's Ok because it has been checked earlier + let assert Ok(var) = from_string_fn(k) + let assert Ok(unit) = dict.get(data_units, k) + + let data_list = + v + |> list.map(fn(s) { + let res = utils.parse_float_or_int(s) + case res { + Ok(f) -> measurement.Measurement(f, unit) + // Will not happen because checked earlier + Error(_) -> panic as "This should not happen" + } + }) + data.TimeRangedData( + ..d, + data: d.data |> dict.insert(var, data_list), + ) + }, + ) + |> Ok + False -> + Error(errors.InternalError( + "Unexpected error : data should contain keys corresponding to `a` and float values", + )) + } + } + // Return empty data is not present. + _, _ -> Ok(data.TimeRangedData([], dict.new())) + } +} + +pub fn refine_raw_current_data( + data_opt: option.Option(dict.Dict(String, String)), + data_units_opt: option.Option(dict.Dict(String, String)), + from_string_fn: fn(String) -> Result(a, errors.InternalError), +) -> Result(option.Option(data.CurrentData(a)), errors.InternalError) { + case data_opt, data_units_opt { + option.Some(data), option.Some(data_units) -> { + use time <- result.try( + dict.get(data, "time") + |> result.map(birl.from_naive) + |> result.flatten + |> result.map_error(fn(_) { + errors.InternalError( + "Unexpected error : `time` field should be present.", + ) + }), + ) + let data = data |> dict.drop(["time", "interval"]) + case + list.all(dict.to_list(data), fn(tuple) { + let #(k, v) = tuple + + case from_string_fn(k) { + Ok(_) -> True + Error(_) -> False + } + && case utils.parse_float_or_int(v) { + Ok(_) -> True + Error(_) -> False + } + && case dict.get(data_units, k) { + Ok(_) -> True + Error(_) -> False + } + }) + { + True -> + data + |> dict.fold( + data.CurrentData(time: time, data: dict.new()), + fn(d, k, v) { + // It's Ok because it has been checked earlier + let assert Ok(var) = from_string_fn(k) + let assert Ok(unit) = dict.get(data_units, k) + let assert Ok(value) = utils.parse_float_or_int(v) + data.CurrentData( + ..d, + data: d.data + |> dict.insert(var, measurement.Measurement(value, unit)), + ) + }, + ) + |> option.Some + |> Ok + False -> + Error(errors.InternalError( + "Unexpected error : data should contain keys corresponding to `a` and float values", + )) + } + } + _, _ -> Ok(option.None) + } +} diff --git a/src/sunny/internal/utils.gleam b/src/sunny/internal/utils.gleam index 84ad34b..9c282a7 100644 --- a/src/sunny/internal/utils.gleam +++ b/src/sunny/internal/utils.gleam @@ -1,8 +1,11 @@ import efetch import gleam/dynamic.{type DecodeError, type Decoder, type Dynamic} +import gleam/float import gleam/http/request +import gleam/int import gleam/list import gleam/result.{try} +import gleam/string pub fn make_request(url: String) -> Result(String, efetch.HttpError) { let assert Ok(request) = request.to(url) @@ -41,11 +44,28 @@ pub fn get_final_url( <> "?" <> list.fold(params, "", fn(a, b) { a <> b.key <> "=" <> b.value <> "&" }) <> case commercial { - True -> "apikey" <> "=" <> apikey + True -> "&apikey" <> "=" <> apikey False -> "" } } +pub fn param_list_to_string( + params: List(a), + to_string_fn: fn(a) -> String, +) -> String { + params + |> list.fold("", fn(a, b) { a <> to_string_fn(b) <> "," }) + |> string.drop_right(1) +} + +pub fn parse_float_or_int(s: String) -> Result(Float, Nil) { + let res = int.parse(s) + case res { + Ok(i) -> Ok(int.to_float(i)) + Error(_) -> float.parse(s) + } +} + // Taken from https://github.com/gleam-lang/stdlib/blob/v0.39.0/src/gleam/dynamic.gleam#L1530 fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) { case result { @@ -55,6 +75,80 @@ fn all_errors(result: Result(a, List(DecodeError))) -> List(DecodeError) { } // Adapted from gleam/dynamic source code + +pub fn decode14( + constructor: fn(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14) -> + t, + t1: Decoder(t1), + t2: Decoder(t2), + t3: Decoder(t3), + t4: Decoder(t4), + t5: Decoder(t5), + t6: Decoder(t6), + t7: Decoder(t7), + t8: Decoder(t8), + t9: Decoder(t9), + t10: Decoder(t10), + t11: Decoder(t11), + t12: Decoder(t12), + t13: Decoder(t13), + t14: Decoder(t14), +) -> Decoder(t) { + fn(x: Dynamic) { + case + t1(x), + t2(x), + t3(x), + t4(x), + t5(x), + t6(x), + t7(x), + t8(x), + t9(x), + t10(x), + t11(x), + t12(x), + t13(x), + t14(x) + { + Ok(a), + Ok(b), + Ok(c), + Ok(d), + Ok(e), + Ok(f), + Ok(g), + Ok(h), + Ok(i), + Ok(j), + Ok(k), + Ok(l), + Ok(m), + Ok(n) + -> Ok(constructor(a, b, c, d, e, f, g, h, i, j, k, l, m, n)) + a, b, c, d, e, f, g, h, i, j, k, l, m, n -> + Error( + list.concat([ + all_errors(a), + all_errors(b), + all_errors(c), + all_errors(d), + all_errors(e), + all_errors(f), + all_errors(g), + all_errors(h), + all_errors(i), + all_errors(j), + all_errors(k), + all_errors(l), + all_errors(m), + all_errors(n), + ]), + ) + } + } +} + pub fn decode18( constructor: fn( t1, diff --git a/src/sunny/measurement.gleam b/src/sunny/measurement.gleam new file mode 100644 index 0000000..f48db53 --- /dev/null +++ b/src/sunny/measurement.gleam @@ -0,0 +1,15 @@ +import gleam/float + +/// Represents a physical measurement with a unit +pub type Measurement { + Measurement(value: Float, unit: String) +} + +/// Converts a measurement to a string, displaying its unit. +pub fn to_string(m: Measurement) -> String { + float.to_string(m.value) + <> case m.unit { + "" -> "" + _ -> " " <> m.unit + } +} diff --git a/src/sunny/position.gleam b/src/sunny/position.gleam new file mode 100644 index 0000000..aa8503f --- /dev/null +++ b/src/sunny/position.gleam @@ -0,0 +1,4 @@ +/// Describes a position on earth using latitude and longitude. +pub type Position { + Position(latitude: Float, longitude: Float) +} diff --git a/src/sunny/wmo_code.gleam b/src/sunny/wmo_code.gleam new file mode 100644 index 0000000..dc852ca --- /dev/null +++ b/src/sunny/wmo_code.gleam @@ -0,0 +1,49 @@ +/// Translates the `Int` WMOCode to a human-readable +///`String`. +/// +/// See https://open-meteo.com/en/docs (at the bottom of the page). +pub fn to_string(code: Int) { + case code { + 0 -> "Clear sky" + + 1 -> "Mainly clear" + 2 -> "Partly cloudy" + 3 -> "Overcast" + + 45 -> "Fog" + 48 -> "Depositing rime fog" + + 51 -> "Light drizzle" + 53 -> "Moderate drizzle" + 55 -> "Dense drizzle" + + 56 -> "Light freezing drizzle" + 57 -> "Dense freezing drizzle" + + 61 -> "Slight rain" + 63 -> "Moderate rain" + 65 -> "Heavy rain" + + 66 -> "Light freezing rain" + 67 -> "Heavy freezing rain" + + 71 -> "Slight snow" + 73 -> "Moderate snow" + 75 -> "Heavy snow" + + 77 -> "Snow grains" + + 80 -> "Slight rain shower" + 81 -> "Moderate rain shower" + 82 -> "Violent rain shower" + + 85 -> "Slight snow shower" + 86 -> "Heavy snow shower" + + 95 -> "Slight or moderate thunderstorm" + 96 -> "Thunderstorm with slight hail" + 99 -> "Thunderstorm with heavy hail" + + _ -> "Unknown WMO code" + } +} diff --git a/test/examples/city_info_test.gleam b/test/examples/city_info_test.gleam new file mode 100644 index 0000000..7dfb634 --- /dev/null +++ b/test/examples/city_info_test.gleam @@ -0,0 +1,31 @@ +import gleam/int +import gleam/io +import gleam/option +import sunny +import sunny/api/geocoding + +pub fn city_info_test() { + // Use `new_commercial("")` if you have a commercial Open-meteo + // API access. + let sunny = sunny.new() + + let assert Ok(location) = + sunny + // If the location your searching for isn't the first result, try + // `geocoding.get_locations` + |> geocoding.get_first_location( + geocoding.params("marseille") + // Changing the language can impact the search results. + |> geocoding.set_language(geocoding.French), + ) + + let assert option.Some(population) = location.population + + io.println( + location.name + <> ", " + <> location.country_code + <> " has a population of : " + <> int.to_string(population), + ) +} diff --git a/test/examples/current_temperature_test.gleam b/test/examples/current_temperature_test.gleam new file mode 100644 index 0000000..211a3c3 --- /dev/null +++ b/test/examples/current_temperature_test.gleam @@ -0,0 +1,42 @@ +import gleam/dict +import gleam/io +import gleam/option +import sunny +import sunny/api/forecast +import sunny/api/forecast/data +import sunny/api/forecast/instant +import sunny/measurement +import sunny/position + +pub fn current_temperature_test() { + // Use `new_commercial("")` if you have a commercial Open-meteo + // API access. + let sunny = sunny.new() + + // You can get the coordinates of a place using the Geocoding API. See + // `sunny/api/geocoding`, or the `city_info` example. + // + // Once you have a `Location`, use `geocoding.location_to_position()` to + // convert it to a position. + let position = position.Position(43.0, 5.0) + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(position) + // All available variables are listed in the `sunny/api/forecast/instant` module. + // Daily variables are in `sunny/api/forecast/daily`. + |> forecast.set_current([instant.Temperature2m]), + ) + + let assert option.Some(data.CurrentData(data: data, ..)) = + forecast_result.current + + let assert Ok(current_temperature) = + data + |> dict.get(instant.Temperature2m) + + io.println( + "Current temperature : " <> measurement.to_string(current_temperature), + ) +} diff --git a/test/examples/hourly_forecast.gleam b/test/examples/hourly_forecast.gleam new file mode 100644 index 0000000..0df7942 --- /dev/null +++ b/test/examples/hourly_forecast.gleam @@ -0,0 +1,49 @@ +import birl +import gleam/float +import gleam/io +import gleam/list +import sunny +import sunny/api/forecast +import sunny/api/forecast/data +import sunny/api/forecast/instant +import sunny/position +import sunny/wmo_code + +pub fn hourly_forecast_test() { + // Use `new_commercial("")` if you have a commercial Open-meteo + // API access. + let sunny = sunny.new() + + // You can get the coordinates of a place using the Geocoding API. See + // `sunny/api/geocoding`, or the `city_info` example. + // + // Once you have a `Location`, use `geocoding.location_to_position()` to + // convert it to a position. + let position = position.Position(43.0, 5.0) + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(position) + // All available variables are listed in the `sunny/api/forecast/instant` + // module. + // Daily variables are in `sunny/api/forecast/daily`. + |> forecast.set_hourly([instant.WeatherCode]) + |> forecast.set_forecast_days(1), + ) + + let assert Ok(hourly_weather) = + forecast_result.hourly + |> data.range_to_data_list(instant.WeatherCode) + + hourly_weather + |> list.each(fn(timed_data) { + io.debug( + birl.to_time_string(timed_data.time) + <> " : " + // `wmo_code.to_string` translates the `Int` WMOCode to a human-readable + // `String`. + <> wmo_code.to_string(float.round(timed_data.data.value)), + ) + }) +} diff --git a/test/forecast_test.gleam b/test/forecast_test.gleam new file mode 100644 index 0000000..44dbd4b --- /dev/null +++ b/test/forecast_test.gleam @@ -0,0 +1,126 @@ +import glacier/should +import gleam/dict +import gleam/list +import gleam/option +import sunny +import sunny/api/forecast +import sunny/api/forecast/daily +import sunny/api/forecast/instant +import sunny/position + +const coords = position.Position(43.0, 5.0) + +pub fn hourly_all_test() { + let sunny = sunny.new() + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(coords) + |> forecast.set_all_hourly([]), + ) + + use var <- list.each(instant.all) + let l = + forecast_result.hourly.data + |> dict.get(var) + |> should.be_ok + + list.length(l) + |> should.equal(list.length(forecast_result.hourly.time)) +} + +pub fn daily_all_test() { + let sunny = sunny.new() + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(coords) + |> forecast.set_all_daily([]), + ) + + use var <- list.each(daily.all) + let l = + forecast_result.daily.data + |> dict.get(var) + |> should.be_ok + + list.length(l) + |> should.equal(list.length(forecast_result.daily.time)) +} + +pub fn minutely_all_test() { + let sunny = sunny.new() + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(coords) |> forecast.set_all_minutely([]), + ) + + use var <- list.each(instant.all) + let l = + forecast_result.minutely.data + |> dict.get(var) + |> should.be_ok + + list.length(l) + |> should.equal(list.length(forecast_result.minutely.time)) +} + +pub fn current_all_test() { + let sunny = sunny.new() + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast( + forecast.params(coords) |> forecast.set_all_current([]), + ) + + let assert option.Some(current) = forecast_result.current + + use var <- list.each(instant.all) + current.data + |> dict.get(var) + |> should.be_ok +} + +pub fn none_test() { + let sunny = sunny.new() + + let assert Ok(forecast_result) = + sunny + |> forecast.get_forecast(forecast.params(coords)) + + forecast_result.hourly.time + |> list.is_empty + |> should.be_true + + forecast_result.daily.time + |> list.is_empty + |> should.be_true + + forecast_result.minutely.time + |> list.is_empty + |> should.be_true + + forecast_result.current + |> should.be_none + + { + use var <- list.each(instant.all) + forecast_result.hourly.data + |> dict.get(var) + |> should.be_error + + forecast_result.minutely.data + |> dict.get(var) + |> should.be_error + } + + use var <- list.each(daily.all) + forecast_result.daily.data + |> dict.get(var) + |> should.be_error +} diff --git a/test/geocoding_test.gleam b/test/geocoding_test.gleam new file mode 100644 index 0000000..9b386a4 --- /dev/null +++ b/test/geocoding_test.gleam @@ -0,0 +1,53 @@ +import glacier/should +import gleam/list +import gleam/result +import sunny +import sunny/api/geocoding +import sunny/errors + +pub fn ok_result_test() { + let sunny = sunny.new() + + let ok_result = + sunny |> geocoding.get_locations(geocoding.params("marseille")) + ok_result |> should.be_ok +} + +pub fn no_result_test() { + let sunny = sunny.new() + + let err_result = sunny |> geocoding.get_locations(geocoding.params("a")) + + err_result |> should.be_error + + use err <- result.map_error(err_result) + err + |> should.equal( + errors.ApiError(errors.NoResultsError("Geocoding search gave no results")), + ) +} + +pub fn commercial_test() { + // Any API key should work, because the API still returns results even if it + // is incorrect. + let sunny = sunny.new_commercial("api_key") + + let ok_result = + sunny |> geocoding.get_locations(geocoding.params("marseille")) + ok_result |> should.be_ok +} + +pub fn first_location_test() { + let sunny = sunny.new() + + let params = geocoding.params("marseille") + + let assert Ok(locations) = sunny |> geocoding.get_locations(params) + + let assert Ok(first_location) = sunny |> geocoding.get_first_location(params) + + use first_location2 <- result.map(list.first(locations)) + first_location + |> should.equal(first_location2) + |> Ok +} diff --git a/test/sunny_test.gleam b/test/sunny_test.gleam new file mode 100644 index 0000000..b904113 --- /dev/null +++ b/test/sunny_test.gleam @@ -0,0 +1,41 @@ +import glacier +import glacier/should +import gleam/dict +import sunny +import sunny/api/forecast +import sunny/api/forecast/instant +import sunny/api/geocoding + +pub fn main() { + glacier.main() +} + +pub fn geocoding_and_forecast_test() { + let sunny = sunny.new() + + let position = + sunny + |> geocoding.get_first_location(geocoding.params("marseille")) + |> should.be_ok + |> geocoding.location_to_position + + let forecast_result = + sunny + |> forecast.get_forecast( + forecast.params(position) + |> forecast.set_current([instant.Temperature2m]), + ) + |> should.be_ok + + let current = + forecast_result.current + |> should.be_some + + current.data + |> dict.get(instant.Temperature2m) + |> should.be_ok + + current.data + |> dict.get(instant.ApparentTemperature) + |> should.be_error +}