diff --git a/config/config.exs b/config/config.exs index 3886eb39..429a9309 100644 --- a/config/config.exs +++ b/config/config.exs @@ -51,7 +51,7 @@ config :microwaveprop, Oban, {Oban.Plugins.Cron, crontab: [ {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}, - {"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}, + {"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker}, {"*/5 * * * *", Microwaveprop.Commercial.PollWorker}, {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker} ]} diff --git a/config/dev.exs b/config/dev.exs index a2be5edf..e1eff2c0 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -79,7 +79,7 @@ config :microwaveprop, Oban, crontab: [ {"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}, {"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker}, - {"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker} + {"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker} ]} ] diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 9ebb312b..bad33f4e 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -3,21 +3,21 @@ defmodule Microwaveprop.Radio do import Ecto.Query + alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Maidenhead - alias Microwaveprop.Radio.Qso alias Microwaveprop.Repo @per_page 20 @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a - def list_qsos(opts \\ []) do + def list_contacts(opts \\ []) do page = max(Keyword.get(opts, :page, 1), 1) offset = (page - 1) * @per_page search = Keyword.get(opts, :search) {sort_field, sort_dir} = sort_opts(opts) - base_query = maybe_search(Qso, search) + base_query = maybe_search(Contact, search) total_entries = Repo.aggregate(base_query, :count) total_pages = max(ceil(total_entries / @per_page), 1) @@ -39,26 +39,26 @@ defmodule Microwaveprop.Radio do end @doc """ - Groups reciprocal QSOs (same station pair swapped, same band, same timestamp). - Returns a list of `{primary_qso, reciprocals}` tuples where reciprocals is - a (possibly empty) list of matching QSOs. + Groups reciprocal contacts (same station pair swapped, same band, same timestamp). + Returns a list of `{primary, reciprocals}` tuples where reciprocals is + a (possibly empty) list of matching contacts. """ - def group_reciprocals(qsos) do + def group_reciprocals(contacts) do {groups, _seen_ids} = - Enum.reduce(qsos, {[], MapSet.new()}, fn qso, {groups, seen} -> - if MapSet.member?(seen, qso.id) do + Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} -> + if MapSet.member?(seen, contact.id) do {groups, seen} else reciprocals = - Enum.filter(qsos, fn other -> - other.id != qso.id and + Enum.filter(contacts, fn other -> + other.id != contact.id and not MapSet.member?(seen, other.id) and - other.band == qso.band and - reciprocal_pair?(qso, other) + other.band == contact.band and + reciprocal_pair?(contact, other) end) - new_seen = Enum.reduce(reciprocals, MapSet.put(seen, qso.id), &MapSet.put(&2, &1.id)) - {[{qso, reciprocals} | groups], new_seen} + new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id)) + {[{contact, reciprocals} | groups], new_seen} end end) @@ -97,75 +97,75 @@ defmodule Microwaveprop.Radio do {field, dir} end - def unprocessed_qsos(limit \\ 500) do - Qso + def unprocessed_contacts(limit \\ 500) do + Contact |> where([q], q.weather_queued == false and not is_nil(q.pos1)) |> order_by([q], asc: q.qso_timestamp) |> limit(^limit) |> Repo.all() end - def mark_weather_queued!(qso_ids) do - Qso - |> where([q], q.id in ^qso_ids) + def mark_weather_queued!(ids) do + Contact + |> where([q], q.id in ^ids) |> Repo.update_all(set: [weather_queued: true]) end - def unprocessed_hrrr_qsos(limit \\ 500) do - Qso + def unprocessed_hrrr_contacts(limit \\ 500) do + Contact |> where([q], q.hrrr_queued == false and not is_nil(q.pos1)) |> order_by([q], asc: q.qso_timestamp) |> limit(^limit) |> Repo.all() end - def mark_hrrr_queued!(qso_ids) do - Qso - |> where([q], q.id in ^qso_ids) + def mark_hrrr_queued!(ids) do + Contact + |> where([q], q.id in ^ids) |> Repo.update_all(set: [hrrr_queued: true]) end - def unprocessed_terrain_qsos(limit \\ 500) do - Qso + def unprocessed_terrain_contacts(limit \\ 500) do + Contact |> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2)) |> order_by([q], asc: q.qso_timestamp) |> limit(^limit) |> Repo.all() end - def mark_terrain_queued!(qso_ids) do - Qso - |> where([q], q.id in ^qso_ids) + def mark_terrain_queued!(ids) do + Contact + |> where([q], q.id in ^ids) |> Repo.update_all(set: [terrain_queued: true]) end - def unprocessed_iemre_qsos(limit \\ 500) do - Qso + def unprocessed_iemre_contacts(limit \\ 500) do + Contact |> where([q], q.iemre_queued == false and not is_nil(q.pos1)) |> order_by([q], asc: q.qso_timestamp) |> limit(^limit) |> Repo.all() end - def mark_iemre_queued!(qso_ids) do - Qso - |> where([q], q.id in ^qso_ids) + def mark_iemre_queued!(ids) do + Contact + |> where([q], q.id in ^ids) |> Repo.update_all(set: [iemre_queued: true]) end @doc """ - Returns a list of {lat, lon} points along the QSO path: pos1, midpoint, pos2. + Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2. Returns [pos1] if pos2 is nil, or [] if pos1 is nil. """ - def qso_path_points(%{pos1: nil}), do: [] + def contact_path_points(%{pos1: nil}), do: [] - def qso_path_points(%{pos1: pos1, pos2: nil}) do + def contact_path_points(%{pos1: pos1, pos2: nil}) do lat = pos1["lat"] lon = pos1["lon"] || pos1["lng"] if lat && lon, do: [{lat, lon}], else: [] end - def qso_path_points(%{pos1: pos1, pos2: pos2}) do + def contact_path_points(%{pos1: pos1, pos2: pos2}) do lat1 = pos1["lat"] lon1 = pos1["lon"] || pos1["lng"] lat2 = pos2["lat"] @@ -195,19 +195,19 @@ defmodule Microwaveprop.Radio do 2 * @earth_radius_km * :math.asin(:math.sqrt(a)) end - def backfill_distances(qsos) do - Enum.each(qsos, fn qso -> - if is_nil(qso.distance_km) && qso.pos1 && qso.pos2 do - lat1 = qso.pos1["lat"] - lon1 = qso.pos1["lon"] || qso.pos1["lng"] - lat2 = qso.pos2["lat"] - lon2 = qso.pos2["lon"] || qso.pos2["lng"] + def backfill_distances(contacts) do + Enum.each(contacts, fn contact -> + if is_nil(contact.distance_km) && contact.pos1 && contact.pos2 do + lat1 = contact.pos1["lat"] + lon1 = contact.pos1["lon"] || contact.pos1["lng"] + lat2 = contact.pos2["lat"] + lon2 = contact.pos2["lon"] || contact.pos2["lng"] if lat1 && lon1 && lat2 && lon2 do dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() - Qso - |> where([q], q.id == ^qso.id) + Contact + |> where([q], q.id == ^contact.id) |> Repo.update_all(set: [distance_km: dist]) end end @@ -216,16 +216,16 @@ defmodule Microwaveprop.Radio do defp deg_to_rad(deg), do: deg * :math.pi() / 180 - def get_qso!(id) do - Repo.get!(Qso, id) + def get_contact!(id) do + Repo.get!(Contact, id) end - def change_qso(qso, attrs \\ %{}) do - Qso.submission_changeset(qso, attrs) + def change_contact(contact, attrs \\ %{}) do + Contact.submission_changeset(contact, attrs) end - def create_qso(attrs) do - changeset = Qso.submission_changeset(%Qso{}, attrs) + def create_contact(attrs) do + changeset = Contact.submission_changeset(%Contact{}, attrs) if changeset.valid? do grid1 = Ecto.Changeset.get_change(changeset, :grid1) diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index ad9b8d13..9a21712f 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -1,4 +1,4 @@ -defmodule Microwaveprop.Radio.Qso do +defmodule Microwaveprop.Radio.Contact do @moduledoc false use Ecto.Schema @@ -33,8 +33,8 @@ defmodule Microwaveprop.Radio.Qso do @required_fields ~w(station1 station2 qso_timestamp mode band)a @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a - def changeset(qso, attrs) do - qso + def changeset(contact, attrs) do + contact |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) end @@ -43,8 +43,8 @@ defmodule Microwaveprop.Radio.Qso do @allowed_modes ~w(CW SSB FM FT8 FT4) @allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1) - def submission_changeset(qso, attrs) do - qso + def submission_changeset(contact, attrs) do + contact |> cast(attrs, @submission_fields) |> validate_required(@submission_fields) |> validate_grid(:grid1) diff --git a/lib/microwaveprop/terrain/terrain_profile.ex b/lib/microwaveprop/terrain/terrain_profile.ex index b4a71f6d..ccb46aea 100644 --- a/lib/microwaveprop/terrain/terrain_profile.ex +++ b/lib/microwaveprop/terrain/terrain_profile.ex @@ -8,7 +8,7 @@ defmodule Microwaveprop.Terrain.TerrainProfile do @foreign_key_type :binary_id schema "terrain_profiles" do - belongs_to :qso, Microwaveprop.Radio.Qso + belongs_to :contact, Microwaveprop.Radio.Contact, foreign_key: :qso_id field :sample_count, :integer field :path_points, {:array, :map} field :max_elevation_m, :float diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 05946e12..ceb0177f 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -126,10 +126,10 @@ defmodule Microwaveprop.Weather do Enum.uniq(times) end - def weather_for_qso(qso_params, opts \\ []) do - lat = qso_params[:lat] || qso_params.lat - lon = qso_params[:lon] || qso_params.lon - timestamp = qso_params[:timestamp] || qso_params.timestamp + def weather_for_contact(contact_params, opts \\ []) do + lat = contact_params[:lat] || contact_params.lat + lon = contact_params[:lon] || contact_params.lon + timestamp = contact_params[:timestamp] || contact_params.timestamp radius_km = Keyword.get(opts, :radius_km, 150) time_window_hours = Keyword.get(opts, :time_window_hours, 6) @@ -203,14 +203,14 @@ defmodule Microwaveprop.Weather do |> Repo.exists?() end - def hrrr_for_qso(%{pos1: nil}), do: nil + def hrrr_for_contact(%{pos1: nil}), do: nil - def hrrr_for_qso(qso) do - lat = qso.pos1["lat"] - lon = qso.pos1["lon"] || qso.pos1["lng"] + def hrrr_for_contact(contact) do + lat = contact.pos1["lat"] + lon = contact.pos1["lon"] || contact.pos1["lng"] if lat && lon do - find_nearest_hrrr(lat, lon, qso.qso_timestamp) + find_nearest_hrrr(lat, lon, contact.qso_timestamp) end end @@ -245,10 +245,10 @@ defmodule Microwaveprop.Weather do def hrrr_profiles_for_path(%{pos1: nil}), do: [] - def hrrr_profiles_for_path(qso) do - qso - |> Microwaveprop.Radio.qso_path_points() - |> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, qso.qso_timestamp) end) + def hrrr_profiles_for_path(contact) do + contact + |> Microwaveprop.Radio.contact_path_points() + |> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end) |> Enum.reject(&is_nil/1) end @@ -301,14 +301,14 @@ defmodule Microwaveprop.Weather do |> Repo.exists?() end - def iemre_for_qso(%{pos1: nil}), do: nil + def iemre_for_contact(%{pos1: nil}), do: nil - def iemre_for_qso(qso) do - lat = qso.pos1["lat"] - lon = qso.pos1["lon"] || qso.pos1["lng"] + def iemre_for_contact(contact) do + lat = contact.pos1["lat"] + lon = contact.pos1["lon"] || contact.pos1["lng"] if lat && lon do - find_nearest_iemre(lat, lon, qso.qso_timestamp) + find_nearest_iemre(lat, lon, contact.qso_timestamp) end end @@ -323,10 +323,10 @@ defmodule Microwaveprop.Weather do def iemre_for_path(%{pos1: nil}), do: [] - def iemre_for_path(qso) do - qso - |> Microwaveprop.Radio.qso_path_points() - |> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, qso.qso_timestamp) end) + def iemre_for_path(contact) do + contact + |> Microwaveprop.Radio.contact_path_points() + |> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end) |> Enum.reject(&is_nil/1) end end diff --git a/lib/microwaveprop/workers/contact_weather_enqueue_worker.ex b/lib/microwaveprop/workers/contact_weather_enqueue_worker.ex index 09666c96..b9f5635b 100644 --- a/lib/microwaveprop/workers/contact_weather_enqueue_worker.ex +++ b/lib/microwaveprop/workers/contact_weather_enqueue_worker.ex @@ -1,4 +1,4 @@ -defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do +defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do @moduledoc false use Oban.Worker, queue: :enqueue, max_attempts: 3 @@ -16,14 +16,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do @insert_batch_size 400 @doc """ - Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single QSO. + Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact. Called directly from submission flow — no Oban indirection. """ - def enqueue_for_qso(qso) do - weather_jobs = build_weather_jobs([qso]) - hrrr_jobs = build_hrrr_jobs([qso]) - terrain_jobs = build_terrain_jobs([qso]) - iemre_jobs = build_iemre_jobs([qso]) + def enqueue_for_contact(contact) do + weather_jobs = build_weather_jobs([contact]) + hrrr_jobs = build_hrrr_jobs([contact]) + terrain_jobs = build_terrain_jobs([contact]) + iemre_jobs = build_iemre_jobs([contact]) all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs @@ -31,11 +31,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do insert_all_chunked(all_jobs) end - qso_ids = [qso.id] - if qso.pos1, do: Radio.mark_weather_queued!(qso_ids) - if qso.pos1, do: Radio.mark_hrrr_queued!(qso_ids) - if qso.pos1 && qso.pos2, do: Radio.mark_terrain_queued!(qso_ids) - if qso.pos1, do: Radio.mark_iemre_queued!(qso_ids) + ids = [contact.id] + if contact.pos1, do: Radio.mark_weather_queued!(ids) + if contact.pos1, do: Radio.mark_hrrr_queued!(ids) + if contact.pos1 && contact.pos2, do: Radio.mark_terrain_queued!(ids) + if contact.pos1, do: Radio.mark_iemre_queued!(ids) :ok end @@ -51,85 +51,82 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do end defp enqueue_weather_jobs do - case Radio.unprocessed_qsos() do + case Radio.unprocessed_contacts() do [] -> :ok - qsos -> - Radio.backfill_distances(qsos) + contacts -> + Radio.backfill_distances(contacts) - jobs = build_weather_jobs(qsos) + jobs = build_weather_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end - qso_ids = Enum.map(qsos, & &1.id) - Radio.mark_weather_queued!(qso_ids) + ids = Enum.map(contacts, & &1.id) + Radio.mark_weather_queued!(ids) enqueue_weather_jobs() end end defp enqueue_hrrr_jobs do - case Radio.unprocessed_hrrr_qsos() do + case Radio.unprocessed_hrrr_contacts() do [] -> :ok - qsos -> - jobs = build_hrrr_jobs(qsos) + contacts -> + jobs = build_hrrr_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end - qso_ids = Enum.map(qsos, & &1.id) - Radio.mark_hrrr_queued!(qso_ids) + ids = Enum.map(contacts, & &1.id) + Radio.mark_hrrr_queued!(ids) enqueue_hrrr_jobs() end end defp enqueue_terrain_jobs do - case Radio.unprocessed_terrain_qsos() do + case Radio.unprocessed_terrain_contacts() do [] -> :ok - qsos -> - jobs = build_terrain_jobs(qsos) + contacts -> + jobs = build_terrain_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end - qso_ids = Enum.map(qsos, & &1.id) - Radio.mark_terrain_queued!(qso_ids) + ids = Enum.map(contacts, & &1.id) + Radio.mark_terrain_queued!(ids) - # Continue until all QSOs are enqueued enqueue_terrain_jobs() end end - def build_terrain_jobs(qsos) do - qsos - |> Enum.filter(fn qso -> qso.pos1 && qso.pos2 end) - |> Enum.map(fn qso -> - TerrainProfileWorker.new(%{"qso_id" => qso.id}) + def build_terrain_jobs(contacts) do + contacts + |> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end) + |> Enum.map(fn contact -> + TerrainProfileWorker.new(%{"qso_id" => contact.id}) end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end - def build_weather_jobs(qsos) do - qsos - |> Enum.flat_map(&jobs_for_qso/1) + def build_weather_jobs(contacts) do + contacts + |> Enum.flat_map(&jobs_for_contact/1) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end - def build_hrrr_jobs(qsos) do - # Group all QSO points by HRRR hour, then create one batch job per hour - # instead of one job per point. This deduplicates GRIB2 downloads. - qsos - |> Enum.flat_map(&hrrr_points_for_qso/1) + def build_hrrr_jobs(contacts) do + contacts + |> Enum.flat_map(&hrrr_points_for_contact/1) |> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end) |> Enum.map(fn {hour, points} -> unique_points = @@ -144,47 +141,47 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do end) end - def build_iemre_jobs(qsos) do - qsos - |> Enum.flat_map(&iemre_job_for_qso/1) + def build_iemre_jobs(contacts) do + contacts + |> Enum.flat_map(&iemre_job_for_contact/1) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end - defp jobs_for_qso(qso) do - qso - |> Radio.qso_path_points() + defp jobs_for_contact(contact) do + contact + |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> - build_asos_jobs(lat, lon, qso.qso_timestamp) ++ - build_raob_jobs(lat, lon, qso.qso_timestamp) + build_asos_jobs(lat, lon, contact.qso_timestamp) ++ + build_raob_jobs(lat, lon, contact.qso_timestamp) end) end defp enqueue_iemre_jobs do - case Radio.unprocessed_iemre_qsos() do + case Radio.unprocessed_iemre_contacts() do [] -> :ok - qsos -> - jobs = build_iemre_jobs(qsos) + contacts -> + jobs = build_iemre_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end - qso_ids = Enum.map(qsos, & &1.id) - Radio.mark_iemre_queued!(qso_ids) + ids = Enum.map(contacts, & &1.id) + Radio.mark_iemre_queued!(ids) enqueue_iemre_jobs() end end - defp hrrr_points_for_qso(%{pos1: nil}), do: [] + defp hrrr_points_for_contact(%{pos1: nil}), do: [] - defp hrrr_points_for_qso(qso) do - rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp) + defp hrrr_points_for_contact(contact) do + rounded_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp) - qso - |> Radio.qso_path_points() + contact + |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> {rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon) @@ -216,13 +213,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do end) end - defp iemre_job_for_qso(%{pos1: nil}), do: [] + defp iemre_job_for_contact(%{pos1: nil}), do: [] - defp iemre_job_for_qso(qso) do - date = DateTime.to_date(qso.qso_timestamp) + defp iemre_job_for_contact(contact) do + date = DateTime.to_date(contact.qso_timestamp) - qso - |> Radio.qso_path_points() + contact + |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> {rlat, rlon} = Weather.round_to_iemre_grid(lat, lon) diff --git a/lib/microwaveprop/workers/terrain_profile_worker.ex b/lib/microwaveprop/workers/terrain_profile_worker.ex index 7f8eb71e..11ed3c87 100644 --- a/lib/microwaveprop/workers/terrain_profile_worker.ex +++ b/lib/microwaveprop/workers/terrain_profile_worker.ex @@ -18,16 +18,16 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do if Terrain.has_terrain_profile?(qso_id) do :ok else - qso = Radio.get_qso!(qso_id) - lat1 = qso.pos1["lat"] - lon1 = qso.pos1["lon"] || qso.pos1["lng"] - lat2 = qso.pos2["lat"] - lon2 = qso.pos2["lon"] || qso.pos2["lng"] - dist_km = Decimal.to_float(qso.distance_km) - freq_ghz = Decimal.to_float(qso.band) / 1000 + contact = Radio.get_contact!(qso_id) + lat1 = contact.pos1["lat"] + lon1 = contact.pos1["lon"] || contact.pos1["lng"] + lat2 = contact.pos2["lat"] + lon2 = contact.pos2["lon"] || contact.pos2["lng"] + dist_km = Decimal.to_float(contact.distance_km) + freq_ghz = Decimal.to_float(contact.band) / 1000 # Look up HRRR refractivity gradient for dynamic k-factor - k = lookup_k_factor(qso) + k = lookup_k_factor(contact) case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do {:ok, profile} -> @@ -64,8 +64,8 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do end end - defp lookup_k_factor(qso) do - case Weather.hrrr_for_qso(qso) do + defp lookup_k_factor(contact) do + case Weather.hrrr_for_contact(contact) do %{min_refractivity_gradient: grad} when not is_nil(grad) -> TerrainAnalysis.k_factor(grad) diff --git a/lib/microwaveprop_web/controllers/page_controller.ex b/lib/microwaveprop_web/controllers/page_controller.ex index e821961d..726de104 100644 --- a/lib/microwaveprop_web/controllers/page_controller.ex +++ b/lib/microwaveprop_web/controllers/page_controller.ex @@ -4,4 +4,12 @@ defmodule MicrowavepropWeb.PageController do def home(conn, _params) do redirect(conn, to: ~p"/map") end + + def redirect_contacts(conn, _params) do + redirect(conn, to: ~p"/contacts") + end + + def redirect_contact(conn, %{"id" => id}) do + redirect(conn, to: ~p"/contacts/#{id}") + end end diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index 995e8781..c3868c93 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -1,4 +1,4 @@ -defmodule MicrowavepropWeb.QsoLive.Index do +defmodule MicrowavepropWeb.ContactLive.Index do @moduledoc false use MicrowavepropWeb, :live_view @@ -21,7 +21,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do search = Map.get(params, "search", "") result = - Radio.list_qsos( + Radio.list_contacts( page: page, sort_by: String.to_existing_atom(sort_by), sort_order: String.to_existing_atom(sort_order), @@ -44,7 +44,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do @impl true def handle_event("search", %{"search" => search}, socket) do - {:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")} + {:noreply, push_patch(socket, to: ~p"/contacts?search=#{search}")} end @impl true @@ -58,7 +58,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do params = %{sort_by: field, sort_order: new_order} params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search) - {:noreply, push_patch(socket, to: ~p"/qsos?#{params}")} + {:noreply, push_patch(socket, to: ~p"/contacts?#{params}")} end defp validate_sort_field(field) when field in @sortable_fields, do: field @@ -91,7 +91,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do class="input input-bordered input-sm w-64" /> - <.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear + <.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear @@ -120,7 +120,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do "hover:bg-base-200 cursor-pointer", reciprocals != [] && "bg-primary/5" ]} - phx-click={JS.navigate(~p"/qsos/#{primary.id}")} + phx-click={JS.navigate(~p"/contacts/#{primary.id}")} > ↳ {recip.station1} @@ -166,7 +166,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do <.link :if={@page > 1} patch={ - ~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}" + ~p"/contacts?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}" } class="btn btn-sm btn-outline" > @@ -179,7 +179,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do <.link :if={@page < @total_pages} patch={ - ~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}" + ~p"/contacts?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}" } class="btn btn-sm btn-outline" > diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 98f42fa1..965e72e7 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -1,4 +1,4 @@ -defmodule MicrowavepropWeb.QsoLive.Show do +defmodule MicrowavepropWeb.ContactLive.Show do @moduledoc false use MicrowavepropWeb, :live_view @@ -8,17 +8,17 @@ defmodule MicrowavepropWeb.QsoLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do - qso = Radio.get_qso!(id) + contact = Radio.get_contact!(id) - weather = load_weather(qso) - solar = load_solar(qso) - hrrr = Weather.hrrr_for_qso(qso) - terrain = Terrain.get_terrain_profile(qso.id) + weather = load_weather(contact) + solar = load_solar(contact) + hrrr = Weather.hrrr_for_contact(contact) + terrain = Terrain.get_terrain_profile(contact.id) {:ok, assign(socket, - page_title: "#{qso.station1} / #{qso.station2}", - qso: qso, + page_title: "#{contact.station1} / #{contact.station2}", + contact: contact, surface_observations: weather.surface_observations, soundings: weather.soundings, solar: solar, @@ -117,12 +117,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0) defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end - defp load_weather(qso) do - lat = qso.pos1 && qso.pos1["lat"] - lon = qso.pos1 && (qso.pos1["lon"] || qso.pos1["lng"]) + defp load_weather(contact) do + lat = contact.pos1 && contact.pos1["lat"] + lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"]) if lat && lon do - Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp}, + Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp}, radius_km: 300 ) else @@ -130,8 +130,8 @@ defmodule MicrowavepropWeb.QsoLive.Show do end end - defp load_solar(qso) do - qso.qso_timestamp + defp load_solar(contact) do + contact.qso_timestamp |> DateTime.to_date() |> Weather.get_solar_index() end @@ -141,10 +141,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do ~H""" <.header> - {@qso.station1} / {@qso.station2} - <:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + {@contact.station1} / {@contact.station2} + <:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} <: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 @@ -154,19 +154,21 @@ defmodule MicrowavepropWeb.QsoLive.Show do

