Rename qsos to contacts throughout codebase, keep DB table name
Rename all modules, functions, variables, routes, and UI text from qso/qsos to contact/contacts. Database table stays as "qsos" to avoid migration. Add /qsos -> /contacts redirects for old URLs.
This commit is contained in:
parent
bc529af392
commit
254e64dedc
26 changed files with 620 additions and 609 deletions
|
|
@ -51,7 +51,7 @@ config :microwaveprop, Oban,
|
||||||
{Oban.Plugins.Cron,
|
{Oban.Plugins.Cron,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||||
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker},
|
{"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker},
|
||||||
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
{"*/5 * * * *", Microwaveprop.Commercial.PollWorker},
|
||||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}
|
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker}
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ config :microwaveprop, Oban,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
{"5 * * * *", Microwaveprop.Workers.PropagationGridWorker},
|
||||||
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
{"0 8 * * *", Microwaveprop.Workers.SolarIndexWorker},
|
||||||
{"*/30 * * * *", Microwaveprop.Workers.QsoWeatherEnqueueWorker}
|
{"*/30 * * * *", Microwaveprop.Workers.ContactWeatherEnqueueWorker}
|
||||||
]}
|
]}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,21 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Radio.Maidenhead
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Radio.Qso
|
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
@per_page 20
|
@per_page 20
|
||||||
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
||||||
|
|
||||||
def list_qsos(opts \\ []) do
|
def list_contacts(opts \\ []) do
|
||||||
page = max(Keyword.get(opts, :page, 1), 1)
|
page = max(Keyword.get(opts, :page, 1), 1)
|
||||||
offset = (page - 1) * @per_page
|
offset = (page - 1) * @per_page
|
||||||
search = Keyword.get(opts, :search)
|
search = Keyword.get(opts, :search)
|
||||||
|
|
||||||
{sort_field, sort_dir} = sort_opts(opts)
|
{sort_field, sort_dir} = sort_opts(opts)
|
||||||
|
|
||||||
base_query = maybe_search(Qso, search)
|
base_query = maybe_search(Contact, search)
|
||||||
|
|
||||||
total_entries = Repo.aggregate(base_query, :count)
|
total_entries = Repo.aggregate(base_query, :count)
|
||||||
total_pages = max(ceil(total_entries / @per_page), 1)
|
total_pages = max(ceil(total_entries / @per_page), 1)
|
||||||
|
|
@ -39,26 +39,26 @@ defmodule Microwaveprop.Radio do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Groups reciprocal QSOs (same station pair swapped, same band, same timestamp).
|
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
|
||||||
Returns a list of `{primary_qso, reciprocals}` tuples where reciprocals is
|
Returns a list of `{primary, reciprocals}` tuples where reciprocals is
|
||||||
a (possibly empty) list of matching QSOs.
|
a (possibly empty) list of matching contacts.
|
||||||
"""
|
"""
|
||||||
def group_reciprocals(qsos) do
|
def group_reciprocals(contacts) do
|
||||||
{groups, _seen_ids} =
|
{groups, _seen_ids} =
|
||||||
Enum.reduce(qsos, {[], MapSet.new()}, fn qso, {groups, seen} ->
|
Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} ->
|
||||||
if MapSet.member?(seen, qso.id) do
|
if MapSet.member?(seen, contact.id) do
|
||||||
{groups, seen}
|
{groups, seen}
|
||||||
else
|
else
|
||||||
reciprocals =
|
reciprocals =
|
||||||
Enum.filter(qsos, fn other ->
|
Enum.filter(contacts, fn other ->
|
||||||
other.id != qso.id and
|
other.id != contact.id and
|
||||||
not MapSet.member?(seen, other.id) and
|
not MapSet.member?(seen, other.id) and
|
||||||
other.band == qso.band and
|
other.band == contact.band and
|
||||||
reciprocal_pair?(qso, other)
|
reciprocal_pair?(contact, other)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, qso.id), &MapSet.put(&2, &1.id))
|
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id))
|
||||||
{[{qso, reciprocals} | groups], new_seen}
|
{[{contact, reciprocals} | groups], new_seen}
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -97,75 +97,75 @@ defmodule Microwaveprop.Radio do
|
||||||
{field, dir}
|
{field, dir}
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessed_qsos(limit \\ 500) do
|
def unprocessed_contacts(limit \\ 500) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.weather_queued == false and not is_nil(q.pos1))
|
|> where([q], q.weather_queued == false and not is_nil(q.pos1))
|
||||||
|> order_by([q], asc: q.qso_timestamp)
|
|> order_by([q], asc: q.qso_timestamp)
|
||||||
|> limit(^limit)
|
|> limit(^limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_weather_queued!(qso_ids) do
|
def mark_weather_queued!(ids) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id in ^qso_ids)
|
|> where([q], q.id in ^ids)
|
||||||
|> Repo.update_all(set: [weather_queued: true])
|
|> Repo.update_all(set: [weather_queued: true])
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessed_hrrr_qsos(limit \\ 500) do
|
def unprocessed_hrrr_contacts(limit \\ 500) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.hrrr_queued == false and not is_nil(q.pos1))
|
|> where([q], q.hrrr_queued == false and not is_nil(q.pos1))
|
||||||
|> order_by([q], asc: q.qso_timestamp)
|
|> order_by([q], asc: q.qso_timestamp)
|
||||||
|> limit(^limit)
|
|> limit(^limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_hrrr_queued!(qso_ids) do
|
def mark_hrrr_queued!(ids) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id in ^qso_ids)
|
|> where([q], q.id in ^ids)
|
||||||
|> Repo.update_all(set: [hrrr_queued: true])
|
|> Repo.update_all(set: [hrrr_queued: true])
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessed_terrain_qsos(limit \\ 500) do
|
def unprocessed_terrain_contacts(limit \\ 500) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2))
|
|> where([q], q.terrain_queued == false and not is_nil(q.pos1) and not is_nil(q.pos2))
|
||||||
|> order_by([q], asc: q.qso_timestamp)
|
|> order_by([q], asc: q.qso_timestamp)
|
||||||
|> limit(^limit)
|
|> limit(^limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_terrain_queued!(qso_ids) do
|
def mark_terrain_queued!(ids) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id in ^qso_ids)
|
|> where([q], q.id in ^ids)
|
||||||
|> Repo.update_all(set: [terrain_queued: true])
|
|> Repo.update_all(set: [terrain_queued: true])
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessed_iemre_qsos(limit \\ 500) do
|
def unprocessed_iemre_contacts(limit \\ 500) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.iemre_queued == false and not is_nil(q.pos1))
|
|> where([q], q.iemre_queued == false and not is_nil(q.pos1))
|
||||||
|> order_by([q], asc: q.qso_timestamp)
|
|> order_by([q], asc: q.qso_timestamp)
|
||||||
|> limit(^limit)
|
|> limit(^limit)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
def mark_iemre_queued!(qso_ids) do
|
def mark_iemre_queued!(ids) do
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id in ^qso_ids)
|
|> where([q], q.id in ^ids)
|
||||||
|> Repo.update_all(set: [iemre_queued: true])
|
|> Repo.update_all(set: [iemre_queued: true])
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a list of {lat, lon} points along the QSO path: pos1, midpoint, pos2.
|
Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2.
|
||||||
Returns [pos1] if pos2 is nil, or [] if pos1 is nil.
|
Returns [pos1] if pos2 is nil, or [] if pos1 is nil.
|
||||||
"""
|
"""
|
||||||
def qso_path_points(%{pos1: nil}), do: []
|
def contact_path_points(%{pos1: nil}), do: []
|
||||||
|
|
||||||
def qso_path_points(%{pos1: pos1, pos2: nil}) do
|
def contact_path_points(%{pos1: pos1, pos2: nil}) do
|
||||||
lat = pos1["lat"]
|
lat = pos1["lat"]
|
||||||
lon = pos1["lon"] || pos1["lng"]
|
lon = pos1["lon"] || pos1["lng"]
|
||||||
if lat && lon, do: [{lat, lon}], else: []
|
if lat && lon, do: [{lat, lon}], else: []
|
||||||
end
|
end
|
||||||
|
|
||||||
def qso_path_points(%{pos1: pos1, pos2: pos2}) do
|
def contact_path_points(%{pos1: pos1, pos2: pos2}) do
|
||||||
lat1 = pos1["lat"]
|
lat1 = pos1["lat"]
|
||||||
lon1 = pos1["lon"] || pos1["lng"]
|
lon1 = pos1["lon"] || pos1["lng"]
|
||||||
lat2 = pos2["lat"]
|
lat2 = pos2["lat"]
|
||||||
|
|
@ -195,19 +195,19 @@ defmodule Microwaveprop.Radio do
|
||||||
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
|
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
|
||||||
end
|
end
|
||||||
|
|
||||||
def backfill_distances(qsos) do
|
def backfill_distances(contacts) do
|
||||||
Enum.each(qsos, fn qso ->
|
Enum.each(contacts, fn contact ->
|
||||||
if is_nil(qso.distance_km) && qso.pos1 && qso.pos2 do
|
if is_nil(contact.distance_km) && contact.pos1 && contact.pos2 do
|
||||||
lat1 = qso.pos1["lat"]
|
lat1 = contact.pos1["lat"]
|
||||||
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
|
lon1 = contact.pos1["lon"] || contact.pos1["lng"]
|
||||||
lat2 = qso.pos2["lat"]
|
lat2 = contact.pos2["lat"]
|
||||||
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
|
lon2 = contact.pos2["lon"] || contact.pos2["lng"]
|
||||||
|
|
||||||
if lat1 && lon1 && lat2 && lon2 do
|
if lat1 && lon1 && lat2 && lon2 do
|
||||||
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
||||||
|
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id == ^qso.id)
|
|> where([q], q.id == ^contact.id)
|
||||||
|> Repo.update_all(set: [distance_km: dist])
|
|> Repo.update_all(set: [distance_km: dist])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -216,16 +216,16 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
||||||
|
|
||||||
def get_qso!(id) do
|
def get_contact!(id) do
|
||||||
Repo.get!(Qso, id)
|
Repo.get!(Contact, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def change_qso(qso, attrs \\ %{}) do
|
def change_contact(contact, attrs \\ %{}) do
|
||||||
Qso.submission_changeset(qso, attrs)
|
Contact.submission_changeset(contact, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_qso(attrs) do
|
def create_contact(attrs) do
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
|
|
||||||
if changeset.valid? do
|
if changeset.valid? do
|
||||||
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
|
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Microwaveprop.Radio.Qso do
|
defmodule Microwaveprop.Radio.Contact do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
|
|
||||||
|
|
@ -33,8 +33,8 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
@required_fields ~w(station1 station2 qso_timestamp mode band)a
|
@required_fields ~w(station1 station2 qso_timestamp mode band)a
|
||||||
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a
|
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km)a
|
||||||
|
|
||||||
def changeset(qso, attrs) do
|
def changeset(contact, attrs) do
|
||||||
qso
|
contact
|
||||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
|
|
@ -43,8 +43,8 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
@allowed_modes ~w(CW SSB FM FT8 FT4)
|
@allowed_modes ~w(CW SSB FM FT8 FT4)
|
||||||
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
|
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
|
||||||
|
|
||||||
def submission_changeset(qso, attrs) do
|
def submission_changeset(contact, attrs) do
|
||||||
qso
|
contact
|
||||||
|> cast(attrs, @submission_fields)
|
|> cast(attrs, @submission_fields)
|
||||||
|> validate_required(@submission_fields)
|
|> validate_required(@submission_fields)
|
||||||
|> validate_grid(:grid1)
|
|> validate_grid(:grid1)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ defmodule Microwaveprop.Terrain.TerrainProfile do
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
schema "terrain_profiles" do
|
schema "terrain_profiles" do
|
||||||
belongs_to :qso, Microwaveprop.Radio.Qso
|
belongs_to :contact, Microwaveprop.Radio.Contact, foreign_key: :qso_id
|
||||||
field :sample_count, :integer
|
field :sample_count, :integer
|
||||||
field :path_points, {:array, :map}
|
field :path_points, {:array, :map}
|
||||||
field :max_elevation_m, :float
|
field :max_elevation_m, :float
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,10 @@ defmodule Microwaveprop.Weather do
|
||||||
Enum.uniq(times)
|
Enum.uniq(times)
|
||||||
end
|
end
|
||||||
|
|
||||||
def weather_for_qso(qso_params, opts \\ []) do
|
def weather_for_contact(contact_params, opts \\ []) do
|
||||||
lat = qso_params[:lat] || qso_params.lat
|
lat = contact_params[:lat] || contact_params.lat
|
||||||
lon = qso_params[:lon] || qso_params.lon
|
lon = contact_params[:lon] || contact_params.lon
|
||||||
timestamp = qso_params[:timestamp] || qso_params.timestamp
|
timestamp = contact_params[:timestamp] || contact_params.timestamp
|
||||||
|
|
||||||
radius_km = Keyword.get(opts, :radius_km, 150)
|
radius_km = Keyword.get(opts, :radius_km, 150)
|
||||||
time_window_hours = Keyword.get(opts, :time_window_hours, 6)
|
time_window_hours = Keyword.get(opts, :time_window_hours, 6)
|
||||||
|
|
@ -203,14 +203,14 @@ defmodule Microwaveprop.Weather do
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
def hrrr_for_qso(%{pos1: nil}), do: nil
|
def hrrr_for_contact(%{pos1: nil}), do: nil
|
||||||
|
|
||||||
def hrrr_for_qso(qso) do
|
def hrrr_for_contact(contact) do
|
||||||
lat = qso.pos1["lat"]
|
lat = contact.pos1["lat"]
|
||||||
lon = qso.pos1["lon"] || qso.pos1["lng"]
|
lon = contact.pos1["lon"] || contact.pos1["lng"]
|
||||||
|
|
||||||
if lat && lon do
|
if lat && lon do
|
||||||
find_nearest_hrrr(lat, lon, qso.qso_timestamp)
|
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -245,10 +245,10 @@ defmodule Microwaveprop.Weather do
|
||||||
|
|
||||||
def hrrr_profiles_for_path(%{pos1: nil}), do: []
|
def hrrr_profiles_for_path(%{pos1: nil}), do: []
|
||||||
|
|
||||||
def hrrr_profiles_for_path(qso) do
|
def hrrr_profiles_for_path(contact) do
|
||||||
qso
|
contact
|
||||||
|> Microwaveprop.Radio.qso_path_points()
|
|> Microwaveprop.Radio.contact_path_points()
|
||||||
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, qso.qso_timestamp) end)
|
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -301,14 +301,14 @@ defmodule Microwaveprop.Weather do
|
||||||
|> Repo.exists?()
|
|> Repo.exists?()
|
||||||
end
|
end
|
||||||
|
|
||||||
def iemre_for_qso(%{pos1: nil}), do: nil
|
def iemre_for_contact(%{pos1: nil}), do: nil
|
||||||
|
|
||||||
def iemre_for_qso(qso) do
|
def iemre_for_contact(contact) do
|
||||||
lat = qso.pos1["lat"]
|
lat = contact.pos1["lat"]
|
||||||
lon = qso.pos1["lon"] || qso.pos1["lng"]
|
lon = contact.pos1["lon"] || contact.pos1["lng"]
|
||||||
|
|
||||||
if lat && lon do
|
if lat && lon do
|
||||||
find_nearest_iemre(lat, lon, qso.qso_timestamp)
|
find_nearest_iemre(lat, lon, contact.qso_timestamp)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -323,10 +323,10 @@ defmodule Microwaveprop.Weather do
|
||||||
|
|
||||||
def iemre_for_path(%{pos1: nil}), do: []
|
def iemre_for_path(%{pos1: nil}), do: []
|
||||||
|
|
||||||
def iemre_for_path(qso) do
|
def iemre_for_path(contact) do
|
||||||
qso
|
contact
|
||||||
|> Microwaveprop.Radio.qso_path_points()
|
|> Microwaveprop.Radio.contact_path_points()
|
||||||
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, qso.qso_timestamp) end)
|
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Oban.Worker, queue: :enqueue, max_attempts: 3
|
use Oban.Worker, queue: :enqueue, max_attempts: 3
|
||||||
|
|
||||||
|
|
@ -16,14 +16,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
@insert_batch_size 400
|
@insert_batch_size 400
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single QSO.
|
Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact.
|
||||||
Called directly from submission flow — no Oban indirection.
|
Called directly from submission flow — no Oban indirection.
|
||||||
"""
|
"""
|
||||||
def enqueue_for_qso(qso) do
|
def enqueue_for_contact(contact) do
|
||||||
weather_jobs = build_weather_jobs([qso])
|
weather_jobs = build_weather_jobs([contact])
|
||||||
hrrr_jobs = build_hrrr_jobs([qso])
|
hrrr_jobs = build_hrrr_jobs([contact])
|
||||||
terrain_jobs = build_terrain_jobs([qso])
|
terrain_jobs = build_terrain_jobs([contact])
|
||||||
iemre_jobs = build_iemre_jobs([qso])
|
iemre_jobs = build_iemre_jobs([contact])
|
||||||
|
|
||||||
all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs
|
all_jobs = weather_jobs ++ hrrr_jobs ++ terrain_jobs ++ iemre_jobs
|
||||||
|
|
||||||
|
|
@ -31,11 +31,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
insert_all_chunked(all_jobs)
|
insert_all_chunked(all_jobs)
|
||||||
end
|
end
|
||||||
|
|
||||||
qso_ids = [qso.id]
|
ids = [contact.id]
|
||||||
if qso.pos1, do: Radio.mark_weather_queued!(qso_ids)
|
if contact.pos1, do: Radio.mark_weather_queued!(ids)
|
||||||
if qso.pos1, do: Radio.mark_hrrr_queued!(qso_ids)
|
if contact.pos1, do: Radio.mark_hrrr_queued!(ids)
|
||||||
if qso.pos1 && qso.pos2, do: Radio.mark_terrain_queued!(qso_ids)
|
if contact.pos1 && contact.pos2, do: Radio.mark_terrain_queued!(ids)
|
||||||
if qso.pos1, do: Radio.mark_iemre_queued!(qso_ids)
|
if contact.pos1, do: Radio.mark_iemre_queued!(ids)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
@ -51,85 +51,82 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_weather_jobs do
|
defp enqueue_weather_jobs do
|
||||||
case Radio.unprocessed_qsos() do
|
case Radio.unprocessed_contacts() do
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
qsos ->
|
contacts ->
|
||||||
Radio.backfill_distances(qsos)
|
Radio.backfill_distances(contacts)
|
||||||
|
|
||||||
jobs = build_weather_jobs(qsos)
|
jobs = build_weather_jobs(contacts)
|
||||||
|
|
||||||
if jobs != [] do
|
if jobs != [] do
|
||||||
insert_all_chunked(jobs)
|
insert_all_chunked(jobs)
|
||||||
end
|
end
|
||||||
|
|
||||||
qso_ids = Enum.map(qsos, & &1.id)
|
ids = Enum.map(contacts, & &1.id)
|
||||||
Radio.mark_weather_queued!(qso_ids)
|
Radio.mark_weather_queued!(ids)
|
||||||
|
|
||||||
enqueue_weather_jobs()
|
enqueue_weather_jobs()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_hrrr_jobs do
|
defp enqueue_hrrr_jobs do
|
||||||
case Radio.unprocessed_hrrr_qsos() do
|
case Radio.unprocessed_hrrr_contacts() do
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
qsos ->
|
contacts ->
|
||||||
jobs = build_hrrr_jobs(qsos)
|
jobs = build_hrrr_jobs(contacts)
|
||||||
|
|
||||||
if jobs != [] do
|
if jobs != [] do
|
||||||
insert_all_chunked(jobs)
|
insert_all_chunked(jobs)
|
||||||
end
|
end
|
||||||
|
|
||||||
qso_ids = Enum.map(qsos, & &1.id)
|
ids = Enum.map(contacts, & &1.id)
|
||||||
Radio.mark_hrrr_queued!(qso_ids)
|
Radio.mark_hrrr_queued!(ids)
|
||||||
|
|
||||||
enqueue_hrrr_jobs()
|
enqueue_hrrr_jobs()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_terrain_jobs do
|
defp enqueue_terrain_jobs do
|
||||||
case Radio.unprocessed_terrain_qsos() do
|
case Radio.unprocessed_terrain_contacts() do
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
qsos ->
|
contacts ->
|
||||||
jobs = build_terrain_jobs(qsos)
|
jobs = build_terrain_jobs(contacts)
|
||||||
|
|
||||||
if jobs != [] do
|
if jobs != [] do
|
||||||
insert_all_chunked(jobs)
|
insert_all_chunked(jobs)
|
||||||
end
|
end
|
||||||
|
|
||||||
qso_ids = Enum.map(qsos, & &1.id)
|
ids = Enum.map(contacts, & &1.id)
|
||||||
Radio.mark_terrain_queued!(qso_ids)
|
Radio.mark_terrain_queued!(ids)
|
||||||
|
|
||||||
# Continue until all QSOs are enqueued
|
|
||||||
enqueue_terrain_jobs()
|
enqueue_terrain_jobs()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_terrain_jobs(qsos) do
|
def build_terrain_jobs(contacts) do
|
||||||
qsos
|
contacts
|
||||||
|> Enum.filter(fn qso -> qso.pos1 && qso.pos2 end)
|
|> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end)
|
||||||
|> Enum.map(fn qso ->
|
|> Enum.map(fn contact ->
|
||||||
TerrainProfileWorker.new(%{"qso_id" => qso.id})
|
TerrainProfileWorker.new(%{"qso_id" => contact.id})
|
||||||
end)
|
end)
|
||||||
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_weather_jobs(qsos) do
|
def build_weather_jobs(contacts) do
|
||||||
qsos
|
contacts
|
||||||
|> Enum.flat_map(&jobs_for_qso/1)
|
|> Enum.flat_map(&jobs_for_contact/1)
|
||||||
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_hrrr_jobs(qsos) do
|
def build_hrrr_jobs(contacts) do
|
||||||
# Group all QSO points by HRRR hour, then create one batch job per hour
|
contacts
|
||||||
# instead of one job per point. This deduplicates GRIB2 downloads.
|
|> Enum.flat_map(&hrrr_points_for_contact/1)
|
||||||
qsos
|
|
||||||
|> Enum.flat_map(&hrrr_points_for_qso/1)
|
|
||||||
|> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end)
|
|> Enum.group_by(fn {_point, hour} -> hour end, fn {point, _hour} -> point end)
|
||||||
|> Enum.map(fn {hour, points} ->
|
|> Enum.map(fn {hour, points} ->
|
||||||
unique_points =
|
unique_points =
|
||||||
|
|
@ -144,47 +141,47 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_iemre_jobs(qsos) do
|
def build_iemre_jobs(contacts) do
|
||||||
qsos
|
contacts
|
||||||
|> Enum.flat_map(&iemre_job_for_qso/1)
|
|> Enum.flat_map(&iemre_job_for_contact/1)
|
||||||
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
|> Enum.uniq_by(fn changeset -> changeset.changes.args end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp jobs_for_qso(qso) do
|
defp jobs_for_contact(contact) do
|
||||||
qso
|
contact
|
||||||
|> Radio.qso_path_points()
|
|> Radio.contact_path_points()
|
||||||
|> Enum.flat_map(fn {lat, lon} ->
|
|> Enum.flat_map(fn {lat, lon} ->
|
||||||
build_asos_jobs(lat, lon, qso.qso_timestamp) ++
|
build_asos_jobs(lat, lon, contact.qso_timestamp) ++
|
||||||
build_raob_jobs(lat, lon, qso.qso_timestamp)
|
build_raob_jobs(lat, lon, contact.qso_timestamp)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_iemre_jobs do
|
defp enqueue_iemre_jobs do
|
||||||
case Radio.unprocessed_iemre_qsos() do
|
case Radio.unprocessed_iemre_contacts() do
|
||||||
[] ->
|
[] ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
qsos ->
|
contacts ->
|
||||||
jobs = build_iemre_jobs(qsos)
|
jobs = build_iemre_jobs(contacts)
|
||||||
|
|
||||||
if jobs != [] do
|
if jobs != [] do
|
||||||
insert_all_chunked(jobs)
|
insert_all_chunked(jobs)
|
||||||
end
|
end
|
||||||
|
|
||||||
qso_ids = Enum.map(qsos, & &1.id)
|
ids = Enum.map(contacts, & &1.id)
|
||||||
Radio.mark_iemre_queued!(qso_ids)
|
Radio.mark_iemre_queued!(ids)
|
||||||
|
|
||||||
enqueue_iemre_jobs()
|
enqueue_iemre_jobs()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp hrrr_points_for_qso(%{pos1: nil}), do: []
|
defp hrrr_points_for_contact(%{pos1: nil}), do: []
|
||||||
|
|
||||||
defp hrrr_points_for_qso(qso) do
|
defp hrrr_points_for_contact(contact) do
|
||||||
rounded_time = HrrrClient.nearest_hrrr_hour(qso.qso_timestamp)
|
rounded_time = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
|
||||||
|
|
||||||
qso
|
contact
|
||||||
|> Radio.qso_path_points()
|
|> Radio.contact_path_points()
|
||||||
|> Enum.flat_map(fn {lat, lon} ->
|
|> Enum.flat_map(fn {lat, lon} ->
|
||||||
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
|
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
|
||||||
|
|
||||||
|
|
@ -216,13 +213,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorker do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp iemre_job_for_qso(%{pos1: nil}), do: []
|
defp iemre_job_for_contact(%{pos1: nil}), do: []
|
||||||
|
|
||||||
defp iemre_job_for_qso(qso) do
|
defp iemre_job_for_contact(contact) do
|
||||||
date = DateTime.to_date(qso.qso_timestamp)
|
date = DateTime.to_date(contact.qso_timestamp)
|
||||||
|
|
||||||
qso
|
contact
|
||||||
|> Radio.qso_path_points()
|
|> Radio.contact_path_points()
|
||||||
|> Enum.flat_map(fn {lat, lon} ->
|
|> Enum.flat_map(fn {lat, lon} ->
|
||||||
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)
|
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,16 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
||||||
if Terrain.has_terrain_profile?(qso_id) do
|
if Terrain.has_terrain_profile?(qso_id) do
|
||||||
:ok
|
:ok
|
||||||
else
|
else
|
||||||
qso = Radio.get_qso!(qso_id)
|
contact = Radio.get_contact!(qso_id)
|
||||||
lat1 = qso.pos1["lat"]
|
lat1 = contact.pos1["lat"]
|
||||||
lon1 = qso.pos1["lon"] || qso.pos1["lng"]
|
lon1 = contact.pos1["lon"] || contact.pos1["lng"]
|
||||||
lat2 = qso.pos2["lat"]
|
lat2 = contact.pos2["lat"]
|
||||||
lon2 = qso.pos2["lon"] || qso.pos2["lng"]
|
lon2 = contact.pos2["lon"] || contact.pos2["lng"]
|
||||||
dist_km = Decimal.to_float(qso.distance_km)
|
dist_km = Decimal.to_float(contact.distance_km)
|
||||||
freq_ghz = Decimal.to_float(qso.band) / 1000
|
freq_ghz = Decimal.to_float(contact.band) / 1000
|
||||||
|
|
||||||
# Look up HRRR refractivity gradient for dynamic k-factor
|
# Look up HRRR refractivity gradient for dynamic k-factor
|
||||||
k = lookup_k_factor(qso)
|
k = lookup_k_factor(contact)
|
||||||
|
|
||||||
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
|
case ElevationClient.fetch_elevation_profile(lat1, lon1, lat2, lon2) do
|
||||||
{:ok, profile} ->
|
{:ok, profile} ->
|
||||||
|
|
@ -64,8 +64,8 @@ defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp lookup_k_factor(qso) do
|
defp lookup_k_factor(contact) do
|
||||||
case Weather.hrrr_for_qso(qso) do
|
case Weather.hrrr_for_contact(contact) do
|
||||||
%{min_refractivity_gradient: grad} when not is_nil(grad) ->
|
%{min_refractivity_gradient: grad} when not is_nil(grad) ->
|
||||||
TerrainAnalysis.k_factor(grad)
|
TerrainAnalysis.k_factor(grad)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,12 @@ defmodule MicrowavepropWeb.PageController do
|
||||||
def home(conn, _params) do
|
def home(conn, _params) do
|
||||||
redirect(conn, to: ~p"/map")
|
redirect(conn, to: ~p"/map")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def redirect_contacts(conn, _params) do
|
||||||
|
redirect(conn, to: ~p"/contacts")
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_contact(conn, %{"id" => id}) do
|
||||||
|
redirect(conn, to: ~p"/contacts/#{id}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule MicrowavepropWeb.QsoLive.Index do
|
defmodule MicrowavepropWeb.ContactLive.Index do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
search = Map.get(params, "search", "")
|
search = Map.get(params, "search", "")
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Radio.list_qsos(
|
Radio.list_contacts(
|
||||||
page: page,
|
page: page,
|
||||||
sort_by: String.to_existing_atom(sort_by),
|
sort_by: String.to_existing_atom(sort_by),
|
||||||
sort_order: String.to_existing_atom(sort_order),
|
sort_order: String.to_existing_atom(sort_order),
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("search", %{"search" => search}, socket) do
|
def handle_event("search", %{"search" => search}, socket) do
|
||||||
{:noreply, push_patch(socket, to: ~p"/qsos?search=#{search}")}
|
{:noreply, push_patch(socket, to: ~p"/contacts?search=#{search}")}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -58,7 +58,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
|
|
||||||
params = %{sort_by: field, sort_order: new_order}
|
params = %{sort_by: field, sort_order: new_order}
|
||||||
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
|
params = if socket.assigns.search == "", do: params, else: Map.put(params, :search, socket.assigns.search)
|
||||||
{:noreply, push_patch(socket, to: ~p"/qsos?#{params}")}
|
{:noreply, push_patch(socket, to: ~p"/contacts?#{params}")}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp validate_sort_field(field) when field in @sortable_fields, do: field
|
defp validate_sort_field(field) when field in @sortable_fields, do: field
|
||||||
|
|
@ -91,7 +91,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
class="input input-bordered input-sm w-64"
|
class="input input-bordered input-sm w-64"
|
||||||
/>
|
/>
|
||||||
<button type="submit" class="btn btn-sm btn-outline">Search</button>
|
<button type="submit" class="btn btn-sm btn-outline">Search</button>
|
||||||
<.link :if={@search != ""} patch={~p"/qsos"} class="btn btn-sm btn-ghost">Clear</.link>
|
<.link :if={@search != ""} patch={~p"/contacts"} class="btn btn-sm btn-ghost">Clear</.link>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
"hover:bg-base-200 cursor-pointer",
|
"hover:bg-base-200 cursor-pointer",
|
||||||
reciprocals != [] && "bg-primary/5"
|
reciprocals != [] && "bg-primary/5"
|
||||||
]}
|
]}
|
||||||
phx-click={JS.navigate(~p"/qsos/#{primary.id}")}
|
phx-click={JS.navigate(~p"/contacts/#{primary.id}")}
|
||||||
>
|
>
|
||||||
<td class="w-8 text-center">
|
<td class="w-8 text-center">
|
||||||
<span
|
<span
|
||||||
|
|
@ -144,7 +144,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
<%= for recip <- reciprocals do %>
|
<%= for recip <- reciprocals do %>
|
||||||
<tr
|
<tr
|
||||||
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
|
class="hover:bg-base-200 cursor-pointer bg-primary/5 opacity-70 border-t-0"
|
||||||
phx-click={JS.navigate(~p"/qsos/#{recip.id}")}
|
phx-click={JS.navigate(~p"/contacts/#{recip.id}")}
|
||||||
>
|
>
|
||||||
<td class="w-8 text-center text-primary">↳</td>
|
<td class="w-8 text-center text-primary">↳</td>
|
||||||
<td class="font-mono">{recip.station1}</td>
|
<td class="font-mono">{recip.station1}</td>
|
||||||
|
|
@ -166,7 +166,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
<.link
|
<.link
|
||||||
:if={@page > 1}
|
:if={@page > 1}
|
||||||
patch={
|
patch={
|
||||||
~p"/qsos?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
|
~p"/contacts?page=#{@page - 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
|
||||||
}
|
}
|
||||||
class="btn btn-sm btn-outline"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
|
|
@ -179,7 +179,7 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
<.link
|
<.link
|
||||||
:if={@page < @total_pages}
|
:if={@page < @total_pages}
|
||||||
patch={
|
patch={
|
||||||
~p"/qsos?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
|
~p"/contacts?page=#{@page + 1}&sort_by=#{@sort_by}&sort_order=#{@sort_order}&search=#{@search}"
|
||||||
}
|
}
|
||||||
class="btn btn-sm btn-outline"
|
class="btn btn-sm btn-outline"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule MicrowavepropWeb.QsoLive.Show do
|
defmodule MicrowavepropWeb.ContactLive.Show do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
|
@ -8,17 +8,17 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
qso = Radio.get_qso!(id)
|
contact = Radio.get_contact!(id)
|
||||||
|
|
||||||
weather = load_weather(qso)
|
weather = load_weather(contact)
|
||||||
solar = load_solar(qso)
|
solar = load_solar(contact)
|
||||||
hrrr = Weather.hrrr_for_qso(qso)
|
hrrr = Weather.hrrr_for_contact(contact)
|
||||||
terrain = Terrain.get_terrain_profile(qso.id)
|
terrain = Terrain.get_terrain_profile(contact.id)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "#{qso.station1} / #{qso.station2}",
|
page_title: "#{contact.station1} / #{contact.station2}",
|
||||||
qso: qso,
|
contact: contact,
|
||||||
surface_observations: weather.surface_observations,
|
surface_observations: weather.surface_observations,
|
||||||
soundings: weather.soundings,
|
soundings: weather.soundings,
|
||||||
solar: solar,
|
solar: solar,
|
||||||
|
|
@ -117,12 +117,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
defp sounding_sort_key("lifted_index"), do: &(&1.lifted_index || 0)
|
||||||
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
defp sounding_sort_key(_), do: fn s -> s.station.name || s.station.station_code end
|
||||||
|
|
||||||
defp load_weather(qso) do
|
defp load_weather(contact) do
|
||||||
lat = qso.pos1 && qso.pos1["lat"]
|
lat = contact.pos1 && contact.pos1["lat"]
|
||||||
lon = qso.pos1 && (qso.pos1["lon"] || qso.pos1["lng"])
|
lon = contact.pos1 && (contact.pos1["lon"] || contact.pos1["lng"])
|
||||||
|
|
||||||
if lat && lon do
|
if lat && lon do
|
||||||
Weather.weather_for_qso(%{lat: lat, lon: lon, timestamp: qso.qso_timestamp},
|
Weather.weather_for_contact(%{lat: lat, lon: lon, timestamp: contact.qso_timestamp},
|
||||||
radius_km: 300
|
radius_km: 300
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|
@ -130,8 +130,8 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_solar(qso) do
|
defp load_solar(contact) do
|
||||||
qso.qso_timestamp
|
contact.qso_timestamp
|
||||||
|> DateTime.to_date()
|
|> DateTime.to_date()
|
||||||
|> Weather.get_solar_index()
|
|> Weather.get_solar_index()
|
||||||
end
|
end
|
||||||
|
|
@ -141,10 +141,10 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash}>
|
||||||
<.header>
|
<.header>
|
||||||
{@qso.station1} / {@qso.station2}
|
{@contact.station1} / {@contact.station2}
|
||||||
<:subtitle>{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
|
<:subtitle>{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
<.link navigate={~p"/qsos"} class="btn btn-sm btn-ghost">
|
<.link navigate={~p"/contacts"} class="btn btn-sm btn-ghost">
|
||||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to QSOs
|
||||||
</.link>
|
</.link>
|
||||||
</:actions>
|
</:actions>
|
||||||
|
|
@ -154,19 +154,21 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
|
|
||||||
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
|
<h2 class="text-base font-semibold mb-2">QSO Details</h2>
|
||||||
<.list>
|
<.list>
|
||||||
<:item title="Station 1">{@qso.station1}</:item>
|
<:item title="Station 1">{@contact.station1}</:item>
|
||||||
<:item title="Station 2">{@qso.station2}</:item>
|
<:item title="Station 2">{@contact.station2}</:item>
|
||||||
<:item title="Band">{@qso.band} MHz</:item>
|
<:item title="Band">{@contact.band} MHz</:item>
|
||||||
<:item title="Mode">{@qso.mode}</:item>
|
<:item title="Mode">{@contact.mode}</:item>
|
||||||
<:item title="Grid 1">{@qso.grid1 || "—"}</:item>
|
<:item title="Grid 1">{@contact.grid1 || "—"}</:item>
|
||||||
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
|
<:item title="Grid 2">{@contact.grid2 || "—"}</:item>
|
||||||
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
|
<:item title="Distance">{@contact.distance_km || "—"} km</:item>
|
||||||
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
|
<:item title="Timestamp">
|
||||||
<:item :if={@qso.user_submitted} title="Source">
|
{Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
||||||
|
</:item>
|
||||||
|
<:item :if={@contact.user_submitted} title="Source">
|
||||||
<span class="badge badge-info badge-sm">User Submitted</span>
|
<span class="badge badge-info badge-sm">User Submitted</span>
|
||||||
</:item>
|
</:item>
|
||||||
<:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter">
|
<:item :if={@contact.user_submitted && @contact.submitter_email} title="Submitter">
|
||||||
{@qso.submitter_email}
|
{@contact.submitter_email}
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||||||
|
|
||||||
@band_options [
|
@band_options [
|
||||||
{"1296 MHz", "1296"},
|
{"1296 MHz", "1296"},
|
||||||
|
|
@ -21,11 +21,11 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
changeset = Radio.change_qso(%Qso{})
|
changeset = Radio.change_contact(%Contact{})
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "Submit QSO",
|
page_title: "Submit Contact",
|
||||||
form: to_form(changeset),
|
form: to_form(changeset),
|
||||||
band_options: @band_options,
|
band_options: @band_options,
|
||||||
mode_options: @mode_options,
|
mode_options: @mode_options,
|
||||||
|
|
@ -34,10 +34,10 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("validate", %{"qso" => qso_params}, socket) do
|
def handle_event("validate", %{"contact" => contact_params}, socket) do
|
||||||
changeset =
|
changeset =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Radio.change_qso(qso_params)
|
|> Radio.change_contact(contact_params)
|
||||||
|> Map.put(:action, :validate)
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
{:noreply, assign(socket, form: to_form(changeset))}
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
|
@ -47,19 +47,19 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
|
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
|
||||||
@submission_cooldown_ms 30_000
|
@submission_cooldown_ms 30_000
|
||||||
|
|
||||||
def handle_event("save", %{"qso" => qso_params}, socket) do
|
def handle_event("save", %{"contact" => contact_params}, socket) do
|
||||||
if recently_submitted?(socket) do
|
if recently_submitted?(socket) do
|
||||||
{:noreply, put_flash(socket, :error, "Please wait before submitting another QSO.")}
|
{:noreply, put_flash(socket, :error, "Please wait before submitting another contact.")}
|
||||||
else
|
else
|
||||||
case Radio.create_qso(qso_params) do
|
case Radio.create_contact(contact_params) do
|
||||||
{:ok, qso} ->
|
{:ok, contact} ->
|
||||||
QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
|
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(submitted_at: System.monotonic_time(:millisecond))
|
|> assign(submitted_at: System.monotonic_time(:millisecond))
|
||||||
|> put_flash(:info, "QSO submitted successfully!")
|
|> put_flash(:info, "Contact submitted successfully!")
|
||||||
|> push_navigate(to: ~p"/qsos/#{qso.id}")}
|
|> push_navigate(to: ~p"/contacts/#{contact.id}")}
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
{:noreply, assign(socket, form: to_form(changeset))}
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
|
@ -79,11 +79,11 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash}>
|
||||||
<.header>
|
<.header>
|
||||||
Submit QSO
|
Submit Contact
|
||||||
<:subtitle>Submit a microwave QSO for propagation analysis</:subtitle>
|
<:subtitle>Submit a microwave contact for propagation analysis</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form for={@form} id="qso-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
<.form for={@form} id="contact-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" />
|
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" />
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -133,7 +133,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO</.button>
|
<.button phx-disable-with="Submitting..." class="btn-primary">Submit Contact</.button>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,13 @@ defmodule MicrowavepropWeb.Router do
|
||||||
|
|
||||||
live "/submit", SubmitLive
|
live "/submit", SubmitLive
|
||||||
live "/map", MapLive
|
live "/map", MapLive
|
||||||
live "/qsos", QsoLive.Index
|
live "/contacts", ContactLive.Index
|
||||||
live "/qsos/:id", QsoLive.Show
|
live "/contacts/:id", ContactLive.Show
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
|
|
||||||
|
# Redirect old /qsos routes
|
||||||
|
get "/qsos", PageController, :redirect_contacts
|
||||||
|
get "/qsos/:id", PageController, :redirect_contact
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Other scopes may use custom stacks.
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ defmodule Mix.Tasks.PropagationTrain do
|
||||||
|
|
||||||
IO.puts("=" <> String.duplicate("─", 50))
|
IO.puts("=" <> String.duplicate("─", 50))
|
||||||
|
|
||||||
{ft_features, ft_targets, ft_counts} = load_qso_data()
|
{ft_features, ft_targets, ft_counts} = load_contact_data()
|
||||||
ft_n = elem(Nx.shape(ft_features), 0)
|
ft_n = elem(Nx.shape(ft_features), 0)
|
||||||
IO.puts("Loaded #{ft_n} QSO training samples:")
|
IO.puts("Loaded #{ft_n} QSO training samples:")
|
||||||
print_band_counts(ft_counts)
|
print_band_counts(ft_counts)
|
||||||
|
|
@ -247,7 +247,7 @@ defmodule Mix.Tasks.PropagationTrain do
|
||||||
|
|
||||||
# ── Phase 2 data: QSO-HRRR + solar + sounding ────────────────────
|
# ── Phase 2 data: QSO-HRRR + solar + sounding ────────────────────
|
||||||
|
|
||||||
defp load_qso_data do
|
defp load_contact_data do
|
||||||
IO.puts(" Running QSO-HRRR-solar-sounding join query...")
|
IO.puts(" Running QSO-HRRR-solar-sounding join query...")
|
||||||
|
|
||||||
sql = """
|
sql = """
|
||||||
|
|
@ -326,7 +326,7 @@ defmodule Mix.Tasks.PropagationTrain do
|
||||||
|
|
||||||
{feature_rows, target_rows} =
|
{feature_rows, target_rows} =
|
||||||
percentile_rows
|
percentile_rows
|
||||||
|> Enum.map(fn {row, percentile} -> encode_qso_row(row, percentile) end)
|
|> Enum.map(fn {row, percentile} -> encode_contact_row(row, percentile) end)
|
||||||
|> Enum.unzip()
|
|> Enum.unzip()
|
||||||
|
|
||||||
IO.puts(" After filtering: #{length(feature_rows)} training samples")
|
IO.puts(" After filtering: #{length(feature_rows)} training samples")
|
||||||
|
|
@ -334,7 +334,7 @@ defmodule Mix.Tasks.PropagationTrain do
|
||||||
{Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), band_counts}
|
{Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), band_counts}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp encode_qso_row(
|
defp encode_contact_row(
|
||||||
[
|
[
|
||||||
band_mhz,
|
band_mhz,
|
||||||
_dist,
|
_dist,
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ defmodule Mix.Tasks.ResetEnrichment do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
@impl Mix.Task
|
@impl Mix.Task
|
||||||
|
|
@ -23,7 +23,7 @@ defmodule Mix.Tasks.ResetEnrichment do
|
||||||
Mix.Task.run("app.start")
|
Mix.Task.run("app.start")
|
||||||
|
|
||||||
{count, _} =
|
{count, _} =
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.weather_queued == true or q.hrrr_queued == true or q.iemre_queued == true)
|
|> where([q], q.weather_queued == true or q.hrrr_queued == true or q.iemre_queued == true)
|
||||||
|> Repo.update_all(set: [weather_queued: false, hrrr_queued: false, iemre_queued: false])
|
|> Repo.update_all(set: [weather_queued: false, hrrr_queued: false, iemre_queued: false])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Microwaveprop.Radio.QsoSubmissionTest do
|
defmodule Microwaveprop.Radio.ContactSubmissionTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
|
|
||||||
@valid_attrs %{
|
@valid_attrs %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
|
|
@ -16,12 +16,12 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do
|
||||||
|
|
||||||
describe "submission_changeset/2" do
|
describe "submission_changeset/2" do
|
||||||
test "valid attrs produce a valid changeset" do
|
test "valid attrs produce a valid changeset" do
|
||||||
changeset = Qso.submission_changeset(%Qso{}, @valid_attrs)
|
changeset = Contact.submission_changeset(%Contact{}, @valid_attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
|
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
|
||||||
changeset = Qso.submission_changeset(%Qso{}, %{})
|
changeset = Contact.submission_changeset(%Contact{}, %{})
|
||||||
errors = errors_on(changeset)
|
errors = errors_on(changeset)
|
||||||
|
|
||||||
assert errors[:station1]
|
assert errors[:station1]
|
||||||
|
|
@ -36,59 +36,59 @@ defmodule Microwaveprop.Radio.QsoSubmissionTest do
|
||||||
|
|
||||||
test "rejects invalid grid1" do
|
test "rejects invalid grid1" do
|
||||||
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
|
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
|
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid grid2" do
|
test "rejects invalid grid2" do
|
||||||
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
|
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
|
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts valid 6-char grids" do
|
test "accepts valid 6-char grids" do
|
||||||
attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"}
|
attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"}
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid mode" do
|
test "rejects invalid mode" do
|
||||||
attrs = Map.put(@valid_attrs, :mode, "RTTY")
|
attrs = Map.put(@valid_attrs, :mode, "RTTY")
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert errors_on(changeset).mode
|
assert errors_on(changeset).mode
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts valid modes" do
|
test "accepts valid modes" do
|
||||||
for mode <- ~w(CW SSB FM FT8 FT4) do
|
for mode <- ~w(CW SSB FM FT8 FT4) do
|
||||||
attrs = Map.put(@valid_attrs, :mode, mode)
|
attrs = Map.put(@valid_attrs, :mode, mode)
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert changeset.valid?, "Expected mode #{mode} to be valid"
|
assert changeset.valid?, "Expected mode #{mode} to be valid"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects invalid band" do
|
test "rejects invalid band" do
|
||||||
attrs = Map.put(@valid_attrs, :band, "144")
|
attrs = Map.put(@valid_attrs, :band, "144")
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert errors_on(changeset).band
|
assert errors_on(changeset).band
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts valid microwave bands" do
|
test "accepts valid microwave bands" do
|
||||||
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
|
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
|
||||||
attrs = Map.put(@valid_attrs, :band, band)
|
attrs = Map.put(@valid_attrs, :band, band)
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert changeset.valid?, "Expected band #{band} to be valid"
|
assert changeset.valid?, "Expected band #{band} to be valid"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects email without @" do
|
test "rejects email without @" do
|
||||||
attrs = Map.put(@valid_attrs, :submitter_email, "notanemail")
|
attrs = Map.put(@valid_attrs, :submitter_email, "notanemail")
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
assert errors_on(changeset).submitter_email
|
assert errors_on(changeset).submitter_email
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not cast user_submitted" do
|
test "does not cast user_submitted" do
|
||||||
attrs = Map.put(@valid_attrs, :user_submitted, true)
|
attrs = Map.put(@valid_attrs, :user_submitted, true)
|
||||||
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
||||||
refute Ecto.Changeset.get_change(changeset, :user_submitted)
|
refute Ecto.Changeset.get_change(changeset, :user_submitted)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Microwaveprop.Radio.QsoTest do
|
defmodule Microwaveprop.Radio.ContactTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
|
|
||||||
@valid_attrs %{
|
@valid_attrs %{
|
||||||
station1: "W1AW",
|
station1: "W1AW",
|
||||||
|
|
@ -18,12 +18,12 @@ defmodule Microwaveprop.Radio.QsoTest do
|
||||||
|
|
||||||
describe "changeset/2" do
|
describe "changeset/2" do
|
||||||
test "valid attributes" do
|
test "valid attributes" do
|
||||||
changeset = Qso.changeset(%Qso{}, @valid_attrs)
|
changeset = Contact.changeset(%Contact{}, @valid_attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "requires station1, station2, qso_timestamp, mode, and band" do
|
test "requires station1, station2, qso_timestamp, mode, and band" do
|
||||||
changeset = Qso.changeset(%Qso{}, %{})
|
changeset = Contact.changeset(%Contact{}, %{})
|
||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
station1: ["can't be blank"],
|
station1: ["can't be blank"],
|
||||||
|
|
@ -35,13 +35,13 @@ defmodule Microwaveprop.Radio.QsoTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "persists to the database" do
|
test "persists to the database" do
|
||||||
changeset = Qso.changeset(%Qso{}, @valid_attrs)
|
changeset = Contact.changeset(%Contact{}, @valid_attrs)
|
||||||
assert {:ok, qso} = Repo.insert(changeset)
|
assert {:ok, contact} = Repo.insert(changeset)
|
||||||
assert qso.station1 == "W1AW"
|
assert contact.station1 == "W1AW"
|
||||||
assert qso.station2 == "K3LR"
|
assert contact.station2 == "K3LR"
|
||||||
assert qso.mode == "SSB"
|
assert contact.mode == "SSB"
|
||||||
assert Decimal.equal?(qso.band, Decimal.new("144.2"))
|
assert Decimal.equal?(contact.band, Decimal.new("144.2"))
|
||||||
assert Decimal.equal?(qso.distance_km, Decimal.new("623.4"))
|
assert Decimal.equal?(contact.distance_km, Decimal.new("623.4"))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ defmodule Microwaveprop.RadioTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
|
|
||||||
defp create_qso(attrs \\ %{}) do
|
defp create_contact(attrs \\ %{}) do
|
||||||
default = %{
|
default = %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
station2: "K5TR",
|
station2: "K5TR",
|
||||||
|
|
@ -18,17 +18,17 @@ defmodule Microwaveprop.RadioTest do
|
||||||
distance_km: Decimal.new("295")
|
distance_km: Decimal.new("295")
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(Map.merge(default, attrs))
|
|> Contact.changeset(Map.merge(default, attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_qsos/1" do
|
describe "list_contacts/1" do
|
||||||
test "returns empty page when no QSOs exist" do
|
test "returns empty page when no QSOs exist" do
|
||||||
result = Radio.list_qsos()
|
result = Radio.list_contacts()
|
||||||
|
|
||||||
assert result.entries == []
|
assert result.entries == []
|
||||||
assert result.page == 1
|
assert result.page == 1
|
||||||
|
|
@ -39,10 +39,10 @@ defmodule Microwaveprop.RadioTest do
|
||||||
test "paginates 20 per page, ordered by qso_timestamp desc" do
|
test "paginates 20 per page, ordered by qso_timestamp desc" do
|
||||||
for i <- 1..25 do
|
for i <- 1..25 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
result = Radio.list_qsos()
|
result = Radio.list_contacts()
|
||||||
|
|
||||||
assert length(result.entries) == 20
|
assert length(result.entries) == 20
|
||||||
assert result.page == 1
|
assert result.page == 1
|
||||||
|
|
@ -57,10 +57,10 @@ defmodule Microwaveprop.RadioTest do
|
||||||
test "returns second page" do
|
test "returns second page" do
|
||||||
for i <- 1..25 do
|
for i <- 1..25 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
result = Radio.list_qsos(page: 2)
|
result = Radio.list_contacts(page: 2)
|
||||||
|
|
||||||
assert length(result.entries) == 5
|
assert length(result.entries) == 5
|
||||||
assert result.page == 2
|
assert result.page == 2
|
||||||
|
|
@ -68,60 +68,60 @@ defmodule Microwaveprop.RadioTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_qsos/1 sorting" do
|
describe "list_contacts/1 sorting" do
|
||||||
test "sorts by station1 ascending" do
|
test "sorts by station1 ascending" do
|
||||||
create_qso(%{station1: "ZZ9ZZ"})
|
create_contact(%{station1: "ZZ9ZZ"})
|
||||||
create_qso(%{station1: "AA1AA"})
|
create_contact(%{station1: "AA1AA"})
|
||||||
|
|
||||||
result = Radio.list_qsos(sort_by: :station1, sort_order: :asc)
|
result = Radio.list_contacts(sort_by: :station1, sort_order: :asc)
|
||||||
|
|
||||||
stations = Enum.map(result.entries, & &1.station1)
|
stations = Enum.map(result.entries, & &1.station1)
|
||||||
assert stations == ["AA1AA", "ZZ9ZZ"]
|
assert stations == ["AA1AA", "ZZ9ZZ"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sorts by distance_km descending" do
|
test "sorts by distance_km descending" do
|
||||||
create_qso(%{distance_km: Decimal.new("100"), station1: "A1A"})
|
create_contact(%{distance_km: Decimal.new("100"), station1: "A1A"})
|
||||||
create_qso(%{distance_km: Decimal.new("500"), station1: "B2B"})
|
create_contact(%{distance_km: Decimal.new("500"), station1: "B2B"})
|
||||||
|
|
||||||
result = Radio.list_qsos(sort_by: :distance_km, sort_order: :desc)
|
result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc)
|
||||||
|
|
||||||
distances = Enum.map(result.entries, & &1.distance_km)
|
distances = Enum.map(result.entries, & &1.distance_km)
|
||||||
assert distances == [Decimal.new("500"), Decimal.new("100")]
|
assert distances == [Decimal.new("500"), Decimal.new("100")]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid sort_by falls back to qso_timestamp desc" do
|
test "invalid sort_by falls back to qso_timestamp desc" do
|
||||||
early = create_qso(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
|
early = create_contact(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
|
||||||
late = create_qso(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
|
late = create_contact(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
|
||||||
|
|
||||||
result = Radio.list_qsos(sort_by: :bogus)
|
result = Radio.list_contacts(sort_by: :bogus)
|
||||||
|
|
||||||
ids = Enum.map(result.entries, & &1.id)
|
ids = Enum.map(result.entries, & &1.id)
|
||||||
assert ids == [late.id, early.id]
|
assert ids == [late.id, early.id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unprocessed_qsos/1" do
|
describe "unprocessed_contacts/1" do
|
||||||
test "returns QSOs where weather_queued is false and pos1 is not nil" do
|
test "returns QSOs where weather_queued is false and pos1 is not nil" do
|
||||||
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
||||||
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_qsos()
|
results = Radio.unprocessed_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert q1.id in ids
|
assert q1.id in ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "excludes QSOs already marked as weather_queued" do
|
test "excludes QSOs already marked as weather_queued" do
|
||||||
q = create_qso()
|
q = create_contact()
|
||||||
Radio.mark_weather_queued!([q.id])
|
Radio.mark_weather_queued!([q.id])
|
||||||
|
|
||||||
assert Radio.unprocessed_qsos() == []
|
assert Radio.unprocessed_contacts() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "orders by qso_timestamp ascending" do
|
test "orders by qso_timestamp ascending" do
|
||||||
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
||||||
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_qsos()
|
results = Radio.unprocessed_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert ids == [q_early.id, q_late.id]
|
assert ids == [q_early.id, q_late.id]
|
||||||
end
|
end
|
||||||
|
|
@ -129,32 +129,32 @@ defmodule Microwaveprop.RadioTest do
|
||||||
test "respects limit parameter" do
|
test "respects limit parameter" do
|
||||||
for i <- 1..5 do
|
for i <- 1..5 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
assert length(Radio.unprocessed_qsos(3)) == 3
|
assert length(Radio.unprocessed_contacts(3)) == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_weather_queued!/1" do
|
describe "mark_weather_queued!/1" do
|
||||||
test "sets weather_queued to true for given IDs" do
|
test "sets weather_queued to true for given IDs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_weather_queued!([q1.id, q2.id])
|
Radio.mark_weather_queued!([q1.id, q2.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).weather_queued == true
|
assert Repo.get!(Contact, q1.id).weather_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).weather_queued == true
|
assert Repo.get!(Contact, q2.id).weather_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not affect other QSOs" do
|
test "does not affect other QSOs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_weather_queued!([q1.id])
|
Radio.mark_weather_queued!([q1.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).weather_queued == true
|
assert Repo.get!(Contact, q1.id).weather_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).weather_queued == false
|
assert Repo.get!(Contact, q2.id).weather_queued == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty list" do
|
test "handles empty list" do
|
||||||
|
|
@ -176,72 +176,72 @@ defmodule Microwaveprop.RadioTest do
|
||||||
|
|
||||||
describe "backfill_distances/1" do
|
describe "backfill_distances/1" do
|
||||||
test "calculates and stores distance for QSOs missing distance_km" do
|
test "calculates and stores distance for QSOs missing distance_km" do
|
||||||
qso = create_qso(%{distance_km: nil})
|
contact = create_contact(%{distance_km: nil})
|
||||||
assert is_nil(qso.distance_km)
|
assert is_nil(contact.distance_km)
|
||||||
|
|
||||||
Radio.backfill_distances([qso])
|
Radio.backfill_distances([contact])
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips QSOs that already have a distance" do
|
test "skips QSOs that already have a distance" do
|
||||||
qso = create_qso(%{distance_km: Decimal.new("100")})
|
contact = create_contact(%{distance_km: Decimal.new("100")})
|
||||||
|
|
||||||
Radio.backfill_distances([qso])
|
Radio.backfill_distances([contact])
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.distance_km == Decimal.new("100")
|
assert updated.distance_km == Decimal.new("100")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips QSOs missing pos2" do
|
test "skips QSOs missing pos2" do
|
||||||
qso = create_qso(%{distance_km: nil, pos2: nil})
|
contact = create_contact(%{distance_km: nil, pos2: nil})
|
||||||
|
|
||||||
Radio.backfill_distances([qso])
|
Radio.backfill_distances([contact])
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert is_nil(updated.distance_km)
|
assert is_nil(updated.distance_km)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "get_qso!/1" do
|
describe "get_contact!/1" do
|
||||||
test "returns a QSO by ID" do
|
test "returns a QSO by ID" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
found = Radio.get_qso!(qso.id)
|
found = Radio.get_contact!(contact.id)
|
||||||
assert found.id == qso.id
|
assert found.id == contact.id
|
||||||
assert found.station1 == "W5XD"
|
assert found.station1 == "W5XD"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises Ecto.NoResultsError on bad ID" do
|
test "raises Ecto.NoResultsError on bad ID" do
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
Radio.get_qso!(Ecto.UUID.generate())
|
Radio.get_contact!(Ecto.UUID.generate())
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unprocessed_hrrr_qsos/1" do
|
describe "unprocessed_hrrr_contacts/1" do
|
||||||
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
|
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
|
||||||
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
||||||
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
_q2 = create_contact(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_hrrr_qsos()
|
results = Radio.unprocessed_hrrr_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert q1.id in ids
|
assert q1.id in ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "excludes QSOs already marked as hrrr_queued" do
|
test "excludes QSOs already marked as hrrr_queued" do
|
||||||
q = create_qso()
|
q = create_contact()
|
||||||
Radio.mark_hrrr_queued!([q.id])
|
Radio.mark_hrrr_queued!([q.id])
|
||||||
|
|
||||||
assert Radio.unprocessed_hrrr_qsos() == []
|
assert Radio.unprocessed_hrrr_contacts() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "orders by qso_timestamp ascending" do
|
test "orders by qso_timestamp ascending" do
|
||||||
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
||||||
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_hrrr_qsos()
|
results = Radio.unprocessed_hrrr_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert ids == [q_early.id, q_late.id]
|
assert ids == [q_early.id, q_late.id]
|
||||||
end
|
end
|
||||||
|
|
@ -249,32 +249,32 @@ defmodule Microwaveprop.RadioTest do
|
||||||
test "respects limit parameter" do
|
test "respects limit parameter" do
|
||||||
for i <- 1..5 do
|
for i <- 1..5 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
assert length(Radio.unprocessed_hrrr_qsos(3)) == 3
|
assert length(Radio.unprocessed_hrrr_contacts(3)) == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_hrrr_queued!/1" do
|
describe "mark_hrrr_queued!/1" do
|
||||||
test "sets hrrr_queued to true for given IDs" do
|
test "sets hrrr_queued to true for given IDs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_hrrr_queued!([q1.id, q2.id])
|
Radio.mark_hrrr_queued!([q1.id, q2.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).hrrr_queued == true
|
assert Repo.get!(Contact, q1.id).hrrr_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).hrrr_queued == true
|
assert Repo.get!(Contact, q2.id).hrrr_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not affect other QSOs" do
|
test "does not affect other QSOs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_hrrr_queued!([q1.id])
|
Radio.mark_hrrr_queued!([q1.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).hrrr_queued == true
|
assert Repo.get!(Contact, q1.id).hrrr_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).hrrr_queued == false
|
assert Repo.get!(Contact, q2.id).hrrr_queued == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty list" do
|
test "handles empty list" do
|
||||||
|
|
@ -282,30 +282,30 @@ defmodule Microwaveprop.RadioTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "unprocessed_terrain_qsos/1" do
|
describe "unprocessed_terrain_contacts/1" do
|
||||||
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
|
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
|
||||||
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
q1 = create_contact(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
||||||
_q2 = create_qso(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
_q2 = create_contact(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
||||||
_q3 = create_qso(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
|
_q3 = create_contact(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_terrain_qsos()
|
results = Radio.unprocessed_terrain_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert q1.id in ids
|
assert q1.id in ids
|
||||||
assert length(ids) == 1
|
assert length(ids) == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "excludes QSOs already marked as terrain_queued" do
|
test "excludes QSOs already marked as terrain_queued" do
|
||||||
q = create_qso()
|
q = create_contact()
|
||||||
Radio.mark_terrain_queued!([q.id])
|
Radio.mark_terrain_queued!([q.id])
|
||||||
|
|
||||||
assert Radio.unprocessed_terrain_qsos() == []
|
assert Radio.unprocessed_terrain_contacts() == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "orders by qso_timestamp ascending" do
|
test "orders by qso_timestamp ascending" do
|
||||||
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
q_late = create_contact(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
||||||
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
q_early = create_contact(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
||||||
|
|
||||||
results = Radio.unprocessed_terrain_qsos()
|
results = Radio.unprocessed_terrain_contacts()
|
||||||
ids = Enum.map(results, & &1.id)
|
ids = Enum.map(results, & &1.id)
|
||||||
assert ids == [q_early.id, q_late.id]
|
assert ids == [q_early.id, q_late.id]
|
||||||
end
|
end
|
||||||
|
|
@ -313,32 +313,32 @@ defmodule Microwaveprop.RadioTest do
|
||||||
test "respects limit parameter" do
|
test "respects limit parameter" do
|
||||||
for i <- 1..5 do
|
for i <- 1..5 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
assert length(Radio.unprocessed_terrain_qsos(3)) == 3
|
assert length(Radio.unprocessed_terrain_contacts(3)) == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "mark_terrain_queued!/1" do
|
describe "mark_terrain_queued!/1" do
|
||||||
test "sets terrain_queued to true for given IDs" do
|
test "sets terrain_queued to true for given IDs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_terrain_queued!([q1.id, q2.id])
|
Radio.mark_terrain_queued!([q1.id, q2.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).terrain_queued == true
|
assert Repo.get!(Contact, q1.id).terrain_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).terrain_queued == true
|
assert Repo.get!(Contact, q2.id).terrain_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not affect other QSOs" do
|
test "does not affect other QSOs" do
|
||||||
q1 = create_qso(%{station1: "A1A"})
|
q1 = create_contact(%{station1: "A1A"})
|
||||||
q2 = create_qso(%{station1: "B2B"})
|
q2 = create_contact(%{station1: "B2B"})
|
||||||
|
|
||||||
Radio.mark_terrain_queued!([q1.id])
|
Radio.mark_terrain_queued!([q1.id])
|
||||||
|
|
||||||
assert Repo.get!(Qso, q1.id).terrain_queued == true
|
assert Repo.get!(Contact, q1.id).terrain_queued == true
|
||||||
assert Repo.get!(Qso, q2.id).terrain_queued == false
|
assert Repo.get!(Contact, q2.id).terrain_queued == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty list" do
|
test "handles empty list" do
|
||||||
|
|
@ -346,15 +346,15 @@ defmodule Microwaveprop.RadioTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "qso_path_points/1" do
|
describe "contact_path_points/1" do
|
||||||
test "returns pos1, midpoint, and pos2 when both positions exist" do
|
test "returns pos1, midpoint, and pos2 when both positions exist" do
|
||||||
qso =
|
contact =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||||
})
|
})
|
||||||
|
|
||||||
points = Radio.qso_path_points(qso)
|
points = Radio.contact_path_points(contact)
|
||||||
|
|
||||||
assert length(points) == 3
|
assert length(points) == 3
|
||||||
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
|
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] = points
|
||||||
|
|
@ -367,44 +367,44 @@ defmodule Microwaveprop.RadioTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns only pos1 when pos2 is nil" do
|
test "returns only pos1 when pos2 is nil" do
|
||||||
qso = create_qso(%{pos2: nil})
|
contact = create_contact(%{pos2: nil})
|
||||||
|
|
||||||
points = Radio.qso_path_points(qso)
|
points = Radio.contact_path_points(contact)
|
||||||
|
|
||||||
assert points == [{32.9, -97.0}]
|
assert points == [{32.9, -97.0}]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns empty list when pos1 is nil" do
|
test "returns empty list when pos1 is nil" do
|
||||||
qso = create_qso(%{pos1: nil})
|
contact = create_contact(%{pos1: nil})
|
||||||
|
|
||||||
assert Radio.qso_path_points(qso) == []
|
assert Radio.contact_path_points(contact) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles lng key in position maps" do
|
test "handles lng key in position maps" do
|
||||||
qso =
|
contact =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
pos1: %{"lat" => 32.9, "lng" => -97.0},
|
pos1: %{"lat" => 32.9, "lng" => -97.0},
|
||||||
pos2: %{"lat" => 30.3, "lng" => -97.7}
|
pos2: %{"lat" => 30.3, "lng" => -97.7}
|
||||||
})
|
})
|
||||||
|
|
||||||
points = Radio.qso_path_points(qso)
|
points = Radio.contact_path_points(contact)
|
||||||
assert length(points) == 3
|
assert length(points) == 3
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "change_qso/2" do
|
describe "change_contact/2" do
|
||||||
test "returns a submission changeset" do
|
test "returns a submission changeset" do
|
||||||
changeset = Radio.change_qso(%Qso{})
|
changeset = Radio.change_contact(%Contact{})
|
||||||
assert %Ecto.Changeset{} = changeset
|
assert %Ecto.Changeset{} = changeset
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts attrs" do
|
test "accepts attrs" do
|
||||||
changeset = Radio.change_qso(%Qso{}, %{station1: "W5XD"})
|
changeset = Radio.change_contact(%Contact{}, %{station1: "W5XD"})
|
||||||
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
|
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "create_qso/1" do
|
describe "create_contact/1" do
|
||||||
@valid_submission %{
|
@valid_submission %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
station2: "K5TR",
|
station2: "K5TR",
|
||||||
|
|
@ -417,38 +417,38 @@ defmodule Microwaveprop.RadioTest do
|
||||||
}
|
}
|
||||||
|
|
||||||
test "creates QSO with computed positions and distance" do
|
test "creates QSO with computed positions and distance" do
|
||||||
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
||||||
|
|
||||||
assert qso.station1 == "W5XD"
|
assert contact.station1 == "W5XD"
|
||||||
assert qso.user_submitted == true
|
assert contact.user_submitted == true
|
||||||
assert qso.pos1["lat"]
|
assert contact.pos1["lat"]
|
||||||
assert qso.pos1["lon"]
|
assert contact.pos1["lon"]
|
||||||
assert qso.pos2["lat"]
|
assert contact.pos2["lat"]
|
||||||
assert qso.pos2["lon"]
|
assert contact.pos2["lon"]
|
||||||
assert qso.distance_km
|
assert contact.distance_km
|
||||||
end
|
end
|
||||||
|
|
||||||
test "computes correct position from grid" do
|
test "computes correct position from grid" do
|
||||||
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
||||||
|
|
||||||
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
|
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
|
||||||
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
|
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
|
||||||
assert_in_delta qso.pos1["lat"], 32.5, 0.1
|
assert_in_delta contact.pos1["lat"], 32.5, 0.1
|
||||||
assert_in_delta qso.pos1["lon"], -97.0, 0.1
|
assert_in_delta contact.pos1["lon"], -97.0, 0.1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets user_submitted to true" do
|
test "sets user_submitted to true" do
|
||||||
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
assert {:ok, contact} = Radio.create_contact(@valid_submission)
|
||||||
assert qso.user_submitted == true
|
assert contact.user_submitted == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error changeset for invalid data" do
|
test "returns error changeset for invalid data" do
|
||||||
assert {:error, %Ecto.Changeset{}} = Radio.create_qso(%{})
|
assert {:error, %Ecto.Changeset{}} = Radio.create_contact(%{})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error changeset for invalid grid" do
|
test "returns error changeset for invalid grid" do
|
||||||
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
|
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
|
||||||
assert {:error, changeset} = Radio.create_qso(attrs)
|
assert {:error, changeset} = Radio.create_contact(attrs)
|
||||||
assert errors_on(changeset).grid1
|
assert errors_on(changeset).grid1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Microwaveprop.Terrain.TerrainProfileTest do
|
defmodule Microwaveprop.Terrain.TerrainProfileTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Terrain.TerrainProfile
|
alias Microwaveprop.Terrain.TerrainProfile
|
||||||
|
|
||||||
@qso_attrs %{
|
@qso_attrs %{
|
||||||
|
|
@ -14,21 +14,21 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||||
}
|
}
|
||||||
|
|
||||||
defp create_qso do
|
defp create_contact do
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(@qso_attrs)
|
|> Contact.changeset(@qso_attrs)
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "changeset/2" do
|
describe "changeset/2" do
|
||||||
test "valid with required fields" do
|
test "valid with required fields" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
qso_id: qso.id,
|
qso_id: contact.id,
|
||||||
sample_count: 65,
|
sample_count: 65,
|
||||||
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
|
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
|
||||||
verdict: "CLEAR"
|
verdict: "CLEAR"
|
||||||
|
|
@ -50,10 +50,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid with all optional fields" do
|
test "valid with all optional fields" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
qso_id: qso.id,
|
qso_id: contact.id,
|
||||||
sample_count: 65,
|
sample_count: 65,
|
||||||
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
|
path_points: [%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0}],
|
||||||
verdict: "BLOCKED",
|
verdict: "BLOCKED",
|
||||||
|
|
@ -69,10 +69,10 @@ defmodule Microwaveprop.Terrain.TerrainProfileTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enforces unique qso_id constraint on insert" do
|
test "enforces unique qso_id constraint on insert" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
qso_id: qso.id,
|
qso_id: contact.id,
|
||||||
sample_count: 65,
|
sample_count: 65,
|
||||||
path_points: [],
|
path_points: [],
|
||||||
verdict: "CLEAR"
|
verdict: "CLEAR"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Microwaveprop.TerrainTest do
|
defmodule Microwaveprop.TerrainTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Terrain
|
alias Microwaveprop.Terrain
|
||||||
|
|
||||||
@qso_attrs %{
|
@qso_attrs %{
|
||||||
|
|
@ -14,18 +14,18 @@ defmodule Microwaveprop.TerrainTest do
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||||
}
|
}
|
||||||
|
|
||||||
defp create_qso do
|
defp create_contact do
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(@qso_attrs)
|
|> Contact.changeset(@qso_attrs)
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
defp terrain_attrs(qso) do
|
defp terrain_attrs(contact) do
|
||||||
%{
|
%{
|
||||||
qso_id: qso.id,
|
qso_id: contact.id,
|
||||||
sample_count: 65,
|
sample_count: 65,
|
||||||
path_points: [
|
path_points: [
|
||||||
%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0},
|
%{"lat" => 32.9, "lon" => -97.0, "elev" => 200.0, "dist_km" => 0.0},
|
||||||
|
|
@ -42,18 +42,18 @@ defmodule Microwaveprop.TerrainTest do
|
||||||
|
|
||||||
describe "upsert_terrain_profile/1" do
|
describe "upsert_terrain_profile/1" do
|
||||||
test "inserts a new terrain profile" do
|
test "inserts a new terrain profile" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
attrs = terrain_attrs(qso)
|
attrs = terrain_attrs(contact)
|
||||||
|
|
||||||
assert {:ok, profile} = Terrain.upsert_terrain_profile(attrs)
|
assert {:ok, profile} = Terrain.upsert_terrain_profile(attrs)
|
||||||
assert profile.qso_id == qso.id
|
assert profile.qso_id == contact.id
|
||||||
assert profile.sample_count == 65
|
assert profile.sample_count == 65
|
||||||
assert profile.verdict == "CLEAR"
|
assert profile.verdict == "CLEAR"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "upserts on conflict (same qso_id)" do
|
test "upserts on conflict (same qso_id)" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
attrs = terrain_attrs(qso)
|
attrs = terrain_attrs(contact)
|
||||||
|
|
||||||
{:ok, _} = Terrain.upsert_terrain_profile(attrs)
|
{:ok, _} = Terrain.upsert_terrain_profile(attrs)
|
||||||
|
|
||||||
|
|
@ -67,30 +67,30 @@ defmodule Microwaveprop.TerrainTest do
|
||||||
|
|
||||||
describe "get_terrain_profile/1" do
|
describe "get_terrain_profile/1" do
|
||||||
test "returns profile for a QSO" do
|
test "returns profile for a QSO" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
|
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact))
|
||||||
|
|
||||||
profile = Terrain.get_terrain_profile(qso.id)
|
profile = Terrain.get_terrain_profile(contact.id)
|
||||||
assert profile.qso_id == qso.id
|
assert profile.qso_id == contact.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil when no profile exists" do
|
test "returns nil when no profile exists" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
assert Terrain.get_terrain_profile(qso.id) == nil
|
assert Terrain.get_terrain_profile(contact.id) == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "has_terrain_profile?/1" do
|
describe "has_terrain_profile?/1" do
|
||||||
test "returns true when profile exists" do
|
test "returns true when profile exists" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(qso))
|
{:ok, _} = Terrain.upsert_terrain_profile(terrain_attrs(contact))
|
||||||
|
|
||||||
assert Terrain.has_terrain_profile?(qso.id)
|
assert Terrain.has_terrain_profile?(contact.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns false when no profile exists" do
|
test "returns false when no profile exists" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
refute Terrain.has_terrain_profile?(qso.id)
|
refute Terrain.has_terrain_profile?(contact.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "weather_for_qso/2" do
|
describe "weather_for_contact/2" do
|
||||||
test "returns nearby surface observations within time window with station preloaded" do
|
test "returns nearby surface observations within time window with station preloaded" do
|
||||||
{:ok, station} = Weather.find_or_create_station(@station_attrs)
|
{:ok, station} = Weather.find_or_create_station(@station_attrs)
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
|
|
||||||
# QSO near the station in space and time
|
# QSO near the station in space and time
|
||||||
result =
|
result =
|
||||||
Weather.weather_for_qso(
|
Weather.weather_for_contact(
|
||||||
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]},
|
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 18:30:00Z]},
|
||||||
radius_km: 100,
|
radius_km: 100,
|
||||||
time_window_hours: 3
|
time_window_hours: 3
|
||||||
|
|
@ -149,7 +149,7 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
result =
|
result =
|
||||||
Weather.weather_for_qso(
|
Weather.weather_for_contact(
|
||||||
%{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]},
|
%{lat: 32.85, lon: -97.28, timestamp: ~U[2026-03-28 14:00:00Z]},
|
||||||
radius_km: 100,
|
radius_km: 100,
|
||||||
time_window_hours: 6
|
time_window_hours: 6
|
||||||
|
|
@ -170,7 +170,7 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
|
|
||||||
# QSO far away
|
# QSO far away
|
||||||
result =
|
result =
|
||||||
Weather.weather_for_qso(
|
Weather.weather_for_contact(
|
||||||
%{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]},
|
%{lat: 40.0, lon: -80.0, timestamp: ~U[2026-03-28 18:00:00Z]},
|
||||||
radius_km: 100,
|
radius_km: 100,
|
||||||
time_window_hours: 3
|
time_window_hours: 3
|
||||||
|
|
@ -190,7 +190,7 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
|
|
||||||
# QSO much later
|
# QSO much later
|
||||||
result =
|
result =
|
||||||
Weather.weather_for_qso(
|
Weather.weather_for_contact(
|
||||||
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]},
|
%{lat: 32.90, lon: -97.05, timestamp: ~U[2026-03-28 22:00:00Z]},
|
||||||
radius_km: 100,
|
radius_km: 100,
|
||||||
time_window_hours: 3
|
time_window_hours: 3
|
||||||
|
|
@ -482,31 +482,31 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "hrrr_for_qso/1" do
|
describe "hrrr_for_contact/1" do
|
||||||
test "returns nearest HRRR profile for a QSO" do
|
test "returns nearest HRRR profile for a QSO" do
|
||||||
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
|
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
||||||
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
profile = Weather.hrrr_for_qso(qso)
|
profile = Weather.hrrr_for_contact(contact)
|
||||||
assert profile
|
assert profile
|
||||||
assert profile.lat == 32.90
|
assert profile.lat == 32.90
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil when no HRRR profiles exist nearby" do
|
test "returns nil when no HRRR profiles exist nearby" do
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert Weather.hrrr_for_qso(qso) == nil
|
assert Weather.hrrr_for_contact(contact) == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil when QSO has no pos1" do
|
test "returns nil when QSO has no pos1" do
|
||||||
assert Weather.hrrr_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
|
assert Weather.hrrr_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -590,36 +590,36 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
lon: -97.70
|
lon: -97.70
|
||||||
})
|
})
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
||||||
pos2: %{"lat" => 30.31, "lon" => -97.71},
|
pos2: %{"lat" => 30.31, "lon" => -97.71},
|
||||||
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles = Weather.hrrr_profiles_for_path(qso)
|
profiles = Weather.hrrr_profiles_for_path(contact)
|
||||||
assert length(profiles) >= 2
|
assert length(profiles) >= 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns empty list when no HRRR profiles exist nearby" do
|
test "returns empty list when no HRRR profiles exist nearby" do
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
||||||
pos2: %{"lat" => 41.0, "lon" => -81.0},
|
pos2: %{"lat" => 41.0, "lon" => -81.0},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert Weather.hrrr_profiles_for_path(qso) == []
|
assert Weather.hrrr_profiles_for_path(contact) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns profiles for pos1 only when pos2 is nil" do
|
test "returns profiles for pos1 only when pos2 is nil" do
|
||||||
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
|
Weather.upsert_hrrr_profile(@hrrr_profile_attrs)
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
pos1: %{"lat" => 32.91, "lon" => -97.05},
|
||||||
pos2: nil,
|
pos2: nil,
|
||||||
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
qso_timestamp: ~U[2026-03-28 18:30:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles = Weather.hrrr_profiles_for_path(qso)
|
profiles = Weather.hrrr_profiles_for_path(contact)
|
||||||
assert length(profiles) == 1
|
assert length(profiles) == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -636,36 +636,36 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
hourly: [%{"hour" => 0, "p01m_mm" => 1.0}]
|
hourly: [%{"hour" => 0, "p01m_mm" => 1.0}]
|
||||||
})
|
})
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
observations = Weather.iemre_for_path(qso)
|
observations = Weather.iemre_for_path(contact)
|
||||||
assert length(observations) >= 2
|
assert length(observations) >= 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns empty list when no observations exist" do
|
test "returns empty list when no observations exist" do
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
||||||
pos2: %{"lat" => 41.0, "lon" => -81.0},
|
pos2: %{"lat" => 41.0, "lon" => -81.0},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert Weather.iemre_for_path(qso) == []
|
assert Weather.iemre_for_path(contact) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns observation for pos1 only when pos2 is nil" do
|
test "returns observation for pos1 only when pos2 is nil" do
|
||||||
Weather.upsert_iemre_observation(@iemre_attrs)
|
Weather.upsert_iemre_observation(@iemre_attrs)
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||||
pos2: nil,
|
pos2: nil,
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
observations = Weather.iemre_for_path(qso)
|
observations = Weather.iemre_for_path(contact)
|
||||||
assert length(observations) == 1
|
assert length(observations) == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -684,31 +684,31 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "iemre_for_qso/1" do
|
describe "iemre_for_contact/1" do
|
||||||
test "returns IEMRE observation matching QSO pos1 and date" do
|
test "returns IEMRE observation matching QSO pos1 and date" do
|
||||||
Weather.upsert_iemre_observation(@iemre_attrs)
|
Weather.upsert_iemre_observation(@iemre_attrs)
|
||||||
|
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
obs = Weather.iemre_for_qso(qso)
|
obs = Weather.iemre_for_contact(contact)
|
||||||
assert obs
|
assert obs
|
||||||
assert obs.lat == 32.875
|
assert obs.lat == 32.875
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil when no IEMRE observation exists" do
|
test "returns nil when no IEMRE observation exists" do
|
||||||
qso = %{
|
contact = %{
|
||||||
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
pos1: %{"lat" => 40.0, "lon" => -80.0},
|
||||||
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
qso_timestamp: ~U[2026-03-28 18:00:00Z]
|
||||||
}
|
}
|
||||||
|
|
||||||
assert Weather.iemre_for_qso(qso) == nil
|
assert Weather.iemre_for_contact(contact) == nil
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns nil when QSO has no pos1" do
|
test "returns nil when QSO has no pos1" do
|
||||||
assert Weather.iemre_for_qso(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
|
assert Weather.iemre_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-03-28 18:00:00Z]}) == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorkerTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
alias Microwaveprop.Weather
|
alias Microwaveprop.Weather
|
||||||
alias Microwaveprop.Weather.HrrrClient
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
alias Microwaveprop.Weather.IemClient
|
alias Microwaveprop.Weather.IemClient
|
||||||
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||||||
|
|
||||||
@qso_attrs %{
|
@qso_attrs %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
|
|
@ -21,13 +21,13 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
distance_km: Decimal.new("295")
|
distance_km: Decimal.new("295")
|
||||||
}
|
}
|
||||||
|
|
||||||
defp create_qso(attrs \\ %{}) do
|
defp create_contact(attrs \\ %{}) do
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(Map.merge(@qso_attrs, attrs))
|
|> Contact.changeset(Map.merge(@qso_attrs, attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_asos_station(code, lat, lon) do
|
defp create_asos_station(code, lat, lon) do
|
||||||
|
|
@ -59,9 +59,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
describe "build_weather_jobs/1" do
|
describe "build_weather_jobs/1" do
|
||||||
test "builds ASOS jobs for nearby stations" do
|
test "builds ASOS jobs for nearby stations" do
|
||||||
station = create_asos_station("KDFW", 32.90, -97.04)
|
station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
|
||||||
|
|
||||||
asos_jobs =
|
asos_jobs =
|
||||||
Enum.filter(jobs, fn j ->
|
Enum.filter(jobs, fn j ->
|
||||||
|
|
@ -79,9 +79,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "builds RAOB jobs for nearby sounding stations" do
|
test "builds RAOB jobs for nearby sounding stations" do
|
||||||
station = create_sounding_station("FWD", 32.83, -97.30)
|
station = create_sounding_station("FWD", 32.83, -97.30)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
|
||||||
|
|
||||||
raob_jobs =
|
raob_jobs =
|
||||||
Enum.filter(jobs, fn j ->
|
Enum.filter(jobs, fn j ->
|
||||||
|
|
@ -100,10 +100,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
|
|
||||||
# Two QSOs at the exact same time produce identical args → deduplicated
|
# Two QSOs at the exact same time produce identical args → deduplicated
|
||||||
q1 = create_qso(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
q1 = create_contact(%{station1: "A1", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||||
q2 = create_qso(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
q2 = create_contact(%{station1: "A2", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([q1, q2])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([q1, q2])
|
||||||
|
|
||||||
asos_jobs =
|
asos_jobs =
|
||||||
Enum.filter(jobs, fn j ->
|
Enum.filter(jobs, fn j ->
|
||||||
|
|
@ -116,31 +116,31 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
test "builds ASOS jobs for stations near pos2 and midpoint" do
|
test "builds ASOS jobs for stations near pos2 and midpoint" do
|
||||||
# Station near pos2 (30.3, -97.7) but NOT near pos1 (32.9, -97.0)
|
# Station near pos2 (30.3, -97.7) but NOT near pos1 (32.9, -97.0)
|
||||||
station_near_pos2 = create_asos_station("KAUS", 30.20, -97.68)
|
station_near_pos2 = create_asos_station("KAUS", 30.20, -97.68)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
|
||||||
|
|
||||||
station_ids = Enum.map(jobs, & &1.changes.args["station_id"])
|
station_ids = Enum.map(jobs, & &1.changes.args["station_id"])
|
||||||
assert station_near_pos2.id in station_ids
|
assert station_near_pos2.id in station_ids
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns empty list when no stations nearby" do
|
test "returns empty list when no stations nearby" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil})
|
contact = create_contact(%{pos1: %{"lat" => 60.0, "lon" => -150.0}, pos2: nil})
|
||||||
|
|
||||||
assert QsoWeatherEnqueueWorker.build_weather_jobs([qso]) == []
|
assert ContactWeatherEnqueueWorker.build_weather_jobs([contact]) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips ASOS job when surface observations already exist for that station and time window" do
|
test "skips ASOS job when surface observations already exist for that station and time window" do
|
||||||
station = create_asos_station("KDFW", 32.90, -97.04)
|
station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
# Insert a surface observation within the time window (qso ± 2 hours)
|
# Insert a surface observation within the time window (contact ± 2 hours)
|
||||||
Weather.upsert_surface_observation(station, %{
|
Weather.upsert_surface_observation(station, %{
|
||||||
observed_at: ~U[2026-03-28 18:00:00Z],
|
observed_at: ~U[2026-03-28 18:00:00Z],
|
||||||
temp_f: 75.0
|
temp_f: 75.0
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
|
||||||
|
|
||||||
asos_jobs =
|
asos_jobs =
|
||||||
Enum.filter(jobs, fn j ->
|
Enum.filter(jobs, fn j ->
|
||||||
|
|
@ -152,10 +152,10 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "skips RAOB job when sounding already exists for that station and time" do
|
test "skips RAOB job when sounding already exists for that station and time" do
|
||||||
station = create_sounding_station("FWD", 32.83, -97.30)
|
station = create_sounding_station("FWD", 32.83, -97.30)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
# Insert soundings for both bracketing times (00Z and 12Z on 2026-03-28)
|
# Insert soundings for both bracketing times (00Z and 12Z on 2026-03-28)
|
||||||
for time <- Weather.sounding_times_around(qso.qso_timestamp) do
|
for time <- Weather.sounding_times_around(contact.qso_timestamp) do
|
||||||
Weather.upsert_sounding(station, %{
|
Weather.upsert_sounding(station, %{
|
||||||
observed_at: time,
|
observed_at: time,
|
||||||
profile: [%{"pres" => 1013.0, "tmpc" => 25.0}],
|
profile: [%{"pres" => 1013.0, "tmpc" => 25.0}],
|
||||||
|
|
@ -163,7 +163,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_weather_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_weather_jobs([contact])
|
||||||
|
|
||||||
raob_jobs =
|
raob_jobs =
|
||||||
Enum.filter(jobs, fn j ->
|
Enum.filter(jobs, fn j ->
|
||||||
|
|
@ -207,58 +207,58 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "enqueues weather jobs and marks QSOs as queued" do
|
test "enqueues weather jobs and marks QSOs as queued" do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
assert qso.weather_queued == false
|
assert contact.weather_queued == false
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.weather_queued == true
|
assert updated.weather_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns :ok when no unprocessed QSOs exist" do
|
test "returns :ok when no unprocessed QSOs exist" do
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
end
|
end
|
||||||
|
|
||||||
test "backfills distance_km for QSOs missing it" do
|
test "backfills distance_km for QSOs missing it" do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso(%{distance_km: nil})
|
contact = create_contact(%{distance_km: nil})
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.distance_km
|
assert updated.distance_km
|
||||||
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips QSOs without positions" do
|
test "skips QSOs without positions" do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
_qso = create_qso(%{pos1: nil})
|
_qso = create_contact(%{pos1: nil})
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
# QSO without pos1 should not be marked as queued
|
# QSO without pos1 should not be marked as queued
|
||||||
all_qsos = Repo.all(Qso)
|
all_qsos = Repo.all(Contact)
|
||||||
refute Enum.any?(all_qsos, & &1.weather_queued)
|
refute Enum.any?(all_qsos, & &1.weather_queued)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enqueues HRRR jobs and marks QSOs as hrrr_queued" do
|
test "enqueues HRRR jobs and marks QSOs as hrrr_queued" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
assert qso.hrrr_queued == false
|
assert contact.hrrr_queued == false
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.hrrr_queued == true
|
assert updated.hrrr_queued == true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build_hrrr_jobs/1" do
|
describe "build_hrrr_jobs/1" do
|
||||||
test "batches all path points into one job per HRRR hour" do
|
test "batches all path points into one job per HRRR hour" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
|
||||||
|
|
||||||
# All 3 path points share the same HRRR hour → 1 batch job
|
# All 3 path points share the same HRRR hour → 1 batch job
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
|
|
@ -271,9 +271,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "batch contains single point when pos2 is nil" do
|
test "batch contains single point when pos2 is nil" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
|
||||||
|
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
job = hd(jobs)
|
job = hd(jobs)
|
||||||
|
|
@ -283,9 +283,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rounds valid_time to nearest hour" do
|
test "rounds valid_time to nearest hour" do
|
||||||
qso = create_qso(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil})
|
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:45:00Z], pos2: nil})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
|
||||||
|
|
||||||
job = hd(jobs)
|
job = hd(jobs)
|
||||||
assert job.changes.args["valid_time"] == "2026-03-28T19:00:00Z"
|
assert job.changes.args["valid_time"] == "2026-03-28T19:00:00Z"
|
||||||
|
|
@ -293,7 +293,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "deduplicates jobs across QSOs with identical path points" do
|
test "deduplicates jobs across QSOs with identical path points" do
|
||||||
q1 =
|
q1 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A1",
|
station1: "A1",
|
||||||
qso_timestamp: ~U[2026-03-28 18:10:00Z],
|
qso_timestamp: ~U[2026-03-28 18:10:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
|
|
@ -301,14 +301,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
q2 =
|
q2 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A2",
|
station1: "A2",
|
||||||
qso_timestamp: ~U[2026-03-28 18:20:00Z],
|
qso_timestamp: ~U[2026-03-28 18:20:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
pos2: nil
|
pos2: nil
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([q1, q2])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([q1, q2])
|
||||||
|
|
||||||
# Both round to same hour and same location → 1 job
|
# Both round to same hour and same location → 1 job
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
|
|
@ -316,26 +316,26 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "deduplicates path points that round to the same HRRR grid cell" do
|
test "deduplicates path points that round to the same HRRR grid cell" do
|
||||||
# pos1 and pos2 only 0.001° apart — all 3 points round to same HRRR cell
|
# pos1 and pos2 only 0.001° apart — all 3 points round to same HRRR cell
|
||||||
qso =
|
contact =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
pos1: %{"lat" => 32.901, "lon" => -97.041},
|
pos1: %{"lat" => 32.901, "lon" => -97.041},
|
||||||
pos2: %{"lat" => 32.902, "lon" => -97.042}
|
pos2: %{"lat" => 32.902, "lon" => -97.042}
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
|
||||||
|
|
||||||
# All 3 path points round to {32.90, -97.04} → 1 job
|
# All 3 path points round to {32.90, -97.04} → 1 job
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips QSOs without pos1" do
|
test "skips QSOs without pos1" do
|
||||||
qso = create_qso(%{pos1: nil})
|
contact = create_contact(%{pos1: nil})
|
||||||
|
|
||||||
assert QsoWeatherEnqueueWorker.build_hrrr_jobs([qso]) == []
|
assert ContactWeatherEnqueueWorker.build_hrrr_jobs([contact]) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "excludes existing points from batch" do
|
test "excludes existing points from batch" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
||||||
|
|
||||||
# Insert a profile at the pos1 rounded grid point only
|
# Insert a profile at the pos1 rounded grid point only
|
||||||
Weather.upsert_hrrr_profile(%{
|
Weather.upsert_hrrr_profile(%{
|
||||||
|
|
@ -351,7 +351,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
surface_pressure_mb: 1013.0
|
surface_pressure_mb: 1013.0
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_hrrr_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_hrrr_jobs([contact])
|
||||||
|
|
||||||
# 1 batch job, but pos1 excluded from points list
|
# 1 batch job, but pos1 excluded from points list
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
|
|
@ -364,19 +364,19 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
describe "build_terrain_jobs/1" do
|
describe "build_terrain_jobs/1" do
|
||||||
test "builds one terrain job per QSO" do
|
test "builds one terrain job per QSO" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact])
|
||||||
|
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
job = hd(jobs)
|
job = hd(jobs)
|
||||||
assert job.changes.args["qso_id"] == qso.id
|
assert job.changes.args["qso_id"] == contact.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "deduplicates by qso_id" do
|
test "deduplicates by qso_id" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_terrain_jobs([qso, qso])
|
jobs = ContactWeatherEnqueueWorker.build_terrain_jobs([contact, contact])
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -410,30 +410,30 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enqueues terrain jobs and marks QSOs as terrain_queued" do
|
test "enqueues terrain jobs and marks QSOs as terrain_queued" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
assert qso.terrain_queued == false
|
assert contact.terrain_queued == false
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.terrain_queued == true
|
assert updated.terrain_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips terrain for QSOs missing pos2" do
|
test "skips terrain for QSOs missing pos2" do
|
||||||
_qso = create_qso(%{pos2: nil})
|
_qso = create_contact(%{pos2: nil})
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
all_qsos = Repo.all(Qso)
|
all_qsos = Repo.all(Contact)
|
||||||
refute Enum.any?(all_qsos, & &1.terrain_queued)
|
refute Enum.any?(all_qsos, & &1.terrain_queued)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "build_iemre_jobs/1" do
|
describe "build_iemre_jobs/1" do
|
||||||
test "builds IEMRE jobs for all path points (pos1, midpoint, pos2)" do
|
test "builds IEMRE jobs for all path points (pos1, midpoint, pos2)" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
|
||||||
|
|
||||||
# pos1, midpoint, pos2 may have distinct IEMRE grid cells
|
# pos1, midpoint, pos2 may have distinct IEMRE grid cells
|
||||||
assert length(jobs) >= 2
|
assert length(jobs) >= 2
|
||||||
|
|
@ -442,9 +442,9 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "builds only one IEMRE job when pos2 is nil" do
|
test "builds only one IEMRE job when pos2 is nil" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}, pos2: nil})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
|
||||||
|
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
job = hd(jobs)
|
job = hd(jobs)
|
||||||
|
|
@ -455,7 +455,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "deduplicates jobs across QSOs with identical path points" do
|
test "deduplicates jobs across QSOs with identical path points" do
|
||||||
q1 =
|
q1 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A1",
|
station1: "A1",
|
||||||
qso_timestamp: ~U[2026-03-28 18:10:00Z],
|
qso_timestamp: ~U[2026-03-28 18:10:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
|
|
@ -463,14 +463,14 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
q2 =
|
q2 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A2",
|
station1: "A2",
|
||||||
qso_timestamp: ~U[2026-03-28 20:20:00Z],
|
qso_timestamp: ~U[2026-03-28 20:20:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
pos2: nil
|
pos2: nil
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
|
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
|
||||||
|
|
||||||
# Same location and same date → 1 job
|
# Same location and same date → 1 job
|
||||||
assert length(jobs) == 1
|
assert length(jobs) == 1
|
||||||
|
|
@ -478,7 +478,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "creates separate jobs for different dates" do
|
test "creates separate jobs for different dates" do
|
||||||
q1 =
|
q1 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A1",
|
station1: "A1",
|
||||||
qso_timestamp: ~U[2026-03-28 23:00:00Z],
|
qso_timestamp: ~U[2026-03-28 23:00:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
|
|
@ -486,24 +486,24 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
q2 =
|
q2 =
|
||||||
create_qso(%{
|
create_contact(%{
|
||||||
station1: "A2",
|
station1: "A2",
|
||||||
qso_timestamp: ~U[2026-03-29 01:00:00Z],
|
qso_timestamp: ~U[2026-03-29 01:00:00Z],
|
||||||
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
pos1: %{"lat" => 32.90, "lon" => -97.04},
|
||||||
pos2: nil
|
pos2: nil
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
|
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([q1, q2])
|
||||||
assert length(jobs) == 2
|
assert length(jobs) == 2
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips QSOs without pos1" do
|
test "skips QSOs without pos1" do
|
||||||
qso = create_qso(%{pos1: nil})
|
contact = create_contact(%{pos1: nil})
|
||||||
assert QsoWeatherEnqueueWorker.build_iemre_jobs([qso]) == []
|
assert ContactWeatherEnqueueWorker.build_iemre_jobs([contact]) == []
|
||||||
end
|
end
|
||||||
|
|
||||||
test "skips only path points where IEMRE observation already exists" do
|
test "skips only path points where IEMRE observation already exists" do
|
||||||
qso = create_qso(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
contact = create_contact(%{pos1: %{"lat" => 32.907, "lon" => -97.038}})
|
||||||
|
|
||||||
# Insert an IEMRE observation at the pos1 rounded grid point only
|
# Insert an IEMRE observation at the pos1 rounded grid point only
|
||||||
Weather.upsert_iemre_observation(%{
|
Weather.upsert_iemre_observation(%{
|
||||||
|
|
@ -513,7 +513,7 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
hourly: [%{"hour" => 0, "p01m_mm" => 0.0}]
|
hourly: [%{"hour" => 0, "p01m_mm" => 0.0}]
|
||||||
})
|
})
|
||||||
|
|
||||||
jobs = QsoWeatherEnqueueWorker.build_iemre_jobs([qso])
|
jobs = ContactWeatherEnqueueWorker.build_iemre_jobs([contact])
|
||||||
|
|
||||||
# pos1 skipped, midpoint + pos2 still need fetching
|
# pos1 skipped, midpoint + pos2 still need fetching
|
||||||
assert length(jobs) >= 1
|
assert length(jobs) >= 1
|
||||||
|
|
@ -551,17 +551,17 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enqueues IEMRE jobs and marks QSOs as iemre_queued" do
|
test "enqueues IEMRE jobs and marks QSOs as iemre_queued" do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
assert qso.iemre_queued == false
|
assert contact.iemre_queued == false
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
assert :ok = ContactWeatherEnqueueWorker.perform(%Oban.Job{args: %{}})
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.iemre_queued == true
|
assert updated.iemre_queued == true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "enqueue_for_qso/1" do
|
describe "enqueue_for_contact/1" do
|
||||||
setup do
|
setup do
|
||||||
Req.Test.stub(IemClient, fn conn ->
|
Req.Test.stub(IemClient, fn conn ->
|
||||||
case conn.request_path do
|
case conn.request_path do
|
||||||
|
|
@ -591,16 +591,16 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "enqueues all enrichment jobs and marks QSO as fully queued" do
|
test "enqueues all enrichment jobs and marks QSO as fully queued" do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
assert qso.weather_queued == false
|
assert contact.weather_queued == false
|
||||||
assert qso.hrrr_queued == false
|
assert contact.hrrr_queued == false
|
||||||
assert qso.terrain_queued == false
|
assert contact.terrain_queued == false
|
||||||
assert qso.iemre_queued == false
|
assert contact.iemre_queued == false
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
|
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.weather_queued == true
|
assert updated.weather_queued == true
|
||||||
assert updated.hrrr_queued == true
|
assert updated.hrrr_queued == true
|
||||||
assert updated.terrain_queued == true
|
assert updated.terrain_queued == true
|
||||||
|
|
@ -609,11 +609,11 @@ defmodule Microwaveprop.Workers.QsoWeatherEnqueueWorkerTest do
|
||||||
|
|
||||||
test "works for QSO without pos2 (marks terrain_queued false)" do
|
test "works for QSO without pos2 (marks terrain_queued false)" do
|
||||||
_station = create_asos_station("KDFW", 32.90, -97.04)
|
_station = create_asos_station("KDFW", 32.90, -97.04)
|
||||||
qso = create_qso(%{pos2: nil})
|
contact = create_contact(%{pos2: nil})
|
||||||
|
|
||||||
assert :ok = QsoWeatherEnqueueWorker.enqueue_for_qso(qso)
|
assert :ok = ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.weather_queued == true
|
assert updated.weather_queued == true
|
||||||
assert updated.hrrr_queued == true
|
assert updated.hrrr_queued == true
|
||||||
# terrain requires pos2, so it can't be queued
|
# terrain requires pos2, so it can't be queued
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Terrain
|
alias Microwaveprop.Terrain
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
alias Microwaveprop.Workers.TerrainProfileWorker
|
alias Microwaveprop.Workers.TerrainProfileWorker
|
||||||
|
|
@ -17,13 +17,13 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
||||||
distance_km: Decimal.new("295")
|
distance_km: Decimal.new("295")
|
||||||
}
|
}
|
||||||
|
|
||||||
defp create_qso(attrs \\ %{}) do
|
defp create_contact(attrs \\ %{}) do
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(Map.merge(@qso_attrs, attrs))
|
|> Contact.changeset(Map.merge(@qso_attrs, attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
defp stub_elevation_api do
|
defp stub_elevation_api do
|
||||||
|
|
@ -38,15 +38,15 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
||||||
describe "perform/1" do
|
describe "perform/1" do
|
||||||
test "fetches elevation and stores terrain profile" do
|
test "fetches elevation and stores terrain profile" do
|
||||||
stub_elevation_api()
|
stub_elevation_api()
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
refute Terrain.has_terrain_profile?(qso.id)
|
refute Terrain.has_terrain_profile?(contact.id)
|
||||||
|
|
||||||
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
|
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
|
||||||
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
||||||
|
|
||||||
assert Terrain.has_terrain_profile?(qso.id)
|
assert Terrain.has_terrain_profile?(contact.id)
|
||||||
profile = Terrain.get_terrain_profile(qso.id)
|
profile = Terrain.get_terrain_profile(contact.id)
|
||||||
assert profile.sample_count == 65
|
assert profile.sample_count == 65
|
||||||
assert profile.verdict in ["CLEAR", "FRESNEL_MINOR", "FRESNEL_PARTIAL", "BLOCKED"]
|
assert profile.verdict in ["CLEAR", "FRESNEL_MINOR", "FRESNEL_PARTIAL", "BLOCKED"]
|
||||||
assert is_list(profile.path_points)
|
assert is_list(profile.path_points)
|
||||||
|
|
@ -54,17 +54,17 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
||||||
|
|
||||||
test "skips if terrain profile already exists" do
|
test "skips if terrain profile already exists" do
|
||||||
stub_elevation_api()
|
stub_elevation_api()
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
Terrain.upsert_terrain_profile(%{
|
Terrain.upsert_terrain_profile(%{
|
||||||
qso_id: qso.id,
|
qso_id: contact.id,
|
||||||
sample_count: 65,
|
sample_count: 65,
|
||||||
path_points: [],
|
path_points: [],
|
||||||
verdict: "CLEAR"
|
verdict: "CLEAR"
|
||||||
})
|
})
|
||||||
|
|
||||||
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
|
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
|
||||||
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -73,22 +73,22 @@ defmodule Microwaveprop.Workers.TerrainProfileWorkerTest do
|
||||||
Plug.Conn.send_resp(conn, 500, "error")
|
Plug.Conn.send_resp(conn, 500, "error")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
|
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
|
||||||
assert {:error, _} = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
assert {:error, _} = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
||||||
|
|
||||||
refute Terrain.has_terrain_profile?(qso.id)
|
refute Terrain.has_terrain_profile?(contact.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "uses correct frequency from band" do
|
test "uses correct frequency from band" do
|
||||||
stub_elevation_api()
|
stub_elevation_api()
|
||||||
qso = create_qso(%{band: Decimal.new("10368")})
|
contact = create_contact(%{band: Decimal.new("10368")})
|
||||||
|
|
||||||
job = TerrainProfileWorker.new(%{"qso_id" => qso.id})
|
job = TerrainProfileWorker.new(%{"qso_id" => contact.id})
|
||||||
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
assert :ok = TerrainProfileWorker.perform(%Oban.Job{args: job.changes.args})
|
||||||
|
|
||||||
profile = Terrain.get_terrain_profile(qso.id)
|
profile = Terrain.get_terrain_profile(contact.id)
|
||||||
assert profile.verdict
|
assert profile.verdict
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
defmodule MicrowavepropWeb.QsoLiveTest do
|
defmodule MicrowavepropWeb.ContactLiveTest do
|
||||||
use MicrowavepropWeb.ConnCase, async: true
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
defp create_qso(attrs \\ %{}) do
|
defp create_contact(attrs \\ %{}) do
|
||||||
default = %{
|
default = %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
station2: "K5TR",
|
station2: "K5TR",
|
||||||
|
|
@ -20,24 +20,24 @@ defmodule MicrowavepropWeb.QsoLiveTest do
|
||||||
distance_km: Decimal.new("295")
|
distance_km: Decimal.new("295")
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(Map.merge(default, attrs))
|
|> Contact.changeset(Map.merge(default, attrs))
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
qso
|
contact
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Index" do
|
describe "Index" do
|
||||||
test "renders page with QSOs heading", %{conn: conn} do
|
test "renders page with QSOs heading", %{conn: conn} do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
assert html =~ "QSOs"
|
assert html =~ "QSOs"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "table shows QSO data", %{conn: conn} do
|
test "table shows QSO data", %{conn: conn} do
|
||||||
create_qso()
|
create_contact()
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
assert html =~ "W5XD"
|
assert html =~ "W5XD"
|
||||||
assert html =~ "K5TR"
|
assert html =~ "K5TR"
|
||||||
assert html =~ "CW"
|
assert html =~ "CW"
|
||||||
|
|
@ -45,17 +45,17 @@ defmodule MicrowavepropWeb.QsoLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "row links to detail page", %{conn: conn} do
|
test "row links to detail page", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
assert html =~ ~p"/qsos/#{qso.id}"
|
assert html =~ ~p"/contacts/#{contact.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sort params order the table", %{conn: conn} do
|
test "sort params order the table", %{conn: conn} do
|
||||||
create_qso(%{station1: "ZZ9ZZ"})
|
create_contact(%{station1: "ZZ9ZZ"})
|
||||||
create_qso(%{station1: "AA1AA"})
|
create_contact(%{station1: "AA1AA"})
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos?sort_by=station1&sort_order=asc")
|
{:ok, _lv, html} = live(conn, ~p"/contacts?sort_by=station1&sort_order=asc")
|
||||||
|
|
||||||
# AA1AA should appear before ZZ9ZZ
|
# AA1AA should appear before ZZ9ZZ
|
||||||
aa_pos = html |> :binary.match("AA1AA") |> elem(0)
|
aa_pos = html |> :binary.match("AA1AA") |> elem(0)
|
||||||
|
|
@ -66,22 +66,22 @@ defmodule MicrowavepropWeb.QsoLiveTest do
|
||||||
test "pagination works", %{conn: conn} do
|
test "pagination works", %{conn: conn} do
|
||||||
for i <- 1..25 do
|
for i <- 1..25 do
|
||||||
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
create_qso(%{qso_timestamp: ts})
|
create_contact(%{qso_timestamp: ts})
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos")
|
{:ok, _lv, html} = live(conn, ~p"/contacts")
|
||||||
assert html =~ "Page 1 of 2"
|
assert html =~ "Page 1 of 2"
|
||||||
|
|
||||||
{:ok, _lv, html2} = live(conn, ~p"/qsos?page=2")
|
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
|
||||||
assert html2 =~ "Page 2 of 2"
|
assert html2 =~ "Page 2 of 2"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Show" do
|
describe "Show" do
|
||||||
test "renders QSO detail fields", %{conn: conn} do
|
test "renders QSO detail fields", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
|
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
assert html =~ "W5XD"
|
assert html =~ "W5XD"
|
||||||
assert html =~ "K5TR"
|
assert html =~ "K5TR"
|
||||||
assert html =~ "CW"
|
assert html =~ "CW"
|
||||||
|
|
@ -91,38 +91,38 @@ defmodule MicrowavepropWeb.QsoLiveTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows surface observations section", %{conn: conn} do
|
test "shows surface observations section", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
assert html =~ "Surface Observations"
|
assert html =~ "Surface Observations"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows sounding section", %{conn: conn} do
|
test "shows sounding section", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
assert html =~ "Soundings"
|
assert html =~ "Soundings"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows solar index section", %{conn: conn} do
|
test "shows solar index section", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, _lv, html} = live(conn, ~p"/qsos/#{qso.id}")
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
assert html =~ "Solar Conditions"
|
assert html =~ "Solar Conditions"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders with sort assigns", %{conn: conn} do
|
test "renders with sort assigns", %{conn: conn} do
|
||||||
qso = create_qso()
|
contact = create_contact()
|
||||||
{:ok, lv, html} = live(conn, ~p"/qsos/#{qso.id}")
|
{:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||||
|
|
||||||
# Page renders without error
|
# Page renders without error
|
||||||
assert html =~ "Surface Observations"
|
assert html =~ "Surface Observations"
|
||||||
assert html =~ "Soundings"
|
assert html =~ "Soundings"
|
||||||
|
|
||||||
# Sort assigns are initialized (no crash on render)
|
# Sort assigns are initialized (no crash on render)
|
||||||
assert render(lv) =~ qso.station1
|
assert render(lv) =~ contact.station1
|
||||||
end
|
end
|
||||||
|
|
||||||
test "raises for bad UUID", %{conn: conn} do
|
test "raises for bad UUID", %{conn: conn} do
|
||||||
assert_raise Ecto.NoResultsError, fn ->
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
live(conn, ~p"/qsos/#{Ecto.UUID.generate()}")
|
live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Terrain.ElevationClient
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
alias Microwaveprop.Weather.HrrrClient
|
alias Microwaveprop.Weather.HrrrClient
|
||||||
|
|
@ -33,7 +33,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
describe "mount" do
|
describe "mount" do
|
||||||
test "renders the submission form", %{conn: conn} do
|
test "renders the submission form", %{conn: conn} do
|
||||||
{:ok, _lv, html} = live(conn, ~p"/submit")
|
{:ok, _lv, html} = live(conn, ~p"/submit")
|
||||||
assert html =~ "Submit QSO"
|
assert html =~ "Submit Contact"
|
||||||
assert html =~ "Station 1"
|
assert html =~ "Station 1"
|
||||||
assert html =~ "Station 2"
|
assert html =~ "Station 2"
|
||||||
assert html =~ "Grid 1"
|
assert html =~ "Grid 1"
|
||||||
|
|
@ -49,7 +49,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
|
|
||||||
html =
|
html =
|
||||||
lv
|
lv
|
||||||
|> form("#qso-form", qso: %{station1: ""})
|
|> form("#contact-form", contact: %{station1: ""})
|
||||||
|> render_change()
|
|> render_change()
|
||||||
|
|
||||||
assert html =~ "can't be blank"
|
assert html =~ "can't be blank"
|
||||||
|
|
@ -61,8 +61,8 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
{:ok, lv, _html} = live(conn, ~p"/submit")
|
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||||
|
|
||||||
lv
|
lv
|
||||||
|> form("#qso-form",
|
|> form("#contact-form",
|
||||||
qso: %{
|
contact: %{
|
||||||
station1: "W5XD",
|
station1: "W5XD",
|
||||||
station2: "K5TR",
|
station2: "K5TR",
|
||||||
qso_timestamp: "2026-03-28T18:00",
|
qso_timestamp: "2026-03-28T18:00",
|
||||||
|
|
@ -75,12 +75,12 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
)
|
)
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
qso = Repo.one!(Qso)
|
contact = Repo.one!(Contact)
|
||||||
assert qso.station1 == "W5XD"
|
assert contact.station1 == "W5XD"
|
||||||
assert qso.user_submitted == true
|
assert contact.user_submitted == true
|
||||||
assert qso.pos1["lat"]
|
assert contact.pos1["lat"]
|
||||||
|
|
||||||
assert_redirect(lv, ~p"/qsos/#{qso.id}")
|
assert_redirect(lv, ~p"/contacts/#{contact.id}")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows errors on invalid submit", %{conn: conn} do
|
test "shows errors on invalid submit", %{conn: conn} do
|
||||||
|
|
@ -88,7 +88,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
|
|
||||||
html =
|
html =
|
||||||
lv
|
lv
|
||||||
|> form("#qso-form", qso: %{station1: ""})
|
|> form("#contact-form", contact: %{station1: ""})
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "can't be blank"
|
assert html =~ "can't be blank"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Mix.Tasks.ResetEnrichmentTest do
|
defmodule Mix.Tasks.ResetEnrichmentTest do
|
||||||
use Microwaveprop.DataCase, async: true
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Contact
|
||||||
alias Mix.Tasks.ResetEnrichment
|
alias Mix.Tasks.ResetEnrichment
|
||||||
|
|
||||||
defp create_queued_qso do
|
defp create_queued_qso do
|
||||||
|
|
@ -20,41 +20,41 @@ defmodule Mix.Tasks.ResetEnrichmentTest do
|
||||||
distance_km: Decimal.new("295")
|
distance_km: Decimal.new("295")
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, qso} =
|
{:ok, contact} =
|
||||||
%Qso{}
|
%Contact{}
|
||||||
|> Qso.changeset(default)
|
|> Contact.changeset(default)
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
|
|
||||||
# Set all queued flags to true (not in changeset cast)
|
# Set all queued flags to true (not in changeset cast)
|
||||||
Qso
|
Contact
|
||||||
|> where([q], q.id == ^qso.id)
|
|> where([q], q.id == ^contact.id)
|
||||||
|> Repo.update_all(set: [weather_queued: true, hrrr_queued: true, iemre_queued: true, terrain_queued: true])
|
|> Repo.update_all(set: [weather_queued: true, hrrr_queued: true, iemre_queued: true, terrain_queued: true])
|
||||||
|
|
||||||
Repo.get!(Qso, qso.id)
|
Repo.get!(Contact, contact.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "run/1" do
|
describe "run/1" do
|
||||||
test "resets weather_queued, hrrr_queued, and iemre_queued to false" do
|
test "resets weather_queued, hrrr_queued, and iemre_queued to false" do
|
||||||
qso = create_queued_qso()
|
contact = create_queued_qso()
|
||||||
|
|
||||||
assert qso.weather_queued == true
|
assert contact.weather_queued == true
|
||||||
assert qso.hrrr_queued == true
|
assert contact.hrrr_queued == true
|
||||||
assert qso.iemre_queued == true
|
assert contact.iemre_queued == true
|
||||||
|
|
||||||
ResetEnrichment.run([])
|
ResetEnrichment.run([])
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.weather_queued == false
|
assert updated.weather_queued == false
|
||||||
assert updated.hrrr_queued == false
|
assert updated.hrrr_queued == false
|
||||||
assert updated.iemre_queued == false
|
assert updated.iemre_queued == false
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not reset terrain_queued" do
|
test "does not reset terrain_queued" do
|
||||||
qso = create_queued_qso()
|
contact = create_queued_qso()
|
||||||
|
|
||||||
ResetEnrichment.run([])
|
ResetEnrichment.run([])
|
||||||
|
|
||||||
updated = Repo.get!(Qso, qso.id)
|
updated = Repo.get!(Contact, contact.id)
|
||||||
assert updated.terrain_queued == true
|
assert updated.terrain_queued == true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue