Rename qsos to contacts throughout codebase, keep DB table name

Rename all modules, functions, variables, routes, and UI text from
qso/qsos to contact/contacts. Database table stays as "qsos" to avoid
migration. Add /qsos -> /contacts redirects for old URLs.
This commit is contained in:
Graham McIntire 2026-04-01 11:25:04 -05:00
parent bc529af392
commit 254e64dedc
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
26 changed files with 620 additions and 609 deletions

View file

@ -51,7 +51,7 @@ config :microwaveprop, Oban,
{Oban.Plugins.Cron,
crontab: [
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker},
{"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker},
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}
]}

View file

@ -79,7 +79,7 @@ config :microwaveprop, Oban,
crontab: [
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
{"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker}
]}
]

View file

@ -3,21 +3,21 @@ defmodule Microwaveprop.Radio do
import Ecto.Query
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo
@per_page 20
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
def list_qsos(opts \\ []) do
def list_contacts(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page
search = Keyword.get(opts, :search)
{sort_field, sort_dir} = sort_opts(opts)
base_query = maybe_search(Qso, search)
base_query = maybe_search(Contact, search)
total_entries = Repo.aggregate(base_query, :count)
total_pages = max(ceil(total_entries / @per_page), 1)
@ -39,26 +39,26 @@ defmodule Microwaveprop.Radio do
end
@doc """
Groups reciprocal QSOs (same station pair swapped, same band, same timestamp).
Returns a list of `{primary_qso, reciprocals}` tuples where reciprocals is
a (possibly empty) list of matching QSOs.
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
Returns a list of `{primary, reciprocals}` tuples where reciprocals is
a (possibly empty) list of matching contacts.
"""
def group_reciprocals(qsos) do
def group_reciprocals(contacts) do
{groups, _seen_ids} =
Enum.reduce(qsos, {[], MapSet.new()}, fn qso, {groups, seen} ->
if MapSet.member?(seen, qso.id) do
Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} ->
if MapSet.member?(seen, contact.id) do
{groups, seen}
else
reciprocals =
Enum.filter(qsos, fn other ->
other.id != qso.id and
Enum.filter(contacts, fn other ->
other.id != contact.id and
not MapSet.member?(seen, other.id) and
other.band == qso.band and
reciprocal_pair?(qso, other)
other.band == contact.band and
reciprocal_pair?(contact, other)
end)
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, qso.id), &MapSet.put(&2, &1.id))
{[{qso, reciprocals} | groups], new_seen}
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id))
{[{contact, reciprocals} | groups], new_seen}
end
end)
@ -97,75 +97,75 @@ defmodule Microwaveprop.Radio do
{field, dir}
end
def unprocessed_qsos(limit \\ 500) do
Qso
def unprocessed_contacts(limit \\ 500) do
Contact
|> where([q], q.weather_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_weather_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
def mark_weather_queued!(ids) do
Contact
|> where([q], q.id in ^ids)
|> Repo.update_all(set: [weather_queued: true])
end
def unprocessed_hrrr_qsos(limit \\ 500) do
Qso
def unprocessed_hrrr_contacts(limit \\ 500) do
Contact
|> where([q], q.hrrr_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_hrrr_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
def mark_hrrr_queued!(ids) do
Contact
|> where([q], q.id in ^ids)
|> Repo.update_all(set: [hrrr_queued: true])
end
def unprocessed_terrain_qsos(limit \\ 500) do
Qso
def unprocessed_terrain_contacts(limit \\ 500) do
Contact
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_terrain_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
def mark_terrain_queued!(ids) do
Contact
|> where([q], q.id in ^ids)
|> Repo.update_all(set: [terrain_queued: true])
end
def unprocessed_iemre_qsos(limit \\ 500) do
Qso
def unprocessed_iemre_contacts(limit \\ 500) do
Contact
|> where([q], q.iemre_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
def mark_iemre_queued!(qso_ids) do
Qso
|> where([q], q.id in ^qso_ids)
def mark_iemre_queued!(ids) do
Contact
|> where([q], q.id in ^ids)
|> Repo.update_all(set: [iemre_queued: true])
end
@doc """
Returns a list of {lat, lon} points along the QSO path: pos1, midpoint, pos2.
Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2.
Returns [pos1] if pos2 is nil, or [] if pos1 is nil.
"""
def qso_path_points(%{pos1: nil}), do: []
def contact_path_points(%{pos1: nil}), do: []
def qso_path_points(%{pos1: pos1, pos2: nil}) do
def contact_path_points(%{pos1: pos1, pos2: nil}) do
lat = pos1["lat"]
lon = pos1["lon"] || pos1["lng"]
if lat && lon, do: [{lat, lon}], else: []
end
def qso_path_points(%{pos1: pos1, pos2: pos2}) do
def contact_path_points(%{pos1: pos1, pos2: pos2}) do
lat1 = pos1["lat"]
lon1 = pos1["lon"] || pos1["lng"]
lat2 = pos2["lat"]
@ -195,19 +195,19 @@ defmodule Microwaveprop.Radio do
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
end
def backfill_distances(qsos) do
Enum.each(qsos, fn qso ->
if is_nil(qso.distance_km) && qso.pos1 && qso.pos2 do
lat1 = qso.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
lat2 = qso.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
def backfill_distances(contacts) do
Enum.each(contacts, fn contact ->
if is_nil(contact.distance_km) && contact.pos1 && contact.pos2 do
lat1 = contact.pos1["lat"]
lon1 = contact.pos1["lon"] || contact.pos1["lng"]
lat2 = contact.pos2["lat"]
lon2 = contact.pos2["lon"] || contact.pos2["lng"]
if lat1 && lon1 && lat2 && lon2 do
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
Qso
|> where([q], q.id == ^qso.id)
Contact
|> where([q], q.id == ^contact.id)
|> Repo.update_all(set: [distance_km: dist])
end
end
@ -216,16 +216,16 @@ defmodule Microwaveprop.Radio do
defp deg_to_rad(deg), do: deg * :math.pi() / 180
def get_qso!(id) do
Repo.get!(Qso, id)
def get_contact!(id) do
Repo.get!(Contact, id)
end
def change_qso(qso, attrs \\ %{}) do
Qso.submission_changeset(qso, attrs)
def change_contact(contact, attrs \\ %{}) do
Contact.submission_changeset(contact, attrs)
end
def create_qso(attrs) do
changeset = Qso.submission_changeset(%Qso{}, attrs)
def create_contact(attrs) do
changeset = Contact.submission_changeset(%Contact{}, attrs)
if changeset.valid? do
grid1 = Ecto.Changeset.get_change(changeset, :grid1)

View file

@ -1,4 +1,4 @@
defmodule Microwaveprop.Radio.Qso do
defmodule Microwaveprop.Radio.Contact do
@moduledoc false
use Ecto.Schema
@ -33,8 +33,8 @@ defmodule Microwaveprop.Radio.Qso do
@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
def changeset(contact, attrs) do
contact
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
end
@ -43,8 +43,8 @@ defmodule Microwaveprop.Radio.Qso do
@allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
def submission_changeset(qso, attrs) do
qso
def submission_changeset(contact, attrs) do
contact
|> cast(attrs, @submission_fields)
|> validate_required(@submission_fields)
|> validate_grid(:grid1)

View file

@ -8,7 +8,7 @@ defmodule Microwaveprop.Terrain.TerrainProfile do
@foreign_key_type :binary_id
schema "terrain_profiles" do
belongs_to :qso, Microwaveprop.Radio.Qso
belongs_to :contact, Microwaveprop.Radio.Contact, foreign_key: :qso_id
field :sample_count, :integer
field :path_points, {:array, :map}
field :max_elevation_m, :float

View file

@ -126,10 +126,10 @@ defmodule Microwaveprop.Weather do
Enum.uniq(times)
end
def weather_for_qso(qso_params, opts \\ []) do
lat = qso_params[:lat] || qso_params.lat
lon = qso_params[:lon] || qso_params.lon
timestamp = qso_params[:timestamp] || qso_params.timestamp
def weather_for_contact(contact_params, opts \\ []) do
lat = contact_params[:lat] || contact_params.lat
lon = contact_params[:lon] || contact_params.lon
timestamp = contact_params[:timestamp] || contact_params.timestamp
radius_km = Keyword.get(opts, :radius_km, 150)
time_window_hours = Keyword.get(opts, :time_window_hours, 6)
@ -203,14 +203,14 @@ defmodule Microwaveprop.Weather do
|> Repo.exists?()
end
def hrrr_for_qso(%{pos1: nil}), do: nil
def hrrr_for_contact(%{pos1: nil}), do: nil
def hrrr_for_qso(qso) do
lat = qso.pos1["lat"]
lon = qso.pos1["lon"] || qso.pos1["lng"]
def hrrr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"] || contact.pos1["lng"]
if lat && lon do
find_nearest_hrrr(lat, lon, qso.qso_timestamp)
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
end
end
@ -245,10 +245,10 @@ defmodule Microwaveprop.Weather do
def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(qso) do
qso
|> Microwaveprop.Radio.qso_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, qso.qso_timestamp) end)
def hrrr_profiles_for_path(contact) do
contact
|> Microwaveprop.Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@ -301,14 +301,14 @@ defmodule Microwaveprop.Weather do
|> Repo.exists?()
end
def iemre_for_qso(%{pos1: nil}), do: nil
def iemre_for_contact(%{pos1: nil}), do: nil
def iemre_for_qso(qso) do
lat = qso.pos1["lat"]
lon = qso.pos1["lon"] || qso.pos1["lng"]
def iemre_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"] || contact.pos1["lng"]
if lat && lon do
find_nearest_iemre(lat, lon, qso.qso_timestamp)
find_nearest_iemre(lat, lon, contact.qso_timestamp)
end
end
@ -323,10 +323,10 @@ defmodule Microwaveprop.Weather do
def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(qso) do
qso
|> Microwaveprop.Radio.qso_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, qso.qso_timestamp) end)
def iemre_for_path(contact) do
contact
|> Microwaveprop.Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
end

View file

@ -1,4 +1,4 @@
defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
@moduledoc false
use Oban.Worker, queue: :enqueue, max_attempts: 3
@ -16,14 +16,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
@insert_batch_size 400
@doc """
Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single QSO.
Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact.
Called directly from submission flow no Oban indirection.
"""
def enqueue_for_qso(qso) do
weather_jobs = build_weather_jobs([qso])
hrrr_jobs = build_hrrr_jobs([qso])
terrain_jobs = build_terrain_jobs([qso])
iemre_jobs = build_iemre_jobs([qso])
def enqueue_for_contact(contact) do
weather_jobs = build_weather_jobs([contact])
hrrr_jobs = build_hrrr_jobs([contact])
terrain_jobs = build_terrain_jobs([contact])
iemre_jobs = build_iemre_jobs([contact])
all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs
@ -31,11 +31,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
insert_all_chunked(all_jobs)
end
qso_ids = [qso.id]
if qso.pos1, do: Radio.mark_weather_queued!(qso_ids)
if qso.pos1, do: Radio.mark_hrrr_queued!(qso_ids)
if qso.pos1 && qso.pos2, do: Radio.mark_terrain_queued!(qso_ids)
if qso.pos1, do: Radio.mark_iemre_queued!(qso_ids)
ids = [contact.id]
if contact.pos1, do: Radio.mark_weather_queued!(ids)
if contact.pos1, do: Radio.mark_hrrr_queued!(ids)
if contact.pos1 && contact.pos2, do: Radio.mark_terrain_queued!(ids)
if contact.pos1, do: Radio.mark_iemre_queued!(ids)
:ok
end
@ -51,85 +51,82 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end
defp enqueue_weather_jobs do
case Radio.unprocessed_qsos() do
case Radio.unprocessed_contacts() do
[] ->
:ok
qsos ->
Radio.backfill_distances(qsos)
contacts ->
Radio.backfill_distances(contacts)
jobs = build_weather_jobs(qsos)
jobs = build_weather_jobs(contacts)
if jobs != [] do
insert_all_chunked(jobs)
end
qso_ids = Enum.map(qsos, & &1.id)
Radio.mark_weather_queued!(qso_ids)
ids = Enum.map(contacts, & &1.id)
Radio.mark_weather_queued!(ids)
enqueue_weather_jobs()
end
end
defp enqueue_hrrr_jobs do
case Radio.unprocessed_hrrr_qsos() do
case Radio.unprocessed_hrrr_contacts() do
[] ->
:ok
qsos ->
jobs = build_hrrr_jobs(qsos)
contacts ->
jobs = build_hrrr_jobs(contacts)
if jobs != [] do
insert_all_chunked(jobs)
end
qso_ids = Enum.map(qsos, & &1.id)
Radio.mark_hrrr_queued!(qso_ids)
ids = Enum.map(contacts, & &1.id)
Radio.mark_hrrr_queued!(ids)
enqueue_hrrr_jobs()
end
end
defp enqueue_terrain_jobs do
case Radio.unprocessed_terrain_qsos() do
case Radio.unprocessed_terrain_contacts() do
[] ->
:ok
qsos ->
jobs = build_terrain_jobs(qsos)
contacts ->
jobs = build_terrain_jobs(contacts)
if jobs != [] do
insert_all_chunked(jobs)
end
qso_ids = Enum.map(qsos, & &1.id)
Radio.mark_terrain_queued!(qso_ids)
ids = Enum.map(contacts, & &1.id)
Radio.mark_terrain_queued!(ids)
# Continue until all QSOs are enqueued
enqueue_terrain_jobs()
end
end
def build_terrain_jobs(qsos) do
qsos
|> Enum.filter(fn qso -> qso.pos1 && qso.pos2 end)
|> Enum.map(fn qso ->
TerrainProfileWorker.new(%{"qso_id" => qso.id})
def build_terrain_jobs(contacts) do
contacts
|> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end)
|> Enum.map(fn contact ->
TerrainProfileWorker.new(%{"qso_id" => contact.id})
end)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
def build_weather_jobs(qsos) do
qsos
|> Enum.flat_map(&jobs_for_qso/1)
def build_weather_jobs(contacts) do
contacts
|> Enum.flat_map(&jobs_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
def build_hrrr_jobs(qsos) do
# Group all QSO points by HRRR hour, then create one batch job per hour
# instead of one job per point. This deduplicates GRIB2 downloads.
qsos
|> Enum.flat_map(&hrrr_points_for_qso/1)
def build_hrrr_jobs(contacts) do
contacts
|> Enum.flat_map(&hrrr_points_for_contact/1)
|> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end)
|> Enum.map(fn {hour, points} ->
unique_points =
@ -144,47 +141,47 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end)
end
def build_iemre_jobs(qsos) do
qsos
|> Enum.flat_map(&iemre_job_for_qso/1)
def build_iemre_jobs(contacts) do
contacts
|> Enum.flat_map(&iemre_job_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end
defp jobs_for_qso(qso) do
qso
|> Radio.qso_path_points()
defp jobs_for_contact(contact) do
contact
|> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} ->
build_asos_jobs(lat, lon, qso.qso_timestamp) ++
build_raob_jobs(lat, lon, qso.qso_timestamp)
build_asos_jobs(lat, lon, contact.qso_timestamp) ++
build_raob_jobs(lat, lon, contact.qso_timestamp)
end)
end
defp enqueue_iemre_jobs do
case Radio.unprocessed_iemre_qsos() do
case Radio.unprocessed_iemre_contacts() do
[] ->
:ok
qsos ->
jobs = build_iemre_jobs(qsos)
contacts ->
jobs = build_iemre_jobs(contacts)
if jobs != [] do
insert_all_chunked(jobs)
end
qso_ids = Enum.map(qsos, & &1.id)
Radio.mark_iemre_queued!(qso_ids)
ids = Enum.map(contacts, & &1.id)
Radio.mark_iemre_queued!(ids)
enqueue_iemre_jobs()
end
end
defp hrrr_points_for_qso(%{pos1: nil}), do: []
defp hrrr_points_for_contact(%{pos1: nil}), do: []
defp hrrr_points_for_qso(qso) do
rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp)
defp hrrr_points_for_contact(contact) do
rounded_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
qso
|> Radio.qso_path_points()
contact
|> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
@ -216,13 +213,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end)
end
defp iemre_job_for_qso(%{pos1: nil}), do: []
defp iemre_job_for_contact(%{pos1: nil}), do: []
defp iemre_job_for_qso(qso) do
date = DateTime.to_date(qso.qso_timestamp)
defp iemre_job_for_contact(contact) do
date = DateTime.to_date(contact.qso_timestamp)
qso
|> Radio.qso_path_points()
contact
|> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)

View file

@ -18,16 +18,16 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
if Terrain.has_terrain_profile?(qso_id) do
:ok
else
qso = Radio.get_qso!(qso_id)
lat1 = qso.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
lat2 = qso.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
dist_km = Decimal.to_float(qso.distance_km)
freq_ghz = Decimal.to_float(qso.band) / 1000
contact = Radio.get_contact!(qso_id)
lat1 = contact.pos1["lat"]
lon1 = contact.pos1["lon"] || contact.pos1["lng"]
lat2 = contact.pos2["lat"]
lon2 = contact.pos2["lon"] || contact.pos2["lng"]
dist_km = Decimal.to_float(contact.distance_km)
freq_ghz = Decimal.to_float(contact.band) / 1000
# Look up HRRR refractivity gradient for dynamic k-factor
k = lookup_k_factor(qso)
k = lookup_k_factor(contact)
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
{:ok, profile} ->
@ -64,8 +64,8 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
end
end
defp lookup_k_factor(qso) do
case Weather.hrrr_for_qso(qso) do
defp lookup_k_factor(contact) do
case Weather.hrrr_for_contact(contact) do
%{min_refractivity_gradient: grad} when not is_nil(grad) ->
TerrainAnalysis.k_factor(grad)

View file

@ -4,4 +4,12 @@ defmodule MicrowavepropWeb.PageController do
def home(conn, _params) do
redirect(conn, to: ~p"/map")
end
def redirect_contacts(conn, _params) do
redirect(conn, to: ~p"/contacts")
end
def redirect_contact(conn, %{"id" => id}) do
redirect(conn, to: ~p"/contacts/#{id}")
end
end

