diff --git a/config/config.exs b/config/config.exs index f4eaf174..8b2bf1b6 100644 --- a/config/config.exs +++ b/config/config.exs @@ -42,6 +42,16 @@ config :microwaveprop, MicrowavepropWeb.Endpoint, pubsub_server: Microwaveprop.PubSub, live_view: [signing_salt: "Gdq36xze"] +config :microwaveprop, Oban, + repo: Microwaveprop.Repo, + queues: [solar: 1], + plugins: [ + {Oban.Plugins.Cron, + crontab: [ + {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker} + ]} + ] + config :microwaveprop, ecto_repos: [Microwaveprop.Repo], generators: [timestamp_type: :utc_datetime, binary_id: true] diff --git a/config/test.exs b/config/test.exs index 19ff63cf..07737462 100644 --- a/config/test.exs +++ b/config/test.exs @@ -26,6 +26,13 @@ config :microwaveprop, MicrowavepropWeb.Endpoint, secret_key_base: "f7KbnwUezGrPtrv/67l6Wi+W9/+wOqwFG5QJ/ZLs24cVM1gg2lBswZwrKuyWsBAZ", server: false +# Run Oban jobs inline during tests +config :microwaveprop, Oban, testing: :inline +config :microwaveprop, iem_req_options: [plug: {Req.Test, Microwaveprop.Weather.IemClient}] + +# Route HTTP requests through Req.Test stubs +config :microwaveprop, solar_req_options: [plug: {Req.Test, Microwaveprop.Weather.SolarClient}] + # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index 1c727eb3..fcf21a92 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -12,8 +12,7 @@ defmodule Microwaveprop.Application do Microwaveprop.Repo, {DNSCluster, query: Application.get_env(:microwaveprop, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Microwaveprop.PubSub}, - # Start a worker by calling: Microwaveprop.Worker.start_link(arg) - # {Microwaveprop.Worker, arg}, + {Oban, Application.fetch_env!(:microwaveprop, Oban)}, # Start to serve requests, typically the last entry MicrowavepropWeb.Endpoint ] diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex new file mode 100644 index 00000000..2706ef47 --- /dev/null +++ b/lib/microwaveprop/radio.ex @@ -0,0 +1,49 @@ +defmodule Microwaveprop.Radio do + @moduledoc false + + import Ecto.Query + + alias Microwaveprop.Radio.Qso + alias Microwaveprop.Repo + + @per_page 20 + @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp)a + + def list_qsos(opts \\ []) do + page = max(Keyword.get(opts, :page, 1), 1) + offset = (page - 1) * @per_page + + {sort_field, sort_dir} = sort_opts(opts) + + total_entries = Repo.aggregate(Qso, :count) + total_pages = max(ceil(total_entries / @per_page), 1) + + entries = + Qso + |> order_by([q], [{^sort_dir, field(q, ^sort_field)}]) + |> limit(^@per_page) + |> offset(^offset) + |> Repo.all() + + %{ + entries: entries, + page: page, + total_pages: total_pages, + total_entries: total_entries + } + end + + defp sort_opts(opts) do + sort_by = Keyword.get(opts, :sort_by, :qso_timestamp) + sort_order = Keyword.get(opts, :sort_order, :desc) + + field = if sort_by in @sortable_fields, do: sort_by, else: :qso_timestamp + dir = if sort_order in [:asc, :desc], do: sort_order, else: :desc + + {field, dir} + end + + def get_qso!(id) do + Repo.get!(Qso, id) + end +end diff --git a/lib/microwaveprop/radio/qso.ex b/lib/microwaveprop/radio/qso.ex new file mode 100644 index 00000000..67b1cb35 --- /dev/null +++ b/lib/microwaveprop/radio/qso.ex @@ -0,0 +1,33 @@ +defmodule Microwaveprop.Radio.Qso do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "qsos" do + field :station1, :string + field :station2, :string + field :qso_timestamp, :utc_datetime + field :grid1, :string + field :grid2, :string + field :pos1, :map + field :pos2, :map + field :mode, :string + field :band, :decimal + field :distance_km, :decimal + + timestamps(type: :utc_datetime) + end + + @required_fields ~w(station1 station2 qso_timestamp mode band)a + @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a + + def changeset(qso, attrs) do + qso + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 15adb630..6169ef3f 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -4,6 +4,7 @@ defmodule Microwaveprop.Weather do import Ecto.Query alias Microwaveprop.Repo + alias Microwaveprop.Weather.SolarIndex alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.Station alias Microwaveprop.Weather.SurfaceObservation @@ -56,6 +57,40 @@ defmodule Microwaveprop.Weather do ) end + def upsert_solar_index(attrs) do + %SolarIndex{} + |> SolarIndex.changeset(attrs) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :date, :inserted_at]}, + conflict_target: [:date], + returning: true + ) + end + + def has_surface_observations?(station_id, start_dt, end_dt) do + SurfaceObservation + |> where([o], o.station_id == ^station_id) + |> where([o], o.observed_at >= ^start_dt and o.observed_at <= ^end_dt) + |> Repo.exists?() + end + + def has_sounding?(station_id, observed_at) do + Sounding + |> where([s], s.station_id == ^station_id and s.observed_at == ^observed_at) + |> Repo.exists?() + end + + def get_solar_index(date) do + Repo.get_by(SolarIndex, date: date) + end + + def existing_solar_dates do + SolarIndex + |> select([s], s.date) + |> Repo.all() + |> MapSet.new() + end + def weather_for_qso(qso_params, opts \\ []) do lat = qso_params[:lat] || qso_params.lat lon = qso_params[:lon] || qso_params.lon @@ -84,12 +119,14 @@ defmodule Microwaveprop.Weather do SurfaceObservation |> where([o], o.station_id in subquery(station_ids)) |> where([o], o.observed_at >= ^time_start and o.observed_at <= ^time_end) + |> preload(:station) |> Repo.all() soundings = Sounding |> where([s], s.station_id in subquery(station_ids)) |> where([s], s.observed_at >= ^time_start and s.observed_at <= ^time_end) + |> preload(:station) |> Repo.all() %{surface_observations: surface_observations, soundings: soundings} diff --git a/lib/microwaveprop/weather/iem_client.ex b/lib/microwaveprop/weather/iem_client.ex index 49e88a20..5cdfd572 100644 --- a/lib/microwaveprop/weather/iem_client.ex +++ b/lib/microwaveprop/weather/iem_client.ex @@ -5,6 +5,10 @@ defmodule Microwaveprop.Weather.IemClient do # --- URL builders --- + def network_url(network) do + "#{@iem_base}/json/network.py?network=#{network}" + end + def asos_url(station_id, start_dt, end_dt) do "#{@iem_base}/cgi-bin/request/asos.py" <> "?station=#{station_id}" <> @@ -24,10 +28,25 @@ defmodule Microwaveprop.Weather.IemClient do # --- HTTP fetchers --- + def fetch_network(network) do + url = network_url(network) + + case Req.get(url, req_options()) do + {:ok, %{status: 200, body: body}} -> + {:ok, parse_network_json(body)} + + {:ok, %{status: status}} -> + {:error, "IEM network HTTP #{status}"} + + {:error, reason} -> + {:error, reason} + end + end + def fetch_asos(station_id, start_dt, end_dt) do url = asos_url(station_id, start_dt, end_dt) - case Req.get(url) do + case Req.get(url, req_options()) do {:ok, %{status: 200, body: body}} -> {:ok, parse_asos_csv(body)} @@ -42,7 +61,7 @@ defmodule Microwaveprop.Weather.IemClient do def fetch_raob(station_id, dt) do url = raob_url(station_id, dt) - case Req.get(url) do + case Req.get(url, req_options()) do {:ok, %{status: 200, body: body}} -> {:ok, parse_raob_json(body)} @@ -54,8 +73,25 @@ defmodule Microwaveprop.Weather.IemClient do end end + defp req_options do + Application.get_env(:microwaveprop, :iem_req_options, []) ++ [retry: false] + end + # --- Parsers --- + def parse_network_json(json) when is_map(json) do + json + |> Map.get("stations", []) + |> Enum.map(fn entry -> + %{ + station_code: entry["id"], + name: entry["name"], + lat: entry["lat"], + lon: entry["lon"] + } + end) + end + def parse_asos_csv(csv_text) do csv_text |> String.split("\n") diff --git a/lib/microwaveprop/weather/solar_client.ex b/lib/microwaveprop/weather/solar_client.ex new file mode 100644 index 00000000..953057d9 --- /dev/null +++ b/lib/microwaveprop/weather/solar_client.ex @@ -0,0 +1,94 @@ +defmodule Microwaveprop.Weather.SolarClient do + @moduledoc false + + @gfz_url "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt" + + def data_url, do: @gfz_url + + def fetch_solar_indices do + req_options = Application.get_env(:microwaveprop, :solar_req_options, []) + + case Req.get(@gfz_url, [receive_timeout: 120_000, retry: false] ++ req_options) do + {:ok, %{status: 200, body: body}} -> + {:ok, parse_gfz_file(body)} + + {:ok, %{status: status}} -> + {:error, "HTTP #{status}"} + + {:error, reason} -> + {:error, reason} + end + end + + def filter_since(records, since_date) do + Enum.filter(records, fn r -> Date.compare(r.date, since_date) != :lt end) + end + + def parse_gfz_file(text) do + text + |> String.split("\n") + |> Enum.reject(fn line -> + trimmed = String.trim(line) + trimmed == "" or String.starts_with?(trimmed, "#") + end) + |> Enum.flat_map(fn line -> + case parse_gfz_row(line) do + {:ok, record} -> [record] + :error -> [] + end + end) + end + + def parse_gfz_row(line) do + fields = String.split(line) + + if length(fields) >= 28 do + [yyyy, mm, dd | rest] = fields + + # Skip days, days_m, Bsr, dB (indices 0-3 of rest) + # Kp1-Kp8 at rest indices 4-11 + # ap1-ap8 at rest indices 12-19 + # Ap at rest index 20 + # SN at rest index 21 + # F10.7obs at rest index 22 + # F10.7adj at rest index 23 + kp_raw = Enum.slice(rest, 4, 8) + ap_daily = Enum.at(rest, 20) + sn = Enum.at(rest, 21) + f107_obs = Enum.at(rest, 22) + f107_adj = Enum.at(rest, 23) + + {:ok, + %{ + date: Date.new!(String.to_integer(yyyy), String.to_integer(mm), String.to_integer(dd)), + sfi: parse_float_or_nil(f107_obs), + sfi_adjusted: parse_float_or_nil(f107_adj), + sunspot_number: parse_int_or_nil(sn), + ap_index: parse_int_or_nil(ap_daily), + kp_values: Enum.map(kp_raw, &parse_float_or_nil/1) + }} + else + :error + end + end + + defp parse_float_or_nil(str) do + val = String.to_float(str) + if val < 0, do: nil, else: val + rescue + ArgumentError -> + case Integer.parse(str) do + {n, _} when n < 0 -> nil + {n, _} -> n / 1 + :error -> nil + end + end + + defp parse_int_or_nil(str) do + case Integer.parse(str) do + {n, _} when n < 0 -> nil + {n, _} -> n + :error -> nil + end + end +end diff --git a/lib/microwaveprop/weather/solar_index.ex b/lib/microwaveprop/weather/solar_index.ex new file mode 100644 index 00000000..c19cb276 --- /dev/null +++ b/lib/microwaveprop/weather/solar_index.ex @@ -0,0 +1,29 @@ +defmodule Microwaveprop.Weather.SolarIndex do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + + schema "solar_indices" do + field :date, :date + field :sfi, :float + field :sfi_adjusted, :float + field :sunspot_number, :integer + field :ap_index, :integer + field :kp_values, {:array, :float} + + timestamps(type: :utc_datetime) + end + + @required_fields ~w(date)a + @optional_fields ~w(sfi sfi_adjusted sunspot_number ap_index kp_values)a + + def changeset(solar_index, attrs) do + solar_index + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> unique_constraint([:date]) + end +end diff --git a/lib/microwaveprop/workers/solar_index_worker.ex b/lib/microwaveprop/workers/solar_index_worker.ex new file mode 100644 index 00000000..dfb32ba3 --- /dev/null +++ b/lib/microwaveprop/workers/solar_index_worker.ex @@ -0,0 +1,24 @@ +defmodule Microwaveprop.Workers.SolarIndexWorker do + @moduledoc false + use Oban.Worker, queue: :solar, max_attempts: 3 + + alias Microwaveprop.Weather + alias Microwaveprop.Weather.SolarClient + + @impl Oban.Worker + def perform(%Oban.Job{}) do + since_date = Date.add(Date.utc_today(), -7) + + case SolarClient.fetch_solar_indices() do + {:ok, all_records} -> + all_records + |> SolarClient.filter_since(since_date) + |> Enum.each(&Weather.upsert_solar_index/1) + + :ok + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/microwaveprop_web/components/core_components.ex b/lib/microwaveprop_web/components/core_components.ex index 4ec05149..91715709 100644 --- a/lib/microwaveprop_web/components/core_components.ex +++ b/lib/microwaveprop_web/components/core_components.ex @@ -346,8 +346,13 @@ defmodule MicrowavepropWeb.CoreComponents do default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" + attr :sort_by, :string, default: nil, doc: "the current sort field" + attr :sort_order, :string, default: "desc", doc: "the current sort direction (asc or desc)" + attr :sort_target, :string, default: nil, doc: "value for phx-value-table to scope sort events" + slot :col, required: true do attr :label, :string + attr :sort_field, :string end slot :action, doc: "the slot for showing user actions in the last table column" @@ -362,7 +367,28 @@ defmodule MicrowavepropWeb.CoreComponents do - + diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index da4335d2..0d4fd0d1 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -37,28 +37,10 @@ defmodule MicrowavepropWeb.Layouts do ~H""" diff --git a/lib/microwaveprop_web/controllers/page_controller.ex b/lib/microwaveprop_web/controllers/page_controller.ex index c96478bf..6fd2d089 100644 --- a/lib/microwaveprop_web/controllers/page_controller.ex +++ b/lib/microwaveprop_web/controllers/page_controller.ex @@ -2,6 +2,6 @@ defmodule MicrowavepropWeb.PageController do use MicrowavepropWeb, :controller def home(conn, _params) do - render(conn, :home) + redirect(conn, to: ~p"/qsos") end end diff --git a/lib/microwaveprop_web/live/qso_live/index.ex b/lib/microwaveprop_web/live/qso_live/index.ex new file mode 100644 index 00000000..ea0b21dc --- /dev/null +++ b/lib/microwaveprop_web/live/qso_live/index.ex @@ -0,0 +1,110 @@ +defmodule MicrowavepropWeb.QsoLive.Index do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Radio + + @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp) + @default_sort_by "qso_timestamp" + @default_sort_order "desc" + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(params, _uri, socket) do + page = params |> Map.get("page", "1") |> String.to_integer() |> max(1) + sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by)) + sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order)) + + result = + Radio.list_qsos( + page: page, + sort_by: String.to_existing_atom(sort_by), + sort_order: String.to_existing_atom(sort_order) + ) + + {:noreply, + assign(socket, + page_title: "QSOs", + page: result.page, + total_pages: result.total_pages, + total_entries: result.total_entries, + qsos: result.entries, + sort_by: sort_by, + sort_order: sort_order + )} + end + + @impl true + def handle_event("sort", %{"field" => field}, socket) do + field = validate_sort_field(field) + + new_order = + if socket.assigns.sort_by == field && socket.assigns.sort_order == "asc", + do: "desc", + else: "asc" + + {:noreply, push_patch(socket, to: ~p"/qsos?sort_by=#{field}&sort_order=#{new_order}")} + end + + defp validate_sort_field(field) when field in @sortable_fields, do: field + defp validate_sort_field(_), do: @default_sort_by + + defp validate_sort_order(order) when order in ~w(asc desc), do: order + defp validate_sort_order(_), do: @default_sort_order + + @impl true + def render(assigns) do + ~H""" + + <.header> + QSOs + <:subtitle>{@total_entries} contacts + + + <.table + id="qsos" + rows={@qsos} + row_id={fn qso -> "qso-#{qso.id}" end} + row_click={fn qso -> JS.navigate(~p"/qsos/#{qso.id}") end} + sort_by={@sort_by} + sort_order={@sort_order} + > + <:col :let={qso} label="Station 1" sort_field="station1">{qso.station1} + <:col :let={qso} label="Station 2" sort_field="station2">{qso.station2} + <:col :let={qso} label="Band" sort_field="band">{qso.band} + <:col :let={qso} label="Mode" sort_field="mode">{qso.mode} + <:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km} + <:col :let={qso} label="Timestamp" sort_field="qso_timestamp"> + {Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")} + + + +
+ <.link + :if={@page > 1} + patch={~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}"} + class="btn btn-sm btn-outline" + > + <.icon name="hero-chevron-left" class="w-4 h-4" /> Previous + + + + Page {@page} of {@total_pages} + + <.link + :if={@page < @total_pages} + patch={~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}"} + class="btn btn-sm btn-outline" + > + Next <.icon name="hero-chevron-right" class="w-4 h-4" /> + + = @total_pages} /> +
+
+ """ + end +end diff --git a/lib/microwaveprop_web/live/qso_live/show.ex b/lib/microwaveprop_web/live/qso_live/show.ex new file mode 100644 index 00000000..d2986d81 --- /dev/null +++ b/lib/microwaveprop_web/live/qso_live/show.ex @@ -0,0 +1,236 @@ +defmodule MicrowavepropWeb.QsoLive.Show do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Radio + alias Microwaveprop.Weather + + @impl true + def mount(%{"id" => id}, _session, socket) do + qso = Radio.get_qso!(id) + + weather = load_weather(qso) + solar = load_solar(qso) + + {:ok, + assign(socket, + page_title: "#{qso.station1} / #{qso.station2}", + qso: qso, + surface_observations: weather.surface_observations, + soundings: weather.soundings, + solar: solar, + obs_sort_by: "station_name", + obs_sort_order: "asc", + sounding_sort_by: "station_name", + sounding_sort_order: "asc" + )} + end + + @impl true + def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do + {sort_by, sort_order} = + toggle_sort(socket.assigns.obs_sort_by, socket.assigns.obs_sort_order, field) + + sorted = sort_observations(socket.assigns.surface_observations, sort_by, sort_order) + + {:noreply, + assign(socket, + surface_observations: sorted, + obs_sort_by: sort_by, + obs_sort_order: sort_order + )} + end + + def handle_event("sort", %{"field" => field, "table" => "soundings"}, socket) do + {sort_by, sort_order} = + toggle_sort(socket.assigns.sounding_sort_by, socket.assigns.sounding_sort_order, field) + + sorted = sort_soundings(socket.assigns.soundings, sort_by, sort_order) + + {:noreply, + assign(socket, + soundings: sorted, + sounding_sort_by: sort_by, + sounding_sort_order: sort_order + )} + end + + defp toggle_sort(current_field, current_order, new_field) do + if current_field == new_field && current_order == "asc", + do: {new_field, "desc"}, + else: {new_field, "asc"} + end + + defp sort_observations(observations, sort_by, sort_order) do + sorter = obs_sort_key(sort_by) + sorted = Enum.sort_by(observations, sorter) + if sort_order == "desc", do: Enum.reverse(sorted), else: sorted + end + + defp obs_sort_key("station_name"), do: fn obs -> obs.station.name || obs.station.station_code end + defp obs_sort_key("observed_at"), do: & &1.observed_at + defp obs_sort_key("temp_f"), do: &(&1.temp_f || 0) + defp obs_sort_key("dewpoint_f"), do: &(&1.dewpoint_f || 0) + defp obs_sort_key("relative_humidity"), do: &(&1.relative_humidity || 0) + defp obs_sort_key("sea_level_pressure_mb"), do: &(&1.sea_level_pressure_mb || 0) + defp obs_sort_key(_), do: fn obs -> obs.station.name || obs.station.station_code end + + defp sort_soundings(soundings, sort_by, sort_order) do + sorter = sounding_sort_key(sort_by) + sorted = Enum.sort_by(soundings, sorter) + if sort_order == "desc", do: Enum.reverse(sorted), else: sorted + end + + defp sounding_sort_key("station_name"), do: fn s -> s.station.name || s.station.station_code end + + defp sounding_sort_key("observed_at"), do: & &1.observed_at + defp sounding_sort_key("surface_temp_c"), do: &(&1.surface_temp_c || 0) + defp sounding_sort_key("surface_refractivity"), do: &(&1.surface_refractivity || 0) + defp sounding_sort_key("k_index"), do: &(&1.k_index || 0) + defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0) + defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end + + defp load_weather(qso) do + case qso.pos1 do + %{"lat" => lat, "lon" => lon} -> + Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp}) + + _ -> + %{surface_observations: [], soundings: []} + end + end + + defp load_solar(qso) do + qso.qso_timestamp + |> DateTime.to_date() + |> Weather.get_solar_index() + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@qso.station1} / {@qso.station2} + <:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + <:actions> + <.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs + + + + +
+ +

QSO Details

+ <.list> + <:item title="Station 1">{@qso.station1} + <:item title="Station 2">{@qso.station2} + <:item title="Band">{@qso.band} MHz + <:item title="Mode">{@qso.mode} + <:item title="Grid 1">{@qso.grid1 || "—"} + <:item title="Grid 2">{@qso.grid2 || "—"} + <:item title="Distance">{@qso.distance_km || "—"} km + <:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + + +
+ +

Surface Observations

+ <%= if @surface_observations == [] do %> +

No surface observations found nearby.

+ <% else %> + <.table + id="surface-obs" + rows={@surface_observations} + row_id={fn obs -> "obs-#{obs.id}" end} + sort_by={@obs_sort_by} + sort_order={@obs_sort_order} + sort_target="obs" + > + <:col :let={obs} label="Station" sort_field="station_name"> + {obs.station.name || obs.station.station_code} + + <:col :let={obs} label="Time" sort_field="observed_at"> + {Calendar.strftime(obs.observed_at, "%H:%M")} + + <:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"} + <:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f"> + {obs.dewpoint_f || "—"} + + <:col :let={obs} label="RH%" sort_field="relative_humidity"> + {format_number(obs.relative_humidity)} + + <:col :let={obs} label="Wind">{format_wind(obs)} + <:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb"> + {obs.sea_level_pressure_mb || "—"} + + <:col :let={obs} label="Sky">{obs.sky_condition || "—"} + + <% end %> + +
+ +

Soundings

+ <%= if @soundings == [] do %> +

No soundings found nearby.

+ <% else %> + <.table + id="soundings" + rows={@soundings} + row_id={fn s -> "sounding-#{s.id}" end} + sort_by={@sounding_sort_by} + sort_order={@sounding_sort_order} + sort_target="soundings" + > + <:col :let={s} label="Station" sort_field="station_name"> + {s.station.name || s.station.station_code} + + <:col :let={s} label="Time" sort_field="observed_at"> + {Calendar.strftime(s.observed_at, "%H:%M")} + + <:col :let={s} label="Sfc Temp (C)" sort_field="surface_temp_c"> + {s.surface_temp_c || "—"} + + <:col :let={s} label="Refractivity" sort_field="surface_refractivity"> + {format_number(s.surface_refractivity)} + + <:col :let={s} label="Ducting">{if s.ducting_detected, do: "Yes", else: "No"} + <:col :let={s} label="K-Index" sort_field="k_index">{format_number(s.k_index)} + <:col :let={s} label="Lifted Index" sort_field="lifted_index"> + {format_number(s.lifted_index)} + + + <% end %> + +
+ +

Solar Conditions

+ <%= if @solar do %> + <.list> + <:item title="SFI">{@solar.sfi || "—"} + <:item title="Sunspot Number">{@solar.sunspot_number || "—"} + <:item title="Ap Index">{@solar.ap_index || "—"} + <:item title="Kp Values">{format_kp(@solar.kp_values)} + + <% else %> +

No solar data available for this date.

+ <% end %> + + """ + end + + defp format_number(nil), do: "—" + defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1) + defp format_number(n), do: to_string(n) + + defp format_wind(obs) do + case {obs.wind_direction_deg, obs.wind_speed_kts} do + {nil, nil} -> "—" + {dir, speed} -> "#{dir || "?"}° @ #{speed || "?"} kts" + end + end + + defp format_kp(nil), do: "—" + defp format_kp(values) when is_list(values), do: Enum.map_join(values, ", ", &format_number/1) +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index d96cdf1d..9500ca04 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -18,6 +18,9 @@ defmodule MicrowavepropWeb.Router do pipe_through :browser get "/", PageController, :home + + live "/qsos", QsoLive.Index + live "/qsos/:id", QsoLive.Show end # Other scopes may use custom stacks. diff --git a/mix.exs b/mix.exs index b1d3b65f..6b279370 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule Microwaveprop.MixProject do {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, {:styler, "~> 1.11", only: [:dev, :test], runtime: false}, + {:oban, "~> 2.19"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] end diff --git a/mix.lock b/mix.lock index 0438da9f..a527dae9 100644 --- a/mix.lock +++ b/mix.lock @@ -24,6 +24,7 @@ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "oban": {:hex, :oban, "2.21.1", "4b6af7b901ef9baca09e239b5a991ef2fa429cf5a13799bc429a131d610ff692", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8162a160924cf4a25905fed2a9242e7787d88e320e3b5b0dcf324eb17c51c4e6"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, diff --git a/priv/repo/import_contacts.exs b/priv/repo/import_contacts.exs new file mode 100644 index 00000000..e557d4ed --- /dev/null +++ b/priv/repo/import_contacts.exs @@ -0,0 +1,310 @@ +alias Microwaveprop.Radio.Qso +alias Microwaveprop.Repo + +# -- Config -- +data_path = Path.expand("~/dev/ntms/propdata/data/microwave_contacts.json") +gridmap_base = "https://gridmap.org/grid/" +batch_size = 1000 + +IO.puts("Reading #{data_path}...") +data = data_path |> File.read!() |> Jason.decode!() + +# -- Band normalization -- +# Contest logs use "10G", "24G", etc. ARRL uses "10 GHz". UKuG uses band_ghz float. +band_map = %{ + "10G" => Decimal.new("10000"), + "24G" => Decimal.new("24000"), + "47G" => Decimal.new("47000"), + "75G" => Decimal.new("75000"), + "LIGHT" => Decimal.new("300000000") +} + +defmodule BandParser do + def parse(band) when is_binary(band) do + case Map.get(band_map(), String.upcase(band)) do + nil -> parse_ghz_string(band) + val -> val + end + end + + def parse(ghz) when is_number(ghz) do + ghz |> Decimal.from_float() |> Decimal.mult(Decimal.new("1000")) + end + + def parse(_), do: nil + + defp parse_ghz_string(s) do + case Float.parse(String.replace(s, ~r/\s*GHz\s*/i, "")) do + {ghz, _} -> ghz |> Decimal.from_float() |> Decimal.mult(Decimal.new("1000")) + :error -> nil + end + end + + defp band_map, do: unquote(Macro.escape(band_map)) +end + +# -- Collect all unique grids -- +IO.puts("Collecting unique grids...") + +collect_grids = fn records, grid_keys -> + Enum.reduce(records, MapSet.new(), fn record, acc -> + Enum.reduce(grid_keys, acc, fn key, inner_acc -> + case Map.get(record, key) do + nil -> inner_acc + "" -> inner_acc + grid -> MapSet.put(inner_acc, String.upcase(grid)) + end + end) + end) +end + +contest_qsos = + Enum.flat_map(data["contest_logs"], fn log -> Map.get(log, "qsos", []) end) + +all_grids = + MapSet.new() + |> MapSet.union(collect_grids.(contest_qsos, ["grid1", "grid2"])) + |> MapSet.union(collect_grids.(data["arrl_distance_records"], ["grid1", "grid2"])) + |> MapSet.union(collect_grids.(data["ukug_distance_records"], ["grid1", "grid2"])) + +IO.puts("Found #{MapSet.size(all_grids)} unique grids to look up") + +# -- Fetch grid coordinates from gridmap.org -- +IO.puts("Fetching coordinates from gridmap.org...") + +grid_coords = + all_grids + |> Task.async_stream( + fn grid -> + case Req.get("#{gridmap_base}#{grid}") do + {:ok, %{status: 200, body: %{"latitude" => lat, "longitude" => lng}}} -> + {grid, %{lat: lat, lng: lng}} + + {:ok, resp} -> + IO.puts(" Warning: grid #{grid} returned status #{resp.status}") + {grid, nil} + + {:error, err} -> + IO.puts(" Error fetching grid #{grid}: #{inspect(err)}") + {grid, nil} + end + end, + max_concurrency: 20, + timeout: 15_000, + ordered: false + ) + |> Enum.reduce(%{}, fn + {:ok, {grid, coords}}, acc -> Map.put(acc, grid, coords) + {:exit, _reason}, acc -> acc + end) + +resolved = Enum.count(grid_coords, fn {_k, v} -> v != nil end) +IO.puts("Resolved #{resolved}/#{MapSet.size(all_grids)} grids") + +# -- Haversine distance -- +defmodule Haversine do + @earth_radius_km 6371.0 + + def distance_km(nil, _), do: nil + def distance_km(_, nil), do: nil + + def distance_km(%{lat: lat1, lng: lng1}, %{lat: lat2, lng: lng2}) do + dlat = deg_to_rad(lat2 - lat1) + dlng = deg_to_rad(lng2 - lng1) + lat1_r = deg_to_rad(lat1) + lat2_r = deg_to_rad(lat2) + + a = + :math.sin(dlat / 2) * :math.sin(dlat / 2) + + :math.cos(lat1_r) * :math.cos(lat2_r) * :math.sin(dlng / 2) * :math.sin(dlng / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + Decimal.from_float(Float.round(@earth_radius_km * c, 1)) + end + + defp deg_to_rad(deg), do: deg * :math.pi() / 180.0 +end + +# -- Build QSO rows -- +IO.puts("Building QSO records...") +now = DateTime.utc_now() |> DateTime.truncate(:second) + +lookup = fn grid -> + case grid do + nil -> nil + "" -> nil + g -> Map.get(grid_coords, String.upcase(g)) + end +end + +parse_timestamp = fn date_str, time_str -> + case {date_str, time_str} do + {nil, _} -> + nil + + {_, nil} -> + nil + + {date, time} -> + padded_time = + time |> String.pad_leading(4, "0") + + case DateTime.new( + Date.from_iso8601!(date), + Time.new!( + String.to_integer(String.slice(padded_time, 0, 2)), + String.to_integer(String.slice(padded_time, 2, 2)), + 0 + ), + "Etc/UTC" + ) do + {:ok, dt} -> DateTime.truncate(dt, :second) + _ -> nil + end + end +end + +parse_date_only = fn date_str -> + cond do + is_nil(date_str) or date_str == "" -> + nil + + # ISO format: "2010-06-10" + String.match?(date_str, ~r/^\d{4}-\d{2}-\d{2}$/) -> + case DateTime.new(Date.from_iso8601!(date_str), ~T[00:00:00], "Etc/UTC") do + {:ok, dt} -> DateTime.truncate(dt, :second) + _ -> nil + end + + # ARRL format: "22-Jun-07" + String.match?(date_str, ~r/^\d{1,2}-\w{3}-\d{2}$/) -> + months = %{ + "Jan" => 1, + "Feb" => 2, + "Mar" => 3, + "Apr" => 4, + "May" => 5, + "Jun" => 6, + "Jul" => 7, + "Aug" => 8, + "Sep" => 9, + "Oct" => 10, + "Nov" => 11, + "Dec" => 12 + } + + [day, mon, yr] = String.split(date_str, "-") + short_year = String.to_integer(yr) + year = if short_year >= 30, do: 1900 + short_year, else: 2000 + short_year + + case DateTime.new( + Date.new!(year, Map.fetch!(months, mon), String.to_integer(day)), + ~T[00:00:00], + "Etc/UTC" + ) do + {:ok, dt} -> DateTime.truncate(dt, :second) + _ -> nil + end + + true -> + nil + end +end + +# Contest log QSOs +contest_rows = + Enum.flat_map(data["contest_logs"], fn log -> + Enum.map(Map.get(log, "qsos", []), fn q -> + g1 = q["grid1"] + g2 = q["grid2"] + pos1 = lookup.(g1) + pos2 = lookup.(g2) + + %{ + station1: q["station1"], + station2: q["station2"], + qso_timestamp: parse_timestamp.(q["date"], q["time"]), + grid1: g1, + grid2: g2, + pos1: pos1, + pos2: pos2, + mode: q["mode"], + band: BandParser.parse(q["band"]), + distance_km: Haversine.distance_km(pos1, pos2), + inserted_at: now, + updated_at: now + } + end) + end) + +# ARRL distance records +arrl_rows = + Enum.map(data["arrl_distance_records"], fn r -> + g1 = r["grid1"] + g2 = r["grid2"] + pos1 = lookup.(g1) + pos2 = lookup.(g2) + + %{ + id: Ecto.UUID.generate(), + station1: r["station1"], + station2: r["station2"], + qso_timestamp: parse_date_only.(r["date"]), + grid1: g1, + grid2: g2, + pos1: pos1, + pos2: pos2, + mode: r["mode"] || "N/A", + band: BandParser.parse(r["band"]), + distance_km: + if(r["distance_km"], do: Decimal.from_float(r["distance_km"] / 1), else: nil), + inserted_at: now, + updated_at: now + } + end) + +# UKuG distance records +ukug_rows = + Enum.map(data["ukug_distance_records"], fn r -> + g1 = r["grid1"] + g2 = r["grid2"] + pos1 = lookup.(g1) + pos2 = lookup.(g2) + + %{ + id: Ecto.UUID.generate(), + station1: r["station1"], + station2: r["station2"], + qso_timestamp: parse_date_only.(r["date"]), + grid1: g1, + grid2: g2, + pos1: pos1, + pos2: pos2, + mode: r["mode"] || "N/A", + band: BandParser.parse(r["band_ghz"]), + distance_km: + if(r["distance_km"], do: Decimal.from_float(r["distance_km"] / 1), else: nil), + inserted_at: now, + updated_at: now + } + end) + +all_rows = + (contest_rows ++ arrl_rows ++ ukug_rows) + |> Enum.filter(fn row -> row.band != nil and row.qso_timestamp != nil end) + +IO.puts("Total records to insert: #{length(all_rows)}") + +# -- Insert in batches -- +IO.puts("Inserting into database in batches of #{batch_size}...") + +all_rows +|> Enum.chunk_every(batch_size) +|> Enum.with_index(1) +|> Enum.each(fn {batch, i} -> + Repo.insert_all(Qso, batch) + IO.puts(" Batch #{i}: inserted #{length(batch)} records") +end) + +total = Repo.aggregate(Qso, :count) +IO.puts("Done! #{total} QSOs in database.") diff --git a/priv/repo/import_weather.exs b/priv/repo/import_weather.exs index 452c665b..8e1291d4 100644 --- a/priv/repo/import_weather.exs +++ b/priv/repo/import_weather.exs @@ -1,120 +1,10 @@ -alias Microwaveprop.{Repo, Weather} -alias Microwaveprop.Weather.{IemClient, SoundingParams} - import Ecto.Query -# --- Station definitions (from PropCast) --- - -asos_stations = [ - %{station_code: "KDFW", name: "Dallas/Fort Worth Intl", lat: 32.897, lon: -97.038, state: "TX"}, - %{station_code: "KDAL", name: "Dallas Love Field", lat: 32.847, lon: -96.851, state: "TX"}, - %{station_code: "KADS", name: "Addison", lat: 32.969, lon: -96.836, state: "TX"}, - %{station_code: "KTKI", name: "McKinney National", lat: 33.178, lon: -96.590, state: "TX"}, - %{station_code: "KGPM", name: "Grand Prairie Muni", lat: 32.698, lon: -97.043, state: "TX"}, - %{station_code: "KFWS", name: "Fort Worth Spinks", lat: 32.565, lon: -97.308, state: "TX"}, - %{station_code: "KFTW", name: "Fort Worth Meacham", lat: 32.820, lon: -97.362, state: "TX"}, - %{station_code: "KAFW", name: "Fort Worth Alliance", lat: 32.988, lon: -97.319, state: "TX"}, - %{station_code: "KRBD", name: "Dallas Executive", lat: 32.681, lon: -96.868, state: "TX"}, - %{station_code: "KGKY", name: "Arlington Muni", lat: 32.664, lon: -97.094, state: "TX"}, - %{station_code: "KCPT", name: "Cleburne Regional", lat: 32.354, lon: -97.434, state: "TX"}, - %{station_code: "KHQZ", name: "Mesquite Metro", lat: 32.747, lon: -96.530, state: "TX"}, - %{station_code: "KGYI", name: "Sherman/Denison", lat: 33.729, lon: -96.669, state: "TX"}, - %{station_code: "KGLE", name: "Gainesville Muni", lat: 33.651, lon: -97.197, state: "TX"}, - %{station_code: "KGVT", name: "Greenville/Hunt Co", lat: 33.068, lon: -96.065, state: "TX"}, - %{station_code: "KJDD", name: "Sulphur Springs", lat: 33.152, lon: -95.623, state: "TX"}, - %{station_code: "KGDJ", name: "Granbury Regional", lat: 32.445, lon: -97.817, state: "TX"}, - %{station_code: "KMWL", name: "Mineral Wells", lat: 32.782, lon: -98.060, state: "TX"}, - %{station_code: "KBKD", name: "Breckenridge/Stephens Co", lat: 32.719, lon: -98.890, state: "TX"}, - %{station_code: "KWEA", name: "Weatherford/Parker Co", lat: 32.746, lon: -97.682, state: "TX"}, - %{station_code: "KGGG", name: "Longview East Texas", lat: 32.384, lon: -94.711, state: "TX"}, - %{station_code: "KTYR", name: "Tyler Pounds", lat: 32.354, lon: -95.402, state: "TX"}, - %{station_code: "KCRS", name: "Corsicana/Navarro Co", lat: 32.028, lon: -96.400, state: "TX"}, - %{station_code: "KWXD", name: "Waxahachie/Ellis Co", lat: 32.392, lon: -96.849, state: "TX"}, - %{station_code: "KPNX", name: "Henderson/Rusk Co", lat: 32.152, lon: -94.913, state: "TX"}, - %{station_code: "KPWG", name: "Waco Regional", lat: 31.611, lon: -97.095, state: "TX"}, - %{station_code: "KACT", name: "Waco TSTC", lat: 31.611, lon: -97.226, state: "TX"}, - %{station_code: "KTPL", name: "Temple/Draughon-Miller", lat: 31.152, lon: -97.408, state: "TX"}, - %{station_code: "KILE", name: "Killeen/Fort Cavazos", lat: 31.086, lon: -97.683, state: "TX"}, - %{station_code: "KGRK", name: "Killeen-Fort Hood", lat: 31.067, lon: -97.829, state: "TX"}, - %{station_code: "KAUS", name: "Austin-Bergstrom", lat: 30.198, lon: -97.670, state: "TX"}, - %{station_code: "KSAT", name: "San Antonio Intl", lat: 29.534, lon: -98.470, state: "TX"}, - %{station_code: "KBMQ", name: "Burnet Muni", lat: 30.739, lon: -98.239, state: "TX"}, - %{station_code: "KSSF", name: "San Antonio Stinson", lat: 29.337, lon: -98.471, state: "TX"}, - %{station_code: "KGTU", name: "Georgetown Muni", lat: 30.678, lon: -97.679, state: "TX"}, - %{station_code: "KHYI", name: "San Marcos Rgnl", lat: 29.893, lon: -97.863, state: "TX"}, - %{station_code: "KHOU", name: "Houston Hobby", lat: 29.645, lon: -95.279, state: "TX"}, - %{station_code: "KIAH", name: "Houston Bush Intcntl", lat: 29.980, lon: -95.342, state: "TX"}, - %{station_code: "KCXO", name: "Conroe North Houston", lat: 30.352, lon: -95.415, state: "TX"}, - %{station_code: "KBPT", name: "Beaumont/Port Arthur", lat: 29.951, lon: -94.021, state: "TX"}, - %{station_code: "KLCH", name: "Lake Charles Regional", lat: 30.126, lon: -93.223, state: "LA"}, - %{station_code: "KVCT", name: "Victoria Regional", lat: 28.852, lon: -96.918, state: "TX"}, - %{station_code: "KCRP", name: "Corpus Christi Intl", lat: 27.770, lon: -97.502, state: "TX"}, - %{station_code: "KOKC", name: "Oklahoma City Will Rogers", lat: 35.393, lon: -97.601, state: "OK"}, - %{station_code: "KOUN", name: "Norman/OU", lat: 35.246, lon: -97.472, state: "OK"}, - %{station_code: "KPWA", name: "Wiley Post", lat: 35.534, lon: -97.647, state: "OK"}, - %{station_code: "KSWO", name: "Stillwater Regional", lat: 36.161, lon: -97.086, state: "OK"}, - %{station_code: "KTIK", name: "Tinker AFB", lat: 35.415, lon: -97.387, state: "OK"}, - %{station_code: "KLAW", name: "Lawton/Fort Sill", lat: 34.568, lon: -98.416, state: "OK"}, - %{station_code: "KADU", name: "Ardmore Downtown", lat: 34.303, lon: -97.019, state: "OK"}, - %{station_code: "KSNL", name: "Shawnee Regional", lat: 35.358, lon: -96.943, state: "OK"}, - %{station_code: "KCSM", name: "Clinton/Sherman", lat: 35.339, lon: -99.201, state: "OK"}, - %{station_code: "KGAG", name: "Gage/Ellis Co", lat: 36.296, lon: -99.777, state: "OK"}, - %{station_code: "KWDG", name: "Enid Woodring", lat: 36.379, lon: -97.791, state: "OK"}, - %{station_code: "KBVO", name: "Bartlesville", lat: 36.765, lon: -96.012, state: "OK"}, - %{station_code: "KTUL", name: "Tulsa Intl", lat: 36.198, lon: -95.888, state: "OK"}, - %{station_code: "KMKO", name: "Muskogee/Davis Field", lat: 35.657, lon: -95.367, state: "OK"}, - %{station_code: "KPOC", name: "Pontotoc Co/Ada", lat: 34.802, lon: -96.671, state: "OK"}, - %{station_code: "KGMJ", name: "Grove Muni", lat: 36.607, lon: -94.733, state: "OK"}, - %{station_code: "KABI", name: "Abilene Regional", lat: 32.411, lon: -99.682, state: "TX"}, - %{station_code: "KMAF", name: "Midland Intl", lat: 31.943, lon: -102.201, state: "TX"}, - %{station_code: "KBPG", name: "Big Spring McMahon", lat: 32.213, lon: -101.522, state: "TX"}, - %{station_code: "KSNS", name: "San Angelo Mathis", lat: 31.358, lon: -100.497, state: "TX"}, - %{station_code: "KLBB", name: "Lubbock Preston Smith", lat: 33.664, lon: -101.823, state: "TX"}, - %{station_code: "KAMA", name: "Amarillo Rick Husband", lat: 35.220, lon: -101.706, state: "TX"}, - %{station_code: "KPPA", name: "Pampa/Perry Lefors", lat: 35.613, lon: -100.996, state: "TX"}, - %{station_code: "KPVW", name: "Plainview Hale Co", lat: 34.183, lon: -101.722, state: "TX"}, - %{station_code: "KINK", name: "Wink/Winkler Co", lat: 31.779, lon: -103.201, state: "TX"}, - %{station_code: "KELP", name: "El Paso Intl", lat: 31.807, lon: -106.378, state: "TX"}, - %{station_code: "KCNM", name: "Carlsbad Cavern City", lat: 32.337, lon: -104.263, state: "NM"}, - %{station_code: "KROW", name: "Roswell Intl Air Ctr", lat: 33.302, lon: -104.531, state: "NM"}, - %{station_code: "KCLO", name: "Clovis Muni", lat: 34.434, lon: -103.079, state: "NM"}, - %{station_code: "KHOB", name: "Hobbs Lea Co", lat: 32.688, lon: -103.217, state: "NM"}, - %{station_code: "KTCC", name: "Tucumcari Muni", lat: 35.183, lon: -103.603, state: "NM"}, - %{station_code: "KICT", name: "Wichita Dwight D. Eisenhower", lat: 37.650, lon: -97.433, state: "KS"}, - %{station_code: "KHUT", name: "Hutchinson Regional", lat: 38.065, lon: -97.861, state: "KS"}, - %{station_code: "KDDC", name: "Dodge City Regional", lat: 37.763, lon: -99.965, state: "KS"}, - %{station_code: "KGLD", name: "Goodland Municipal", lat: 39.370, lon: -101.700, state: "KS"}, - %{station_code: "KGBD", name: "Great Bend Municipal", lat: 38.344, lon: -98.859, state: "KS"}, - %{station_code: "KCNU", name: "Chanute Martin Johnson", lat: 37.669, lon: -95.485, state: "KS"}, - %{station_code: "KPTT", name: "Pratt Regional", lat: 37.700, lon: -98.747, state: "KS"}, - %{station_code: "KSGF", name: "Springfield Branson Natl", lat: 37.246, lon: -93.389, state: "MO"}, - %{station_code: "KJLN", name: "Joplin Regional", lat: 37.152, lon: -94.498, state: "MO"}, - %{station_code: "KFSM", name: "Fort Smith Regional", lat: 35.337, lon: -94.368, state: "AR"}, - %{station_code: "KTXK", name: "Texarkana Regional", lat: 33.454, lon: -93.991, state: "TX"}, - %{station_code: "KLIT", name: "Little Rock Clinton", lat: 34.729, lon: -92.224, state: "AR"}, - %{station_code: "KHRO", name: "Harrison Boone Co", lat: 36.261, lon: -93.155, state: "AR"}, - %{station_code: "KFYV", name: "Fayetteville Drake Field", lat: 36.005, lon: -94.170, state: "AR"}, - %{station_code: "KXNA", name: "NW Arkansas Natl", lat: 36.282, lon: -94.307, state: "AR"}, - %{station_code: "KSHV", name: "Shreveport Regional", lat: 32.447, lon: -93.826, state: "LA"}, - %{station_code: "KMLU", name: "Monroe Regional", lat: 32.511, lon: -92.038, state: "LA"}, - %{station_code: "KJAN", name: "Jackson Medgar Wiley Evers", lat: 32.311, lon: -90.076, state: "MS"}, - %{station_code: "KMEI", name: "Meridian Regional", lat: 32.334, lon: -88.752, state: "MS"}, - %{station_code: "KNAC", name: "Nacogdoches/Mangham", lat: 31.578, lon: -94.710, state: "TX"}, - %{station_code: "KNEW", name: "New Orleans Lakefront", lat: 30.042, lon: -90.028, state: "LA"}, - %{station_code: "KMSY", name: "New Orleans Armstrong", lat: 29.993, lon: -90.258, state: "LA"} -] - -sounding_stations = [ - %{station_code: "FWD", wmo_number: 72_249, name: "Fort Worth TX", lat: 32.835, lon: -97.298, state: "TX"}, - %{station_code: "OUN", wmo_number: 72_357, name: "Oklahoma City OK", lat: 35.181, lon: -97.436, state: "OK"}, - %{station_code: "SHV", wmo_number: 72_248, name: "Shreveport LA", lat: 32.450, lon: -93.841, state: "LA"}, - %{station_code: "AMA", wmo_number: 72_363, name: "Amarillo TX", lat: 35.233, lon: -101.706, state: "TX"}, - %{station_code: "LCH", wmo_number: 72_240, name: "Lake Charles LA", lat: 30.125, lon: -93.217, state: "LA"}, - %{station_code: "MAF", wmo_number: 72_265, name: "Midland TX", lat: 31.943, lon: -102.189, state: "TX"}, - %{station_code: "LZK", wmo_number: 72_340, name: "Little Rock AR", lat: 34.836, lon: -92.263, state: "AR"}, - %{station_code: "BRO", wmo_number: 72_250, name: "Brownsville TX", lat: 25.910, lon: -97.419, state: "TX"}, - %{station_code: "SPS", wmo_number: 72_451, name: "Wichita Falls TX", lat: 33.989, lon: -98.492, state: "TX"} -] +alias Microwaveprop.Repo +alias Microwaveprop.Weather +alias Microwaveprop.Weather.IemClient +alias Microwaveprop.Weather.SolarClient +alias Microwaveprop.Weather.SoundingParams # --- Helpers --- @@ -123,6 +13,17 @@ defmodule ImportHelpers do @km_per_deg 111.0 + @us_networks ~w( + AL_ASOS AK_ASOS AZ_ASOS AR_ASOS CA_ASOS CO_ASOS CT_ASOS DE_ASOS FL_ASOS + GA_ASOS HI_ASOS ID_ASOS IL_ASOS IN_ASOS IA_ASOS KS_ASOS KY_ASOS LA_ASOS + ME_ASOS MD_ASOS MA_ASOS MI_ASOS MN_ASOS MS_ASOS MO_ASOS MT_ASOS NE_ASOS + NV_ASOS NH_ASOS NJ_ASOS NM_ASOS NY_ASOS NC_ASOS ND_ASOS OH_ASOS OK_ASOS + OR_ASOS PA_ASOS RI_ASOS SC_ASOS SD_ASOS TN_ASOS TX_ASOS UT_ASOS VT_ASOS + VA_ASOS WA_ASOS WV_ASOS WI_ASOS WY_ASOS DC_ASOS PR_ASOS + ) + + def us_networks, do: @us_networks + def nearest_stations(lat, lon, stations, radius_km) do Enum.filter(stations, fn s -> dlat = (s.lat - lat) * @km_per_deg @@ -151,15 +52,91 @@ defmodule ImportHelpers do Enum.uniq(times) end - - def rate_limit(delay_ms \\ 200) do - Process.sleep(delay_ms) - end end -# --- Seed stations --- +# --- Phase 0: Import solar indices --- -IO.puts("Seeding #{length(asos_stations)} ASOS stations...") +IO.puts("=== Phase 0: Importing solar indices (since 2000-01-01) ===") + +existing_solar = Weather.existing_solar_dates() +IO.puts("Found #{MapSet.size(existing_solar)} solar index dates already in DB") + +IO.puts("Downloading GFZ solar index file...") + +case SolarClient.fetch_solar_indices() do + {:ok, all_records} -> + IO.puts("Parsed #{length(all_records)} total daily records from GFZ file") + + records = + all_records + |> SolarClient.filter_since(~D[2000-01-01]) + |> Enum.reject(fn r -> MapSet.member?(existing_solar, r.date) end) + + IO.puts("#{length(records)} new records to import (skipping dates already in DB)") + + records + |> Enum.with_index(1) + |> Enum.each(fn {record, idx} -> + if rem(idx, 500) == 0, do: IO.puts(" Upserting solar index #{idx}/#{length(records)}...") + Weather.upsert_solar_index(record) + end) + + IO.puts("Solar index import complete") + + {:error, reason} -> + IO.puts("Warning: failed to fetch solar indices: #{inspect(reason)}") +end + +# --- Phase 1: Discover and seed stations from IEM --- + +IO.puts("=== Phase 1: Discovering stations from IEM ===") + +# Fetch ASOS stations from all US state networks +IO.puts("Fetching ASOS stations from #{length(ImportHelpers.us_networks())} state networks...") + +asos_stations = + ImportHelpers.us_networks() + |> Task.async_stream( + fn network -> + state = network |> String.split("_") |> hd() + + case IemClient.fetch_network(network) do + {:ok, stations} -> + Enum.map(stations, &Map.put(&1, :state, state)) + + {:error, reason} -> + IO.puts(" Warning: failed to fetch #{network}: #{inspect(reason)}") + [] + end + end, + max_concurrency: 10, + timeout: 30_000, + ordered: false + ) + |> Enum.flat_map(fn + {:ok, stations} -> stations + {:exit, _reason} -> [] + end) + +IO.puts("Discovered #{length(asos_stations)} ASOS stations across all US networks") + +# Fetch RAOB (sounding) stations +IO.puts("Fetching RAOB stations...") + +raob_stations = + case IemClient.fetch_network("RAOB") do + {:ok, stations} -> + stations + + {:error, reason} -> + IO.puts(" Warning: failed to fetch RAOB network: #{inspect(reason)}") + [] + end + +IO.puts("Discovered #{length(raob_stations)} RAOB stations") + +# Seed ASOS stations +IO.puts("Seeding ASOS stations...") asos_station_map = Enum.reduce(asos_stations, %{}, fn attrs, acc -> @@ -169,19 +146,35 @@ asos_station_map = Map.put(acc, station.station_code, station) end) -IO.puts("Seeding #{length(sounding_stations)} sounding stations...") +IO.puts("Seeded #{map_size(asos_station_map)} unique ASOS stations") + +# Seed sounding stations +IO.puts("Seeding sounding stations...") sounding_station_map = - Enum.reduce(sounding_stations, %{}, fn attrs, acc -> + Enum.reduce(raob_stations, %{}, fn attrs, acc -> {:ok, station} = Weather.find_or_create_station(Map.put(attrs, :station_type, "sounding")) Map.put(acc, station.station_code, station) end) -IO.puts("Stations seeded: #{map_size(asos_station_map)} ASOS, #{map_size(sounding_station_map)} sounding") +IO.puts("Seeded #{map_size(sounding_station_map)} unique sounding stations") -# --- Fetch weather for each QSO --- +# Build lookup lists with lat/lon for nearest-station searches +asos_station_list = + asos_stations + |> Enum.map(fn s -> Map.put(s, :station_type, "asos") end) + |> Enum.uniq_by(& &1.station_code) + +sounding_station_list = + raob_stations + |> Enum.map(fn s -> Map.put(s, :station_type, "sounding") end) + |> Enum.uniq_by(& &1.station_code) + +# --- Phase 2: Fetch weather for each QSO --- + +IO.puts("\n=== Phase 2: Importing weather data ===") qsos = Microwaveprop.Radio.Qso @@ -195,112 +188,120 @@ IO.puts("Processing #{length(qsos)} QSOs with positions...") asos_fetched = :ets.new(:asos_fetched, [:set, :public]) raob_fetched = :ets.new(:raob_fetched, [:set, :public]) -asos_station_list = Enum.map(asos_stations, fn s -> Map.put(s, :station_type, "asos") end) - total = length(qsos) +progress = :counters.new(1, [:atomics]) qsos -|> Enum.with_index(1) -|> Enum.each(fn {qso, idx} -> - lat = qso.pos1["lat"] || qso.pos1["latitude"] - lon = qso.pos1["lng"] || qso.pos1["lon"] || qso.pos1["longitude"] +|> Task.async_stream( + fn qso -> + lat = qso.pos1["lat"] || qso.pos1["latitude"] + lon = qso.pos1["lng"] || qso.pos1["lon"] || qso.pos1["longitude"] - if lat && lon do - if rem(idx, 100) == 0, do: IO.puts(" QSO #{idx}/#{total}") + if lat && lon do + :counters.add(progress, 1, 1) + val = :counters.get(progress, 1) + if rem(val, 500) == 0, do: IO.puts(" Processed #{val}/#{total} QSOs") - # Find nearby ASOS stations (within 150km) - nearby_asos = ImportHelpers.nearest_stations(lat, lon, asos_station_list, 150) + # Find nearby ASOS stations (within 150km) + nearby_asos = ImportHelpers.nearest_stations(lat, lon, asos_station_list, 150) - Enum.each(nearby_asos, fn s -> - # Fetch a 4-hour window around QSO time - start_dt = DateTime.add(qso.qso_timestamp, -2 * 3600, :second) - end_dt = DateTime.add(qso.qso_timestamp, 2 * 3600, :second) - key = {s.station_code, DateTime.to_date(start_dt), div(start_dt.hour, 4)} + Enum.each(nearby_asos, fn s -> + # Fetch a 4-hour window around QSO time + start_dt = DateTime.add(qso.qso_timestamp, -2 * 3600, :second) + end_dt = DateTime.add(qso.qso_timestamp, 2 * 3600, :second) + key = {s.station_code, DateTime.to_date(start_dt), div(start_dt.hour, 4)} - unless :ets.member(asos_fetched, key) do - :ets.insert(asos_fetched, {key, true}) + if !:ets.member(asos_fetched, key) do + :ets.insert(asos_fetched, {key, true}) - station = Map.get(asos_station_map, s.station_code) - - if station do - case IemClient.fetch_asos(s.station_code, start_dt, end_dt) do - {:ok, rows} -> - Enum.each(rows, fn row -> - if row.observed_at do - Weather.upsert_surface_observation(station, row) - end - end) - - {:error, reason} -> - IO.puts(" ASOS error #{s.station_code}: #{inspect(reason)}") - end - - ImportHelpers.rate_limit() - end - end - end) - - # Find nearby sounding stations (within 300km) - nearby_soundings = ImportHelpers.nearest_stations(lat, lon, sounding_stations, 300) - - Enum.each(nearby_soundings, fn s -> - sounding_times = ImportHelpers.sounding_times_around(qso.qso_timestamp) - - Enum.each(sounding_times, fn sounding_time -> - key = {s.station_code, sounding_time} - - unless :ets.member(raob_fetched, key) do - :ets.insert(raob_fetched, {key, true}) - - station = Map.get(sounding_station_map, s.station_code) + station = Map.get(asos_station_map, s.station_code) if station do - case IemClient.fetch_raob(s.station_code, sounding_time) do - {:ok, [parsed | _]} -> - params = SoundingParams.derive(parsed.profile) + # Skip HTTP fetch if we already have observations in this window + if !Weather.has_surface_observations?(station.id, start_dt, end_dt) do + case IemClient.fetch_asos(s.station_code, start_dt, end_dt) do + {:ok, rows} -> + Enum.each(rows, fn row -> + if row.observed_at do + Weather.upsert_surface_observation(station, row) + end + end) - sounding_attrs = - if params do - %{ - observed_at: parsed.observed_at, - profile: parsed.profile, - level_count: params.level_count, - surface_pressure_mb: params.surface_pressure_mb, - surface_temp_c: params.surface_temp_c, - surface_dewpoint_c: params.surface_dewpoint_c, - surface_refractivity: params.surface_refractivity, - min_refractivity_gradient: params.min_refractivity_gradient, - boundary_layer_depth_m: params.boundary_layer_depth_m, - precipitable_water_mm: params.precipitable_water_mm, - k_index: params.k_index, - lifted_index: params.lifted_index, - ducting_detected: params.ducting_detected, - duct_characteristics: params.duct_characteristics - } - else - %{ - observed_at: parsed.observed_at, - profile: parsed.profile, - level_count: length(parsed.profile) - } - end - - Weather.upsert_sounding(station, sounding_attrs) - - {:ok, []} -> - :ok - - {:error, reason} -> - IO.puts(" RAOB error #{s.station_code}: #{inspect(reason)}") + {:error, reason} -> + IO.puts(" ASOS error #{s.station_code}: #{inspect(reason)}") + end end - - ImportHelpers.rate_limit() end end end) - end) - end -end) + + # Find nearby sounding stations (within 300km) + nearby_soundings = ImportHelpers.nearest_stations(lat, lon, sounding_station_list, 300) + + Enum.each(nearby_soundings, fn s -> + sounding_times = ImportHelpers.sounding_times_around(qso.qso_timestamp) + + Enum.each(sounding_times, fn sounding_time -> + key = {s.station_code, sounding_time} + + if !:ets.member(raob_fetched, key) do + :ets.insert(raob_fetched, {key, true}) + + station = Map.get(sounding_station_map, s.station_code) + + if station do + # Skip HTTP fetch if we already have this sounding + if !Weather.has_sounding?(station.id, sounding_time) do + case IemClient.fetch_raob(s.station_code, sounding_time) do + {:ok, [parsed | _]} -> + params = SoundingParams.derive(parsed.profile) + + sounding_attrs = + if params do + %{ + observed_at: parsed.observed_at, + profile: parsed.profile, + level_count: params.level_count, + surface_pressure_mb: params.surface_pressure_mb, + surface_temp_c: params.surface_temp_c, + surface_dewpoint_c: params.surface_dewpoint_c, + surface_refractivity: params.surface_refractivity, + min_refractivity_gradient: params.min_refractivity_gradient, + boundary_layer_depth_m: params.boundary_layer_depth_m, + precipitable_water_mm: params.precipitable_water_mm, + k_index: params.k_index, + lifted_index: params.lifted_index, + ducting_detected: params.ducting_detected, + duct_characteristics: params.duct_characteristics + } + else + %{ + observed_at: parsed.observed_at, + profile: parsed.profile, + level_count: length(parsed.profile) + } + end + + Weather.upsert_sounding(station, sounding_attrs) + + {:ok, []} -> + :ok + + {:error, reason} -> + IO.puts(" RAOB error #{s.station_code}: #{inspect(reason)}") + end + end + end + end + end) + end) + end + end, + max_concurrency: 10, + timeout: 60_000, + ordered: false +) +|> Stream.run() :ets.delete(asos_fetched) :ets.delete(raob_fetched) @@ -310,10 +311,12 @@ end) asos_count = Repo.aggregate(Microwaveprop.Weather.SurfaceObservation, :count) sounding_count = Repo.aggregate(Microwaveprop.Weather.Sounding, :count) station_count = Repo.aggregate(Microwaveprop.Weather.Station, :count) +solar_count = Repo.aggregate(Microwaveprop.Weather.SolarIndex, :count) IO.puts(""" Import complete: + Solar index days: #{solar_count} Stations: #{station_count} Surface observations: #{asos_count} Soundings: #{sounding_count} diff --git a/priv/repo/migrations/20260328172746_create_qsos.exs b/priv/repo/migrations/20260328172746_create_qsos.exs new file mode 100644 index 00000000..d9426ece --- /dev/null +++ b/priv/repo/migrations/20260328172746_create_qsos.exs @@ -0,0 +1,20 @@ +defmodule Microwaveprop.Repo.Migrations.CreateQsos do + use Ecto.Migration + + def change do + create table(:qsos, primary_key: false) do + add :id, :binary_id, primary_key: true + add :station1, :string, null: false + add :station2, :string, null: false + add :qso_timestamp, :utc_datetime, null: false + add :grid1, :string + add :grid2, :string + add :pos1, :map + add :pos2, :map + add :mode, :string, null: false + add :band, :decimal, null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20260328173025_add_distance_km_to_qsos.exs b/priv/repo/migrations/20260328173025_add_distance_km_to_qsos.exs new file mode 100644 index 00000000..28edd1e3 --- /dev/null +++ b/priv/repo/migrations/20260328173025_add_distance_km_to_qsos.exs @@ -0,0 +1,9 @@ +defmodule Microwaveprop.Repo.Migrations.AddDistanceKmToQsos do + use Ecto.Migration + + def change do + alter table(:qsos) do + add :distance_km, :decimal + end + end +end diff --git a/priv/repo/migrations/20260328211118_create_solar_indices.exs b/priv/repo/migrations/20260328211118_create_solar_indices.exs new file mode 100644 index 00000000..d0566132 --- /dev/null +++ b/priv/repo/migrations/20260328211118_create_solar_indices.exs @@ -0,0 +1,19 @@ +defmodule Microwaveprop.Repo.Migrations.CreateSolarIndices do + use Ecto.Migration + + def change do + create table(:solar_indices, primary_key: false) do + add :id, :binary_id, primary_key: true + add :date, :date, null: false + add :sfi, :float + add :sfi_adjusted, :float + add :sunspot_number, :integer + add :ap_index, :integer + add :kp_values, {:array, :float} + + timestamps(type: :utc_datetime) + end + + create unique_index(:solar_indices, [:date]) + end +end diff --git a/priv/repo/migrations/20260329142459_add_oban.exs b/priv/repo/migrations/20260329142459_add_oban.exs new file mode 100644 index 00000000..67cd5fc2 --- /dev/null +++ b/priv/repo/migrations/20260329142459_add_oban.exs @@ -0,0 +1,6 @@ +defmodule Microwaveprop.Repo.Migrations.AddOban do + use Ecto.Migration + + def up, do: Oban.Migration.up() + def down, do: Oban.Migration.down() +end diff --git a/test/microwaveprop/radio/qso_test.exs b/test/microwaveprop/radio/qso_test.exs new file mode 100644 index 00000000..94ba091e --- /dev/null +++ b/test/microwaveprop/radio/qso_test.exs @@ -0,0 +1,47 @@ +defmodule Microwaveprop.Radio.QsoTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Radio.Qso + + @valid_attrs %{ + station1: "W1AW", + station2: "K3LR", + qso_timestamp: ~U[2026-03-28 12:00:00Z], + grid1: "FN31pr", + grid2: "EN91jm", + pos1: %{lat: 41.714, lng: -72.727}, + pos2: %{lat: 41.049, lng: -80.166}, + mode: "SSB", + band: Decimal.new("144.2"), + distance_km: Decimal.new("623.4") + } + + describe "changeset/2" do + test "valid attributes" do + changeset = Qso.changeset(%Qso{}, @valid_attrs) + assert changeset.valid? + end + + test "requires station1, station2, qso_timestamp, mode, and band" do + changeset = Qso.changeset(%Qso{}, %{}) + + assert %{ + station1: ["can't be blank"], + station2: ["can't be blank"], + qso_timestamp: ["can't be blank"], + mode: ["can't be blank"], + band: ["can't be blank"] + } = errors_on(changeset) + end + + test "persists to the database" do + changeset = Qso.changeset(%Qso{}, @valid_attrs) + assert {:ok, qso} = Repo.insert(changeset) + assert qso.station1 == "W1AW" + assert qso.station2 == "K3LR" + assert qso.mode == "SSB" + assert Decimal.equal?(qso.band, Decimal.new("144.2")) + assert Decimal.equal?(qso.distance_km, Decimal.new("623.4")) + end + end +end diff --git a/test/microwaveprop/radio_test.exs b/test/microwaveprop/radio_test.exs new file mode 100644 index 00000000..b4749294 --- /dev/null +++ b/test/microwaveprop/radio_test.exs @@ -0,0 +1,118 @@ +defmodule Microwaveprop.RadioTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Radio + alias Microwaveprop.Radio.Qso + + defp create_qso(attrs \\ %{}) do + default = %{ + station1: "W5XD", + station2: "K5TR", + qso_timestamp: ~U[2026-03-28 18:00:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295") + } + + {:ok, qso} = + %Qso{} + |> Qso.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + qso + end + + describe "list_qsos/1" do + test "returns empty page when no QSOs exist" do + result = Radio.list_qsos() + + assert result.entries == [] + assert result.page == 1 + assert result.total_pages == 1 + assert result.total_entries == 0 + end + + test "paginates 20 per page, ordered by qso_timestamp desc" do + for i <- 1..25 do + ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) + create_qso(%{qso_timestamp: ts}) + end + + result = Radio.list_qsos() + + assert length(result.entries) == 20 + assert result.page == 1 + assert result.total_pages == 2 + assert result.total_entries == 25 + + # Most recent first + first = hd(result.entries) + assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second) + end + + test "returns second page" do + for i <- 1..25 do + ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) + create_qso(%{qso_timestamp: ts}) + end + + result = Radio.list_qsos(page: 2) + + assert length(result.entries) == 5 + assert result.page == 2 + assert result.total_pages == 2 + end + end + + describe "list_qsos/1 sorting" do + test "sorts by station1 ascending" do + create_qso(%{station1: "ZZ9ZZ"}) + create_qso(%{station1: "AA1AA"}) + + result = Radio.list_qsos(sort_by: :station1, sort_order: :asc) + + stations = Enum.map(result.entries, & &1.station1) + assert stations == ["AA1AA", "ZZ9ZZ"] + end + + test "sorts by distance_km descending" do + create_qso(%{distance_km: Decimal.new("100"), station1: "A1A"}) + create_qso(%{distance_km: Decimal.new("500"), station1: "B2B"}) + + result = Radio.list_qsos(sort_by: :distance_km, sort_order: :desc) + + distances = Enum.map(result.entries, & &1.distance_km) + assert distances == [Decimal.new("500"), Decimal.new("100")] + end + + test "invalid sort_by falls back to qso_timestamp desc" do + early = create_qso(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]}) + late = create_qso(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]}) + + result = Radio.list_qsos(sort_by: :bogus) + + ids = Enum.map(result.entries, & &1.id) + assert ids == [late.id, early.id] + end + end + + describe "get_qso!/1" do + test "returns a QSO by ID" do + qso = create_qso() + + found = Radio.get_qso!(qso.id) + assert found.id == qso.id + assert found.station1 == "W5XD" + end + + test "raises Ecto.NoResultsError on bad ID" do + assert_raise Ecto.NoResultsError, fn -> + Radio.get_qso!(Ecto.UUID.generate()) + end + end + end +end diff --git a/test/microwaveprop/weather/iem_client_test.exs b/test/microwaveprop/weather/iem_client_test.exs index e491c13f..55918384 100644 --- a/test/microwaveprop/weather/iem_client_test.exs +++ b/test/microwaveprop/weather/iem_client_test.exs @@ -116,4 +116,161 @@ defmodule Microwaveprop.Weather.IemClientTest do assert url =~ "ts=202603281200" end end + + describe "network_url/1" do + test "builds correct URL for state ASOS network" do + url = IemClient.network_url("NY_ASOS") + assert url == "https://mesonet.agron.iastate.edu/json/network.py?network=NY_ASOS" + end + + test "builds correct URL for RAOB network" do + url = IemClient.network_url("RAOB") + assert url == "https://mesonet.agron.iastate.edu/json/network.py?network=RAOB" + end + end + + describe "fetch_network/1" do + test "returns parsed stations on success" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.json(conn, %{ + "stations" => [ + %{"id" => "KJFK", "name" => "JFK Intl", "lat" => 40.64, "lon" => -73.78} + ] + }) + end) + + assert {:ok, [station]} = IemClient.fetch_network("NY_ASOS") + assert station.station_code == "KJFK" + assert station.lat == 40.64 + end + + test "returns error on non-200 status" do + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 503, "Service Unavailable") + end) + + assert {:error, "IEM network HTTP 503"} = IemClient.fetch_network("NY_ASOS") + end + end + + describe "fetch_asos/3" do + test "returns parsed observations on success" do + csv = """ + station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1 + KDFW,2026-03-28 18:53,75.0,55.0,49.12,12,180,1013.2,29.92,SCT + """ + + Req.Test.stub(IemClient, fn conn -> + Req.Test.text(conn, csv) + end) + + assert {:ok, [obs]} = + IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z]) + + assert obs.temp_f == 75.0 + assert obs.observed_at == ~U[2026-03-28 18:53:00Z] + end + + test "returns error on non-200 status" do + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 500, "error") + end) + + assert {:error, "IEM ASOS HTTP 500"} = + IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z]) + end + end + + describe "fetch_raob/2" do + test "returns parsed soundings on success" do + Req.Test.stub(IemClient, fn conn -> + Req.Test.json(conn, %{ + "profiles" => [ + %{ + "station" => "FWD", + "valid" => "2026-03-28 12:00:00+00:00", + "profile" => [ + %{"pres" => 1013.0, "hght" => 171.0, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180.0, "sknt" => 10.0} + ] + } + ] + }) + end) + + assert {:ok, [sounding]} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z]) + assert sounding.observed_at == ~U[2026-03-28 12:00:00Z] + assert length(sounding.profile) == 1 + end + + test "returns error on non-200 status" do + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 404, "not found") + end) + + assert {:error, "IEM RAOB HTTP 404"} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z]) + end + end + + describe "parse_network_json/1" do + test "parses station list from network JSON response" do + json = %{ + "stations" => [ + %{ + "id" => "KJFK", + "name" => "New York/JFK Intl", + "lat" => 40.6399, + "lon" => -73.7787 + }, + %{ + "id" => "KLGA", + "name" => "New York/LaGuardia", + "lat" => 40.7772, + "lon" => -73.8726 + } + ] + } + + stations = IemClient.parse_network_json(json) + assert length(stations) == 2 + + first = hd(stations) + assert first.station_code == "KJFK" + assert first.name == "New York/JFK Intl" + assert first.lat == 40.6399 + assert first.lon == -73.7787 + end + + test "returns empty list when no stations" do + assert IemClient.parse_network_json(%{"stations" => []}) == [] + end + + test "returns empty list when stations key missing" do + assert IemClient.parse_network_json(%{}) == [] + end + + test "handles stations with extra fields gracefully" do + json = %{ + "stations" => [ + %{ + "id" => "KDFW", + "name" => "Dallas/Fort Worth", + "lat" => 32.897, + "lon" => -97.038, + "elevation" => 171.0, + "state" => "TX", + "country" => "US", + "network" => "TX_ASOS" + } + ] + } + + stations = IemClient.parse_network_json(json) + assert length(stations) == 1 + + station = hd(stations) + assert station.station_code == "KDFW" + assert station.lat == 32.897 + assert station.lon == -97.038 + end + end end diff --git a/test/microwaveprop/weather/solar_client_test.exs b/test/microwaveprop/weather/solar_client_test.exs new file mode 100644 index 00000000..e43e2312 --- /dev/null +++ b/test/microwaveprop/weather/solar_client_test.exs @@ -0,0 +1,100 @@ +defmodule Microwaveprop.Weather.SolarClientTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Weather.SolarClient + + @sample_gfz_file """ + # Comment line 1 + # Comment line 2 + # YYYY MM DD days days_m Bsr dB Kp1 Kp2 Kp3 Kp4 Kp5 Kp6 Kp7 Kp8 ap1 ap2 ap3 ap4 ap5 ap6 ap7 ap8 Ap SN F10.7obs F10.7adj D + 2024 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2 + 2024 06 16 33770 33770.5 2589 19 3.333 4.000 2.667 1.000 0.667 1.333 2.000 1.667 18 27 12 4 3 5 7 6 10 115 145.0 143.2 2 + """ + + @sample_row_with_missing "1932 01 01 0 0.5 1352 10 3.333 2.667 2.333 2.667 3.333 2.667 3.333 3.333 18 12 9 12 18 12 18 18 15 22 -1.0 -1.0 2" + + @sample_row_all_missing "1940 05 01 3043 3043.5 1385 23 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1.0 -1.0 0" + + describe "parse_gfz_row/1" do + test "parses a complete data row" do + row = + "2024 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2" + + assert {:ok, parsed} = SolarClient.parse_gfz_row(row) + assert parsed.date == ~D[2024-06-15] + assert parsed.sfi == 150.2 + assert parsed.sfi_adjusted == 148.5 + assert parsed.sunspot_number == 120 + assert parsed.ap_index == 8 + assert parsed.kp_values == [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667] + end + + test "maps -1 values to nil for F10.7" do + assert {:ok, parsed} = SolarClient.parse_gfz_row(@sample_row_with_missing) + assert parsed.date == ~D[1932-01-01] + assert is_nil(parsed.sfi) + assert is_nil(parsed.sfi_adjusted) + assert parsed.sunspot_number == 22 + assert parsed.ap_index == 15 + end + + test "maps all -1 values to nil" do + assert {:ok, parsed} = SolarClient.parse_gfz_row(@sample_row_all_missing) + assert parsed.date == ~D[1940-05-01] + assert is_nil(parsed.sfi) + assert is_nil(parsed.sfi_adjusted) + assert is_nil(parsed.sunspot_number) + assert is_nil(parsed.ap_index) + assert parsed.kp_values == [nil, nil, nil, nil, nil, nil, nil, nil] + end + end + + describe "parse_gfz_file/1" do + test "skips comment lines and parses data rows" do + result = SolarClient.parse_gfz_file(@sample_gfz_file) + assert length(result) == 2 + + [first, second] = result + assert first.date == ~D[2024-06-15] + assert first.sfi == 150.2 + assert second.date == ~D[2024-06-16] + assert second.sfi == 145.0 + end + + test "returns empty list for empty input" do + assert SolarClient.parse_gfz_file("") == [] + end + + test "returns empty list for comments-only input" do + assert SolarClient.parse_gfz_file("# just a comment\n# another") == [] + end + end + + describe "fetch_solar_indices_since/1" do + test "filters records to those on or after the given date" do + records = SolarClient.parse_gfz_file(@sample_gfz_file) + # Records are 2024-06-15 and 2024-06-16 + filtered = SolarClient.filter_since(records, ~D[2024-06-16]) + assert length(filtered) == 1 + assert hd(filtered).date == ~D[2024-06-16] + end + + test "returns all records when since_date is before all records" do + records = SolarClient.parse_gfz_file(@sample_gfz_file) + filtered = SolarClient.filter_since(records, ~D[2020-01-01]) + assert length(filtered) == 2 + end + + test "returns empty list when since_date is after all records" do + records = SolarClient.parse_gfz_file(@sample_gfz_file) + filtered = SolarClient.filter_since(records, ~D[2025-01-01]) + assert filtered == [] + end + end + + describe "data_url/0" do + test "returns the GFZ data URL" do + assert SolarClient.data_url() == "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt" + end + end +end diff --git a/test/microwaveprop/weather_test.exs b/test/microwaveprop/weather_test.exs index e8bd17f3..658b6cc4 100644 --- a/test/microwaveprop/weather_test.exs +++ b/test/microwaveprop/weather_test.exs @@ -114,7 +114,7 @@ defmodule Microwaveprop.WeatherTest do end describe "weather_for_qso/2" do - test "returns nearby surface observations within time window" do + test "returns nearby surface observations within time window with station preloaded" do {:ok, station} = Weather.find_or_create_station(@station_attrs) {:ok, _obs} = @@ -132,10 +132,12 @@ defmodule Microwaveprop.WeatherTest do ) assert length(result.surface_observations) == 1 - assert hd(result.surface_observations).temp_f == 75.0 + obs = hd(result.surface_observations) + assert obs.temp_f == 75.0 + assert obs.station.station_code == "KDFW" end - test "returns nearby soundings within time window" do + test "returns nearby soundings within time window with station preloaded" do {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) {:ok, _sounding} = @@ -154,6 +156,7 @@ defmodule Microwaveprop.WeatherTest do ) assert length(result.soundings) == 1 + assert hd(result.soundings).station.station_code == "FWD" end test "excludes observations outside radius" do @@ -196,4 +199,142 @@ defmodule Microwaveprop.WeatherTest do assert result.surface_observations == [] end end + + describe "has_surface_observations?/3" do + test "returns true when observations exist in the time window" do + {:ok, station} = Weather.find_or_create_station(@station_attrs) + + Weather.upsert_surface_observation(station, %{ + observed_at: ~U[2026-03-28 18:00:00Z], + temp_f: 75.0 + }) + + assert Weather.has_surface_observations?( + station.id, + ~U[2026-03-28 16:00:00Z], + ~U[2026-03-28 20:00:00Z] + ) + end + + test "returns false when no observations exist in the time window" do + {:ok, station} = Weather.find_or_create_station(@station_attrs) + + refute Weather.has_surface_observations?( + station.id, + ~U[2026-03-28 16:00:00Z], + ~U[2026-03-28 20:00:00Z] + ) + end + + test "returns false when observations are outside the time window" do + {:ok, station} = Weather.find_or_create_station(@station_attrs) + + Weather.upsert_surface_observation(station, %{ + observed_at: ~U[2026-03-28 06:00:00Z], + temp_f: 60.0 + }) + + refute Weather.has_surface_observations?( + station.id, + ~U[2026-03-28 16:00:00Z], + ~U[2026-03-28 20:00:00Z] + ) + end + end + + describe "has_sounding?/2" do + test "returns true when a sounding exists at the given time" do + {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) + + Weather.upsert_sounding(station, %{ + observed_at: ~U[2026-03-28 12:00:00Z], + profile: [%{"pres" => 1013.0, "tmpc" => 25.0}], + level_count: 1 + }) + + assert Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z]) + end + + test "returns false when no sounding exists at the given time" do + {:ok, station} = Weather.find_or_create_station(@sounding_station_attrs) + + refute Weather.has_sounding?(station.id, ~U[2026-03-28 12:00:00Z]) + end + end + + describe "existing_solar_dates/0" do + test "returns set of dates already in the database" do + Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2}) + Weather.upsert_solar_index(%{date: ~D[2024-06-16], sfi: 145.0}) + + dates = Weather.existing_solar_dates() + assert MapSet.member?(dates, ~D[2024-06-15]) + assert MapSet.member?(dates, ~D[2024-06-16]) + refute MapSet.member?(dates, ~D[2024-06-17]) + end + + test "returns empty set when no solar indices exist" do + assert Weather.existing_solar_dates() == MapSet.new() + end + end + + describe "get_solar_index/1" do + test "returns the solar index record for a given date" do + {:ok, record} = Weather.upsert_solar_index(%{date: ~D[2024-06-15], sfi: 150.2, sunspot_number: 120}) + + found = Weather.get_solar_index(~D[2024-06-15]) + assert found.id == record.id + assert found.sfi == 150.2 + end + + test "returns nil when no record exists" do + assert Weather.get_solar_index(~D[2024-01-01]) == nil + end + end + + describe "upsert_solar_index/1" do + test "inserts a new solar index record" do + attrs = %{ + date: ~D[2024-06-15], + sfi: 150.2, + sfi_adjusted: 148.5, + sunspot_number: 120, + ap_index: 8, + kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667] + } + + assert {:ok, record} = Weather.upsert_solar_index(attrs) + assert record.date == ~D[2024-06-15] + assert record.sfi == 150.2 + assert record.sunspot_number == 120 + assert record.ap_index == 8 + assert length(record.kp_values) == 8 + end + + test "updates existing record on date conflict" do + attrs = %{ + date: ~D[2024-06-15], + sfi: 150.2, + sfi_adjusted: 148.5, + sunspot_number: 120, + ap_index: 8, + kp_values: [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667] + } + + {:ok, first} = Weather.upsert_solar_index(attrs) + {:ok, second} = Weather.upsert_solar_index(%{attrs | sfi: 155.0}) + + assert first.id == second.id + assert second.sfi == 155.0 + end + + test "inserts record with nil optional fields" do + attrs = %{date: ~D[1932-01-01], sfi: nil, sfi_adjusted: nil, sunspot_number: 22, ap_index: 15} + + assert {:ok, record} = Weather.upsert_solar_index(attrs) + assert record.date == ~D[1932-01-01] + assert is_nil(record.sfi) + assert record.sunspot_number == 22 + end + end end diff --git a/test/microwaveprop/workers/solar_index_worker_test.exs b/test/microwaveprop/workers/solar_index_worker_test.exs new file mode 100644 index 00000000..2a09c7a7 --- /dev/null +++ b/test/microwaveprop/workers/solar_index_worker_test.exs @@ -0,0 +1,49 @@ +defmodule Microwaveprop.Workers.SolarIndexWorkerTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Weather.SolarClient + alias Microwaveprop.Weather.SolarIndex + alias Microwaveprop.Workers.SolarIndexWorker + + defp gfz_body_with_recent_dates do + today = Date.utc_today() + yesterday = Date.add(today, -1) + + """ + # Comment line + #{today.year} #{String.pad_leading("#{today.month}", 2, "0")} #{String.pad_leading("#{today.day}", 2, "0")} 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2 + #{yesterday.year} #{String.pad_leading("#{yesterday.month}", 2, "0")} #{String.pad_leading("#{yesterday.day}", 2, "0")} 33770 33770.5 2589 19 3.333 4.000 2.667 1.000 0.667 1.333 2.000 1.667 18 27 12 4 3 5 7 6 10 115 145.0 143.2 2 + """ + end + + describe "perform/1" do + test "fetches and upserts solar indices from the last 7 days" do + Req.Test.stub(SolarClient, fn conn -> + Req.Test.text(conn, gfz_body_with_recent_dates()) + end) + + assert :ok = SolarIndexWorker.perform(%Oban.Job{}) + + assert Repo.aggregate(SolarIndex, :count) == 2 + end + + test "returns error when HTTP request fails" do + Req.Test.stub(SolarClient, fn conn -> + Plug.Conn.send_resp(conn, 500, "Internal Server Error") + end) + + assert {:error, "HTTP 500"} = SolarIndexWorker.perform(%Oban.Job{}) + end + + test "is idempotent — re-running upserts without duplicating" do + Req.Test.stub(SolarClient, fn conn -> + Req.Test.text(conn, gfz_body_with_recent_dates()) + end) + + assert :ok = SolarIndexWorker.perform(%Oban.Job{}) + assert :ok = SolarIndexWorker.perform(%Oban.Job{}) + + assert Repo.aggregate(SolarIndex, :count) == 2 + end + end +end diff --git a/test/microwaveprop_web/controllers/page_controller_test.exs b/test/microwaveprop_web/controllers/page_controller_test.exs index c1b4e25d..1f08dba8 100644 --- a/test/microwaveprop_web/controllers/page_controller_test.exs +++ b/test/microwaveprop_web/controllers/page_controller_test.exs @@ -1,8 +1,8 @@ defmodule MicrowavepropWeb.PageControllerTest do use MicrowavepropWeb.ConnCase - test "GET /", %{conn: conn} do + test "GET / redirects to /qsos", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert redirected_to(conn) == "/qsos" end end diff --git a/test/microwaveprop_web/live/qso_live_test.exs b/test/microwaveprop_web/live/qso_live_test.exs new file mode 100644 index 00000000..faf250f8 --- /dev/null +++ b/test/microwaveprop_web/live/qso_live_test.exs @@ -0,0 +1,150 @@ +defmodule MicrowavepropWeb.QsoLiveTest do + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Microwaveprop.Radio.Qso + alias Microwaveprop.Repo + + defp create_qso(attrs \\ %{}) do + default = %{ + station1: "W5XD", + station2: "K5TR", + qso_timestamp: ~U[2026-03-28 18:00:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295") + } + + {:ok, qso} = + %Qso{} + |> Qso.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + qso + end + + describe "Index" do + test "renders page with QSOs heading", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/qsos") + assert html =~ "QSOs" + end + + test "table shows QSO data", %{conn: conn} do + create_qso() + + {:ok, _lv, html} = live(conn, ~p"/qsos") + assert html =~ "W5XD" + assert html =~ "K5TR" + assert html =~ "CW" + assert html =~ "1296" + end + + test "row links to detail page", %{conn: conn} do + qso = create_qso() + + {:ok, _lv, html} = live(conn, ~p"/qsos") + assert html =~ ~p"/qsos/#{qso.id}" + end + + test "clicking sort header changes URL params", %{conn: conn} do + create_qso(%{station1: "ZZ9ZZ"}) + create_qso(%{station1: "AA1AA"}) + + {:ok, lv, _html} = live(conn, ~p"/qsos") + + lv |> element("span[phx-click=sort][phx-value-field=station1]") |> render_click() + + assert_patch(lv, "/qsos?sort_by=station1&sort_order=asc") + end + + test "sort params order the table", %{conn: conn} do + create_qso(%{station1: "ZZ9ZZ"}) + create_qso(%{station1: "AA1AA"}) + + {:ok, _lv, html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc") + + # AA1AA should appear before ZZ9ZZ + aa_pos = html |> :binary.match("AA1AA") |> elem(0) + zz_pos = html |> :binary.match("ZZ9ZZ") |> elem(0) + assert aa_pos < zz_pos + end + + test "clicking same sort header toggles direction", %{conn: conn} do + create_qso() + + {:ok, lv, _html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc") + + lv |> element("span[phx-click=sort][phx-value-field=station1]") |> render_click() + + assert_patch(lv, "/qsos?sort_by=station1&sort_order=desc") + end + + test "pagination works", %{conn: conn} do + for i <- 1..25 do + ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) + create_qso(%{qso_timestamp: ts}) + end + + {:ok, _lv, html} = live(conn, ~p"/qsos") + assert html =~ "Page 1 of 2" + + {:ok, _lv, html2} = live(conn, ~p"/qsos?page=2") + assert html2 =~ "Page 2 of 2" + end + end + + describe "Show" do + test "renders QSO detail fields", %{conn: conn} do + qso = create_qso() + + {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + assert html =~ "W5XD" + assert html =~ "K5TR" + assert html =~ "CW" + assert html =~ "1296" + assert html =~ "EM12" + assert html =~ "EM00" + end + + test "shows surface observations section", %{conn: conn} do + qso = create_qso() + {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + assert html =~ "Surface Observations" + end + + test "shows sounding section", %{conn: conn} do + qso = create_qso() + {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + assert html =~ "Soundings" + end + + test "shows solar index section", %{conn: conn} do + qso = create_qso() + {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + assert html =~ "Solar Conditions" + end + + test "renders with sort assigns", %{conn: conn} do + qso = create_qso() + {:ok, lv, html} = live(conn, ~p"/qsos/#{qso.id}") + + # Page renders without error + assert html =~ "Surface Observations" + assert html =~ "Soundings" + + # Sort assigns are initialized (no crash on render) + assert render(lv) =~ qso.station1 + end + + test "raises for bad UUID", %{conn: conn} do + assert_raise Ecto.NoResultsError, fn -> + live(conn, ~p"/qsos/#{Ecto.UUID.generate()}") + end + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index b58f1876..26a69277 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -20,6 +20,7 @@ defmodule MicrowavepropWeb.ConnCase do using do quote do use MicrowavepropWeb, :verified_routes + import MicrowavepropWeb.ConnCase import Phoenix.ConnTest import Plug.Conn
{col[:label]} + + {col[:label]} + <.icon + :if={@sort_by == col[:sort_field] && @sort_order == "asc"} + name="hero-chevron-up" + class="w-3 h-3" + /> + <.icon + :if={@sort_by == col[:sort_field] && @sort_order == "desc"} + name="hero-chevron-down" + class="w-3 h-3" + /> + + {col[:label]} + {gettext("Actions")}