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, {Oban.Plugins.Cron,
crontab: [ crontab: [
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}, {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}, {"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker},
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker}, {"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker} {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}
]} ]}

View file

@ -79,7 +79,7 @@ config :microwaveprop, Oban,
crontab: [ crontab: [
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}, {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}, {"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 import Ecto.Query
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Radio.Qso
alias Microwaveprop.Repo alias Microwaveprop.Repo
@per_page 20 @per_page 20
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a @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) page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page offset = (page - 1) * @per_page
search = Keyword.get(opts, :search) search = Keyword.get(opts, :search)
{sort_field, sort_dir} = sort_opts(opts) {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_entries = Repo.aggregate(base_query, :count)
total_pages = max(ceil(total_entries / @per_page), 1) total_pages = max(ceil(total_entries / @per_page), 1)
@ -39,26 +39,26 @@ defmodule Microwaveprop.Radio do
end end
@doc """ @doc """
Groups reciprocal QSOs (same station pair swapped, same band, same timestamp). Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
Returns a list of `{primary_qso, reciprocals}` tuples where reciprocals is Returns a list of `{primary, reciprocals}` tuples where reciprocals is
a (possibly empty) list of matching QSOs. a (possibly empty) list of matching contacts.
""" """
def group_reciprocals(qsos) do def group_reciprocals(contacts) do
{groups, _seen_ids} = {groups, _seen_ids} =
Enum.reduce(qsos, {[], MapSet.new()}, fn qso, {groups, seen} -> Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} ->
if MapSet.member?(seen, qso.id) do if MapSet.member?(seen, contact.id) do
{groups, seen} {groups, seen}
else else
reciprocals = reciprocals =
Enum.filter(qsos, fn other -> Enum.filter(contacts, fn other ->
other.id != qso.id and other.id != contact.id and
not MapSet.member?(seen, other.id) and not MapSet.member?(seen, other.id) and
other.band == qso.band and other.band == contact.band and
reciprocal_pair?(qso, other) reciprocal_pair?(contact, other)
end) end)
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, qso.id), &MapSet.put(&2, &1.id)) new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id))
{[{qso, reciprocals} | groups], new_seen} {[{contact, reciprocals} | groups], new_seen}
end end
end) end)
@ -97,75 +97,75 @@ defmodule Microwaveprop.Radio do
{field, dir} {field, dir}
end end
def unprocessed_qsos(limit \\ 500) do def unprocessed_contacts(limit \\ 500) do
Qso Contact
|> where([q], q.weather_queued == false and not is_nil(q.pos1)) |> where([q], q.weather_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp) |> order_by([q], asc: q.qso_timestamp)
|> limit(^limit) |> limit(^limit)
|> Repo.all() |> Repo.all()
end end
def mark_weather_queued!(qso_ids) do def mark_weather_queued!(ids) do
Qso Contact
|> where([q], q.id in ^qso_ids) |> where([q], q.id in ^ids)
|> Repo.update_all(set: [weather_queued: true]) |> Repo.update_all(set: [weather_queued: true])
end end
def unprocessed_hrrr_qsos(limit \\ 500) do def unprocessed_hrrr_contacts(limit \\ 500) do
Qso Contact
|> where([q], q.hrrr_queued == false and not is_nil(q.pos1)) |> where([q], q.hrrr_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp) |> order_by([q], asc: q.qso_timestamp)
|> limit(^limit) |> limit(^limit)
|> Repo.all() |> Repo.all()
end end
def mark_hrrr_queued!(qso_ids) do def mark_hrrr_queued!(ids) do
Qso Contact
|> where([q], q.id in ^qso_ids) |> where([q], q.id in ^ids)
|> Repo.update_all(set: [hrrr_queued: true]) |> Repo.update_all(set: [hrrr_queued: true])
end end
def unprocessed_terrain_qsos(limit \\ 500) do def unprocessed_terrain_contacts(limit \\ 500) do
Qso Contact
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2)) |> 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) |> order_by([q], asc: q.qso_timestamp)
|> limit(^limit) |> limit(^limit)
|> Repo.all() |> Repo.all()
end end
def mark_terrain_queued!(qso_ids) do def mark_terrain_queued!(ids) do
Qso Contact
|> where([q], q.id in ^qso_ids) |> where([q], q.id in ^ids)
|> Repo.update_all(set: [terrain_queued: true]) |> Repo.update_all(set: [terrain_queued: true])
end end
def unprocessed_iemre_qsos(limit \\ 500) do def unprocessed_iemre_contacts(limit \\ 500) do
Qso Contact
|> where([q], q.iemre_queued == false and not is_nil(q.pos1)) |> where([q], q.iemre_queued == false and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp) |> order_by([q], asc: q.qso_timestamp)
|> limit(^limit) |> limit(^limit)
|> Repo.all() |> Repo.all()
end end
def mark_iemre_queued!(qso_ids) do def mark_iemre_queued!(ids) do
Qso Contact
|> where([q], q.id in ^qso_ids) |> where([q], q.id in ^ids)
|> Repo.update_all(set: [iemre_queued: true]) |> Repo.update_all(set: [iemre_queued: true])
end end
@doc """ @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. 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"] lat = pos1["lat"]
lon = pos1["lon"] || pos1["lng"] lon = pos1["lon"] || pos1["lng"]
if lat && lon, do: [{lat, lon}], else: [] if lat && lon, do: [{lat, lon}], else: []
end end
def qso_path_points(%{pos1: pos1, pos2: pos2}) do def contact_path_points(%{pos1: pos1, pos2: pos2}) do
lat1 = pos1["lat"] lat1 = pos1["lat"]
lon1 = pos1["lon"] || pos1["lng"] lon1 = pos1["lon"] || pos1["lng"]
lat2 = pos2["lat"] lat2 = pos2["lat"]
@ -195,19 +195,19 @@ defmodule Microwaveprop.Radio do
2 * @earth_radius_km * :math.asin(:math.sqrt(a)) 2 * @earth_radius_km * :math.asin(:math.sqrt(a))
end end
def backfill_distances(qsos) do def backfill_distances(contacts) do
Enum.each(qsos, fn qso -> Enum.each(contacts, fn contact ->
if is_nil(qso.distance_km) && qso.pos1 && qso.pos2 do if is_nil(contact.distance_km) && contact.pos1 && contact.pos2 do
lat1 = qso.pos1["lat"] lat1 = contact.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"] lon1 = contact.pos1["lon"] || contact.pos1["lng"]
lat2 = qso.pos2["lat"] lat2 = contact.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"] lon2 = contact.pos2["lon"] || contact.pos2["lng"]
if lat1 && lon1 && lat2 && lon2 do if lat1 && lon1 && lat2 && lon2 do
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
Qso Contact
|> where([q], q.id == ^qso.id) |> where([q], q.id == ^contact.id)
|> Repo.update_all(set: [distance_km: dist]) |> Repo.update_all(set: [distance_km: dist])
end end
end end
@ -216,16 +216,16 @@ defmodule Microwaveprop.Radio do
defp deg_to_rad(deg), do: deg * :math.pi() / 180 defp deg_to_rad(deg), do: deg * :math.pi() / 180
def get_qso!(id) do def get_contact!(id) do
Repo.get!(Qso, id) Repo.get!(Contact, id)
end end
def change_qso(qso, attrs \\ %{}) do def change_contact(contact, attrs \\ %{}) do
Qso.submission_changeset(qso, attrs) Contact.submission_changeset(contact, attrs)
end end
def create_qso(attrs) do def create_contact(attrs) do
changeset = Qso.submission_changeset(%Qso{}, attrs) changeset = Contact.submission_changeset(%Contact{}, attrs)
if changeset.valid? do if changeset.valid? do
grid1 = Ecto.Changeset.get_change(changeset, :grid1) 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 @moduledoc false
use Ecto.Schema use Ecto.Schema
@ -33,8 +33,8 @@ defmodule Microwaveprop.Radio.Qso do
@required_fields ~w(station1 station2 qso_timestamp mode band)a @required_fields ~w(station1 station2 qso_timestamp mode band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a
def changeset(qso, attrs) do def changeset(contact, attrs) do
qso contact
|> cast(attrs, @required_fields ++ @optional_fields) |> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields) |> validate_required(@required_fields)
end end
@ -43,8 +43,8 @@ defmodule Microwaveprop.Radio.Qso do
@allowed_modes ~w(CW SSB FM FT8 FT4) @allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1) @allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
def submission_changeset(qso, attrs) do def submission_changeset(contact, attrs) do
qso contact
|> cast(attrs, @submission_fields) |> cast(attrs, @submission_fields)
|> validate_required(@submission_fields) |> validate_required(@submission_fields)
|> validate_grid(:grid1) |> validate_grid(:grid1)

View file

@ -8,7 +8,7 @@ defmodule Microwaveprop.Terrain.TerrainProfile do
@foreign_key_type :binary_id @foreign_key_type :binary_id
schema "terrain_profiles" do 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 :sample_count, :integer
field :path_points, {:array, :map} field :path_points, {:array, :map}
field :max_elevation_m, :float field :max_elevation_m, :float

View file

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

View file

@ -1,4 +1,4 @@
defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
@moduledoc false @moduledoc false
use Oban.Worker, queue: :enqueue, max_attempts: 3 use Oban.Worker, queue: :enqueue, max_attempts: 3
@ -16,14 +16,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
@insert_batch_size 400 @insert_batch_size 400
@doc """ @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. Called directly from submission flow no Oban indirection.
""" """
def enqueue_for_qso(qso) do def enqueue_for_contact(contact) do
weather_jobs = build_weather_jobs([qso]) weather_jobs = build_weather_jobs([contact])
hrrr_jobs = build_hrrr_jobs([qso]) hrrr_jobs = build_hrrr_jobs([contact])
terrain_jobs = build_terrain_jobs([qso]) terrain_jobs = build_terrain_jobs([contact])
iemre_jobs = build_iemre_jobs([qso]) iemre_jobs = build_iemre_jobs([contact])
all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs
@ -31,11 +31,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
insert_all_chunked(all_jobs) insert_all_chunked(all_jobs)
end end
qso_ids = [qso.id] ids = [contact.id]
if qso.pos1, do: Radio.mark_weather_queued!(qso_ids) if contact.pos1, do: Radio.mark_weather_queued!(ids)
if qso.pos1, do: Radio.mark_hrrr_queued!(qso_ids) if contact.pos1, do: Radio.mark_hrrr_queued!(ids)
if qso.pos1 && qso.pos2, do: Radio.mark_terrain_queued!(qso_ids) if contact.pos1 && contact.pos2, do: Radio.mark_terrain_queued!(ids)
if qso.pos1, do: Radio.mark_iemre_queued!(qso_ids) if contact.pos1, do: Radio.mark_iemre_queued!(ids)
:ok :ok
end end
@ -51,85 +51,82 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end end
defp enqueue_weather_jobs do defp enqueue_weather_jobs do
case Radio.unprocessed_qsos() do case Radio.unprocessed_contacts() do
[] -> [] ->
:ok :ok
qsos -> contacts ->
Radio.backfill_distances(qsos) Radio.backfill_distances(contacts)
jobs = build_weather_jobs(qsos) jobs = build_weather_jobs(contacts)
if jobs != [] do if jobs != [] do
insert_all_chunked(jobs) insert_all_chunked(jobs)
end end
qso_ids = Enum.map(qsos, & &1.id) ids = Enum.map(contacts, & &1.id)
Radio.mark_weather_queued!(qso_ids) Radio.mark_weather_queued!(ids)
enqueue_weather_jobs() enqueue_weather_jobs()
end end
end end
defp enqueue_hrrr_jobs do defp enqueue_hrrr_jobs do
case Radio.unprocessed_hrrr_qsos() do case Radio.unprocessed_hrrr_contacts() do
[] -> [] ->
:ok :ok
qsos -> contacts ->
jobs = build_hrrr_jobs(qsos) jobs = build_hrrr_jobs(contacts)
if jobs != [] do if jobs != [] do
insert_all_chunked(jobs) insert_all_chunked(jobs)
end end
qso_ids = Enum.map(qsos, & &1.id) ids = Enum.map(contacts, & &1.id)
Radio.mark_hrrr_queued!(qso_ids) Radio.mark_hrrr_queued!(ids)
enqueue_hrrr_jobs() enqueue_hrrr_jobs()
end end
end end
defp enqueue_terrain_jobs do defp enqueue_terrain_jobs do
case Radio.unprocessed_terrain_qsos() do case Radio.unprocessed_terrain_contacts() do
[] -> [] ->
:ok :ok
qsos -> contacts ->
jobs = build_terrain_jobs(qsos) jobs = build_terrain_jobs(contacts)
if jobs != [] do if jobs != [] do
insert_all_chunked(jobs) insert_all_chunked(jobs)
end end
qso_ids = Enum.map(qsos, & &1.id) ids = Enum.map(contacts, & &1.id)
Radio.mark_terrain_queued!(qso_ids) Radio.mark_terrain_queued!(ids)
# Continue until all QSOs are enqueued
enqueue_terrain_jobs() enqueue_terrain_jobs()
end end
end end
def build_terrain_jobs(qsos) do def build_terrain_jobs(contacts) do
qsos contacts
|> Enum.filter(fn qso -> qso.pos1 && qso.pos2 end) |> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end)
|> Enum.map(fn qso -> |> Enum.map(fn contact ->
TerrainProfileWorker.new(%{"qso_id" => qso.id}) TerrainProfileWorker.new(%{"qso_id" => contact.id})
end) end)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end end
def build_weather_jobs(qsos) do def build_weather_jobs(contacts) do
qsos contacts
|> Enum.flat_map(&jobs_for_qso/1) |> Enum.flat_map(&jobs_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end end
def build_hrrr_jobs(qsos) do def build_hrrr_jobs(contacts) do
# Group all QSO points by HRRR hour, then create one batch job per hour contacts
# instead of one job per point. This deduplicates GRIB2 downloads. |> Enum.flat_map(&hrrr_points_for_contact/1)
qsos
|> Enum.flat_map(&hrrr_points_for_qso/1)
|> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end) |> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end)
|> Enum.map(fn {hour, points} -> |> Enum.map(fn {hour, points} ->
unique_points = unique_points =
@ -144,47 +141,47 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end) end)
end end
def build_iemre_jobs(qsos) do def build_iemre_jobs(contacts) do
qsos contacts
|> Enum.flat_map(&iemre_job_for_qso/1) |> Enum.flat_map(&iemre_job_for_contact/1)
|> Enum.uniq_by(fn changeset -> changeset.changes.args end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end)
end end
defp jobs_for_qso(qso) do defp jobs_for_contact(contact) do
qso contact
|> Radio.qso_path_points() |> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} -> |> Enum.flat_map(fn {lat, lon} ->
build_asos_jobs(lat, lon, qso.qso_timestamp) ++ build_asos_jobs(lat, lon, contact.qso_timestamp) ++
build_raob_jobs(lat, lon, qso.qso_timestamp) build_raob_jobs(lat, lon, contact.qso_timestamp)
end) end)
end end
defp enqueue_iemre_jobs do defp enqueue_iemre_jobs do
case Radio.unprocessed_iemre_qsos() do case Radio.unprocessed_iemre_contacts() do
[] -> [] ->
:ok :ok
qsos -> contacts ->
jobs = build_iemre_jobs(qsos) jobs = build_iemre_jobs(contacts)
if jobs != [] do if jobs != [] do
insert_all_chunked(jobs) insert_all_chunked(jobs)
end end
qso_ids = Enum.map(qsos, & &1.id) ids = Enum.map(contacts, & &1.id)
Radio.mark_iemre_queued!(qso_ids) Radio.mark_iemre_queued!(ids)
enqueue_iemre_jobs() enqueue_iemre_jobs()
end end
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 defp hrrr_points_for_contact(contact) do
rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp) rounded_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
qso contact
|> Radio.qso_path_points() |> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} -> |> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
@ -216,13 +213,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
end) end)
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 defp iemre_job_for_contact(contact) do
date = DateTime.to_date(qso.qso_timestamp) date = DateTime.to_date(contact.qso_timestamp)
qso contact
|> Radio.qso_path_points() |> Radio.contact_path_points()
|> Enum.flat_map(fn {lat, lon} -> |> Enum.flat_map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_iemre_grid(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 if Terrain.has_terrain_profile?(qso_id) do
:ok :ok
else else
qso = Radio.get_qso!(qso_id) contact = Radio.get_contact!(qso_id)
lat1 = qso.pos1["lat"] lat1 = contact.pos1["lat"]
lon1 = qso.pos1["lon"] || qso.pos1["lng"] lon1 = contact.pos1["lon"] || contact.pos1["lng"]
lat2 = qso.pos2["lat"] lat2 = contact.pos2["lat"]
lon2 = qso.pos2["lon"] || qso.pos2["lng"] lon2 = contact.pos2["lon"] || contact.pos2["lng"]
dist_km = Decimal.to_float(qso.distance_km) dist_km = Decimal.to_float(contact.distance_km)
freq_ghz = Decimal.to_float(qso.band) / 1000 freq_ghz = Decimal.to_float(contact.band) / 1000
# Look up HRRR refractivity gradient for dynamic k-factor # 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 case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
{:ok, profile} -> {:ok, profile} ->
@ -64,8 +64,8 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
end end
end end
defp lookup_k_factor(qso) do defp lookup_k_factor(contact) do
case Weather.hrrr_for_qso(qso) do case Weather.hrrr_for_contact(contact) do
%{min_refractivity_gradient: grad} when not is_nil(grad) -> %{min_refractivity_gradient: grad} when not is_nil(grad) ->
TerrainAnalysis.k_factor(grad) TerrainAnalysis.k_factor(grad)

View file

@ -4,4 +4,12 @@ defmodule MicrowavepropWeb.PageController do
def home(conn, _params) do def home(conn, _params) do
redirect(conn, to: ~p"/map") redirect(conn, to: ~p"/map")
end 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 end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,12 @@
defmodule MicrowavepropWeb.QsoLiveTest do defmodule MicrowavepropWeb.ContactLiveTest do
use MicrowavepropWeb.ConnCase, async: true use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Qso alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo alias Microwaveprop.Repo
defp create_qso(attrs \\ %{}) do defp create_contact(attrs \\ %{}) do
default = %{ default = %{
station1: "W5XD", station1: "W5XD",
station2: "K5TR", station2: "K5TR",
@ -20,24 +20,24 @@ defmodule MicrowavepropWeb.QsoLiveTest do
distance_km: Decimal.new("295") distance_km: Decimal.new("295")
} }
{:ok, qso} = {:ok, contact} =
%Qso{} %Contact{}
|> Qso.changeset(Map.merge(default, attrs)) |> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert() |> Repo.insert()
qso contact
end end
describe "Index" do describe "Index" do
test "renders page with QSOs heading", %{conn: conn} 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" assert html =~ "QSOs"
end end
test "table shows QSO data", %{conn: conn} do 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 =~ "W5XD"
assert html =~ "K5TR" assert html =~ "K5TR"
assert html =~ "CW" assert html =~ "CW"
@ -45,17 +45,17 @@ defmodule MicrowavepropWeb.QsoLiveTest do
end end
test "row links to detail page", %{conn: conn} do test "row links to detail page", %{conn: conn} do
qso = create_qso() contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/qsos") {:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ ~p"/qsos/#{qso.id}" assert html =~ ~p"/contacts/#{contact.id}"
end end
test "sort params order the table", %{conn: conn} do test "sort params order the table", %{conn: conn} do
create_qso(%{station1: "ZZ9ZZ"}) create_contact(%{station1: "ZZ9ZZ"})
create_qso(%{station1: "AA1AA"}) 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 # AA1AA should appear before ZZ9ZZ
aa_pos = html |> :binary.match("AA1AA") |> elem(0) aa_pos = html |> :binary.match("AA1AA") |> elem(0)
@ -66,22 +66,22 @@ defmodule MicrowavepropWeb.QsoLiveTest do
test "pagination works", %{conn: conn} do test "pagination works", %{conn: conn} do
for i <- 1..25 do for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_qso(%{qso_timestamp: ts}) create_contact(%{qso_timestamp: ts})
end end
{:ok, _lv, html} = live(conn, ~p"/qsos") {:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Page 1 of 2" 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" assert html2 =~ "Page 2 of 2"
end end
end end
describe "Show" do describe "Show" do
test "renders QSO detail fields", %{conn: conn} 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 =~ "W5XD"
assert html =~ "K5TR" assert html =~ "K5TR"
assert html =~ "CW" assert html =~ "CW"
@ -91,38 +91,38 @@ defmodule MicrowavepropWeb.QsoLiveTest do
end end
test "shows surface observations section", %{conn: conn} do test "shows surface observations section", %{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 =~ "Surface Observations" assert html =~ "Surface Observations"
end end
test "shows sounding section", %{conn: conn} do test "shows sounding section", %{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 =~ "Soundings" assert html =~ "Soundings"
end end
test "shows solar index section", %{conn: conn} do test "shows solar index section", %{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 =~ "Solar Conditions" assert html =~ "Solar Conditions"
end end
test "renders with sort assigns", %{conn: conn} do test "renders with sort assigns", %{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}")
# Page renders without error # Page renders without error
assert html =~ "Surface Observations" assert html =~ "Surface Observations"
assert html =~ "Soundings" assert html =~ "Soundings"
# Sort assigns are initialized (no crash on render) # Sort assigns are initialized (no crash on render)
assert render(lv) =~ qso.station1 assert render(lv) =~ contact.station1
end end
test "raises for bad UUID", %{conn: conn} do test "raises for bad UUID", %{conn: conn} do
assert_raise Ecto.NoResultsError, fn -> assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/qsos/#{Ecto.UUID.generate()}") live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
end end
end end
end end

View file

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

View file

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