View file

@ -1,4 +1,4 @@
defmodule MicrowavepropWeb.QsoLive.Index do
defmodule MicrowavepropWeb.ContactLive.Index do
@moduledoc false
use MicrowavepropWeb, :live_view
@ -21,7 +21,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
search = Map.get(params, "search", "")
result =
Radio.list_qsos(
Radio.list_contacts(
page: page,
sort_by: String.to_existing_atom(sort_by),
sort_order: String.to_existing_atom(sort_order),
@ -44,7 +44,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")}
{:noreply, push_patch(socket, to: ~p"/contacts?search=#{search}")}
end
@impl true
@ -58,7 +58,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
params = %{sort_by: field, sort_order: new_order}
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/qsos?#{params}")}
{:noreply, push_patch(socket, to: ~p"/contacts?#{params}")}
end
defp validate_sort_field(field) when field in @sortable_fields, do: field
@ -91,7 +91,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
class="input input-bordered input-sm w-64"
/>
<button type="submit" class="btn btn-sm btn-outline">Search</button>
<.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear</.link>
<.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear</.link>
</div>
</form>
@ -120,7 +120,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
"hover:bg-base-200 cursor-pointer",
reciprocals != [] && "bg-primary/5"
]}
phx-click={JS.navigate(~p"/qsos/#{primary.id}")}
phx-click={JS.navigate(~p"/contacts/#{primary.id}")}
>
<td class="w-8 text-center">
<span
@ -144,7 +144,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<%= for recip <- reciprocals do %>
<tr
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
phx-click={JS.navigate(~p"/qsos/#{recip.id}")}
phx-click={JS.navigate(~p"/contacts/#{recip.id}")}
>
<td class="w-8 text-center text-primary"></td>
<td class="font-mono">{recip.station1}</td>
@ -166,7 +166,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<.link
:if={@page > 1}
patch={
~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
~p"/contacts?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>
@ -179,7 +179,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
<.link
:if={@page < @total_pages}
patch={
~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
~p"/contacts?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
}
class="btn btn-sm btn-outline"
>

View file

@ -1,4 +1,4 @@
defmodule MicrowavepropWeb.QsoLive.Show do
defmodule MicrowavepropWeb.ContactLive.Show do
@moduledoc false
use MicrowavepropWeb, :live_view
@ -8,17 +8,17 @@ defmodule MicrowavepropWeb.QsoLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
qso = Radio.get_qso!(id)
contact = Radio.get_contact!(id)
weather = load_weather(qso)
solar = load_solar(qso)
hrrr = Weather.hrrr_for_qso(qso)
terrain = Terrain.get_terrain_profile(qso.id)
weather = load_weather(contact)
solar = load_solar(contact)
hrrr = Weather.hrrr_for_contact(contact)
terrain = Terrain.get_terrain_profile(contact.id)
{:ok,
assign(socket,
page_title: "#{qso.station1} / #{qso.station2}",
qso: qso,
page_title: "#{contact.station1} / #{contact.station2}",
contact: contact,
surface_observations: weather.surface_observations,
soundings: weather.soundings,
solar: solar,
@ -117,12 +117,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do
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
lat = qso.pos1 && qso.pos1["lat"]
lon = qso.pos1 && (qso.pos1["lon"] || qso.pos1["lng"])
defp load_weather(contact) do
lat = contact.pos1 && contact.pos1["lat"]
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
if lat && lon do
Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp},
Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp},
radius_km: 300
)
else
@ -130,8 +130,8 @@ defmodule MicrowavepropWeb.QsoLive.Show do
end
end
defp load_solar(qso) do
qso.qso_timestamp
defp load_solar(contact) do
contact.qso_timestamp
|> DateTime.to_date()
|> Weather.get_solar_index()
end
@ -141,10 +141,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@qso.station1} / {@qso.station2}
<:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
{@contact.station1} / {@contact.station2}
<:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
<:actions>
<.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost">
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
</.link>
</:actions>
@ -154,19 +154,21 @@ defmodule MicrowavepropWeb.QsoLive.Show do
<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>
<:item :if={@qso.user_submitted} title="Source">
<:item title="Station 1">{@contact.station1}</:item>
<:item title="Station 2">{@contact.station2}</:item>
<:item title="Band">{@contact.band} MHz</:item>
<:item title="Mode">{@contact.mode}</:item>
<:item title="Grid 1">{@contact.grid1 || ""}</:item>
<:item title="Grid 2">{@contact.grid2 || ""}</:item>
<:item title="Distance">{@contact.distance_km || ""} km</:item>
<:item title="Timestamp">
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
</:item>
<:item :if={@contact.user_submitted} title="Source">
<span class="badge badge-info badge-sm">User Submitted</span>
</:item>
<:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter">
{@qso.submitter_email}
<:item :if={@contact.user_submitted && @contact.submitter_email} title="Submitter">
{@contact.submitter_email}
</:item>
</.list>

View file

@ -3,8 +3,8 @@ defmodule MicrowavepropWeb.SubmitLive do
use MicrowavepropWeb, :live_view
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@band_options [
{"1296 MHz", "1296"},
@ -21,11 +21,11 @@ defmodule MicrowavepropWeb.SubmitLive do
@impl true
def mount(_params, _session, socket) do
changeset = Radio.change_qso(%Qso{})
changeset = Radio.change_contact(%Contact{})
{:ok,
assign(socket,
page_title: "Submit QSO",
page_title: "Submit Contact",
form: to_form(changeset),
band_options: @band_options,
mode_options: @mode_options,
@ -34,10 +34,10 @@ defmodule MicrowavepropWeb.SubmitLive do
end
@impl true
def handle_event("validate", %{"qso" => qso_params}, socket) do
def handle_event("validate", %{"contact" => contact_params}, socket) do
changeset =
%Qso{}
|> Radio.change_qso(qso_params)
%Contact{}
|> Radio.change_contact(contact_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
@ -47,19 +47,19 @@ defmodule MicrowavepropWeb.SubmitLive do
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
@submission_cooldown_ms 30_000
def handle_event("save", %{"qso" => qso_params}, socket) do
def handle_event("save", %{"contact" => contact_params}, socket) do
if recently_submitted?(socket) do
{:noreply, put_flash(socket, :error, "Please wait before submitting another QSO.")}
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
else
case Radio.create_qso(qso_params) do
{:ok, qso} ->
QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
case Radio.create_contact(contact_params) do
{:ok, contact} ->
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
{:noreply,
socket
|> assign(submitted_at: System.monotonic_time(:millisecond))
|> put_flash(:info, "QSO submitted successfully!")
|> push_navigate(to: ~p"/qsos/#{qso.id}")}
|> put_flash(:info, "Contact submitted successfully!")
|> push_navigate(to: ~p"/contacts/#{contact.id}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
@ -79,11 +79,11 @@ defmodule MicrowavepropWeb.SubmitLive do
~H"""
<Layouts.app flash={@flash}>
<.header>
Submit QSO
<:subtitle>Submit a microwave QSO for propagation analysis</:subtitle>
Submit Contact
<:subtitle>Submit a microwave contact for propagation analysis</:subtitle>
</.header>
<.form for={@form} id="qso-form" phx-change="validate" phx-submit="save" class="space-y-4">
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" />
<.input
@ -133,7 +133,7 @@ defmodule MicrowavepropWeb.SubmitLive do
/>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO</.button>
<.button phx-disable-with="Submitting..." class="btn-primary">Submit Contact</.button>
</div>
</.form>
</Layouts.app>

View file

@ -21,9 +21,13 @@ defmodule MicrowavepropWeb.Router do
live "/submit", SubmitLive
live "/map", MapLive
live "/qsos", QsoLive.Index
live "/qsos/:id", QsoLive.Show
live "/contacts", ContactLive.Index
live "/contacts/:id", ContactLive.Show
live "/algo", AlgoLive
# Redirect old /qsos routes
get "/qsos", PageController, :redirect_contacts
get "/qsos/:id", PageController, :redirect_contact
end
# Other scopes may use custom stacks.

View file

@ -118,7 +118,7 @@ defmodule Mix.Tasks.PropagationTrain do
IO.puts("=" <> String.duplicate("", 50))
{ft_features, ft_targets, ft_counts} = load_qso_data()
{ft_features, ft_targets, ft_counts} = load_contact_data()
ft_n = elem(Nx.shape(ft_features), 0)
IO.puts("Loaded #{ft_n} QSO training samples:")
print_band_counts(ft_counts)
@ -247,7 +247,7 @@ defmodule Mix.Tasks.PropagationTrain do
# ── Phase 2 data: QSO-HRRR + solar + sounding ────────────────────
defp load_qso_data do
defp load_contact_data do
IO.puts(" Running QSO-HRRR-solar-sounding join query...")
sql = """
@ -326,7 +326,7 @@ defmodule Mix.Tasks.PropagationTrain do
{feature_rows, target_rows} =
percentile_rows
|> Enum.map(fn {row, percentile} -> encode_qso_row(row, percentile) end)
|> Enum.map(fn {row, percentile} -> encode_contact_row(row, percentile) end)
|> Enum.unzip()
IO.puts(" After filtering: #{length(feature_rows)} training samples")
@ -334,7 +334,7 @@ defmodule Mix.Tasks.PropagationTrain do
{Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), band_counts}
end
defp encode_qso_row(
defp encode_contact_row(
[
band_mhz,
_dist,

View file

@ -15,7 +15,7 @@ defmodule Mix.Tasks.ResetEnrichment do
import Ecto.Query
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@impl Mix.Task
@ -23,7 +23,7 @@ defmodule Mix.Tasks.ResetEnrichment do
Mix.Task.run("app.start")
{count, _} =
Qso
Contact
|> where([q], q.weather_queued == true or q.hrrr_queued == true or q.iemre_queued == true)
|> Repo.update_all(set: [weather_queued: false, hrrr_queued: false, iemre_queued: false])

View file

@ -1,7 +1,7 @@
defmodule Microwaveprop.Radio.QsoSubmissionTest do
defmodule Microwaveprop.Radio.ContactSubmissionTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
@valid_attrs %{
station1: "W5XD",
@ -16,12 +16,12 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do
describe "submission_changeset/2" do
test "valid attrs produce a valid changeset" do
changeset = Qso.submission_changeset(%Qso{}, @valid_attrs)
changeset = Contact.submission_changeset(%Contact{}, @valid_attrs)
assert changeset.valid?
end
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
changeset = Qso.submission_changeset(%Qso{}, %{})
changeset = Contact.submission_changeset(%Contact{}, %{})
errors = errors_on(changeset)
assert errors[:station1]
@ -36,59 +36,59 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do
test "rejects invalid grid1" do
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
end
test "rejects invalid grid2" do
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
end
test "accepts valid 6-char grids" do
attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"}
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert changeset.valid?
end
test "rejects invalid mode" do
attrs = Map.put(@valid_attrs, :mode, "RTTY")
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert errors_on(changeset).mode
end
test "accepts valid modes" do
for mode <- ~w(CW SSB FM FT8 FT4) do
attrs = Map.put(@valid_attrs, :mode, mode)
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert changeset.valid?, "Expected mode #{mode} to be valid"
end
end
test "rejects invalid band" do
attrs = Map.put(@valid_attrs, :band, "144")
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert errors_on(changeset).band
end
test "accepts valid microwave bands" do
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
attrs = Map.put(@valid_attrs, :band, band)
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert changeset.valid?, "Expected band #{band} to be valid"
end
end
test "rejects email without @" do
attrs = Map.put(@valid_attrs, :submitter_email, "notanemail")
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert errors_on(changeset).submitter_email
end
test "does not cast user_submitted" do
attrs = Map.put(@valid_attrs, :user_submitted, true)
changeset = Qso.submission_changeset(%Qso{}, attrs)
changeset = Contact.submission_changeset(%Contact{}, attrs)
refute Ecto.Changeset.get_change(changeset, :user_submitted)
end
end

View file

@ -1,7 +1,7 @@
defmodule Microwaveprop.Radio.QsoTest do
defmodule Microwaveprop.Radio.ContactTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
@valid_attrs %{
station1: "W1AW",
@ -18,12 +18,12 @@ defmodule Microwaveprop.Radio.QsoTest do
describe "changeset/2" do
test "valid attributes" do
changeset = Qso.changeset(%Qso{}, @valid_attrs)
changeset = Contact.changeset(%Contact{}, @valid_attrs)
assert changeset.valid?
end
test "requires station1, station2, qso_timestamp, mode, and band" do
changeset = Qso.changeset(%Qso{}, %{})
changeset = Contact.changeset(%Contact{}, %{})
assert %{
station1: ["can't be blank"],
@ -35,13 +35,13 @@ defmodule Microwaveprop.Radio.QsoTest do
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"))
changeset = Contact.changeset(%Contact{}, @valid_attrs)
assert {:ok, contact} = Repo.insert(changeset)
assert contact.station1 == "W1AW"
assert contact.station2 == "K3LR"
assert contact.mode == "SSB"
assert Decimal.equal?(contact.band, Decimal.new("144.2"))
assert Decimal.equal?(contact.distance_km, Decimal.new("623.4"))
end
end
end

View file

@ -2,9 +2,9 @@ defmodule Microwaveprop.RadioTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
defp create_qso(attrs \\ %{}) do
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
@ -18,17 +18,17 @@ defmodule Microwaveprop.RadioTest do
distance_km: Decimal.new("295")
}
{:ok, qso} =
%Qso{}
|> Qso.changeset(Map.merge(default, attrs))
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
qso
contact
end
describe "list_qsos/1" do
describe "list_contacts/1" do
test "returns empty page when no QSOs exist" do
result = Radio.list_qsos()
result = Radio.list_contacts()
assert result.entries == []
assert result.page == 1
@ -39,10 +39,10 @@ defmodule Microwaveprop.RadioTest do
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})
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_qsos()
result = Radio.list_contacts()
assert length(result.entries) == 20
assert result.page == 1
@ -57,10 +57,10 @@ defmodule Microwaveprop.RadioTest do
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})
create_contact(%{qso_timestamp: ts})
end
result = Radio.list_qsos(page: 2)
result = Radio.list_contacts(page: 2)
assert length(result.entries) == 5
assert result.page == 2
@ -68,60 +68,60 @@ defmodule Microwaveprop.RadioTest do
end
end
describe "list_qsos/1 sorting" do
describe "list_contacts/1 sorting" do
test "sorts by station1 ascending" do
create_qso(%{station1: "ZZ9ZZ"})
create_qso(%{station1: "AA1AA"})
create_contact(%{station1: "ZZ9ZZ"})
create_contact(%{station1: "AA1AA"})
result = Radio.list_qsos(sort_by: :station1, sort_order: :asc)
result = Radio.list_contacts(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"})
create_contact(%{distance_km: Decimal.new("100"), station1: "A1A"})
create_contact(%{distance_km: Decimal.new("500"), station1: "B2B"})
result = Radio.list_qsos(sort_by: :distance_km, sort_order: :desc)
result = Radio.list_contacts(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]})
early = create_contact(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
late = create_contact(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
result = Radio.list_qsos(sort_by: :bogus)
result = Radio.list_contacts(sort_by: :bogus)
ids = Enum.map(result.entries, & &1.id)
assert ids == [late.id, early.id]
end
end
describe "unprocessed_qsos/1" do
describe "unprocessed_contacts/1" do
test "returns QSOs where weather_queued is false and pos1 is not nil" do
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
results = Radio.unprocessed_qsos()
results = Radio.unprocessed_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
end
test "excludes QSOs already marked as weather_queued" do
q = create_qso()
q = create_contact()
Radio.mark_weather_queued!([q.id])
assert Radio.unprocessed_qsos() == []
assert Radio.unprocessed_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_qsos()
results = Radio.unprocessed_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
@ -129,32 +129,32 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_qso(%{qso_timestamp: ts})
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_qsos(3)) == 3
assert length(Radio.unprocessed_contacts(3)) == 3
end
end
describe "mark_weather_queued!/1" do
test "sets weather_queued to true for given IDs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_weather_queued!([q1.id, q2.id])
assert Repo.get!(Qso, q1.id).weather_queued == true
assert Repo.get!(Qso, q2.id).weather_queued == true
assert Repo.get!(Contact, q1.id).weather_queued == true
assert Repo.get!(Contact, q2.id).weather_queued == true
end
test "does not affect other QSOs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_weather_queued!([q1.id])
assert Repo.get!(Qso, q1.id).weather_queued == true
assert Repo.get!(Qso, q2.id).weather_queued == false
assert Repo.get!(Contact, q1.id).weather_queued == true
assert Repo.get!(Contact, q2.id).weather_queued == false
end
test "handles empty list" do
@ -176,72 +176,72 @@ defmodule Microwaveprop.RadioTest do
describe "backfill_distances/1" do
test "calculates and stores distance for QSOs missing distance_km" do
qso = create_qso(%{distance_km: nil})
assert is_nil(qso.distance_km)
contact = create_contact(%{distance_km: nil})
assert is_nil(contact.distance_km)
Radio.backfill_distances([qso])
Radio.backfill_distances([contact])
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
end
test "skips QSOs that already have a distance" do
qso = create_qso(%{distance_km: Decimal.new("100")})
contact = create_contact(%{distance_km: Decimal.new("100")})
Radio.backfill_distances([qso])
Radio.backfill_distances([contact])
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.distance_km == Decimal.new("100")
end
test "skips QSOs missing pos2" do
qso = create_qso(%{distance_km: nil, pos2: nil})
contact = create_contact(%{distance_km: nil, pos2: nil})
Radio.backfill_distances([qso])
Radio.backfill_distances([contact])
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert is_nil(updated.distance_km)
end
end
describe "get_qso!/1" do
describe "get_contact!/1" do
test "returns a QSO by ID" do
qso = create_qso()
contact = create_contact()
found = Radio.get_qso!(qso.id)
assert found.id == qso.id
found = Radio.get_contact!(contact.id)
assert found.id == contact.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())
Radio.get_contact!(Ecto.UUID.generate())
end
end
end
describe "unprocessed_hrrr_qsos/1" do
describe "unprocessed_hrrr_contacts/1" do
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
results = Radio.unprocessed_hrrr_qsos()
results = Radio.unprocessed_hrrr_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
end
test "excludes QSOs already marked as hrrr_queued" do
q = create_qso()
q = create_contact()
Radio.mark_hrrr_queued!([q.id])
assert Radio.unprocessed_hrrr_qsos() == []
assert Radio.unprocessed_hrrr_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_hrrr_qsos()
results = Radio.unprocessed_hrrr_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
@ -249,32 +249,32 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_qso(%{qso_timestamp: ts})
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_hrrr_qsos(3)) == 3
assert length(Radio.unprocessed_hrrr_contacts(3)) == 3
end
end
describe "mark_hrrr_queued!/1" do
test "sets hrrr_queued to true for given IDs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_hrrr_queued!([q1.id, q2.id])
assert Repo.get!(Qso, q1.id).hrrr_queued == true
assert Repo.get!(Qso, q2.id).hrrr_queued == true
assert Repo.get!(Contact, q1.id).hrrr_queued == true
assert Repo.get!(Contact, q2.id).hrrr_queued == true
end
test "does not affect other QSOs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_hrrr_queued!([q1.id])
assert Repo.get!(Qso, q1.id).hrrr_queued == true
assert Repo.get!(Qso, q2.id).hrrr_queued == false
assert Repo.get!(Contact, q1.id).hrrr_queued == true
assert Repo.get!(Contact, q2.id).hrrr_queued == false
end
test "handles empty list" do
@ -282,30 +282,30 @@ defmodule Microwaveprop.RadioTest do
end
end
describe "unprocessed_terrain_qsos/1" do
describe "unprocessed_terrain_contacts/1" do
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_qso(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
_q3 = create_qso(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
_q2 = create_contact(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
_q3 = create_contact(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
results = Radio.unprocessed_terrain_qsos()
results = Radio.unprocessed_terrain_contacts()
ids = Enum.map(results, & &1.id)
assert q1.id in ids
assert length(ids) == 1
end
test "excludes QSOs already marked as terrain_queued" do
q = create_qso()
q = create_contact()
Radio.mark_terrain_queued!([q.id])
assert Radio.unprocessed_terrain_qsos() == []
assert Radio.unprocessed_terrain_contacts() == []
end
test "orders by qso_timestamp ascending" do
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
results = Radio.unprocessed_terrain_qsos()
results = Radio.unprocessed_terrain_contacts()
ids = Enum.map(results, & &1.id)
assert ids == [q_early.id, q_late.id]
end
@ -313,32 +313,32 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_qso(%{qso_timestamp: ts})
create_contact(%{qso_timestamp: ts})
end
assert length(Radio.unprocessed_terrain_qsos(3)) == 3
assert length(Radio.unprocessed_terrain_contacts(3)) == 3
end
end
describe "mark_terrain_queued!/1" do
test "sets terrain_queued to true for given IDs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id, q2.id])
assert Repo.get!(Qso, q1.id).terrain_queued == true
assert Repo.get!(Qso, q2.id).terrain_queued == true
assert Repo.get!(Contact, q1.id).terrain_queued == true
assert Repo.get!(Contact, q2.id).terrain_queued == true
end
test "does not affect other QSOs" do
q1 = create_qso(%{station1: "A1A"})
q2 = create_qso(%{station1: "B2B"})
q1 = create_contact(%{station1: "A1A"})
q2 = create_contact(%{station1: "B2B"})
Radio.mark_terrain_queued!([q1.id])
assert Repo.get!(Qso, q1.id).terrain_queued == true
assert Repo.get!(Qso, q2.id).terrain_queued == false
assert Repo.get!(Contact, q1.id).terrain_queued == true
assert Repo.get!(Contact, q2.id).terrain_queued == false
end
test "handles empty list" do
@ -346,15 +346,15 @@ defmodule Microwaveprop.RadioTest do
end
end
describe "qso_path_points/1" do
describe "contact_path_points/1" do
test "returns pos1, midpoint, and pos2 when both positions exist" do
qso =
create_qso(%{
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
points = Radio.qso_path_points(qso)
points = Radio.contact_path_points(contact)
assert length(points) == 3
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
@ -367,44 +367,44 @@ defmodule Microwaveprop.RadioTest do
end
test "returns only pos1 when pos2 is nil" do
qso = create_qso(%{pos2: nil})
contact = create_contact(%{pos2: nil})
points = Radio.qso_path_points(qso)
points = Radio.contact_path_points(contact)
assert points == [{32.9, -97.0}]
end
test "returns empty list when pos1 is nil" do
qso = create_qso(%{pos1: nil})
contact = create_contact(%{pos1: nil})
assert Radio.qso_path_points(qso) == []
assert Radio.contact_path_points(contact) == []
end
test "handles lng key in position maps" do
qso =
create_qso(%{
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lng" => -97.0},
pos2: %{"lat" => 30.3, "lng" => -97.7}
})
points = Radio.qso_path_points(qso)
points = Radio.contact_path_points(contact)
assert length(points) == 3
end
end
describe "change_qso/2" do
describe "change_contact/2" do
test "returns a submission changeset" do
changeset = Radio.change_qso(%Qso{})
changeset = Radio.change_contact(%Contact{})
assert %Ecto.Changeset{} = changeset
end
test "accepts attrs" do
changeset = Radio.change_qso(%Qso{}, %{station1: "W5XD"})
changeset = Radio.change_contact(%Contact{}, %{station1: "W5XD"})
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
end
end
describe "create_qso/1" do
describe "create_contact/1" do
@valid_submission %{
station1: "W5XD",
station2: "K5TR",
@ -417,38 +417,38 @@ defmodule Microwaveprop.RadioTest do
}
test "creates QSO with computed positions and distance" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
assert {:ok, contact} = Radio.create_contact(@valid_submission)
assert qso.station1 == "W5XD"
assert qso.user_submitted == true
assert qso.pos1["lat"]
assert qso.pos1["lon"]
assert qso.pos2["lat"]
assert qso.pos2["lon"]
assert qso.distance_km
assert contact.station1 == "W5XD"
assert contact.user_submitted == true
assert contact.pos1["lat"]
assert contact.pos1["lon"]
assert contact.pos2["lat"]
assert contact.pos2["lon"]
assert contact.distance_km
end
test "computes correct position from grid" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
assert {:ok, contact} = Radio.create_contact(@valid_submission)
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
assert_in_delta qso.pos1["lat"], 32.5, 0.1
assert_in_delta qso.pos1["lon"], -97.0, 0.1
assert_in_delta contact.pos1["lat"], 32.5, 0.1
assert_in_delta contact.pos1["lon"], -97.0, 0.1
end
test "sets user_submitted to true" do
assert {:ok, qso} = Radio.create_qso(@valid_submission)
assert qso.user_submitted == true
assert {:ok, contact} = Radio.create_contact(@valid_submission)
assert contact.user_submitted == true
end
test "returns error changeset for invalid data" do
assert {:error, %Ecto.Changeset{}} = Radio.create_qso(%{})
assert {:error, %Ecto.Changeset{}} = Radio.create_contact(%{})
end
test "returns error changeset for invalid grid" do
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
assert {:error, changeset} = Radio.create_qso(attrs)
assert {:error, changeset} = Radio.create_contact(attrs)
assert errors_on(changeset).grid1
end
end

View file

@ -1,7 +1,7 @@
defmodule Microwaveprop.Terrain.TerrainProfileTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain.TerrainProfile
@qso_attrs %{
@ -14,21 +14,21 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
pos2: %{"lat" => 30.3, "lon" => -97.7}
}
defp create_qso do
{:ok, qso} =
%Qso{}
|> Qso.changeset(@qso_attrs)
defp create_contact do
{:ok, contact} =
%Contact{}
|> Contact.changeset(@qso_attrs)
|> Repo.insert()
qso
contact
end
describe "changeset/2" do
test "valid with required fields" do
qso = create_qso()
contact = create_contact()
attrs = %{
qso_id: qso.id,
qso_id: contact.id,
sample_count: 65,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
verdict: "CLEAR"
@ -50,10 +50,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
end
test "valid with all optional fields" do
qso = create_qso()
contact = create_contact()
attrs = %{
qso_id: qso.id,
qso_id: contact.id,
sample_count: 65,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
verdict: "BLOCKED",
@ -69,10 +69,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
end
test "enforces unique qso_id constraint on insert" do
qso = create_qso()
contact = create_contact()
attrs = %{
qso_id: qso.id,
qso_id: contact.id,
sample_count: 65,
path_points: [],
verdict: "CLEAR"

View file

@ -1,7 +1,7 @@
defmodule Microwaveprop.TerrainTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain
@qso_attrs %{
@ -14,18 +14,18 @@ defmodule Microwaveprop.TerrainTest do
pos2: %{"lat" => 30.3, "lon" => -97.7}
}
defp create_qso do
{:ok, qso} =
%Qso{}
|> Qso.changeset(@qso_attrs)
defp create_contact do
{:ok, contact} =
%Contact{}
|> Contact.changeset(@qso_attrs)
|> Repo.insert()
qso
contact
end
defp terrain_attrs(qso) do
defp terrain_attrs(contact) do
%{
qso_id: qso.id,
qso_id: contact.id,
sample_count: 65,
path_points: [
%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0},
@ -42,18 +42,18 @@ defmodule Microwaveprop.TerrainTest do
describe "upsert_terrain_profile/1" do
test "inserts a new terrain profile" do
qso = create_qso()
attrs = terrain_attrs(qso)
contact = create_contact()
attrs = terrain_attrs(contact)
assert {:ok, profile} = Terrain.upsert_terrain_profile(attrs)
assert profile.qso_id == qso.id
assert profile.qso_id == contact.id
assert profile.sample_count == 65
assert profile.verdict == "CLEAR"
end
test "upserts on conflict (same qso_id)" do
qso = create_qso()
attrs = terrain_attrs(qso)
contact = create_contact()
attrs = terrain_attrs(contact)
{:ok, _} = Terrain.upsert_terrain_profile(attrs)
@ -67,30 +67,30 @@ defmodule Microwaveprop.TerrainTest do
describe "get_terrain_profile/1" do
test "returns profile for a QSO" do
qso = create_qso()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
contact = create_contact()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact))
profile = Terrain.get_terrain_profile(qso.id)
assert profile.qso_id == qso.id
profile = Terrain.get_terrain_profile(contact.id)
assert profile.qso_id == contact.id
end
test "returns nil when no profile exists" do
qso = create_qso()
assert Terrain.get_terrain_profile(qso.id) == nil
contact = create_contact()
assert Terrain.get_terrain_profile(contact.id) == nil
end
end
describe "has_terrain_profile?/1" do
test "returns true when profile exists" do
qso = create_qso()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
contact = create_contact()
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact))
assert Terrain.has_terrain_profile?(qso.id)
assert Terrain.has_terrain_profile?(contact.id)
end
test "returns false when no profile exists" do
qso = create_qso()
refute Terrain.has_terrain_profile?(qso.id)
contact = create_contact()
refute Terrain.has_terrain_profile?(contact.id)
end
end
end

View file

@ -113,7 +113,7 @@ defmodule Microwaveprop.WeatherTest do
end
end
describe "weather_for_qso/2" do
describe "weather_for_contact/2" do
test "returns nearby surface observations within time window with station preloaded" do
{:ok, station} = Weather.find_or_create_station(@station_attrs)
@ -125,7 +125,7 @@ defmodule Microwaveprop.WeatherTest do
# QSO near the station in space and time
result =
Weather.weather_for_qso(
Weather.weather_for_contact(
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]},
radius_km: 100,
time_window_hours: 3
@ -149,7 +149,7 @@ defmodule Microwaveprop.WeatherTest do
})
result =
Weather.weather_for_qso(
Weather.weather_for_contact(
%{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]},
radius_km: 100,
time_window_hours: 6
@ -170,7 +170,7 @@ defmodule Microwaveprop.WeatherTest do
# QSO far away
result =
Weather.weather_for_qso(
Weather.weather_for_contact(
%{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]},
radius_km: 100,
time_window_hours: 3
@ -190,7 +190,7 @@ defmodule Microwaveprop.WeatherTest do
# QSO much later
result =
Weather.weather_for_qso(
Weather.weather_for_contact(
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]},
radius_km: 100,
time_window_hours: 3
@ -482,31 +482,31 @@ defmodule Microwaveprop.WeatherTest do
end
end
describe "hrrr_for_qso/1" do
describe "hrrr_for_contact/1" do
test "returns nearest HRRR profile for a QSO" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
qso = %{
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profile = Weather.hrrr_for_qso(qso)
profile = Weather.hrrr_for_contact(contact)
assert profile
assert profile.lat == 32.90
end
test "returns nil when no HRRR profiles exist nearby" do
qso = %{
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.hrrr_for_qso(qso) == nil
assert Weather.hrrr_for_contact(contact) == nil
end
test "returns nil when QSO has no pos1" do
assert Weather.hrrr_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
assert Weather.hrrr_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
end
end
@ -590,36 +590,36 @@ defmodule Microwaveprop.WeatherTest do
lon: -97.70
})
qso = %{
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: %{"lat" => 30.31, "lon" => -97.71},
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(qso)
profiles = Weather.hrrr_profiles_for_path(contact)
assert length(profiles) >= 2
end
test "returns empty list when no HRRR profiles exist nearby" do
qso = %{
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.hrrr_profiles_for_path(qso) == []
assert Weather.hrrr_profiles_for_path(contact) == []
end
test "returns profiles for pos1 only when pos2 is nil" do
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
qso = %{
contact = %{
pos1: %{"lat" => 32.91, "lon" => -97.05},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:30:00Z]
}
profiles = Weather.hrrr_profiles_for_path(qso)
profiles = Weather.hrrr_profiles_for_path(contact)
assert length(profiles) == 1
end
end
@ -636,36 +636,36 @@ defmodule Microwaveprop.WeatherTest do
hourly: [%{"hour" => 0, "p01m_mm" => 1.0}]
})
qso = %{
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(qso)
observations = Weather.iemre_for_path(contact)
assert length(observations) >= 2
end
test "returns empty list when no observations exist" do
qso = %{
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
pos2: %{"lat" => 41.0, "lon" => -81.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.iemre_for_path(qso) == []
assert Weather.iemre_for_path(contact) == []
end
test "returns observation for pos1 only when pos2 is nil" do
Weather.upsert_iemre_observation(@iemre_attrs)
qso = %{
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: nil,
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
observations = Weather.iemre_for_path(qso)
observations = Weather.iemre_for_path(contact)
assert length(observations) == 1
end
end
@ -684,31 +684,31 @@ defmodule Microwaveprop.WeatherTest do
end
end
describe "iemre_for_qso/1" do
describe "iemre_for_contact/1" do
test "returns IEMRE observation matching QSO pos1 and date" do
Weather.upsert_iemre_observation(@iemre_attrs)
qso = %{
contact = %{
pos1: %{"lat" => 32.9, "lon" => -97.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
obs = Weather.iemre_for_qso(qso)
obs = Weather.iemre_for_contact(contact)
assert obs
assert obs.lat == 32.875
end
test "returns nil when no IEMRE observation exists" do
qso = %{
contact = %{
pos1: %{"lat" => 40.0, "lon" => -80.0},
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
assert Weather.iemre_for_qso(qso) == nil
assert Weather.iemre_for_contact(contact) == nil
end
test "returns nil when QSO has no pos1" do
assert Weather.iemre_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
assert Weather.iemre_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
end
end
end

View file

@ -1,12 +1,12 @@
defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
@qso_attrs %{
station1: "W5XD",
@ -21,13 +21,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
distance_km: Decimal.new("295")
}
defp create_qso(attrs \\ %{}) do
{:ok, qso} =
%Qso{}
|> Qso.changeset(Map.merge(@qso_attrs, attrs))
defp create_contact(attrs \\ %{}) do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(@qso_attrs, attrs))
|> Repo.insert()
qso
contact
end
defp create_asos_station(code, lat, lon) do
@ -59,9 +59,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
describe "build_weather_jobs/1" do
test "builds ASOS jobs for nearby stations" do
station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso()
contact = create_contact()
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
asos_jobs =
Enum.filter(jobs, fn j ->
@ -79,9 +79,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "builds RAOB jobs for nearby sounding stations" do
station = create_sounding_station("FWD", 32.83, -97.30)
qso = create_qso()
contact = create_contact()
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
raob_jobs =
Enum.filter(jobs, fn j ->
@ -100,10 +100,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
_station = create_asos_station("KDFW", 32.90, -97.04)
# Two QSOs at the exact same time produce identical args → deduplicated
q1 = create_qso(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
q2 = create_qso(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
q1 = create_contact(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
q2 = create_contact(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([q1, q2])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([q1, q2])
asos_jobs =
Enum.filter(jobs, fn j ->
@ -116,31 +116,31 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "builds ASOS jobs for stations near pos2 and midpoint" do
# Station near pos2 (30.3, -97.7) but NOT near pos1 (32.9, -97.0)
station_near_pos2 = create_asos_station("KAUS", 30.20, -97.68)
qso = create_qso()
contact = create_contact()
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
station_ids = Enum.map(jobs, & &1.changes.args["station_id"])
assert station_near_pos2.id in station_ids
end
test "returns empty list when no stations nearby" do
qso = create_qso(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil})
contact = create_contact(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil})
assert QsoWeatherEnqueueWorker.build_weather_jobs([qso]) == []
assert ContactWeatherEnqueueWorker.build_weather_jobs([contact]) == []
end
test "skips ASOS job when surface observations already exist for that station and time window" do
station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso()
contact = create_contact()
# Insert a surface observation within the time window (qso ± 2 hours)
# Insert a surface observation within the time window (contact ± 2 hours)
Weather.upsert_surface_observation(station, %{
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 75.0
})
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
asos_jobs =
Enum.filter(jobs, fn j ->
@ -152,10 +152,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "skips RAOB job when sounding already exists for that station and time" do
station = create_sounding_station("FWD", 32.83, -97.30)
qso = create_qso()
contact = create_contact()
# Insert soundings for both bracketing times (00Z and 12Z on 2026-03-28)
for time <- Weather.sounding_times_around(qso.qso_timestamp) do
for time <- Weather.sounding_times_around(contact.qso_timestamp) do
Weather.upsert_sounding(station, %{
observed_at: time,
profile: [%{"pres" => 1013.0, "tmpc" => 25.0}],
@ -163,7 +163,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
})
end
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
raob_jobs =
Enum.filter(jobs, fn j ->
@ -207,58 +207,58 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "enqueues weather jobs and marks QSOs as queued" do
_station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso()
contact = create_contact()
assert qso.weather_queued == false
assert contact.weather_queued == false
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.weather_queued == true
end
test "returns :ok when no unprocessed QSOs exist" do
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
end
test "backfills distance_km for QSOs missing it" do
_station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso(%{distance_km: nil})
contact = create_contact(%{distance_km: nil})
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.distance_km
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
end
test "skips QSOs without positions" do
_station = create_asos_station("KDFW", 32.90, -97.04)
_qso = create_qso(%{pos1: nil})
_qso = create_contact(%{pos1: nil})
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
# QSO without pos1 should not be marked as queued
all_qsos = Repo.all(Qso)
all_qsos = Repo.all(Contact)
refute Enum.any?(all_qsos, & &1.weather_queued)
end
test "enqueues HRRR jobs and marks QSOs as hrrr_queued" do
qso = create_qso()
assert qso.hrrr_queued == false
contact = create_contact()
assert contact.hrrr_queued == false
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.hrrr_queued == true
end
end
describe "build_hrrr_jobs/1" do
test "batches all path points into one job per HRRR hour" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
# All 3 path points share the same HRRR hour → 1 batch job
assert length(jobs) == 1
@ -271,9 +271,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "batch contains single point when pos2 is nil" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
assert length(jobs) == 1
job = hd(jobs)
@ -283,9 +283,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "rounds valid_time to nearest hour" do
qso = create_qso(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil})
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
job = hd(jobs)
assert job.changes.args["valid_time"] == "2026-03-28T19:00:00Z"
@ -293,7 +293,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "deduplicates jobs across QSOs with identical path points" do
q1 =
create_qso(%{
create_contact(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 18:10:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
@ -301,14 +301,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
})
q2 =
create_qso(%{
create_contact(%{
station1: "A2",
qso_timestamp: ~U[2026-03-28 18:20:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([q1, q2])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([q1, q2])
# Both round to same hour and same location → 1 job
assert length(jobs) == 1
@ -316,26 +316,26 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "deduplicates path points that round to the same HRRR grid cell" do
# pos1 and pos2 only 0.001° apart — all 3 points round to same HRRR cell
qso =
create_qso(%{
contact =
create_contact(%{
pos1: %{"lat" => 32.901, "lon" => -97.041},
pos2: %{"lat" => 32.902, "lon" => -97.042}
})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
# All 3 path points round to {32.90, -97.04} → 1 job
assert length(jobs) == 1
end
test "skips QSOs without pos1" do
qso = create_qso(%{pos1: nil})
contact = create_contact(%{pos1: nil})
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
assert ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) == []
end
test "excludes existing points from batch" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
# Insert a profile at the pos1 rounded grid point only
Weather.upsert_hrrr_profile(%{
@ -351,7 +351,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
surface_pressure_mb: 1013.0
})
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
# 1 batch job, but pos1 excluded from points list
assert length(jobs) == 1
@ -364,19 +364,19 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
describe "build_terrain_jobs/1" do
test "builds one terrain job per QSO" do
qso = create_qso()
contact = create_contact()
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact])
assert length(jobs) == 1
job = hd(jobs)
assert job.changes.args["qso_id"] == qso.id
assert job.changes.args["qso_id"] == contact.id
end
test "deduplicates by qso_id" do
qso = create_qso()
contact = create_contact()
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso, qso])
jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact, contact])
assert length(jobs) == 1
end
end
@ -410,30 +410,30 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "enqueues terrain jobs and marks QSOs as terrain_queued" do
qso = create_qso()
assert qso.terrain_queued == false
contact = create_contact()
assert contact.terrain_queued == false
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.terrain_queued == true
end
test "skips terrain for QSOs missing pos2" do
_qso = create_qso(%{pos2: nil})
_qso = create_contact(%{pos2: nil})
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
all_qsos = Repo.all(Qso)
all_qsos = Repo.all(Contact)
refute Enum.any?(all_qsos, & &1.terrain_queued)
end
end
describe "build_iemre_jobs/1" do
test "builds IEMRE jobs for all path points (pos1, midpoint, pos2)" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
# pos1, midpoint, pos2 may have distinct IEMRE grid cells
assert length(jobs) >= 2
@ -442,9 +442,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "builds only one IEMRE job when pos2 is nil" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
assert length(jobs) == 1
job = hd(jobs)
@ -455,7 +455,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "deduplicates jobs across QSOs with identical path points" do
q1 =
create_qso(%{
create_contact(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 18:10:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
@ -463,14 +463,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
})
q2 =
create_qso(%{
create_contact(%{
station1: "A2",
qso_timestamp: ~U[2026-03-28 20:20:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
# Same location and same date → 1 job
assert length(jobs) == 1
@ -478,7 +478,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "creates separate jobs for different dates" do
q1 =
create_qso(%{
create_contact(%{
station1: "A1",
qso_timestamp: ~U[2026-03-28 23:00:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
@ -486,24 +486,24 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
})
q2 =
create_qso(%{
create_contact(%{
station1: "A2",
qso_timestamp: ~U[2026-03-29 01:00:00Z],
pos1: %{"lat" => 32.90, "lon" => -97.04},
pos2: nil
})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
assert length(jobs) == 2
end
test "skips QSOs without pos1" do
qso = create_qso(%{pos1: nil})
assert QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) == []
contact = create_contact(%{pos1: nil})
assert ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) == []
end
test "skips only path points where IEMRE observation already exists" do
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
# Insert an IEMRE observation at the pos1 rounded grid point only
Weather.upsert_iemre_observation(%{
@ -513,7 +513,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
hourly: [%{"hour" => 0, "p01m_mm" => 0.0}]
})
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
# pos1 skipped, midpoint + pos2 still need fetching
assert length(jobs) >= 1
@ -551,17 +551,17 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
end
test "enqueues IEMRE jobs and marks QSOs as iemre_queued" do
qso = create_qso()
assert qso.iemre_queued == false
contact = create_contact()
assert contact.iemre_queued == false
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.iemre_queued == true
end
end
describe "enqueue_for_qso/1" do
describe "enqueue_for_contact/1" do
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
@ -591,16 +591,16 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "enqueues all enrichment jobs and marks QSO as fully queued" do
_station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso()
contact = create_contact()
assert qso.weather_queued == false
assert qso.hrrr_queued == false
assert qso.terrain_queued == false
assert qso.iemre_queued == false
assert contact.weather_queued == false
assert contact.hrrr_queued == false
assert contact.terrain_queued == false
assert contact.iemre_queued == false
assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.weather_queued == true
assert updated.hrrr_queued == true
assert updated.terrain_queued == true
@ -609,11 +609,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
test "works for QSO without pos2 (marks terrain_queued false)" do
_station = create_asos_station("KDFW", 32.90, -97.04)
qso = create_qso(%{pos2: nil})
contact = create_contact(%{pos2: nil})
assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.weather_queued == true
assert updated.hrrr_queued == true
# terrain requires pos2, so it can't be queued

View file

@ -1,7 +1,7 @@
defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Workers.TerrainProfileWorker
@ -17,13 +17,13 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
distance_km: Decimal.new("295")
}
defp create_qso(attrs \\ %{}) do
{:ok, qso} =
%Qso{}
|> Qso.changeset(Map.merge(@qso_attrs, attrs))
defp create_contact(attrs \\ %{}) do
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(@qso_attrs, attrs))
|> Repo.insert()
qso
contact
end
defp stub_elevation_api do
@ -38,15 +38,15 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
describe "perform/1" do
test "fetches elevation and stores terrain profile" do
stub_elevation_api()
qso = create_qso()
contact = create_contact()
refute Terrain.has_terrain_profile?(qso.id)
refute Terrain.has_terrain_profile?(contact.id)
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
assert Terrain.has_terrain_profile?(qso.id)
profile = Terrain.get_terrain_profile(qso.id)
assert Terrain.has_terrain_profile?(contact.id)
profile = Terrain.get_terrain_profile(contact.id)
assert profile.sample_count == 65
assert profile.verdict in ["CLEAR", "FRESNEL_MINOR", "FRESNEL_PARTIAL", "BLOCKED"]
assert is_list(profile.path_points)
@ -54,17 +54,17 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
test "skips if terrain profile already exists" do
stub_elevation_api()
qso = create_qso()
contact = create_contact()
{:ok, _} =
Terrain.upsert_terrain_profile(%{
qso_id: qso.id,
qso_id: contact.id,
sample_count: 65,
path_points: [],
verdict: "CLEAR"
})
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
end
@ -73,22 +73,22 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
Plug.Conn.send_resp(conn, 500, "error")
end)
qso = create_qso()
contact = create_contact()
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
assert {:error, _} = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
refute Terrain.has_terrain_profile?(qso.id)
refute Terrain.has_terrain_profile?(contact.id)
end
test "uses correct frequency from band" do
stub_elevation_api()
qso = create_qso(%{band: Decimal.new("10368")})
contact = create_contact(%{band: Decimal.new("10368")})
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
profile = Terrain.get_terrain_profile(qso.id)
profile = Terrain.get_terrain_profile(contact.id)
assert profile.verdict
end
end

View file

@ -1,12 +1,12 @@
defmodule MicrowavepropWeb.QsoLiveTest do
defmodule MicrowavepropWeb.ContactLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
defp create_qso(attrs \\ %{}) do
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
@ -20,24 +20,24 @@ defmodule MicrowavepropWeb.QsoLiveTest do
distance_km: Decimal.new("295")
}
{:ok, qso} =
%Qso{}
|> Qso.changeset(Map.merge(default, attrs))
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
qso
contact
end
describe "Index" do
test "renders page with QSOs heading", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/qsos")
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "QSOs"
end
test "table shows QSO data", %{conn: conn} do
create_qso()
create_contact()
{:ok, _lv, html} = live(conn, ~p"/qsos")
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5XD"
assert html =~ "K5TR"
assert html =~ "CW"
@ -45,17 +45,17 @@ defmodule MicrowavepropWeb.QsoLiveTest do
end
test "row links to detail page", %{conn: conn} do
qso = create_qso()
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/qsos")
assert html =~ ~p"/qsos/#{qso.id}"
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ ~p"/contacts/#{contact.id}"
end
test "sort params order the table", %{conn: conn} do
create_qso(%{station1: "ZZ9ZZ"})
create_qso(%{station1: "AA1AA"})
create_contact(%{station1: "ZZ9ZZ"})
create_contact(%{station1: "AA1AA"})
{:ok, _lv, html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc")
{:ok, _lv, html} = live(conn, ~p"/contacts?sort_by=station1&sort_order=asc")
# AA1AA should appear before ZZ9ZZ
aa_pos = html |> :binary.match("AA1AA") |> elem(0)
@ -66,22 +66,22 @@ defmodule MicrowavepropWeb.QsoLiveTest do
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})
create_contact(%{qso_timestamp: ts})
end
{:ok, _lv, html} = live(conn, ~p"/qsos")
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Page 1 of 2"
{:ok, _lv, html2} = live(conn, ~p"/qsos?page=2")
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
assert html2 =~ "Page 2 of 2"
end
end
describe "Show" do
test "renders QSO detail fields", %{conn: conn} do
qso = create_qso()
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
assert html =~ "K5TR"
assert html =~ "CW"
@ -91,38 +91,38 @@ defmodule MicrowavepropWeb.QsoLiveTest do
end
test "shows surface observations section", %{conn: conn} do
qso = create_qso()
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.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}")
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.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}")
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.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}")
contact = create_contact()
{:ok, lv, html} = live(conn, ~p"/contacts/#{contact.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
assert render(lv) =~ contact.station1
end
test "raises for bad UUID", %{conn: conn} do
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/qsos/#{Ecto.UUID.generate()}")
live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
end
end
end

View file

@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrClient
@ -33,7 +33,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
describe "mount" do
test "renders the submission form", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/submit")
assert html =~ "Submit QSO"
assert html =~ "Submit Contact"
assert html =~ "Station 1"
assert html =~ "Station 2"
assert html =~ "Grid 1"
@ -49,7 +49,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
html =
lv
|> form("#qso-form", qso: %{station1: ""})
|> form("#contact-form", contact: %{station1: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
@ -61,8 +61,8 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
{:ok, lv, _html} = live(conn, ~p"/submit")
lv
|> form("#qso-form",
qso: %{
|> form("#contact-form",
contact: %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: "2026-03-28T18:00",
@ -75,12 +75,12 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
)
|> render_submit()
qso = Repo.one!(Qso)
assert qso.station1 == "W5XD"
assert qso.user_submitted == true
assert qso.pos1["lat"]
contact = Repo.one!(Contact)
assert contact.station1 == "W5XD"
assert contact.user_submitted == true
assert contact.pos1["lat"]
assert_redirect(lv, ~p"/qsos/#{qso.id}")
assert_redirect(lv, ~p"/contacts/#{contact.id}")
end
test "shows errors on invalid submit", %{conn: conn} do
@ -88,7 +88,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
html =
lv
|> form("#qso-form", qso: %{station1: ""})
|> form("#contact-form", contact: %{station1: ""})
|> render_submit()
assert html =~ "can&#39;t be blank"

View file

@ -1,7 +1,7 @@
defmodule Mix.Tasks.ResetEnrichmentTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Radio.Contact
alias Mix.Tasks.ResetEnrichment
defp create_queued_qso do
@ -20,41 +20,41 @@ defmodule Mix.Tasks.ResetEnrichmentTest do
distance_km: Decimal.new("295")
}
{:ok, qso} =
%Qso{}
|> Qso.changeset(default)
{:ok, contact} =
%Contact{}
|> Contact.changeset(default)
|> Repo.insert()
# Set all queued flags to true (not in changeset cast)
Qso
|> where([q], q.id == ^qso.id)
Contact
|> where([q], q.id == ^contact.id)
|> Repo.update_all(set: [weather_queued: true, hrrr_queued: true, iemre_queued: true, terrain_queued: true])
Repo.get!(Qso, qso.id)
Repo.get!(Contact, contact.id)
end
describe "run/1" do
test "resets weather_queued, hrrr_queued, and iemre_queued to false" do
qso = create_queued_qso()
contact = create_queued_qso()
assert qso.weather_queued == true
assert qso.hrrr_queued == true
assert qso.iemre_queued == true
assert contact.weather_queued == true
assert contact.hrrr_queued == true
assert contact.iemre_queued == true
ResetEnrichment.run([])
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.weather_queued == false
assert updated.hrrr_queued == false
assert updated.iemre_queued == false
end
test "does not reset terrain_queued" do
qso = create_queued_qso()
contact = create_queued_qso()
ResetEnrichment.run([])
updated = Repo.get!(Qso, qso.id)
updated = Repo.get!(Contact, contact.id)
assert updated.terrain_queued == true
end