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:
Graham McIntire 2026-03-29 13:04:55 -05:00
parent 6489f08138
commit 6f16395f44
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
33 changed files with 2054 additions and 247 deletions

View file

@ -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]

View file

@ -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

View file

@ -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
]

View 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

View 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

View file

@ -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}

View file

@ -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")

View 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

View 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

View 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

View file

@ -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>

View file

@ -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">&rarr;</span>
</a>
</li>
</ul>
<.theme_toggle />
</div>
</header>

View file

@ -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

View 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

View 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

View file

@ -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.

View file

@ -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

View file

@ -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"},

View 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.")

View file

@ -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}

View 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

View file

@ -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

View 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

View 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

View 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

View 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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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