From 60e6998f406208edcc6a450ca781334233d04741 Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:20:01 +0100 Subject: [PATCH 1/8] feat: implement USWAN forecast service and DTO for MeteoSIX --- lib/chronodash/datasource/meteosix/uswan.ex | 20 +++++++ lib/chronodash/datasource/meteosix/wrf.ex | 2 +- .../datasource/meteosix/{wrf => }/forecast.ex | 4 +- lib/meteosix/operations.ex | 39 ++++++++++++- lib/meteosix/uswan.ex | 55 +++++++++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 lib/chronodash/datasource/meteosix/uswan.ex rename lib/chronodash/models/datasource/meteosix/{wrf => }/forecast.ex (94%) create mode 100644 lib/meteosix/uswan.ex diff --git a/lib/chronodash/datasource/meteosix/uswan.ex b/lib/chronodash/datasource/meteosix/uswan.ex new file mode 100644 index 0000000..b913e4d --- /dev/null +++ b/lib/chronodash/datasource/meteosix/uswan.ex @@ -0,0 +1,20 @@ +defmodule Chronodash.DataSource.MeteoSIX.USWAN do + @moduledoc """ + High-level service for fetching USWAN Forecast from MeteoSIX. + """ + alias MeteoSIX.USWAN + alias Chronodash.Models.DataSource.MeteoSIX.Forecast + + @doc """ + Fetches forecast from MeteoSIX and parses it into a DTO. + """ + def get_forecast(id_or_coords, var_atom, opts \\ []) do + case USWAN.get_forecast(id_or_coords, var_atom, opts) do + {:ok, response} -> + {:ok, Forecast.new(response, var_atom)} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/chronodash/datasource/meteosix/wrf.ex b/lib/chronodash/datasource/meteosix/wrf.ex index f56b3b4..fbbdbe5 100644 --- a/lib/chronodash/datasource/meteosix/wrf.ex +++ b/lib/chronodash/datasource/meteosix/wrf.ex @@ -3,7 +3,7 @@ defmodule Chronodash.DataSource.MeteoSIX.WRF do High-level service for fetching WRF Forecast from MeteoSIX. """ alias MeteoSIX.WRF - alias Chronodash.Models.DataSource.MeteoSIX.WRF.Forecast + alias Chronodash.Models.DataSource.MeteoSIX.Forecast @doc """ Fetches forecast from MeteoSIX and parses it into a DTO. diff --git a/lib/chronodash/models/datasource/meteosix/wrf/forecast.ex b/lib/chronodash/models/datasource/meteosix/forecast.ex similarity index 94% rename from lib/chronodash/models/datasource/meteosix/wrf/forecast.ex rename to lib/chronodash/models/datasource/meteosix/forecast.ex index 8681281..e18ceb2 100644 --- a/lib/chronodash/models/datasource/meteosix/wrf/forecast.ex +++ b/lib/chronodash/models/datasource/meteosix/forecast.ex @@ -1,6 +1,6 @@ -defmodule Chronodash.Models.DataSource.MeteoSIX.WRF.Forecast do +defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do @moduledoc """ - DTO for MeteoSIX WRF Forecast data. + DTO for MeteoSIX WRF and USWAN Forecast data. """ defstruct [ :location_name, diff --git a/lib/meteosix/operations.ex b/lib/meteosix/operations.ex index 421fd5a..154af20 100644 --- a/lib/meteosix/operations.ex +++ b/lib/meteosix/operations.ex @@ -1,11 +1,11 @@ defmodule MeteoSIX.Operations do @moduledoc """ - Handles location-related operations for MeteoSIX. + Handles operations for MeteoSIX API endpoints """ alias MeteoSIX @doc """ - Finds places by name using MeteoSIX's findPlaces endpoint. + Finds places by name using MeteoSIX's '/findPlaces' endpoint. """ def find_places(query, opts \\ []) do params = [ @@ -16,4 +16,39 @@ defmodule MeteoSIX.Operations do MeteoSIX.request("findPlaces", params, opts) end + + @doc """ + Returns tidal information for the nearest port on the Galician coast. + """ + def get_tides_info(location, opts \\ []) do + params = + location_params(location) ++ + [ + startTime: opts[:start_time], + endTime: opts[:end_time], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid" + ] + + MeteoSIX.request("getTidesInfo", params, opts) + end + + @doc """ + Returns sunrise, midday, sunset and daylight duration for any point on Earth. + """ + def get_solar_info(location, opts \\ []) do + params = + location_params(location) ++ + [ + startTime: opts[:start_time], + endTime: opts[:end_time], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid" + ] + + MeteoSIX.request("getSolarInfo", params, opts) + end + + defp location_params({lat, lon}), do: [coords: "#{lon},#{lat}"] + defp location_params(location_id), do: [locationIds: location_id] end diff --git a/lib/meteosix/uswan.ex b/lib/meteosix/uswan.ex new file mode 100644 index 0000000..4bbb1bb --- /dev/null +++ b/lib/meteosix/uswan.ex @@ -0,0 +1,55 @@ +defmodule MeteoSIX.USWAN do + @moduledoc """ + Handles USWAN model numeric forecast info from MeteoSIX. + """ + alias MeteoSIX + + @type vars :: + :significative_wave_height + + @var_mapping %{ + significative_wave_height: "significative_wave_height", + } + + @doc """ + Gets numeric forecast info (USWAN model). + """ + def get_forecast(id_or_coords, var_atom, opts \\ []) + + def get_forecast({lat, lon}, var, opts) do + params = + [ + coords: "#{lon},#{lat}", + variables: Map.get(@var_mapping, var, to_string(var)) + ] + |> Keyword.merge(default_params(opts)) + + MeteoSIX.request("getNumericForecastInfo", params, opts) + end + + def get_forecast(location_id, var, opts) + when is_binary(location_id) or is_integer(location_id) do + params = + [ + locationIds: location_id, + variables: Map.get(@var_mapping, var, to_string(var)) + ] + |> Keyword.merge(default_params(opts)) + + MeteoSIX.request("getNumericForecastInfo", params, opts) + end + + defp default_params(opts) do + [ + models: "USWAN", + grids: opts[:grids] || "Galicia", + units: opts[:units], + startTime: opts[:startTime], + endTime: opts[:endTime], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid", + autoAdjustPosition: "true" + ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + end +end From 6a048a2f74e166901ac503bf1f28125aadf87b31 Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:05:20 +0100 Subject: [PATCH 2/8] feat: add Solar and Tides modules with DTOs for MeteoSIX --- lib/chronodash/datasource/meteosix/solar.ex | 17 +++ lib/chronodash/datasource/meteosix/tides.ex | 17 +++ .../datasource/meteosix/solar_forecast.ex | 70 ++++++++++++ .../datasource/meteosix/tides_forecast.ex | 105 ++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 lib/chronodash/datasource/meteosix/solar.ex create mode 100644 lib/chronodash/datasource/meteosix/tides.ex create mode 100644 lib/chronodash/models/datasource/meteosix/solar_forecast.ex create mode 100644 lib/chronodash/models/datasource/meteosix/tides_forecast.ex diff --git a/lib/chronodash/datasource/meteosix/solar.ex b/lib/chronodash/datasource/meteosix/solar.ex new file mode 100644 index 0000000..3979e3b --- /dev/null +++ b/lib/chronodash/datasource/meteosix/solar.ex @@ -0,0 +1,17 @@ +defmodule Chronodash.DataSource.MeteoSIX.Solar do + @moduledoc """ + High-level service for fetching Solar info from MeteoSIX. + """ + alias MeteoSIX.Operations + alias Chronodash.Models.DataSource.MeteoSIX.SolarForecast + + @doc """ + Fetches solar info from MeteoSIX and parses it into a DTO. + """ + def get_solar_info(id_or_coords, opts \\ []) do + case Operations.get_solar_info(id_or_coords, opts) do + {:ok, response} -> SolarForecast.new(response) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/chronodash/datasource/meteosix/tides.ex b/lib/chronodash/datasource/meteosix/tides.ex new file mode 100644 index 0000000..2902bf6 --- /dev/null +++ b/lib/chronodash/datasource/meteosix/tides.ex @@ -0,0 +1,17 @@ +defmodule Chronodash.DataSource.MeteoSIX.Tides do + @moduledoc """ + High-level service for fetching Tides info from MeteoSIX. + """ + alias MeteoSIX.Operations + alias Chronodash.Models.DataSource.MeteoSIX.TidesForecast + + @doc """ + Fetches tidal info from MeteoSIX and parses it into a DTO. + """ + def get_tides_info(id_or_coords, opts \\ []) do + case Operations.get_tides_info(id_or_coords, opts) do + {:ok, response} -> TidesForecast.new(response) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/chronodash/models/datasource/meteosix/solar_forecast.ex b/lib/chronodash/models/datasource/meteosix/solar_forecast.ex new file mode 100644 index 0000000..1883916 --- /dev/null +++ b/lib/chronodash/models/datasource/meteosix/solar_forecast.ex @@ -0,0 +1,70 @@ +defmodule Chronodash.Models.DataSource.MeteoSIX.SolarForecast do + @moduledoc """ + DTO for MeteoSIX solar info data (`/getSolarInfo`). + """ + + defstruct [:coords, days: []] + + @type solar_day :: %{ + date: Date.t() | nil, + sunrise: DateTime.t() | nil, + midday: DateTime.t() | nil, + sunset: DateTime.t() | nil, + duration: String.t() | nil + } + + @type t :: %__MODULE__{ + coords: {float(), float()} | nil, + days: list(solar_day()) + } + + @doc """ + Parses a MeteoSIX `/getSolarInfo` response into a SolarForecast DTO. + """ + def new(response) do + case response["features"] do + [feature | _] -> + {:ok, + %__MODULE__{ + coords: extract_coords(feature["geometry"]), + days: parse_days(feature["properties"]["days"]) + }} + + _ -> + {:error, :no_data} + end + end + + defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} + defp extract_coords(_), do: nil + + defp parse_days(nil), do: [] + + defp parse_days(days) do + Enum.map(days, fn day -> + var = Enum.find(day["variables"], fn v -> v["name"] == "solar" end) + + %{ + date: parse_date(get_in(day, ["timePeriod", "begin", "timeInstant"])), + sunrise: parse_timestamp(var["sunrise"]), + midday: parse_timestamp(var["midday"]), + sunset: parse_timestamp(var["sunset"]), + duration: var["duration"] + } + end) + end + + defp parse_date(nil), do: nil + defp parse_date(ts), do: ts |> parse_timestamp() |> DateTime.to_date() + + defp parse_timestamp(nil), do: nil + + defp parse_timestamp(ts) do + normalised = Regex.replace(~r/([+-]\d{2})$/, ts, "\\1:00") + + case DateTime.from_iso8601(normalised) do + {:ok, dt, _} -> dt + _ -> nil + end + end +end diff --git a/lib/chronodash/models/datasource/meteosix/tides_forecast.ex b/lib/chronodash/models/datasource/meteosix/tides_forecast.ex new file mode 100644 index 0000000..5e0e969 --- /dev/null +++ b/lib/chronodash/models/datasource/meteosix/tides_forecast.ex @@ -0,0 +1,105 @@ +defmodule Chronodash.Models.DataSource.MeteoSIX.TidesForecast do + @moduledoc """ + DTO for MeteoSIX tidal info data (`/getTidesInfo`). + """ + + defstruct [:coords, days: []] + + @type tides_event :: %{ + id: String.t(), + state: String.t(), + timestamp: DateTime.t() | nil, + height: float() | nil + } + + @type tides_reading :: %{ + timestamp: DateTime.t() | nil, + height: float() | nil + } + + @type tides_day :: %{ + date: Date.t() | nil, + units: String.t() | nil, + summary: list(tides_event()), + values: list(tides_reading()) + } + + @type t :: %__MODULE__{ + coords: {float(), float()} | nil, + days: list(tides_day()) + } + + @doc """ + Parses a MeteoSIX `/getTidesInfo` response into a TidesForecast DTO. + """ + def new(response) do + case response["features"] do + [feature | _] -> + {:ok, + %__MODULE__{ + coords: extract_coords(feature["geometry"]), + days: parse_days(feature["properties"]["days"]) + }} + + _ -> + {:error, :no_data} + end + end + + + defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} + defp extract_coords(_), do: nil + + defp parse_days(nil), do: [] + + defp parse_days(days) do + Enum.map(days, fn day -> + var = Enum.find(day["variables"], fn v -> v["name"] == "tides" end) + + %{ + date: parse_date(get_in(day, ["timePeriod", "begin", "timeInstant"])), + units: var["units"], + summary: parse_summary(var["summary"]), + values: parse_values(var["values"]) + } + end) + end + + defp parse_summary(nil), do: [] + + defp parse_summary(events) do + Enum.map(events, fn e -> + %{ + id: e["id"], + state: e["state"], + timestamp: parse_timestamp(e["timeInstant"]), + height: e["height"] + } + end) + end + + defp parse_values(nil), do: [] + + defp parse_values(readings) do + Enum.map(readings, fn r -> + %{ + timestamp: parse_timestamp(r["timeInstant"]), + height: r["height"] + } + end) + end + + defp parse_date(nil), do: nil + defp parse_date(ts), do: ts |> parse_timestamp() |> DateTime.to_date() + + defp parse_timestamp(nil), do: nil + + defp parse_timestamp(ts) do + normalised = Regex.replace(~r/([+-]\d{2})$/, ts, "\\1:00") + + case DateTime.from_iso8601(normalised) do + {:ok, dt, _} -> dt + _ -> nil + end + end +end From 4fd5dd4eced458315dbb325b734bcfb45f850557 Mon Sep 17 00:00:00 2001 From: yagogarea <127434255+yagogarea@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:29:53 +0100 Subject: [PATCH 3/8] feat: add custom alerts by config (#20) --- .env.example | 3 + README.md | 1 - config/config.exs | 5 + config/runtime.exs | 30 +++ docker/docker-compose.tel.yml | 1 + etc/grafana/dashboards/meteosix_wrf.json | 28 +++ etc/grafana/datasource.yml | 1 + .../provisioning/alerting/contact_points.yaml | 10 + .../alerting/notification_policies.yaml | 10 + etc/grafana/provisioning/alerting/rules.yaml | 81 ++++++++ lib/chronodash/alerting/dispatcher.ex | 47 +++++ lib/chronodash/alerting/manager.ex | 196 ++++++++++++++++++ lib/chronodash/alerting/provider.ex | 7 + lib/chronodash/alerting/providers/discord.ex | 31 +++ lib/chronodash/application.ex | 13 +- lib/chronodash/polling/worker.ex | 18 +- 16 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 etc/grafana/provisioning/alerting/contact_points.yaml create mode 100644 etc/grafana/provisioning/alerting/notification_policies.yaml create mode 100644 etc/grafana/provisioning/alerting/rules.yaml create mode 100644 lib/chronodash/alerting/dispatcher.ex create mode 100644 lib/chronodash/alerting/manager.ex create mode 100644 lib/chronodash/alerting/provider.ex create mode 100644 lib/chronodash/alerting/providers/discord.ex diff --git a/.env.example b/.env.example index 5b9e602..8fea1e5 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,6 @@ DB_PASSWORD=your_database_password_here # MeteoSIX # Get your key at https://www.meteogalicia.gal/web/servizos/meteosix/solicitudeClave.action METEOSIX_API_KEY=YOUR_KEY_HERE + +# Alerting +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... diff --git a/README.md b/README.md index 1cb59e9..e0062bb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ Chronodash is a multi-datasource monitoring and monitoring application built with **Elixir**, **Phoenix**, and the **Ash Framework**. It is designed to collect time-series metrics from various providers (starting with MeteoSIX) and visualize them in **Grafana** using **TimescaleDB**. ---- ## Key Architecture Concepts diff --git a/config/config.exs b/config/config.exs index 6c073a5..7ca38a1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -111,6 +111,11 @@ config :chronodash, :polling_jobs, [ } ] +# Alerts configuration +config :chronodash, :alerts, + rules: [], + channels: %{} + # Finch configuration config :chronodash, :http_client, Chronodash.HttpClient.Finch diff --git a/config/runtime.exs b/config/runtime.exs index 211e54a..35b42aa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -24,6 +24,36 @@ config :chronodash, :meteosix, api_key: System.get_env("METEOSIX_API_KEY") || "KEY", base_url: "https://servizos.meteogalicia.gal/apiv5" +config :chronodash, :alerts, + rules: [ + # Alert if wind speed > 30 km/h at any point in the next 24h + %{ + id: "high_wind_warning", + metric: "wind", + threshold: 30.0, + condition: :max_gt, + window_hours: 24, + cooldown_hours: 12, + message: "⚠️ High Wind Warning in %{location} in the next 24h: %{value} km/h", + channels: [:discord] + }, + # Alert if no precipitation is expected in the next 6 hours + %{ + id: "no_rain_forecast", + metric: "precipitation_amount", + threshold: 1.0, + # Maximum in the window is less than 1.0 + condition: :max_lt, + window_hours: 6, + cooldown_hours: 6, + message: "☀️ Good news! No significant rain predicted in %{location} for the next 6 hours.", + channels: [:discord] + } + ], + channels: %{ + discord: %{url: System.get_env("DISCORD_WEBHOOK_URL")} + } + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || diff --git a/docker/docker-compose.tel.yml b/docker/docker-compose.tel.yml index 57689b7..9e48776 100644 --- a/docker/docker-compose.tel.yml +++ b/docker/docker-compose.tel.yml @@ -17,6 +17,7 @@ services: volumes: - "../etc/grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml" - "../etc/grafana/dashboard_provider.yml:/etc/grafana/provisioning/dashboards/main.yaml" + - "../etc/grafana/provisioning/alerting:/etc/grafana/provisioning/alerting" - "../etc/grafana/dashboards:/var/lib/grafana/dashboards" environment: - GF_SECURITY_ADMIN_PASSWORD=admin diff --git a/etc/grafana/dashboards/meteosix_wrf.json b/etc/grafana/dashboards/meteosix_wrf.json index 539ef15..3f31554 100644 --- a/etc/grafana/dashboards/meteosix_wrf.json +++ b/etc/grafana/dashboards/meteosix_wrf.json @@ -89,6 +89,34 @@ "y": 0 }, "id": 2, + "alert": { + "conditions": [ + { + "evaluator": { + "params": [5], + "type": "gt" + }, + "operator": { + "type": "and" + }, + "query": { + "params": ["A", "5m", "now"] + }, + "reducer": { + "params": [], + "type": "max" + }, + "type": "query" + } + ], + "executionErrorState": "alerting", + "for": "5m", + "frequency": "1m", + "handler": 1, + "name": "High Precipitation Alert", + "noDataState": "no_data", + "notifications": [] + }, "options": { "legend": { "displayMode": "list", diff --git a/etc/grafana/datasource.yml b/etc/grafana/datasource.yml index c7eefcb..9399148 100644 --- a/etc/grafana/datasource.yml +++ b/etc/grafana/datasource.yml @@ -9,6 +9,7 @@ datasources: editable: true - name: PostgreSQL + uid: postgres_db type: postgres access: proxy url: postgres:5432 diff --git a/etc/grafana/provisioning/alerting/contact_points.yaml b/etc/grafana/provisioning/alerting/contact_points.yaml new file mode 100644 index 0000000..11a639a --- /dev/null +++ b/etc/grafana/provisioning/alerting/contact_points.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +contactPoints: + - orgId: 1 + name: Discord + receivers: + - uid: discord_alert + type: discord + settings: + url: ${DISCORD_WEBHOOK_URL} diff --git a/etc/grafana/provisioning/alerting/notification_policies.yaml b/etc/grafana/provisioning/alerting/notification_policies.yaml new file mode 100644 index 0000000..d6df6e7 --- /dev/null +++ b/etc/grafana/provisioning/alerting/notification_policies.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +# Notification policies for Grafana alerting +policies: + - orgId: 1 + receiver: Discord + group_by: ['alertname', 'severity'] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h diff --git a/etc/grafana/provisioning/alerting/rules.yaml b/etc/grafana/provisioning/alerting/rules.yaml new file mode 100644 index 0000000..a386d32 --- /dev/null +++ b/etc/grafana/provisioning/alerting/rules.yaml @@ -0,0 +1,81 @@ +apiVersion: 1 + +groups: + - orgId: 1 + name: Weather Alerts + folder: MeteoSIX Alerts + interval: 1m + rules: + - uid: high_wind_alert + title: High Wind Warning + condition: C + data: + - refId: A + datasourceUid: postgres_db + relativeTimeRange: + from: 3600 + to: 0 + model: + rawSql: "SELECT value, timestamp FROM observations WHERE metric_type = 'wind_module' AND timestamp > now() AND timestamp < now() + interval '24 hours' ORDER BY timestamp ASC" + format: table + - refId: B + datasourceUid: __expr__ + model: + expression: A + type: reduce + reducer: max + - refId: C + datasourceUid: __expr__ + model: + expression: B + type: threshold + conditions: + - evaluator: + params: [30] + type: gt + operator: + type: and + noDataState: OK + execErrState: Error + for: 0s + annotations: + summary: "High wind predicted: {{ index $values \"B\" }} km/h" + labels: + severity: warning + + - uid: high_precip_alert + title: High Precipitation Warning + condition: C + data: + - refId: A + datasourceUid: postgres_db + relativeTimeRange: + from: 3600 + to: 0 + model: + rawSql: "SELECT value, timestamp FROM observations WHERE metric_type = 'precipitation_amount' AND timestamp > now() AND timestamp < now() + interval '6 hours' ORDER BY timestamp ASC" + format: table + - refId: B + datasourceUid: __expr__ + model: + expression: A + type: reduce + reducer: max + - refId: C + datasourceUid: __expr__ + model: + expression: B + type: threshold + conditions: + - evaluator: + params: [5] + type: gt + operator: + type: and + noDataState: OK + execErrState: Error + for: 0s + annotations: + summary: "Heavy rain predicted: {{ index $values \"B\" }} mm" + labels: + severity: critical diff --git a/lib/chronodash/alerting/dispatcher.ex b/lib/chronodash/alerting/dispatcher.ex new file mode 100644 index 0000000..0061184 --- /dev/null +++ b/lib/chronodash/alerting/dispatcher.ex @@ -0,0 +1,47 @@ +defmodule Chronodash.Alerting.Dispatcher do + @moduledoc """ + Dispatches alert messages to configured channel providers. + """ + require Logger + + alias Chronodash.Alerting.Providers + + @providers %{ + discord: Providers.Discord + } + + # ============================================================================ + # Public API + # ============================================================================ + + @doc """ + Sends a message to a list of channels. + """ + def dispatch(message, channel_keys) when is_list(channel_keys) do + config = Application.get_env(:chronodash, :alerts, []) + channel_configs = Keyword.get(config, :channels, %{}) + + Enum.each(channel_keys, fn key -> + case Map.get(channel_configs, key) do + nil -> + Logger.warning("Attempted to dispatch to unknown channel: #{key}") + + chan_config -> + do_dispatch(message, key, chan_config) + end + end) + end + + defp do_dispatch(message, key, config) do + case Map.get(@providers, key) do + nil -> + Logger.error("No provider implementation for channel type: #{key}") + + provider -> + # We wrap in Task.start to ensure alerting doesn't block the caller + Task.start(fn -> + provider.deliver(message, config) + end) + end + end +end diff --git a/lib/chronodash/alerting/manager.ex b/lib/chronodash/alerting/manager.ex new file mode 100644 index 0000000..d46c3a3 --- /dev/null +++ b/lib/chronodash/alerting/manager.ex @@ -0,0 +1,196 @@ +defmodule Chronodash.Alerting.Manager do + @moduledoc """ + Subscribes to telemetry, evaluates alert rules, and manages alert cooldowns. + """ + use GenServer + require Logger + alias Chronodash.Alerting.Dispatcher + + @table_name :alert_cooldowns + + # ============================================================================ + # PUBLIC API + # ============================================================================ + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Telemetry handler callback. Redirects to GenServer for state-aware evaluation. + """ + def handle_telemetry_event(event, measurements, metadata, _config) do + GenServer.cast(__MODULE__, {:evaluate, event, measurements, metadata}) + end + + # ============================================================================ + # GenServer Callbacks + # ============================================================================ + + @impl true + def init(_opts) do + :ets.new(@table_name, [:set, :protected, :named_table]) + + :telemetry.attach_many( + "chronodash-alert-manager", + [ + [:chronodash, :polling, :observation], + [:chronodash, :polling, :forecast_received] + ], + &__MODULE__.handle_telemetry_event/4, + nil + ) + + {:ok, %{}} + end + + @impl true + def handle_cast( + {:evaluate, [:chronodash, :polling, :observation], %{value: value}, metadata}, + state + ) do + get_instant_rules() + |> Enum.each(&process_instant_rule(&1, value, metadata)) + + {:noreply, state} + end + + @impl true + def handle_cast( + {:evaluate, [:chronodash, :polling, :forecast_received], _measurements, metadata}, + state + ) do + get_window_rules() + |> Enum.each(&process_window_rule(&1, metadata)) + + {:noreply, state} + end + + # ============================================================================ + # Internal Functions - Processing + # ============================================================================ + + defp process_instant_rule(rule, value, metadata) do + if not in_cooldown?(rule, metadata.location) and should_alert_point?(rule, value, metadata) do + trigger_alert(rule, value, metadata) + end + end + + defp process_window_rule(rule, metadata) do + if not in_cooldown?(rule, metadata.location) do + {triggered, peak_value} = evaluate_forecast(rule, metadata.points) + if triggered, do: trigger_alert(rule, peak_value, metadata) + end + end + + # ============================================================================ + # Internal Functions - Evaluation + # ============================================================================ + + defp evaluate_forecast(rule, points) do + window_end = DateTime.add(DateTime.utc_now(), rule.window_hours, :hour) + + points + |> filter_relevant_points(rule, window_end) + |> check_forecast_condition(rule.condition, rule.threshold) + end + + defp filter_relevant_points(points, rule, window_end) do + for p <- points, + DateTime.compare(p.timestamp, window_end) != :gt, + m <- p.metrics, + metric_name_match?(m.name, rule.metric), + do: normalize_raw_value(m.value) + end + + defp metric_name_match?(m_name, rule_metric) do + to_string(m_name) == to_string(rule_metric) or + (to_string(rule_metric) == "wind" and m_name == "wind_module") + end + + defp check_forecast_condition([], _cond, _thresh), do: {false, nil} + + defp check_forecast_condition(values, :max_gt, thresh) do + max = Enum.max(values) + {max > thresh, max} + end + + defp check_forecast_condition(values, :max_lt, thresh) do + max = Enum.max(values) + {max < thresh, max} + end + + defp check_forecast_condition(values, :min_lt, thresh) do + min = Enum.min(values) + {min < thresh, min} + end + + defp check_forecast_condition(values, :gt_any, thresh) do + val = Enum.find(values, &(&1 > thresh)) + {!is_nil(val), val} + end + + defp check_forecast_condition(_, _, _), do: {false, nil} + + # ============================================================================ + # Helper Functions + # ============================================================================ + + defp in_cooldown?(rule, location) do + cooldown_ms = Map.get(rule, :cooldown_hours, 24) * 3600 * 1000 + now = System.system_time(:millisecond) + + case :ets.lookup(@table_name, {rule.id, location}) do + [{{_id, _loc}, last_triggered_at}] -> now - last_triggered_at < cooldown_ms + [] -> false + end + end + + defp should_alert_point?(rule, value, metadata) do + metric_match = to_string(rule.metric) == to_string(metadata.variable) + if metric_match, do: evaluate_condition(rule.condition, value, rule.threshold), else: false + end + + defp trigger_alert(rule, value, metadata) do + :ets.insert(@table_name, {{rule.id, metadata.location}, System.system_time(:millisecond)}) + message = format_message(rule.message, value, metadata) + Dispatcher.dispatch(message, rule.channels) + Logger.info("Alert triggered: #{rule.id} for #{metadata.location}. Entering cooldown.") + end + + defp normalize_raw_value(v) when is_number(v), do: v * 1.0 + + defp normalize_raw_value(v) when is_binary(v) do + case Float.parse(v) do + {f, _} -> f + _ -> 0.0 + end + end + + defp normalize_raw_value(_), do: 0.0 + + defp evaluate_condition(:gt, val, thresh), do: val > thresh + defp evaluate_condition(:lt, val, thresh), do: val < thresh + defp evaluate_condition(:eq, val, thresh), do: val == thresh + defp evaluate_condition(_, _, _), do: false + + defp format_message(template, value, metadata) do + template + |> String.replace("%{location}", to_string(metadata.location)) + |> String.replace("%{value}", to_string(value)) + |> String.replace("%{variable}", to_string(metadata.variable)) + end + + defp get_instant_rules do + get_rules() |> Enum.reject(&Map.has_key?(&1, :window_hours)) + end + + defp get_window_rules do + get_rules() |> Enum.filter(&Map.has_key?(&1, :window_hours)) + end + + defp get_rules do + config = Application.get_env(:chronodash, :alerts, []) + Keyword.get(config, :rules, []) + end +end diff --git a/lib/chronodash/alerting/provider.ex b/lib/chronodash/alerting/provider.ex new file mode 100644 index 0000000..2331120 --- /dev/null +++ b/lib/chronodash/alerting/provider.ex @@ -0,0 +1,7 @@ +defmodule Chronodash.Alerting.Provider do + @moduledoc """ + Behaviour for alert delivery channels (Discord, Telegram, etc). + """ + + @callback deliver(message :: String.t(), config :: map()) :: :ok | {:error, any()} +end diff --git a/lib/chronodash/alerting/providers/discord.ex b/lib/chronodash/alerting/providers/discord.ex new file mode 100644 index 0000000..65cf645 --- /dev/null +++ b/lib/chronodash/alerting/providers/discord.ex @@ -0,0 +1,31 @@ +defmodule Chronodash.Alerting.Providers.Discord do + @moduledoc """ + Discord implementation for alert delivery. + + Expects a configuration map with: + - `:url` - The Discord webhook URL to send messages to. + """ + + @behaviour Chronodash.Alerting.Provider + require Logger + + @impl true + def deliver(message, %{url: url}) when is_binary(url) do + case Req.post(url, json: %{content: message}) do + {:ok, %Req.Response{status: status}} when status in 200..299 -> + :ok + + {:ok, %Req.Response{status: status, body: body}} -> + Logger.error("Discord delivery failed with status #{status}: #{inspect(body)}") + {:error, :http_status} + + {:error, reason} -> + Logger.error("Discord delivery failed: #{inspect(reason)}") + {:error, reason} + end + end + + def deliver(_message, _config) do + {:error, :invalid_config} + end +end diff --git a/lib/chronodash/application.ex b/lib/chronodash/application.ex index 6ed0c84..1f205fa 100644 --- a/lib/chronodash/application.ex +++ b/lib/chronodash/application.ex @@ -21,7 +21,9 @@ defmodule Chronodash.Application do {Finch, name: Chronodash.Finch}, # Polling Services Chronodash.Polling.Supervisor, - Chronodash.Polling.Scheduler + Chronodash.Polling.Scheduler, + # Alerting Services + Chronodash.Alerting.Manager ] Chronodash.Release.create_and_migrate() @@ -29,7 +31,14 @@ defmodule Chronodash.Application do # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Chronodash.Supervisor] - Supervisor.start_link(children, opts) + + case Supervisor.start_link(children, opts) do + {:ok, pid} -> + {:ok, pid} + + error -> + error + end end # Tell Phoenix to update the endpoint configuration diff --git a/lib/chronodash/polling/worker.ex b/lib/chronodash/polling/worker.ex index a47cbbc..3803a5a 100644 --- a/lib/chronodash/polling/worker.ex +++ b/lib/chronodash/polling/worker.ex @@ -47,10 +47,13 @@ defmodule Chronodash.Polling.Worker do end defp handle_success(forecast, state) do - # 1. Find or create the location in our DB + # 1. Emit full forecast event for alerting (window rules) + emit_forecast_telemetry(forecast, state) + + # 2. Find or create the location in our DB location = get_or_create_location(forecast) - # 2. Save all forecast points to the DB + # 3. Save all forecast points to the DB # TODO: If you eventually poll thousands of locations, you could batch the Ash.bulk_create calls # (e.g., save in groups of 500 using Enum.chunk_every/2) to avoid long-running transactions. process_forecast_points(forecast, location, state) @@ -133,6 +136,17 @@ defmodule Chronodash.Polling.Worker do end end + defp emit_forecast_telemetry(forecast, state) do + metadata = + Map.merge(state.metadata, %{ + location: forecast.location_name, + variable: forecast.metric_type, + points: forecast.points + }) + + :telemetry.execute([:chronodash, :polling, :forecast_received], %{count: 1}, metadata) + end + defp emit_observation(metric, timestamp, location_name, state) do # We emit a metric for each data point measurements = %{value: normalize_value(metric.value)} From 9b5d7152bce63a807b0d7e4a5e28ef0127603ca3 Mon Sep 17 00:00:00 2001 From: Sasiiiiiiiiiiiiii <146834593+Sasiiiiiiiiiiiiii@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:22:21 +0100 Subject: [PATCH 4/8] fix: meteosix_wrf.json --- etc/grafana/dashboards/meteosix_wrf.json | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/etc/grafana/dashboards/meteosix_wrf.json b/etc/grafana/dashboards/meteosix_wrf.json index 3f31554..73f8f41 100644 --- a/etc/grafana/dashboards/meteosix_wrf.json +++ b/etc/grafana/dashboards/meteosix_wrf.json @@ -153,10 +153,26 @@ { "options": { "1": { "color": "yellow", "text": "SUNNY" }, - "2": { "color": "light-blue", "text": "PARTLY_CLOUDY" }, - "3": { "color": "blue", "text": "HIGH_CLOUDS" }, + "2": { "color": "light-blue", "text": "HIGH_CLOUDS" }, + "3": { "color": "light-blue", "text": "PARTLY_CLOUDY" }, "4": { "color": "grey", "text": "CLOUDY" }, - "5": { "color": "dark-grey", "text": "OVERCAST" } + "5": { "color": "dark-grey", "text": "OVERCAST" }, + "6": { "color": "orange", "text": "FOG" }, + "7": { "color": "blue", "text": "SHOWERS" }, + "8": { "color": "blue", "text": "OVERCAST_AND_SHOWERS" }, + "9": { "color": "light-grey", "text": "INTERMITENT_SNOW" }, + "10": { "color": "light-blue", "text": "DRIZZLE" }, + "11": { "color": "blue", "text": "RAIN" }, + "12": { "color": "white", "text": "SNOW" }, + "13": { "color": "red", "text": "STORMS" }, + "14": { "color": "orange", "text": "MIST" }, + "15": { "color": "orange", "text": "FOG_BANK" }, + "16": { "color": "light-blue", "text": "MID_CLOUDS" }, + "17": { "color": "light-blue", "text": "WEAK_RAIN" }, + "18": { "color": "light-blue", "text": "WEAK_SHOWERS" }, + "19": { "color": "red", "text": "STORM_THEN_CLOUDY" }, + "20": { "color": "white", "text": "MELTED_SNOW" }, + "21": { "color": "purple", "text": "RAIN_HAIL" } }, "type": "value" } From e9ebff14725f3a4ee52657f4ec7ff2c27ba33de1 Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:20:01 +0100 Subject: [PATCH 5/8] feat: implement USWAN forecast service and DTO for MeteoSIX --- lib/chronodash/datasource/meteosix/uswan.ex | 20 +++++++ lib/chronodash/datasource/meteosix/wrf.ex | 2 +- .../datasource/meteosix/{wrf => }/forecast.ex | 5 +- lib/meteosix/operations.ex | 39 ++++++++++++- lib/meteosix/uswan.ex | 55 +++++++++++++++++++ 5 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 lib/chronodash/datasource/meteosix/uswan.ex rename lib/chronodash/models/datasource/meteosix/{wrf => }/forecast.ex (94%) create mode 100644 lib/meteosix/uswan.ex diff --git a/lib/chronodash/datasource/meteosix/uswan.ex b/lib/chronodash/datasource/meteosix/uswan.ex new file mode 100644 index 0000000..b913e4d --- /dev/null +++ b/lib/chronodash/datasource/meteosix/uswan.ex @@ -0,0 +1,20 @@ +defmodule Chronodash.DataSource.MeteoSIX.USWAN do + @moduledoc """ + High-level service for fetching USWAN Forecast from MeteoSIX. + """ + alias MeteoSIX.USWAN + alias Chronodash.Models.DataSource.MeteoSIX.Forecast + + @doc """ + Fetches forecast from MeteoSIX and parses it into a DTO. + """ + def get_forecast(id_or_coords, var_atom, opts \\ []) do + case USWAN.get_forecast(id_or_coords, var_atom, opts) do + {:ok, response} -> + {:ok, Forecast.new(response, var_atom)} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/chronodash/datasource/meteosix/wrf.ex b/lib/chronodash/datasource/meteosix/wrf.ex index 11706f0..2cba960 100644 --- a/lib/chronodash/datasource/meteosix/wrf.ex +++ b/lib/chronodash/datasource/meteosix/wrf.ex @@ -2,8 +2,8 @@ defmodule Chronodash.DataSource.MeteoSIX.WRF do @moduledoc """ High-level service for fetching WRF Forecast from MeteoSIX. """ - alias Chronodash.Models.DataSource.MeteoSIX.WRF.Forecast alias MeteoSIX.WRF + alias Chronodash.Models.DataSource.MeteoSIX.Forecast @doc """ Fetches forecast from MeteoSIX and parses it into a DTO. diff --git a/lib/chronodash/models/datasource/meteosix/wrf/forecast.ex b/lib/chronodash/models/datasource/meteosix/forecast.ex similarity index 94% rename from lib/chronodash/models/datasource/meteosix/wrf/forecast.ex rename to lib/chronodash/models/datasource/meteosix/forecast.ex index 5e158c4..2cd2489 100644 --- a/lib/chronodash/models/datasource/meteosix/wrf/forecast.ex +++ b/lib/chronodash/models/datasource/meteosix/forecast.ex @@ -1,7 +1,6 @@ -defmodule Chronodash.Models.DataSource.MeteoSIX.WRF.Forecast do +defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do @moduledoc """ - DTO for MeteoSIX WRF Forecast data. - Handles both simple and composite metrics (like wind). + DTO for MeteoSIX WRF and USWAN Forecast data. """ defstruct [ :location_name, diff --git a/lib/meteosix/operations.ex b/lib/meteosix/operations.ex index 421fd5a..154af20 100644 --- a/lib/meteosix/operations.ex +++ b/lib/meteosix/operations.ex @@ -1,11 +1,11 @@ defmodule MeteoSIX.Operations do @moduledoc """ - Handles location-related operations for MeteoSIX. + Handles operations for MeteoSIX API endpoints """ alias MeteoSIX @doc """ - Finds places by name using MeteoSIX's findPlaces endpoint. + Finds places by name using MeteoSIX's '/findPlaces' endpoint. """ def find_places(query, opts \\ []) do params = [ @@ -16,4 +16,39 @@ defmodule MeteoSIX.Operations do MeteoSIX.request("findPlaces", params, opts) end + + @doc """ + Returns tidal information for the nearest port on the Galician coast. + """ + def get_tides_info(location, opts \\ []) do + params = + location_params(location) ++ + [ + startTime: opts[:start_time], + endTime: opts[:end_time], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid" + ] + + MeteoSIX.request("getTidesInfo", params, opts) + end + + @doc """ + Returns sunrise, midday, sunset and daylight duration for any point on Earth. + """ + def get_solar_info(location, opts \\ []) do + params = + location_params(location) ++ + [ + startTime: opts[:start_time], + endTime: opts[:end_time], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid" + ] + + MeteoSIX.request("getSolarInfo", params, opts) + end + + defp location_params({lat, lon}), do: [coords: "#{lon},#{lat}"] + defp location_params(location_id), do: [locationIds: location_id] end diff --git a/lib/meteosix/uswan.ex b/lib/meteosix/uswan.ex new file mode 100644 index 0000000..4bbb1bb --- /dev/null +++ b/lib/meteosix/uswan.ex @@ -0,0 +1,55 @@ +defmodule MeteoSIX.USWAN do + @moduledoc """ + Handles USWAN model numeric forecast info from MeteoSIX. + """ + alias MeteoSIX + + @type vars :: + :significative_wave_height + + @var_mapping %{ + significative_wave_height: "significative_wave_height", + } + + @doc """ + Gets numeric forecast info (USWAN model). + """ + def get_forecast(id_or_coords, var_atom, opts \\ []) + + def get_forecast({lat, lon}, var, opts) do + params = + [ + coords: "#{lon},#{lat}", + variables: Map.get(@var_mapping, var, to_string(var)) + ] + |> Keyword.merge(default_params(opts)) + + MeteoSIX.request("getNumericForecastInfo", params, opts) + end + + def get_forecast(location_id, var, opts) + when is_binary(location_id) or is_integer(location_id) do + params = + [ + locationIds: location_id, + variables: Map.get(@var_mapping, var, to_string(var)) + ] + |> Keyword.merge(default_params(opts)) + + MeteoSIX.request("getNumericForecastInfo", params, opts) + end + + defp default_params(opts) do + [ + models: "USWAN", + grids: opts[:grids] || "Galicia", + units: opts[:units], + startTime: opts[:startTime], + endTime: opts[:endTime], + lang: opts[:lang] || "gl", + tz: opts[:tz] || "Europe/Madrid", + autoAdjustPosition: "true" + ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + end +end From 5c7e0019b7904ae5a5f7b7084dc16c3565ea552d Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:05:20 +0100 Subject: [PATCH 6/8] feat: add Solar and Tides modules with DTOs for MeteoSIX --- lib/chronodash/datasource/meteosix/solar.ex | 17 +++ lib/chronodash/datasource/meteosix/tides.ex | 17 +++ .../datasource/meteosix/solar_forecast.ex | 70 ++++++++++++ .../datasource/meteosix/tides_forecast.ex | 105 ++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 lib/chronodash/datasource/meteosix/solar.ex create mode 100644 lib/chronodash/datasource/meteosix/tides.ex create mode 100644 lib/chronodash/models/datasource/meteosix/solar_forecast.ex create mode 100644 lib/chronodash/models/datasource/meteosix/tides_forecast.ex diff --git a/lib/chronodash/datasource/meteosix/solar.ex b/lib/chronodash/datasource/meteosix/solar.ex new file mode 100644 index 0000000..3979e3b --- /dev/null +++ b/lib/chronodash/datasource/meteosix/solar.ex @@ -0,0 +1,17 @@ +defmodule Chronodash.DataSource.MeteoSIX.Solar do + @moduledoc """ + High-level service for fetching Solar info from MeteoSIX. + """ + alias MeteoSIX.Operations + alias Chronodash.Models.DataSource.MeteoSIX.SolarForecast + + @doc """ + Fetches solar info from MeteoSIX and parses it into a DTO. + """ + def get_solar_info(id_or_coords, opts \\ []) do + case Operations.get_solar_info(id_or_coords, opts) do + {:ok, response} -> SolarForecast.new(response) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/chronodash/datasource/meteosix/tides.ex b/lib/chronodash/datasource/meteosix/tides.ex new file mode 100644 index 0000000..2902bf6 --- /dev/null +++ b/lib/chronodash/datasource/meteosix/tides.ex @@ -0,0 +1,17 @@ +defmodule Chronodash.DataSource.MeteoSIX.Tides do + @moduledoc """ + High-level service for fetching Tides info from MeteoSIX. + """ + alias MeteoSIX.Operations + alias Chronodash.Models.DataSource.MeteoSIX.TidesForecast + + @doc """ + Fetches tidal info from MeteoSIX and parses it into a DTO. + """ + def get_tides_info(id_or_coords, opts \\ []) do + case Operations.get_tides_info(id_or_coords, opts) do + {:ok, response} -> TidesForecast.new(response) + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/chronodash/models/datasource/meteosix/solar_forecast.ex b/lib/chronodash/models/datasource/meteosix/solar_forecast.ex new file mode 100644 index 0000000..1883916 --- /dev/null +++ b/lib/chronodash/models/datasource/meteosix/solar_forecast.ex @@ -0,0 +1,70 @@ +defmodule Chronodash.Models.DataSource.MeteoSIX.SolarForecast do + @moduledoc """ + DTO for MeteoSIX solar info data (`/getSolarInfo`). + """ + + defstruct [:coords, days: []] + + @type solar_day :: %{ + date: Date.t() | nil, + sunrise: DateTime.t() | nil, + midday: DateTime.t() | nil, + sunset: DateTime.t() | nil, + duration: String.t() | nil + } + + @type t :: %__MODULE__{ + coords: {float(), float()} | nil, + days: list(solar_day()) + } + + @doc """ + Parses a MeteoSIX `/getSolarInfo` response into a SolarForecast DTO. + """ + def new(response) do + case response["features"] do + [feature | _] -> + {:ok, + %__MODULE__{ + coords: extract_coords(feature["geometry"]), + days: parse_days(feature["properties"]["days"]) + }} + + _ -> + {:error, :no_data} + end + end + + defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} + defp extract_coords(_), do: nil + + defp parse_days(nil), do: [] + + defp parse_days(days) do + Enum.map(days, fn day -> + var = Enum.find(day["variables"], fn v -> v["name"] == "solar" end) + + %{ + date: parse_date(get_in(day, ["timePeriod", "begin", "timeInstant"])), + sunrise: parse_timestamp(var["sunrise"]), + midday: parse_timestamp(var["midday"]), + sunset: parse_timestamp(var["sunset"]), + duration: var["duration"] + } + end) + end + + defp parse_date(nil), do: nil + defp parse_date(ts), do: ts |> parse_timestamp() |> DateTime.to_date() + + defp parse_timestamp(nil), do: nil + + defp parse_timestamp(ts) do + normalised = Regex.replace(~r/([+-]\d{2})$/, ts, "\\1:00") + + case DateTime.from_iso8601(normalised) do + {:ok, dt, _} -> dt + _ -> nil + end + end +end diff --git a/lib/chronodash/models/datasource/meteosix/tides_forecast.ex b/lib/chronodash/models/datasource/meteosix/tides_forecast.ex new file mode 100644 index 0000000..5e0e969 --- /dev/null +++ b/lib/chronodash/models/datasource/meteosix/tides_forecast.ex @@ -0,0 +1,105 @@ +defmodule Chronodash.Models.DataSource.MeteoSIX.TidesForecast do + @moduledoc """ + DTO for MeteoSIX tidal info data (`/getTidesInfo`). + """ + + defstruct [:coords, days: []] + + @type tides_event :: %{ + id: String.t(), + state: String.t(), + timestamp: DateTime.t() | nil, + height: float() | nil + } + + @type tides_reading :: %{ + timestamp: DateTime.t() | nil, + height: float() | nil + } + + @type tides_day :: %{ + date: Date.t() | nil, + units: String.t() | nil, + summary: list(tides_event()), + values: list(tides_reading()) + } + + @type t :: %__MODULE__{ + coords: {float(), float()} | nil, + days: list(tides_day()) + } + + @doc """ + Parses a MeteoSIX `/getTidesInfo` response into a TidesForecast DTO. + """ + def new(response) do + case response["features"] do + [feature | _] -> + {:ok, + %__MODULE__{ + coords: extract_coords(feature["geometry"]), + days: parse_days(feature["properties"]["days"]) + }} + + _ -> + {:error, :no_data} + end + end + + + defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} + defp extract_coords(_), do: nil + + defp parse_days(nil), do: [] + + defp parse_days(days) do + Enum.map(days, fn day -> + var = Enum.find(day["variables"], fn v -> v["name"] == "tides" end) + + %{ + date: parse_date(get_in(day, ["timePeriod", "begin", "timeInstant"])), + units: var["units"], + summary: parse_summary(var["summary"]), + values: parse_values(var["values"]) + } + end) + end + + defp parse_summary(nil), do: [] + + defp parse_summary(events) do + Enum.map(events, fn e -> + %{ + id: e["id"], + state: e["state"], + timestamp: parse_timestamp(e["timeInstant"]), + height: e["height"] + } + end) + end + + defp parse_values(nil), do: [] + + defp parse_values(readings) do + Enum.map(readings, fn r -> + %{ + timestamp: parse_timestamp(r["timeInstant"]), + height: r["height"] + } + end) + end + + defp parse_date(nil), do: nil + defp parse_date(ts), do: ts |> parse_timestamp() |> DateTime.to_date() + + defp parse_timestamp(nil), do: nil + + defp parse_timestamp(ts) do + normalised = Regex.replace(~r/([+-]\d{2})$/, ts, "\\1:00") + + case DateTime.from_iso8601(normalised) do + {:ok, dt, _} -> dt + _ -> nil + end + end +end From 93b2130efcf6248884931960c03f36e9f5e9b54b Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:20:01 +0100 Subject: [PATCH 7/8] feat: implement USWAN forecast service and DTO for MeteoSIX --- .../models/datasource/meteosix/forecast.ex | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/lib/chronodash/models/datasource/meteosix/forecast.ex b/lib/chronodash/models/datasource/meteosix/forecast.ex index 2cd2489..5a83ed8 100644 --- a/lib/chronodash/models/datasource/meteosix/forecast.ex +++ b/lib/chronodash/models/datasource/meteosix/forecast.ex @@ -6,6 +6,7 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do :location_name, :coords, :metric_type, +<<<<<<< HEAD # List of %{timestamp: DateTime.t(), metrics: [%{name: String.t(), value: any(), unit: String.t()}]} :points ] @@ -13,11 +14,21 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do @type metric_entry :: %{name: String.t(), value: any(), unit: String.t() | nil} @type point :: %{timestamp: DateTime.t(), metrics: list(metric_entry())} +======= + # List of %{timestamp: DateTime.t(), value: any()} + :values + ] + +>>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) @type t :: %__MODULE__{ location_name: String.t(), coords: {float(), float()}, metric_type: atom(), +<<<<<<< HEAD points: list(point()) +======= + values: list(%{timestamp: DateTime.t(), value: any()}) +>>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) } @doc """ @@ -26,6 +37,7 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do def new(response, metric_type) do case response["features"] do [feature | _] -> +<<<<<<< HEAD coords = extract_coords(feature["geometry"]) name = feature["properties"]["name"] || default_name(coords) @@ -34,6 +46,13 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do coords: coords, metric_type: metric_type, points: parse_points(feature["properties"]["days"], metric_type) +======= + %__MODULE__{ + location_name: feature["properties"]["name"], + coords: extract_coords(feature["geometry"]), + metric_type: metric_type, + values: parse_values(feature["properties"]["days"], metric_type) +>>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) } _ -> @@ -41,6 +60,7 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do end end +<<<<<<< HEAD defp default_name({lat, lon}), do: "#{lat}, #{lon}" defp default_name(_), do: "Unknown Location" @@ -95,6 +115,30 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do } ] end +======= + defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} + defp extract_coords(_), do: nil + + defp parse_values(days, metric_type) when is_list(days) do + Enum.flat_map(days, fn day -> + # Find the variable in this day + variable = Enum.find(day["variables"], fn v -> v["name"] == to_string(metric_type) end) + + if variable do + Enum.map(variable["values"], fn val -> + %{ + timestamp: parse_timestamp(val["timeInstant"]), + value: val["value"] + } + end) + else + [] + end + end) + end + + defp parse_values(_, _), do: [] +>>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) defp parse_timestamp(nil), do: nil From 9e1cf5eb40e3a9f714420a41c837347bd76e587a Mon Sep 17 00:00:00 2001 From: paulacarril <145045483+paulacarril@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:06:19 +0100 Subject: [PATCH 8/8] fix: correct spelling of "ChronoDash" in OpenAPI specification --- .../models/datasource/meteosix/forecast.ex | 78 ------------------- priv/specs/chronodash/openapi.json | 2 +- 2 files changed, 1 insertion(+), 79 deletions(-) diff --git a/lib/chronodash/models/datasource/meteosix/forecast.ex b/lib/chronodash/models/datasource/meteosix/forecast.ex index d328056..e18ceb2 100644 --- a/lib/chronodash/models/datasource/meteosix/forecast.ex +++ b/lib/chronodash/models/datasource/meteosix/forecast.ex @@ -6,29 +6,15 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do :location_name, :coords, :metric_type, -<<<<<<< HEAD - # List of %{timestamp: DateTime.t(), metrics: [%{name: String.t(), value: any(), unit: String.t()}]} - :points - ] - - @type metric_entry :: %{name: String.t(), value: any(), unit: String.t() | nil} - @type point :: %{timestamp: DateTime.t(), metrics: list(metric_entry())} - -======= # List of %{timestamp: DateTime.t(), value: any()} :values ] ->>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) @type t :: %__MODULE__{ location_name: String.t(), coords: {float(), float()}, metric_type: atom(), -<<<<<<< HEAD - points: list(point()) -======= values: list(%{timestamp: DateTime.t(), value: any()}) ->>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) } @doc """ @@ -37,22 +23,11 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do def new(response, metric_type) do case response["features"] do [feature | _] -> -<<<<<<< HEAD - coords = extract_coords(feature["geometry"]) - name = feature["properties"]["name"] || default_name(coords) - - %__MODULE__{ - location_name: feature["properties"]["name"], - coords: extract_coords(feature["geometry"]), - metric_type: metric_type, - points: parse_points(feature["properties"]["days"], metric_type) -======= %__MODULE__{ location_name: feature["properties"]["name"], coords: extract_coords(feature["geometry"]), metric_type: metric_type, values: parse_values(feature["properties"]["days"], metric_type) ->>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) } _ -> @@ -60,58 +35,6 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do end end -<<<<<<< HEAD - defp default_name({lat, lon}), do: "#{lat}, #{lon}" - defp default_name(_), do: "Unknown Location" - - defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} - defp extract_coords(_), do: nil - - defp parse_values(days, metric_type) when is_list(days) do - Enum.flat_map(days, fn day -> - # Find the variable in this day - variable = Enum.find(day["variables"], fn v -> v["name"] == to_string(metric_type) end) - - if variable do - Enum.map(variable["values"], fn val -> - %{ - timestamp: parse_timestamp(val["timeInstant"]), - value: val["value"] - } - end) - else - [] - end - end) - end - - # Special handling for composite variables - defp extract_metrics(val, variable, :wind) do - [ - %{ - name: "wind_module", - value: val["moduleValue"], - unit: variable["moduleUnits"] - }, - %{ - name: "wind_direction", - value: val["directionValue"], - unit: variable["directionUnits"] - } - ] - end - - # Default handling for simple variables - defp extract_metrics(val, variable, metric_type) do - [ - %{ - name: to_string(metric_type), - value: val["value"], - unit: variable["units"] - } - ] - end -======= defp extract_coords(%{"coordinates" => [lon, lat]}), do: {lat, lon} defp extract_coords(_), do: nil @@ -134,7 +57,6 @@ defmodule Chronodash.Models.DataSource.MeteoSIX.Forecast do end defp parse_values(_, _), do: [] ->>>>>>> 60e6998 (feat: implement USWAN forecast service and DTO for MeteoSIX) defp parse_timestamp(nil), do: nil diff --git a/priv/specs/chronodash/openapi.json b/priv/specs/chronodash/openapi.json index f5e7c3c..08b1e60 100644 --- a/priv/specs/chronodash/openapi.json +++ b/priv/specs/chronodash/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.0", "info": { - "title": "CronoDash API", + "title": "ChronoDash API", "version": "0.1.0", "license": { "name": "MIT",