QSO Details

<.list> - <:item title="Station 1">{@qso.station1} - <:item title="Station 2">{@qso.station2} - <:item title="Band">{@qso.band} MHz - <:item title="Mode">{@qso.mode} - <:item title="Grid 1">{@qso.grid1 || "—"} - <:item title="Grid 2">{@qso.grid2 || "—"} - <:item title="Distance">{@qso.distance_km || "—"} km - <:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} - <:item :if={@qso.user_submitted} title="Source"> + <:item title="Station 1">{@contact.station1} + <:item title="Station 2">{@contact.station2} + <:item title="Band">{@contact.band} MHz + <:item title="Mode">{@contact.mode} + <:item title="Grid 1">{@contact.grid1 || "—"} + <:item title="Grid 2">{@contact.grid2 || "—"} + <:item title="Distance">{@contact.distance_km || "—"} km + <:item title="Timestamp"> + {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + + <:item :if={@contact.user_submitted} title="Source"> User Submitted - <:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter"> - {@qso.submitter_email} + <:item :if={@contact.user_submitted && @contact.submitter_email} title="Submitter"> + {@contact.submitter_email} diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index 018ddca4..f5821178 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -3,8 +3,8 @@ defmodule MicrowavepropWeb.SubmitLive do use MicrowavepropWeb, :live_view alias Microwaveprop.Radio - alias Microwaveprop.Radio.Qso - alias Microwaveprop.Workers.QsoWeatherEnqueueWorker + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @band_options [ {"1296 MHz", "1296"}, @@ -21,11 +21,11 @@ defmodule MicrowavepropWeb.SubmitLive do @impl true def mount(_params, _session, socket) do - changeset = Radio.change_qso(%Qso{}) + changeset = Radio.change_contact(%Contact{}) {:ok, assign(socket, - page_title: "Submit QSO", + page_title: "Submit Contact", form: to_form(changeset), band_options: @band_options, mode_options: @mode_options, @@ -34,10 +34,10 @@ defmodule MicrowavepropWeb.SubmitLive do end @impl true - def handle_event("validate", %{"qso" => qso_params}, socket) do + def handle_event("validate", %{"contact" => contact_params}, socket) do changeset = - %Qso{} - |> Radio.change_qso(qso_params) + %Contact{} + |> Radio.change_contact(contact_params) |> Map.put(:action, :validate) {:noreply, assign(socket, form: to_form(changeset))} @@ -47,19 +47,19 @@ defmodule MicrowavepropWeb.SubmitLive do # For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare). @submission_cooldown_ms 30_000 - def handle_event("save", %{"qso" => qso_params}, socket) do + def handle_event("save", %{"contact" => contact_params}, socket) do if recently_submitted?(socket) do - {:noreply, put_flash(socket, :error, "Please wait before submitting another QSO.")} + {:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")} else - case Radio.create_qso(qso_params) do - {:ok, qso} -> - QsoWeatherEnqueueWorker.enqueue_for_qso(qso) + case Radio.create_contact(contact_params) do + {:ok, contact} -> + ContactWeatherEnqueueWorker.enqueue_for_contact(contact) {:noreply, socket |> assign(submitted_at: System.monotonic_time(:millisecond)) - |> put_flash(:info, "QSO submitted successfully!") - |> push_navigate(to: ~p"/qsos/#{qso.id}")} + |> put_flash(:info, "Contact submitted successfully!") + |> push_navigate(to: ~p"/contacts/#{contact.id}")} {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} @@ -79,11 +79,11 @@ defmodule MicrowavepropWeb.SubmitLive do ~H""" <.header> - Submit QSO - <:subtitle>Submit a microwave QSO for propagation analysis + Submit Contact + <:subtitle>Submit a microwave contact for propagation analysis - <.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">
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" /> <.input @@ -133,7 +133,7 @@ defmodule MicrowavepropWeb.SubmitLive do />
- <.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO + <.button phx-disable-with="Submitting..." class="btn-primary">Submit Contact
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 0ff1e2a8..73935255 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -21,9 +21,13 @@ defmodule MicrowavepropWeb.Router do live "/submit", SubmitLive live "/map", MapLive - live "/qsos", QsoLive.Index - live "/qsos/:id", QsoLive.Show + live "/contacts", ContactLive.Index + live "/contacts/:id", ContactLive.Show live "/algo", AlgoLive + + # Redirect old /qsos routes + get "/qsos", PageController, :redirect_contacts + get "/qsos/:id", PageController, :redirect_contact end # Other scopes may use custom stacks. diff --git a/lib/mix/tasks/propagation_train.ex b/lib/mix/tasks/propagation_train.ex index 1e9eba4c..d3731ae1 100644 --- a/lib/mix/tasks/propagation_train.ex +++ b/lib/mix/tasks/propagation_train.ex @@ -118,7 +118,7 @@ defmodule Mix.Tasks.PropagationTrain do IO.puts("=" <> String.duplicate("─", 50)) - {ft_features, ft_targets, ft_counts} = load_qso_data() + {ft_features, ft_targets, ft_counts} = load_contact_data() ft_n = elem(Nx.shape(ft_features), 0) IO.puts("Loaded #{ft_n} QSO training samples:") print_band_counts(ft_counts) @@ -247,7 +247,7 @@ defmodule Mix.Tasks.PropagationTrain do # ── Phase 2 data: QSO-HRRR + solar + sounding ──────────────────── - defp load_qso_data do + defp load_contact_data do IO.puts(" Running QSO-HRRR-solar-sounding join query...") sql = """ @@ -326,7 +326,7 @@ defmodule Mix.Tasks.PropagationTrain do {feature_rows, target_rows} = percentile_rows - |> Enum.map(fn {row, percentile} -> encode_qso_row(row, percentile) end) + |> Enum.map(fn {row, percentile} -> encode_contact_row(row, percentile) end) |> Enum.unzip() IO.puts(" After filtering: #{length(feature_rows)} training samples") @@ -334,7 +334,7 @@ defmodule Mix.Tasks.PropagationTrain do {Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), band_counts} end - defp encode_qso_row( + defp encode_contact_row( [ band_mhz, _dist, diff --git a/lib/mix/tasks/reset_enrichment.ex b/lib/mix/tasks/reset_enrichment.ex index d7b56a33..fcacdc76 100644 --- a/lib/mix/tasks/reset_enrichment.ex +++ b/lib/mix/tasks/reset_enrichment.ex @@ -15,7 +15,7 @@ defmodule Mix.Tasks.ResetEnrichment do import Ecto.Query - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo @impl Mix.Task @@ -23,7 +23,7 @@ defmodule Mix.Tasks.ResetEnrichment do Mix.Task.run("app.start") {count, _} = - Qso + Contact |> where([q], q.weather_queued == true or q.hrrr_queued == true or q.iemre_queued == true) |> Repo.update_all(set: [weather_queued: false, hrrr_queued: false, iemre_queued: false]) diff --git a/test/microwaveprop/radio/contact_submission_test.exs b/test/microwaveprop/radio/contact_submission_test.exs index f1685036..f5fbe499 100644 --- a/test/microwaveprop/radio/contact_submission_test.exs +++ b/test/microwaveprop/radio/contact_submission_test.exs @@ -1,7 +1,7 @@ -defmodule Microwaveprop.Radio.QsoSubmissionTest do +defmodule Microwaveprop.Radio.ContactSubmissionTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact @valid_attrs %{ station1: "W5XD", @@ -16,12 +16,12 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do describe "submission_changeset/2" do test "valid attrs produce a valid changeset" do - changeset = Qso.submission_changeset(%Qso{}, @valid_attrs) + changeset = Contact.submission_changeset(%Contact{}, @valid_attrs) assert changeset.valid? end test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do - changeset = Qso.submission_changeset(%Qso{}, %{}) + changeset = Contact.submission_changeset(%Contact{}, %{}) errors = errors_on(changeset) assert errors[:station1] @@ -36,59 +36,59 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do test "rejects invalid grid1" do attrs = Map.put(@valid_attrs, :grid1, "ZZ99") - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1 end test "rejects invalid grid2" do attrs = Map.put(@valid_attrs, :grid2, "ZZ99") - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2 end test "accepts valid 6-char grids" do attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"} - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid? end test "rejects invalid mode" do attrs = Map.put(@valid_attrs, :mode, "RTTY") - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).mode end test "accepts valid modes" do for mode <- ~w(CW SSB FM FT8 FT4) do attrs = Map.put(@valid_attrs, :mode, mode) - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "Expected mode #{mode} to be valid" end end test "rejects invalid band" do attrs = Map.put(@valid_attrs, :band, "144") - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).band end test "accepts valid microwave bands" do for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do attrs = Map.put(@valid_attrs, :band, band) - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert changeset.valid?, "Expected band #{band} to be valid" end end test "rejects email without @" do attrs = Map.put(@valid_attrs, :submitter_email, "notanemail") - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) assert errors_on(changeset).submitter_email end test "does not cast user_submitted" do attrs = Map.put(@valid_attrs, :user_submitted, true) - changeset = Qso.submission_changeset(%Qso{}, attrs) + changeset = Contact.submission_changeset(%Contact{}, attrs) refute Ecto.Changeset.get_change(changeset, :user_submitted) end end diff --git a/test/microwaveprop/radio/contact_test.exs b/test/microwaveprop/radio/contact_test.exs index 94ba091e..d1e8dd4f 100644 --- a/test/microwaveprop/radio/contact_test.exs +++ b/test/microwaveprop/radio/contact_test.exs @@ -1,7 +1,7 @@ -defmodule Microwaveprop.Radio.QsoTest do +defmodule Microwaveprop.Radio.ContactTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact @valid_attrs %{ station1: "W1AW", @@ -18,12 +18,12 @@ defmodule Microwaveprop.Radio.QsoTest do describe "changeset/2" do test "valid attributes" do - changeset = Qso.changeset(%Qso{}, @valid_attrs) + changeset = Contact.changeset(%Contact{}, @valid_attrs) assert changeset.valid? end test "requires station1, station2, qso_timestamp, mode, and band" do - changeset = Qso.changeset(%Qso{}, %{}) + changeset = Contact.changeset(%Contact{}, %{}) assert %{ station1: ["can't be blank"], @@ -35,13 +35,13 @@ defmodule Microwaveprop.Radio.QsoTest do end test "persists to the database" do - changeset = Qso.changeset(%Qso{}, @valid_attrs) - assert {:ok, qso} = Repo.insert(changeset) - assert qso.station1 == "W1AW" - assert qso.station2 == "K3LR" - assert qso.mode == "SSB" - assert Decimal.equal?(qso.band, Decimal.new("144.2")) - assert Decimal.equal?(qso.distance_km, Decimal.new("623.4")) + changeset = Contact.changeset(%Contact{}, @valid_attrs) + assert {:ok, contact} = Repo.insert(changeset) + assert contact.station1 == "W1AW" + assert contact.station2 == "K3LR" + assert contact.mode == "SSB" + assert Decimal.equal?(contact.band, Decimal.new("144.2")) + assert Decimal.equal?(contact.distance_km, Decimal.new("623.4")) end end end diff --git a/test/microwaveprop/radio_test.exs b/test/microwaveprop/radio_test.exs index 1f84ea87..ec47ae9c 100644 --- a/test/microwaveprop/radio_test.exs +++ b/test/microwaveprop/radio_test.exs @@ -2,9 +2,9 @@ defmodule Microwaveprop.RadioTest do use Microwaveprop.DataCase, async: true alias Microwaveprop.Radio - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact - defp create_qso(attrs \\ %{}) do + defp create_contact(attrs \\ %{}) do default = %{ station1: "W5XD", station2: "K5TR", @@ -18,17 +18,17 @@ defmodule Microwaveprop.RadioTest do distance_km: Decimal.new("295") } - {:ok, qso} = - %Qso{} - |> Qso.changeset(Map.merge(default, attrs)) + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(default, attrs)) |> Repo.insert() - qso + contact end - describe "list_qsos/1" do + describe "list_contacts/1" do test "returns empty page when no QSOs exist" do - result = Radio.list_qsos() + result = Radio.list_contacts() assert result.entries == [] assert result.page == 1 @@ -39,10 +39,10 @@ defmodule Microwaveprop.RadioTest do test "paginates 20 per page, ordered by qso_timestamp desc" do for i <- 1..25 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - result = Radio.list_qsos() + result = Radio.list_contacts() assert length(result.entries) == 20 assert result.page == 1 @@ -57,10 +57,10 @@ defmodule Microwaveprop.RadioTest do test "returns second page" do for i <- 1..25 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - result = Radio.list_qsos(page: 2) + result = Radio.list_contacts(page: 2) assert length(result.entries) == 5 assert result.page == 2 @@ -68,60 +68,60 @@ defmodule Microwaveprop.RadioTest do end end - describe "list_qsos/1 sorting" do + describe "list_contacts/1 sorting" do test "sorts by station1 ascending" do - create_qso(%{station1: "ZZ9ZZ"}) - create_qso(%{station1: "AA1AA"}) + create_contact(%{station1: "ZZ9ZZ"}) + create_contact(%{station1: "AA1AA"}) - result = Radio.list_qsos(sort_by: :station1, sort_order: :asc) + result = Radio.list_contacts(sort_by: :station1, sort_order: :asc) stations = Enum.map(result.entries, & &1.station1) assert stations == ["AA1AA", "ZZ9ZZ"] end test "sorts by distance_km descending" do - create_qso(%{distance_km: Decimal.new("100"), station1: "A1A"}) - create_qso(%{distance_km: Decimal.new("500"), station1: "B2B"}) + create_contact(%{distance_km: Decimal.new("100"), station1: "A1A"}) + create_contact(%{distance_km: Decimal.new("500"), station1: "B2B"}) - result = Radio.list_qsos(sort_by: :distance_km, sort_order: :desc) + result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc) distances = Enum.map(result.entries, & &1.distance_km) assert distances == [Decimal.new("500"), Decimal.new("100")] end test "invalid sort_by falls back to qso_timestamp desc" do - early = create_qso(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]}) - late = create_qso(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]}) + early = create_contact(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]}) + late = create_contact(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]}) - result = Radio.list_qsos(sort_by: :bogus) + result = Radio.list_contacts(sort_by: :bogus) ids = Enum.map(result.entries, & &1.id) assert ids == [late.id, early.id] end end - describe "unprocessed_qsos/1" do + describe "unprocessed_contacts/1" do test "returns QSOs where weather_queued is false and pos1 is not nil" do - q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) - _q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) + q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) + _q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) - results = Radio.unprocessed_qsos() + results = Radio.unprocessed_contacts() ids = Enum.map(results, & &1.id) assert q1.id in ids end test "excludes QSOs already marked as weather_queued" do - q = create_qso() + q = create_contact() Radio.mark_weather_queued!([q.id]) - assert Radio.unprocessed_qsos() == [] + assert Radio.unprocessed_contacts() == [] end test "orders by qso_timestamp ascending" do - q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) - q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) + q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) + q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) - results = Radio.unprocessed_qsos() + results = Radio.unprocessed_contacts() ids = Enum.map(results, & &1.id) assert ids == [q_early.id, q_late.id] end @@ -129,32 +129,32 @@ defmodule Microwaveprop.RadioTest do test "respects limit parameter" do for i <- 1..5 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - assert length(Radio.unprocessed_qsos(3)) == 3 + assert length(Radio.unprocessed_contacts(3)) == 3 end end describe "mark_weather_queued!/1" do test "sets weather_queued to true for given IDs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_weather_queued!([q1.id, q2.id]) - assert Repo.get!(Qso, q1.id).weather_queued == true - assert Repo.get!(Qso, q2.id).weather_queued == true + assert Repo.get!(Contact, q1.id).weather_queued == true + assert Repo.get!(Contact, q2.id).weather_queued == true end test "does not affect other QSOs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_weather_queued!([q1.id]) - assert Repo.get!(Qso, q1.id).weather_queued == true - assert Repo.get!(Qso, q2.id).weather_queued == false + assert Repo.get!(Contact, q1.id).weather_queued == true + assert Repo.get!(Contact, q2.id).weather_queued == false end test "handles empty list" do @@ -176,72 +176,72 @@ defmodule Microwaveprop.RadioTest do describe "backfill_distances/1" do test "calculates and stores distance for QSOs missing distance_km" do - qso = create_qso(%{distance_km: nil}) - assert is_nil(qso.distance_km) + contact = create_contact(%{distance_km: nil}) + assert is_nil(contact.distance_km) - Radio.backfill_distances([qso]) + Radio.backfill_distances([contact]) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert_in_delta Decimal.to_float(updated.distance_km), 295, 5 end test "skips QSOs that already have a distance" do - qso = create_qso(%{distance_km: Decimal.new("100")}) + contact = create_contact(%{distance_km: Decimal.new("100")}) - Radio.backfill_distances([qso]) + Radio.backfill_distances([contact]) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.distance_km == Decimal.new("100") end test "skips QSOs missing pos2" do - qso = create_qso(%{distance_km: nil, pos2: nil}) + contact = create_contact(%{distance_km: nil, pos2: nil}) - Radio.backfill_distances([qso]) + Radio.backfill_distances([contact]) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert is_nil(updated.distance_km) end end - describe "get_qso!/1" do + describe "get_contact!/1" do test "returns a QSO by ID" do - qso = create_qso() + contact = create_contact() - found = Radio.get_qso!(qso.id) - assert found.id == qso.id + found = Radio.get_contact!(contact.id) + assert found.id == contact.id assert found.station1 == "W5XD" end test "raises Ecto.NoResultsError on bad ID" do assert_raise Ecto.NoResultsError, fn -> - Radio.get_qso!(Ecto.UUID.generate()) + Radio.get_contact!(Ecto.UUID.generate()) end end end - describe "unprocessed_hrrr_qsos/1" do + describe "unprocessed_hrrr_contacts/1" do test "returns QSOs where hrrr_queued is false and pos1 is not nil" do - q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) - _q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) + q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) + _q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) - results = Radio.unprocessed_hrrr_qsos() + results = Radio.unprocessed_hrrr_contacts() ids = Enum.map(results, & &1.id) assert q1.id in ids end test "excludes QSOs already marked as hrrr_queued" do - q = create_qso() + q = create_contact() Radio.mark_hrrr_queued!([q.id]) - assert Radio.unprocessed_hrrr_qsos() == [] + assert Radio.unprocessed_hrrr_contacts() == [] end test "orders by qso_timestamp ascending" do - q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) - q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) + q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) + q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) - results = Radio.unprocessed_hrrr_qsos() + results = Radio.unprocessed_hrrr_contacts() ids = Enum.map(results, & &1.id) assert ids == [q_early.id, q_late.id] end @@ -249,32 +249,32 @@ defmodule Microwaveprop.RadioTest do test "respects limit parameter" do for i <- 1..5 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - assert length(Radio.unprocessed_hrrr_qsos(3)) == 3 + assert length(Radio.unprocessed_hrrr_contacts(3)) == 3 end end describe "mark_hrrr_queued!/1" do test "sets hrrr_queued to true for given IDs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_hrrr_queued!([q1.id, q2.id]) - assert Repo.get!(Qso, q1.id).hrrr_queued == true - assert Repo.get!(Qso, q2.id).hrrr_queued == true + assert Repo.get!(Contact, q1.id).hrrr_queued == true + assert Repo.get!(Contact, q2.id).hrrr_queued == true end test "does not affect other QSOs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_hrrr_queued!([q1.id]) - assert Repo.get!(Qso, q1.id).hrrr_queued == true - assert Repo.get!(Qso, q2.id).hrrr_queued == false + assert Repo.get!(Contact, q1.id).hrrr_queued == true + assert Repo.get!(Contact, q2.id).hrrr_queued == false end test "handles empty list" do @@ -282,30 +282,30 @@ defmodule Microwaveprop.RadioTest do end end - describe "unprocessed_terrain_qsos/1" do + describe "unprocessed_terrain_contacts/1" do test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do - q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) - _q2 = create_qso(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) - _q3 = create_qso(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]}) + q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]}) + _q2 = create_contact(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]}) + _q3 = create_contact(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]}) - results = Radio.unprocessed_terrain_qsos() + results = Radio.unprocessed_terrain_contacts() ids = Enum.map(results, & &1.id) assert q1.id in ids assert length(ids) == 1 end test "excludes QSOs already marked as terrain_queued" do - q = create_qso() + q = create_contact() Radio.mark_terrain_queued!([q.id]) - assert Radio.unprocessed_terrain_qsos() == [] + assert Radio.unprocessed_terrain_contacts() == [] end test "orders by qso_timestamp ascending" do - q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) - q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) + q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]}) + q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]}) - results = Radio.unprocessed_terrain_qsos() + results = Radio.unprocessed_terrain_contacts() ids = Enum.map(results, & &1.id) assert ids == [q_early.id, q_late.id] end @@ -313,32 +313,32 @@ defmodule Microwaveprop.RadioTest do test "respects limit parameter" do for i <- 1..5 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - assert length(Radio.unprocessed_terrain_qsos(3)) == 3 + assert length(Radio.unprocessed_terrain_contacts(3)) == 3 end end describe "mark_terrain_queued!/1" do test "sets terrain_queued to true for given IDs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_terrain_queued!([q1.id, q2.id]) - assert Repo.get!(Qso, q1.id).terrain_queued == true - assert Repo.get!(Qso, q2.id).terrain_queued == true + assert Repo.get!(Contact, q1.id).terrain_queued == true + assert Repo.get!(Contact, q2.id).terrain_queued == true end test "does not affect other QSOs" do - q1 = create_qso(%{station1: "A1A"}) - q2 = create_qso(%{station1: "B2B"}) + q1 = create_contact(%{station1: "A1A"}) + q2 = create_contact(%{station1: "B2B"}) Radio.mark_terrain_queued!([q1.id]) - assert Repo.get!(Qso, q1.id).terrain_queued == true - assert Repo.get!(Qso, q2.id).terrain_queued == false + assert Repo.get!(Contact, q1.id).terrain_queued == true + assert Repo.get!(Contact, q2.id).terrain_queued == false end test "handles empty list" do @@ -346,15 +346,15 @@ defmodule Microwaveprop.RadioTest do end end - describe "qso_path_points/1" do + describe "contact_path_points/1" do test "returns pos1, midpoint, and pos2 when both positions exist" do - qso = - create_qso(%{ + contact = + create_contact(%{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7} }) - points = Radio.qso_path_points(qso) + points = Radio.contact_path_points(contact) assert length(points) == 3 [{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points @@ -367,44 +367,44 @@ defmodule Microwaveprop.RadioTest do end test "returns only pos1 when pos2 is nil" do - qso = create_qso(%{pos2: nil}) + contact = create_contact(%{pos2: nil}) - points = Radio.qso_path_points(qso) + points = Radio.contact_path_points(contact) assert points == [{32.9, -97.0}] end test "returns empty list when pos1 is nil" do - qso = create_qso(%{pos1: nil}) + contact = create_contact(%{pos1: nil}) - assert Radio.qso_path_points(qso) == [] + assert Radio.contact_path_points(contact) == [] end test "handles lng key in position maps" do - qso = - create_qso(%{ + contact = + create_contact(%{ pos1: %{"lat" => 32.9, "lng" => -97.0}, pos2: %{"lat" => 30.3, "lng" => -97.7} }) - points = Radio.qso_path_points(qso) + points = Radio.contact_path_points(contact) assert length(points) == 3 end end - describe "change_qso/2" do + describe "change_contact/2" do test "returns a submission changeset" do - changeset = Radio.change_qso(%Qso{}) + changeset = Radio.change_contact(%Contact{}) assert %Ecto.Changeset{} = changeset end test "accepts attrs" do - changeset = Radio.change_qso(%Qso{}, %{station1: "W5XD"}) + changeset = Radio.change_contact(%Contact{}, %{station1: "W5XD"}) assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD" end end - describe "create_qso/1" do + describe "create_contact/1" do @valid_submission %{ station1: "W5XD", station2: "K5TR", @@ -417,38 +417,38 @@ defmodule Microwaveprop.RadioTest do } test "creates QSO with computed positions and distance" do - assert {:ok, qso} = Radio.create_qso(@valid_submission) + assert {:ok, contact} = Radio.create_contact(@valid_submission) - assert qso.station1 == "W5XD" - assert qso.user_submitted == true - assert qso.pos1["lat"] - assert qso.pos1["lon"] - assert qso.pos2["lat"] - assert qso.pos2["lon"] - assert qso.distance_km + assert contact.station1 == "W5XD" + assert contact.user_submitted == true + assert contact.pos1["lat"] + assert contact.pos1["lon"] + assert contact.pos2["lat"] + assert contact.pos2["lon"] + assert contact.distance_km end test "computes correct position from grid" do - assert {:ok, qso} = Radio.create_qso(@valid_submission) + assert {:ok, contact} = Radio.create_contact(@valid_submission) # EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait # E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5 - assert_in_delta qso.pos1["lat"], 32.5, 0.1 - assert_in_delta qso.pos1["lon"], -97.0, 0.1 + assert_in_delta contact.pos1["lat"], 32.5, 0.1 + assert_in_delta contact.pos1["lon"], -97.0, 0.1 end test "sets user_submitted to true" do - assert {:ok, qso} = Radio.create_qso(@valid_submission) - assert qso.user_submitted == true + assert {:ok, contact} = Radio.create_contact(@valid_submission) + assert contact.user_submitted == true end test "returns error changeset for invalid data" do - assert {:error, %Ecto.Changeset{}} = Radio.create_qso(%{}) + assert {:error, %Ecto.Changeset{}} = Radio.create_contact(%{}) end test "returns error changeset for invalid grid" do attrs = Map.put(@valid_submission, :grid1, "ZZ99") - assert {:error, changeset} = Radio.create_qso(attrs) + assert {:error, changeset} = Radio.create_contact(attrs) assert errors_on(changeset).grid1 end end diff --git a/test/microwaveprop/terrain/terrain_profile_test.exs b/test/microwaveprop/terrain/terrain_profile_test.exs index e8d4c90f..7e5f227b 100644 --- a/test/microwaveprop/terrain/terrain_profile_test.exs +++ b/test/microwaveprop/terrain/terrain_profile_test.exs @@ -1,7 +1,7 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Terrain.TerrainProfile @qso_attrs %{ @@ -14,21 +14,21 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do pos2: %{"lat" => 30.3, "lon" => -97.7} } - defp create_qso do - {:ok, qso} = - %Qso{} - |> Qso.changeset(@qso_attrs) + defp create_contact do + {:ok, contact} = + %Contact{} + |> Contact.changeset(@qso_attrs) |> Repo.insert() - qso + contact end describe "changeset/2" do test "valid with required fields" do - qso = create_qso() + contact = create_contact() attrs = %{ - qso_id: qso.id, + qso_id: contact.id, sample_count: 65, path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}], verdict: "CLEAR" @@ -50,10 +50,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do end test "valid with all optional fields" do - qso = create_qso() + contact = create_contact() attrs = %{ - qso_id: qso.id, + qso_id: contact.id, sample_count: 65, path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}], verdict: "BLOCKED", @@ -69,10 +69,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do end test "enforces unique qso_id constraint on insert" do - qso = create_qso() + contact = create_contact() attrs = %{ - qso_id: qso.id, + qso_id: contact.id, sample_count: 65, path_points: [], verdict: "CLEAR" diff --git a/test/microwaveprop/terrain_test.exs b/test/microwaveprop/terrain_test.exs index 9da90bc4..342e64c3 100644 --- a/test/microwaveprop/terrain_test.exs +++ b/test/microwaveprop/terrain_test.exs @@ -1,7 +1,7 @@ defmodule Microwaveprop.TerrainTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Terrain @qso_attrs %{ @@ -14,18 +14,18 @@ defmodule Microwaveprop.TerrainTest do pos2: %{"lat" => 30.3, "lon" => -97.7} } - defp create_qso do - {:ok, qso} = - %Qso{} - |> Qso.changeset(@qso_attrs) + defp create_contact do + {:ok, contact} = + %Contact{} + |> Contact.changeset(@qso_attrs) |> Repo.insert() - qso + contact end - defp terrain_attrs(qso) do + defp terrain_attrs(contact) do %{ - qso_id: qso.id, + qso_id: contact.id, sample_count: 65, path_points: [ %{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}, @@ -42,18 +42,18 @@ defmodule Microwaveprop.TerrainTest do describe "upsert_terrain_profile/1" do test "inserts a new terrain profile" do - qso = create_qso() - attrs = terrain_attrs(qso) + contact = create_contact() + attrs = terrain_attrs(contact) assert {:ok, profile} = Terrain.upsert_terrain_profile(attrs) - assert profile.qso_id == qso.id + assert profile.qso_id == contact.id assert profile.sample_count == 65 assert profile.verdict == "CLEAR" end test "upserts on conflict (same qso_id)" do - qso = create_qso() - attrs = terrain_attrs(qso) + contact = create_contact() + attrs = terrain_attrs(contact) {:ok, _} = Terrain.upsert_terrain_profile(attrs) @@ -67,30 +67,30 @@ defmodule Microwaveprop.TerrainTest do describe "get_terrain_profile/1" do test "returns profile for a QSO" do - qso = create_qso() - {:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso)) + contact = create_contact() + {:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact)) - profile = Terrain.get_terrain_profile(qso.id) - assert profile.qso_id == qso.id + profile = Terrain.get_terrain_profile(contact.id) + assert profile.qso_id == contact.id end test "returns nil when no profile exists" do - qso = create_qso() - assert Terrain.get_terrain_profile(qso.id) == nil + contact = create_contact() + assert Terrain.get_terrain_profile(contact.id) == nil end end describe "has_terrain_profile?/1" do test "returns true when profile exists" do - qso = create_qso() - {:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso)) + contact = create_contact() + {:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact)) - assert Terrain.has_terrain_profile?(qso.id) + assert Terrain.has_terrain_profile?(contact.id) end test "returns false when no profile exists" do - qso = create_qso() - refute Terrain.has_terrain_profile?(qso.id) + contact = create_contact() + refute Terrain.has_terrain_profile?(contact.id) end end end diff --git a/test/microwaveprop/weather_test.exs b/test/microwaveprop/weather_test.exs index 84a4ad11..89b5f14f 100644 --- a/test/microwaveprop/weather_test.exs +++ b/test/microwaveprop/weather_test.exs @@ -113,7 +113,7 @@ defmodule Microwaveprop.WeatherTest do end end - describe "weather_for_qso/2" do + describe "weather_for_contact/2" do test "returns nearby surface observations within time window with station preloaded" do {:ok, station} = Weather.find_or_create_station(@station_attrs) @@ -125,7 +125,7 @@ defmodule Microwaveprop.WeatherTest do # QSO near the station in space and time result = - Weather.weather_for_qso( + Weather.weather_for_contact( %{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]}, radius_km: 100, time_window_hours: 3 @@ -149,7 +149,7 @@ defmodule Microwaveprop.WeatherTest do }) result = - Weather.weather_for_qso( + Weather.weather_for_contact( %{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]}, radius_km: 100, time_window_hours: 6 @@ -170,7 +170,7 @@ defmodule Microwaveprop.WeatherTest do # QSO far away result = - Weather.weather_for_qso( + Weather.weather_for_contact( %{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]}, radius_km: 100, time_window_hours: 3 @@ -190,7 +190,7 @@ defmodule Microwaveprop.WeatherTest do # QSO much later result = - Weather.weather_for_qso( + Weather.weather_for_contact( %{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]}, radius_km: 100, time_window_hours: 3 @@ -482,31 +482,31 @@ defmodule Microwaveprop.WeatherTest do end end - describe "hrrr_for_qso/1" do + describe "hrrr_for_contact/1" do test "returns nearest HRRR profile for a QSO" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) - qso = %{ + contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, qso_timestamp: ~U[2026-03-28 18:30:00Z] } - profile = Weather.hrrr_for_qso(qso) + profile = Weather.hrrr_for_contact(contact) assert profile assert profile.lat == 32.90 end test "returns nil when no HRRR profiles exist nearby" do - qso = %{ + contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - assert Weather.hrrr_for_qso(qso) == nil + assert Weather.hrrr_for_contact(contact) == nil end test "returns nil when QSO has no pos1" do - assert Weather.hrrr_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil + assert Weather.hrrr_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil end end @@ -590,36 +590,36 @@ defmodule Microwaveprop.WeatherTest do lon: -97.70 }) - qso = %{ + contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, pos2: %{"lat" => 30.31, "lon" => -97.71}, qso_timestamp: ~U[2026-03-28 18:30:00Z] } - profiles = Weather.hrrr_profiles_for_path(qso) + profiles = Weather.hrrr_profiles_for_path(contact) assert length(profiles) >= 2 end test "returns empty list when no HRRR profiles exist nearby" do - qso = %{ + contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: %{"lat" => 41.0, "lon" => -81.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - assert Weather.hrrr_profiles_for_path(qso) == [] + assert Weather.hrrr_profiles_for_path(contact) == [] end test "returns profiles for pos1 only when pos2 is nil" do Weather.upsert_hrrr_profile(@hrrr_profile_attrs) - qso = %{ + contact = %{ pos1: %{"lat" => 32.91, "lon" => -97.05}, pos2: nil, qso_timestamp: ~U[2026-03-28 18:30:00Z] } - profiles = Weather.hrrr_profiles_for_path(qso) + profiles = Weather.hrrr_profiles_for_path(contact) assert length(profiles) == 1 end end @@ -636,36 +636,36 @@ defmodule Microwaveprop.WeatherTest do hourly: [%{"hour" => 0, "p01m_mm" => 1.0}] }) - qso = %{ + contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - observations = Weather.iemre_for_path(qso) + observations = Weather.iemre_for_path(contact) assert length(observations) >= 2 end test "returns empty list when no observations exist" do - qso = %{ + contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, pos2: %{"lat" => 41.0, "lon" => -81.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - assert Weather.iemre_for_path(qso) == [] + assert Weather.iemre_for_path(contact) == [] end test "returns observation for pos1 only when pos2 is nil" do Weather.upsert_iemre_observation(@iemre_attrs) - qso = %{ + contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - observations = Weather.iemre_for_path(qso) + observations = Weather.iemre_for_path(contact) assert length(observations) == 1 end end @@ -684,31 +684,31 @@ defmodule Microwaveprop.WeatherTest do end end - describe "iemre_for_qso/1" do + describe "iemre_for_contact/1" do test "returns IEMRE observation matching QSO pos1 and date" do Weather.upsert_iemre_observation(@iemre_attrs) - qso = %{ + contact = %{ pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - obs = Weather.iemre_for_qso(qso) + obs = Weather.iemre_for_contact(contact) assert obs assert obs.lat == 32.875 end test "returns nil when no IEMRE observation exists" do - qso = %{ + contact = %{ pos1: %{"lat" => 40.0, "lon" => -80.0}, qso_timestamp: ~U[2026-03-28 18:00:00Z] } - assert Weather.iemre_for_qso(qso) == nil + assert Weather.iemre_for_contact(contact) == nil end test "returns nil when QSO has no pos1" do - assert Weather.iemre_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil + assert Weather.iemre_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil end end end diff --git a/test/microwaveprop/workers/contact_weather_enqueue_worker_test.exs b/test/microwaveprop/workers/contact_weather_enqueue_worker_test.exs index d11969d1..debee52e 100644 --- a/test/microwaveprop/workers/contact_weather_enqueue_worker_test.exs +++ b/test/microwaveprop/workers/contact_weather_enqueue_worker_test.exs @@ -1,12 +1,12 @@ -defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do +defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorkerTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrClient alias Microwaveprop.Weather.IemClient - alias Microwaveprop.Workers.QsoWeatherEnqueueWorker + alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @qso_attrs %{ station1: "W5XD", @@ -21,13 +21,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do distance_km: Decimal.new("295") } - defp create_qso(attrs \\ %{}) do - {:ok, qso} = - %Qso{} - |> Qso.changeset(Map.merge(@qso_attrs, attrs)) + defp create_contact(attrs \\ %{}) do + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(@qso_attrs, attrs)) |> Repo.insert() - qso + contact end defp create_asos_station(code, lat, lon) do @@ -59,9 +59,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do describe "build_weather_jobs/1" do test "builds ASOS jobs for nearby stations" do station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso() + contact = create_contact() - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) asos_jobs = Enum.filter(jobs, fn j -> @@ -79,9 +79,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "builds RAOB jobs for nearby sounding stations" do station = create_sounding_station("FWD", 32.83, -97.30) - qso = create_qso() + contact = create_contact() - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) raob_jobs = Enum.filter(jobs, fn j -> @@ -100,10 +100,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do _station = create_asos_station("KDFW", 32.90, -97.04) # Two QSOs at the exact same time produce identical args → deduplicated - q1 = create_qso(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]}) - q2 = create_qso(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + q1 = create_contact(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]}) + q2 = create_contact(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]}) - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([q1, q2]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([q1, q2]) asos_jobs = Enum.filter(jobs, fn j -> @@ -116,31 +116,31 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "builds ASOS jobs for stations near pos2 and midpoint" do # Station near pos2 (30.3, -97.7) but NOT near pos1 (32.9, -97.0) station_near_pos2 = create_asos_station("KAUS", 30.20, -97.68) - qso = create_qso() + contact = create_contact() - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) station_ids = Enum.map(jobs, & &1.changes.args["station_id"]) assert station_near_pos2.id in station_ids end test "returns empty list when no stations nearby" do - qso = create_qso(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil}) + contact = create_contact(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil}) - assert QsoWeatherEnqueueWorker.build_weather_jobs([qso]) == [] + assert ContactWeatherEnqueueWorker.build_weather_jobs([contact]) == [] end test "skips ASOS job when surface observations already exist for that station and time window" do station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso() + contact = create_contact() - # Insert a surface observation within the time window (qso ± 2 hours) + # Insert a surface observation within the time window (contact ± 2 hours) Weather.upsert_surface_observation(station, %{ observed_at: ~U[2026-03-28 18:00:00Z], temp_f: 75.0 }) - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) asos_jobs = Enum.filter(jobs, fn j -> @@ -152,10 +152,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "skips RAOB job when sounding already exists for that station and time" do station = create_sounding_station("FWD", 32.83, -97.30) - qso = create_qso() + contact = create_contact() # Insert soundings for both bracketing times (00Z and 12Z on 2026-03-28) - for time <- Weather.sounding_times_around(qso.qso_timestamp) do + for time <- Weather.sounding_times_around(contact.qso_timestamp) do Weather.upsert_sounding(station, %{ observed_at: time, profile: [%{"pres" => 1013.0, "tmpc" => 25.0}], @@ -163,7 +163,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do }) end - jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact]) raob_jobs = Enum.filter(jobs, fn j -> @@ -207,58 +207,58 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "enqueues weather jobs and marks QSOs as queued" do _station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso() + contact = create_contact() - assert qso.weather_queued == false + assert contact.weather_queued == false - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.weather_queued == true end test "returns :ok when no unprocessed QSOs exist" do - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) end test "backfills distance_km for QSOs missing it" do _station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso(%{distance_km: nil}) + contact = create_contact(%{distance_km: nil}) - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.distance_km assert_in_delta Decimal.to_float(updated.distance_km), 295, 5 end test "skips QSOs without positions" do _station = create_asos_station("KDFW", 32.90, -97.04) - _qso = create_qso(%{pos1: nil}) + _qso = create_contact(%{pos1: nil}) - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) # QSO without pos1 should not be marked as queued - all_qsos = Repo.all(Qso) + all_qsos = Repo.all(Contact) refute Enum.any?(all_qsos, & &1.weather_queued) end test "enqueues HRRR jobs and marks QSOs as hrrr_queued" do - qso = create_qso() - assert qso.hrrr_queued == false + contact = create_contact() + assert contact.hrrr_queued == false - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.hrrr_queued == true end end describe "build_hrrr_jobs/1" do test "batches all path points into one job per HRRR hour" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) # All 3 path points share the same HRRR hour → 1 batch job assert length(jobs) == 1 @@ -271,9 +271,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do end test "batch contains single point when pos2 is nil" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil}) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) assert length(jobs) == 1 job = hd(jobs) @@ -283,9 +283,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do end test "rounds valid_time to nearest hour" do - qso = create_qso(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil}) + contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil}) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) job = hd(jobs) assert job.changes.args["valid_time"] == "2026-03-28T19:00:00Z" @@ -293,7 +293,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "deduplicates jobs across QSOs with identical path points" do q1 = - create_qso(%{ + create_contact(%{ station1: "A1", qso_timestamp: ~U[2026-03-28 18:10:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, @@ -301,14 +301,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do }) q2 = - create_qso(%{ + create_contact(%{ station1: "A2", qso_timestamp: ~U[2026-03-28 18:20:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, pos2: nil }) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([q1, q2]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([q1, q2]) # Both round to same hour and same location → 1 job assert length(jobs) == 1 @@ -316,26 +316,26 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "deduplicates path points that round to the same HRRR grid cell" do # pos1 and pos2 only 0.001° apart — all 3 points round to same HRRR cell - qso = - create_qso(%{ + contact = + create_contact(%{ pos1: %{"lat" => 32.901, "lon" => -97.041}, pos2: %{"lat" => 32.902, "lon" => -97.042} }) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) # All 3 path points round to {32.90, -97.04} → 1 job assert length(jobs) == 1 end test "skips QSOs without pos1" do - qso = create_qso(%{pos1: nil}) + contact = create_contact(%{pos1: nil}) - assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == [] + assert ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) == [] end test "excludes existing points from batch" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) # Insert a profile at the pos1 rounded grid point only Weather.upsert_hrrr_profile(%{ @@ -351,7 +351,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do surface_pressure_mb: 1013.0 }) - jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) # 1 batch job, but pos1 excluded from points list assert length(jobs) == 1 @@ -364,19 +364,19 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do describe "build_terrain_jobs/1" do test "builds one terrain job per QSO" do - qso = create_qso() + contact = create_contact() - jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact]) assert length(jobs) == 1 job = hd(jobs) - assert job.changes.args["qso_id"] == qso.id + assert job.changes.args["qso_id"] == contact.id end test "deduplicates by qso_id" do - qso = create_qso() + contact = create_contact() - jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso, qso]) + jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact, contact]) assert length(jobs) == 1 end end @@ -410,30 +410,30 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do end test "enqueues terrain jobs and marks QSOs as terrain_queued" do - qso = create_qso() - assert qso.terrain_queued == false + contact = create_contact() + assert contact.terrain_queued == false - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.terrain_queued == true end test "skips terrain for QSOs missing pos2" do - _qso = create_qso(%{pos2: nil}) + _qso = create_contact(%{pos2: nil}) - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - all_qsos = Repo.all(Qso) + all_qsos = Repo.all(Contact) refute Enum.any?(all_qsos, & &1.terrain_queued) end end describe "build_iemre_jobs/1" do test "builds IEMRE jobs for all path points (pos1, midpoint, pos2)" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) - jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) # pos1, midpoint, pos2 may have distinct IEMRE grid cells assert length(jobs) >= 2 @@ -442,9 +442,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do end test "builds only one IEMRE job when pos2 is nil" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil}) - jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) assert length(jobs) == 1 job = hd(jobs) @@ -455,7 +455,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "deduplicates jobs across QSOs with identical path points" do q1 = - create_qso(%{ + create_contact(%{ station1: "A1", qso_timestamp: ~U[2026-03-28 18:10:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, @@ -463,14 +463,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do }) q2 = - create_qso(%{ + create_contact(%{ station1: "A2", qso_timestamp: ~U[2026-03-28 20:20:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, pos2: nil }) - jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2]) + jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2]) # Same location and same date → 1 job assert length(jobs) == 1 @@ -478,7 +478,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "creates separate jobs for different dates" do q1 = - create_qso(%{ + create_contact(%{ station1: "A1", qso_timestamp: ~U[2026-03-28 23:00:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, @@ -486,24 +486,24 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do }) q2 = - create_qso(%{ + create_contact(%{ station1: "A2", qso_timestamp: ~U[2026-03-29 01:00:00Z], pos1: %{"lat" => 32.90, "lon" => -97.04}, pos2: nil }) - jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2]) + jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2]) assert length(jobs) == 2 end test "skips QSOs without pos1" do - qso = create_qso(%{pos1: nil}) - assert QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) == [] + contact = create_contact(%{pos1: nil}) + assert ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) == [] end test "skips only path points where IEMRE observation already exists" do - qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) + contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}}) # Insert an IEMRE observation at the pos1 rounded grid point only Weather.upsert_iemre_observation(%{ @@ -513,7 +513,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do hourly: [%{"hour" => 0, "p01m_mm" => 0.0}] }) - jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) + jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) # pos1 skipped, midpoint + pos2 still need fetching assert length(jobs) >= 1 @@ -551,17 +551,17 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do end test "enqueues IEMRE jobs and marks QSOs as iemre_queued" do - qso = create_qso() - assert qso.iemre_queued == false + contact = create_contact() + assert contact.iemre_queued == false - assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) + assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}}) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.iemre_queued == true end end - describe "enqueue_for_qso/1" do + describe "enqueue_for_contact/1" do setup do Req.Test.stub(IemClient, fn conn -> case conn.request_path do @@ -591,16 +591,16 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "enqueues all enrichment jobs and marks QSO as fully queued" do _station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso() + contact = create_contact() - assert qso.weather_queued == false - assert qso.hrrr_queued == false - assert qso.terrain_queued == false - assert qso.iemre_queued == false + assert contact.weather_queued == false + assert contact.hrrr_queued == false + assert contact.terrain_queued == false + assert contact.iemre_queued == false - assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso) + assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.weather_queued == true assert updated.hrrr_queued == true assert updated.terrain_queued == true @@ -609,11 +609,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do test "works for QSO without pos2 (marks terrain_queued false)" do _station = create_asos_station("KDFW", 32.90, -97.04) - qso = create_qso(%{pos2: nil}) + contact = create_contact(%{pos2: nil}) - assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso) + assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.weather_queued == true assert updated.hrrr_queued == true # terrain requires pos2, so it can't be queued diff --git a/test/microwaveprop/workers/terrain_profile_worker_test.exs b/test/microwaveprop/workers/terrain_profile_worker_test.exs index 77f7844b..68c1a768 100644 --- a/test/microwaveprop/workers/terrain_profile_worker_test.exs +++ b/test/microwaveprop/workers/terrain_profile_worker_test.exs @@ -1,7 +1,7 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Terrain alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Workers.TerrainProfileWorker @@ -17,13 +17,13 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do distance_km: Decimal.new("295") } - defp create_qso(attrs \\ %{}) do - {:ok, qso} = - %Qso{} - |> Qso.changeset(Map.merge(@qso_attrs, attrs)) + defp create_contact(attrs \\ %{}) do + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(@qso_attrs, attrs)) |> Repo.insert() - qso + contact end defp stub_elevation_api do @@ -38,15 +38,15 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do describe "perform/1" do test "fetches elevation and stores terrain profile" do stub_elevation_api() - qso = create_qso() + contact = create_contact() - refute Terrain.has_terrain_profile?(qso.id) + refute Terrain.has_terrain_profile?(contact.id) - job = TerrainProfileWorker.new(%{"qso_id" => qso.id}) + job = TerrainProfileWorker.new(%{"qso_id" => contact.id}) assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args}) - assert Terrain.has_terrain_profile?(qso.id) - profile = Terrain.get_terrain_profile(qso.id) + assert Terrain.has_terrain_profile?(contact.id) + profile = Terrain.get_terrain_profile(contact.id) assert profile.sample_count == 65 assert profile.verdict in ["CLEAR", "FRESNEL_MINOR", "FRESNEL_PARTIAL", "BLOCKED"] assert is_list(profile.path_points) @@ -54,17 +54,17 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do test "skips if terrain profile already exists" do stub_elevation_api() - qso = create_qso() + contact = create_contact() {:ok, _} = Terrain.upsert_terrain_profile(%{ - qso_id: qso.id, + qso_id: contact.id, sample_count: 65, path_points: [], verdict: "CLEAR" }) - job = TerrainProfileWorker.new(%{"qso_id" => qso.id}) + job = TerrainProfileWorker.new(%{"qso_id" => contact.id}) assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args}) end @@ -73,22 +73,22 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do Plug.Conn.send_resp(conn, 500, "error") end) - qso = create_qso() + contact = create_contact() - job = TerrainProfileWorker.new(%{"qso_id" => qso.id}) + job = TerrainProfileWorker.new(%{"qso_id" => contact.id}) assert {:error, _} = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args}) - refute Terrain.has_terrain_profile?(qso.id) + refute Terrain.has_terrain_profile?(contact.id) end test "uses correct frequency from band" do stub_elevation_api() - qso = create_qso(%{band: Decimal.new("10368")}) + contact = create_contact(%{band: Decimal.new("10368")}) - job = TerrainProfileWorker.new(%{"qso_id" => qso.id}) + job = TerrainProfileWorker.new(%{"qso_id" => contact.id}) assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args}) - profile = Terrain.get_terrain_profile(qso.id) + profile = Terrain.get_terrain_profile(contact.id) assert profile.verdict end end diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index 1a8e8a24..37b14641 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -1,12 +1,12 @@ -defmodule MicrowavepropWeb.QsoLiveTest do +defmodule MicrowavepropWeb.ContactLiveTest do use MicrowavepropWeb.ConnCase, async: true import Phoenix.LiveViewTest - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo - defp create_qso(attrs \\ %{}) do + defp create_contact(attrs \\ %{}) do default = %{ station1: "W5XD", station2: "K5TR", @@ -20,24 +20,24 @@ defmodule MicrowavepropWeb.QsoLiveTest do distance_km: Decimal.new("295") } - {:ok, qso} = - %Qso{} - |> Qso.changeset(Map.merge(default, attrs)) + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(default, attrs)) |> Repo.insert() - qso + contact end describe "Index" do test "renders page with QSOs heading", %{conn: conn} do - {:ok, _lv, html} = live(conn, ~p"/qsos") + {:ok, _lv, html} = live(conn, ~p"/contacts") assert html =~ "QSOs" end test "table shows QSO data", %{conn: conn} do - create_qso() + create_contact() - {:ok, _lv, html} = live(conn, ~p"/qsos") + {:ok, _lv, html} = live(conn, ~p"/contacts") assert html =~ "W5XD" assert html =~ "K5TR" assert html =~ "CW" @@ -45,17 +45,17 @@ defmodule MicrowavepropWeb.QsoLiveTest do end test "row links to detail page", %{conn: conn} do - qso = create_qso() + contact = create_contact() - {:ok, _lv, html} = live(conn, ~p"/qsos") - assert html =~ ~p"/qsos/#{qso.id}" + {:ok, _lv, html} = live(conn, ~p"/contacts") + assert html =~ ~p"/contacts/#{contact.id}" end test "sort params order the table", %{conn: conn} do - create_qso(%{station1: "ZZ9ZZ"}) - create_qso(%{station1: "AA1AA"}) + create_contact(%{station1: "ZZ9ZZ"}) + create_contact(%{station1: "AA1AA"}) - {:ok, _lv, html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc") + {:ok, _lv, html} = live(conn, ~p"/contacts?sort_by=station1&sort_order=asc") # AA1AA should appear before ZZ9ZZ aa_pos = html |> :binary.match("AA1AA") |> elem(0) @@ -66,22 +66,22 @@ defmodule MicrowavepropWeb.QsoLiveTest do test "pagination works", %{conn: conn} do for i <- 1..25 do ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second) - create_qso(%{qso_timestamp: ts}) + create_contact(%{qso_timestamp: ts}) end - {:ok, _lv, html} = live(conn, ~p"/qsos") + {:ok, _lv, html} = live(conn, ~p"/contacts") assert html =~ "Page 1 of 2" - {:ok, _lv, html2} = live(conn, ~p"/qsos?page=2") + {:ok, _lv, html2} = live(conn, ~p"/contacts?page=2") assert html2 =~ "Page 2 of 2" end end describe "Show" do test "renders QSO detail fields", %{conn: conn} do - qso = create_qso() + contact = create_contact() - {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}") assert html =~ "W5XD" assert html =~ "K5TR" assert html =~ "CW" @@ -91,38 +91,38 @@ defmodule MicrowavepropWeb.QsoLiveTest do end test "shows surface observations section", %{conn: conn} do - qso = create_qso() - {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + contact = create_contact() + {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}") assert html =~ "Surface Observations" end test "shows sounding section", %{conn: conn} do - qso = create_qso() - {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + contact = create_contact() + {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}") assert html =~ "Soundings" end test "shows solar index section", %{conn: conn} do - qso = create_qso() - {:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}") + contact = create_contact() + {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}") assert html =~ "Solar Conditions" end test "renders with sort assigns", %{conn: conn} do - qso = create_qso() - {:ok, lv, html} = live(conn, ~p"/qsos/#{qso.id}") + contact = create_contact() + {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}") # Page renders without error assert html =~ "Surface Observations" assert html =~ "Soundings" # Sort assigns are initialized (no crash on render) - assert render(lv) =~ qso.station1 + assert render(lv) =~ contact.station1 end test "raises for bad UUID", %{conn: conn} do assert_raise Ecto.NoResultsError, fn -> - live(conn, ~p"/qsos/#{Ecto.UUID.generate()}") + live(conn, ~p"/contacts/#{Ecto.UUID.generate()}") end end end diff --git a/test/microwaveprop_web/live/submit_live_test.exs b/test/microwaveprop_web/live/submit_live_test.exs index 97868917..edb4acc1 100644 --- a/test/microwaveprop_web/live/submit_live_test.exs +++ b/test/microwaveprop_web/live/submit_live_test.exs @@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do import Phoenix.LiveViewTest - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Weather.HrrrClient @@ -33,7 +33,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do describe "mount" do test "renders the submission form", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/submit") - assert html =~ "Submit QSO" + assert html =~ "Submit Contact" assert html =~ "Station 1" assert html =~ "Station 2" assert html =~ "Grid 1" @@ -49,7 +49,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do html = lv - |> form("#qso-form", qso: %{station1: ""}) + |> form("#contact-form", contact: %{station1: ""}) |> render_change() assert html =~ "can't be blank" @@ -61,8 +61,8 @@ defmodule MicrowavepropWeb.SubmitLiveTest do {:ok, lv, _html} = live(conn, ~p"/submit") lv - |> form("#qso-form", - qso: %{ + |> form("#contact-form", + contact: %{ station1: "W5XD", station2: "K5TR", qso_timestamp: "2026-03-28T18:00", @@ -75,12 +75,12 @@ defmodule MicrowavepropWeb.SubmitLiveTest do ) |> render_submit() - qso = Repo.one!(Qso) - assert qso.station1 == "W5XD" - assert qso.user_submitted == true - assert qso.pos1["lat"] + contact = Repo.one!(Contact) + assert contact.station1 == "W5XD" + assert contact.user_submitted == true + assert contact.pos1["lat"] - assert_redirect(lv, ~p"/qsos/#{qso.id}") + assert_redirect(lv, ~p"/contacts/#{contact.id}") end test "shows errors on invalid submit", %{conn: conn} do @@ -88,7 +88,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do html = lv - |> form("#qso-form", qso: %{station1: ""}) + |> form("#contact-form", contact: %{station1: ""}) |> render_submit() assert html =~ "can't be blank" diff --git a/test/mix/tasks/reset_enrichment_test.exs b/test/mix/tasks/reset_enrichment_test.exs index cb6f8d7c..c12613f6 100644 --- a/test/mix/tasks/reset_enrichment_test.exs +++ b/test/mix/tasks/reset_enrichment_test.exs @@ -1,7 +1,7 @@ defmodule Mix.Tasks.ResetEnrichmentTest do use Microwaveprop.DataCase, async: true - alias Microwaveprop.Radio.Qso + alias Microwaveprop.Radio.Contact alias Mix.Tasks.ResetEnrichment defp create_queued_qso do @@ -20,41 +20,41 @@ defmodule Mix.Tasks.ResetEnrichmentTest do distance_km: Decimal.new("295") } - {:ok, qso} = - %Qso{} - |> Qso.changeset(default) + {:ok, contact} = + %Contact{} + |> Contact.changeset(default) |> Repo.insert() # Set all queued flags to true (not in changeset cast) - Qso - |> where([q], q.id == ^qso.id) + Contact + |> where([q], q.id == ^contact.id) |> Repo.update_all(set: [weather_queued: true, hrrr_queued: true, iemre_queued: true, terrain_queued: true]) - Repo.get!(Qso, qso.id) + Repo.get!(Contact, contact.id) end describe "run/1" do test "resets weather_queued, hrrr_queued, and iemre_queued to false" do - qso = create_queued_qso() + contact = create_queued_qso() - assert qso.weather_queued == true - assert qso.hrrr_queued == true - assert qso.iemre_queued == true + assert contact.weather_queued == true + assert contact.hrrr_queued == true + assert contact.iemre_queued == true ResetEnrichment.run([]) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.weather_queued == false assert updated.hrrr_queued == false assert updated.iemre_queued == false end test "does not reset terrain_queued" do - qso = create_queued_qso() + contact = create_queued_qso() ResetEnrichment.run([]) - updated = Repo.get!(Qso, qso.id) + updated = Repo.get!(Contact, contact.id) assert updated.terrain_queued == true end