Add QSO import, solar indices, Oban workers, LiveView UI, and parallel weather import
- Radio context with QSO schema and CSV import script (58K contacts) - Solar index schema, GFZ client, and daily Oban cron worker - LiveView dashboard with QSO table and weather correlation views - Parallelize weather import script using Task.async_stream (10 concurrent workers) - Replace sequential rate_limit sleeps with concurrency-based backpressure - Atomic progress counter for interleave-safe reporting
This commit is contained in:
parent
6489f08138
commit
6f16395f44
33 changed files with 2054 additions and 247 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
49
lib/microwaveprop/radio.ex
Normal file
49
lib/microwaveprop/radio.ex
Normal file
|
|
@ -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
|
||||
33
lib/microwaveprop/radio/qso.ex
Normal file
33
lib/microwaveprop/radio/qso.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
94
lib/microwaveprop/weather/solar_client.ex
Normal file
94
lib/microwaveprop/weather/solar_client.ex
Normal file
|
|
@ -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
|
||||
29
lib/microwaveprop/weather/solar_index.ex
Normal file
29
lib/microwaveprop/weather/solar_index.ex
Normal file
|
|
@ -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
|
||||
24
lib/microwaveprop/workers/solar_index_worker.ex
Normal file
24
lib/microwaveprop/workers/solar_index_worker.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
|||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={col <- @col}>
|
||||
<span
|
||||
:if={col[:sort_field]}
|
||||
class="cursor-pointer select-none inline-flex items-center gap-1"
|
||||
phx-click="sort"
|
||||
phx-value-field={col[:sort_field]}
|
||||
phx-value-table={@sort_target}
|
||||
>
|
||||
{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"
|
||||
/>
|
||||
</span>
|
||||
<span :if={!col[:sort_field]}>{col[:label]}</span>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -37,28 +37,10 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
~H"""
|
||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||
</a>
|
||||
<a href="/" class="font-semibold">Microwaveprop</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||
<li>
|
||||
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<.theme_toggle />
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<.theme_toggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
110
lib/microwaveprop_web/live/qso_live/index.ex
Normal file
110
lib/microwaveprop_web/live/qso_live/index.ex
Normal file
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
QSOs
|
||||
<:subtitle>{@total_entries} contacts</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.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>
|
||||
<:col :let={qso} label="Station 2" sort_field="station2">{qso.station2}</:col>
|
||||
<:col :let={qso} label="Band" sort_field="band">{qso.band}</:col>
|
||||
<:col :let={qso} label="Mode" sort_field="mode">{qso.mode}</:col>
|
||||
<:col :let={qso} label="Distance (km)" sort_field="distance_km">{qso.distance_km}</:col>
|
||||
<:col :let={qso} label="Timestamp" sort_field="qso_timestamp">
|
||||
{Calendar.strftime(qso.qso_timestamp, "%Y-%m-%d %H:%M")}
|
||||
</:col>
|
||||
</.table>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<.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
|
||||
</.link>
|
||||
<span :if={@page <= 1} />
|
||||
|
||||
<span class="text-sm text-base-content/70">Page {@page} of {@total_pages}</span>
|
||||
|
||||
<.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" />
|
||||
</.link>
|
||||
<span :if={@page >= @total_pages} />
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
236
lib/microwaveprop_web/live/qso_live/show.ex
Normal file
236
lib/microwaveprop_web/live/qso_live/show.ex
Normal file
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash}>
|
||||
<.header>
|
||||
{@qso.station1} / {@qso.station2}
|
||||
<:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
|
||||
<:actions>
|
||||
<.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost">
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
|
||||
<.list>
|
||||
<:item title="Station 1">{@qso.station1}</:item>
|
||||
<:item title="Station 2">{@qso.station2}</:item>
|
||||
<:item title="Band">{@qso.band} MHz</:item>
|
||||
<:item title="Mode">{@qso.mode}</:item>
|
||||
<:item title="Grid 1">{@qso.grid1 || "—"}</:item>
|
||||
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
|
||||
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
|
||||
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
|
||||
</.list>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">Surface Observations</h2>
|
||||
<%= if @surface_observations == [] do %>
|
||||
<p class="text-sm text-base-content/50 italic">No surface observations found nearby.</p>
|
||||
<% 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>
|
||||
<:col :let={obs} label="Time" sort_field="observed_at">
|
||||
{Calendar.strftime(obs.observed_at, "%H:%M")}
|
||||
</:col>
|
||||
<:col :let={obs} label="Temp (F)" sort_field="temp_f">{obs.temp_f || "—"}</:col>
|
||||
<:col :let={obs} label="Dewpoint (F)" sort_field="dewpoint_f">
|
||||
{obs.dewpoint_f || "—"}
|
||||
</:col>
|
||||
<:col :let={obs} label="RH%" sort_field="relative_humidity">
|
||||
{format_number(obs.relative_humidity)}
|
||||
</:col>
|
||||
<:col :let={obs} label="Wind">{format_wind(obs)}</:col>
|
||||
<:col :let={obs} label="Pressure (mb)" sort_field="sea_level_pressure_mb">
|
||||
{obs.sea_level_pressure_mb || "—"}
|
||||
</:col>
|
||||
<:col :let={obs} label="Sky">{obs.sky_condition || "—"}</:col>
|
||||
</.table>
|
||||
<% end %>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">Soundings</h2>
|
||||
<%= if @soundings == [] do %>
|
||||
<p class="text-sm text-base-content/50 italic">No soundings found nearby.</p>
|
||||
<% 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>
|
||||
<:col :let={s} label="Time" sort_field="observed_at">
|
||||
{Calendar.strftime(s.observed_at, "%H:%M")}
|
||||
</:col>
|
||||
<:col :let={s} label="Sfc Temp (C)" sort_field="surface_temp_c">
|
||||
{s.surface_temp_c || "—"}
|
||||
</:col>
|
||||
<:col :let={s} label="Refractivity" sort_field="surface_refractivity">
|
||||
{format_number(s.surface_refractivity)}
|
||||
</:col>
|
||||
<:col :let={s} label="Ducting">{if s.ducting_detected, do: "Yes", else: "No"}</:col>
|
||||
<:col :let={s} label="K-Index" sort_field="k_index">{format_number(s.k_index)}</:col>
|
||||
<:col :let={s} label="Lifted Index" sort_field="lifted_index">
|
||||
{format_number(s.lifted_index)}
|
||||
</:col>
|
||||
</.table>
|
||||
<% end %>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<h2 class="text-base font-semibold mb-2">Solar Conditions</h2>
|
||||
<%= if @solar do %>
|
||||
<.list>
|
||||
<:item title="SFI">{@solar.sfi || "—"}</:item>
|
||||
<:item title="Sunspot Number">{@solar.sunspot_number || "—"}</:item>
|
||||
<:item title="Ap Index">{@solar.ap_index || "—"}</:item>
|
||||
<:item title="Kp Values">{format_kp(@solar.kp_values)}</:item>
|
||||
</.list>
|
||||
<% else %>
|
||||
<p class="text-sm text-base-content/50 italic">No solar data available for this date.</p>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
1
mix.exs
1
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
|
||||
|
|
|
|||
1
mix.lock
1
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"},
|
||||
|
|
|
|||
310
priv/repo/import_contacts.exs
Normal file
310
priv/repo/import_contacts.exs
Normal file
|
|
@ -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.")
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
20
priv/repo/migrations/20260328172746_create_qsos.exs
Normal file
20
priv/repo/migrations/20260328172746_create_qsos.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
19
priv/repo/migrations/20260328211118_create_solar_indices.exs
Normal file
19
priv/repo/migrations/20260328211118_create_solar_indices.exs
Normal file
|
|
@ -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
|
||||
6
priv/repo/migrations/20260329142459_add_oban.exs
Normal file
6
priv/repo/migrations/20260329142459_add_oban.exs
Normal file
|
|
@ -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
|
||||
47
test/microwaveprop/radio/qso_test.exs
Normal file
47
test/microwaveprop/radio/qso_test.exs
Normal file
|
|
@ -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
|
||||
118
test/microwaveprop/radio_test.exs
Normal file
118
test/microwaveprop/radio_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
100
test/microwaveprop/weather/solar_client_test.exs
Normal file
100
test/microwaveprop/weather/solar_client_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
49
test/microwaveprop/workers/solar_index_worker_test.exs
Normal file
49
test/microwaveprop/workers/solar_index_worker_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
150
test/microwaveprop_web/live/qso_live_test.exs
Normal file
150
test/microwaveprop_web/live/qso_live_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue