Fetch elevation data along the path between two stations via the Open-Meteo Elevation API (with Open-Topo-Data fallback), compute Fresnel zone clearance, earth bulge, and knife-edge diffraction loss, and store the results per QSO. - terrain_profiles table with migration and TerrainProfile schema - ElevationClient with batched API calls and fallback - TerrainAnalysis with Fresnel/diffraction physics (ITU-R P.526-15) - TerrainProfileWorker on Oban :terrain queue - QsoWeatherEnqueueWorker enqueues terrain jobs automatically - QSO show page displays verdict badge and collapsible elevation table - Reorder show page: terrain, soundings, solar, HRRR, surface obs - Fix Dockerfile wgrib2 build (add cmake dependency)
348 lines
10 KiB
Elixir
348 lines
10 KiB
Elixir
defmodule Microwaveprop.RadioTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.Qso
|
|
|
|
defp create_qso(attrs \\ %{}) do
|
|
default = %{
|
|
station1: "W5XD",
|
|
station2: "K5TR",
|
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
|
mode: "CW",
|
|
band: Decimal.new("1296"),
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
|
distance_km: Decimal.new("295")
|
|
}
|
|
|
|
{:ok, qso} =
|
|
%Qso{}
|
|
|> Qso.changeset(Map.merge(default, attrs))
|
|
|> Repo.insert()
|
|
|
|
qso
|
|
end
|
|
|
|
describe "list_qsos/1" do
|
|
test "returns empty page when no QSOs exist" do
|
|
result = Radio.list_qsos()
|
|
|
|
assert result.entries == []
|
|
assert result.page == 1
|
|
assert result.total_pages == 1
|
|
assert result.total_entries == 0
|
|
end
|
|
|
|
test "paginates 20 per page, ordered by qso_timestamp desc" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_qso(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_qsos()
|
|
|
|
assert length(result.entries) == 20
|
|
assert result.page == 1
|
|
assert result.total_pages == 2
|
|
assert result.total_entries == 25
|
|
|
|
# Most recent first
|
|
first = hd(result.entries)
|
|
assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second)
|
|
end
|
|
|
|
test "returns second page" do
|
|
for i <- 1..25 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_qso(%{qso_timestamp: ts})
|
|
end
|
|
|
|
result = Radio.list_qsos(page: 2)
|
|
|
|
assert length(result.entries) == 5
|
|
assert result.page == 2
|
|
assert result.total_pages == 2
|
|
end
|
|
end
|
|
|
|
describe "list_qsos/1 sorting" do
|
|
test "sorts by station1 ascending" do
|
|
create_qso(%{station1: "ZZ9ZZ"})
|
|
create_qso(%{station1: "AA1AA"})
|
|
|
|
result = Radio.list_qsos(sort_by: :station1, sort_order: :asc)
|
|
|
|
stations = Enum.map(result.entries, & &1.station1)
|
|
assert stations == ["AA1AA", "ZZ9ZZ"]
|
|
end
|
|
|
|
test "sorts by distance_km descending" do
|
|
create_qso(%{distance_km: Decimal.new("100"), station1: "A1A"})
|
|
create_qso(%{distance_km: Decimal.new("500"), station1: "B2B"})
|
|
|
|
result = Radio.list_qsos(sort_by: :distance_km, sort_order: :desc)
|
|
|
|
distances = Enum.map(result.entries, & &1.distance_km)
|
|
assert distances == [Decimal.new("500"), Decimal.new("100")]
|
|
end
|
|
|
|
test "invalid sort_by falls back to qso_timestamp desc" do
|
|
early = create_qso(%{qso_timestamp: ~U[2026-01-01 00:00:00Z]})
|
|
late = create_qso(%{qso_timestamp: ~U[2026-03-01 00:00:00Z]})
|
|
|
|
result = Radio.list_qsos(sort_by: :bogus)
|
|
|
|
ids = Enum.map(result.entries, & &1.id)
|
|
assert ids == [late.id, early.id]
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_qsos/1" do
|
|
test "returns QSOs where weather_queued is false and pos1 is not nil" do
|
|
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
|
|
results = Radio.unprocessed_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
end
|
|
|
|
test "excludes QSOs already marked as weather_queued" do
|
|
q = create_qso()
|
|
Radio.mark_weather_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_qsos() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_qso(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_qsos(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_weather_queued!/1" do
|
|
test "sets weather_queued to true for given IDs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_weather_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).weather_queued == true
|
|
assert Repo.get!(Qso, q2.id).weather_queued == true
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_weather_queued!([q1.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).weather_queued == true
|
|
assert Repo.get!(Qso, q2.id).weather_queued == false
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_weather_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
|
|
describe "haversine_km/4" do
|
|
test "calculates distance between two known points" do
|
|
# Dallas to Austin ~roughly 295 km
|
|
dist = Radio.haversine_km(32.9, -97.0, 30.3, -97.7)
|
|
assert_in_delta dist, 295, 5
|
|
end
|
|
|
|
test "returns 0 for same point" do
|
|
assert Radio.haversine_km(32.9, -97.0, 32.9, -97.0) == 0.0
|
|
end
|
|
end
|
|
|
|
describe "backfill_distances/1" do
|
|
test "calculates and stores distance for QSOs missing distance_km" do
|
|
qso = create_qso(%{distance_km: nil})
|
|
assert is_nil(qso.distance_km)
|
|
|
|
Radio.backfill_distances([qso])
|
|
|
|
updated = Repo.get!(Qso, qso.id)
|
|
assert_in_delta Decimal.to_float(updated.distance_km), 295, 5
|
|
end
|
|
|
|
test "skips QSOs that already have a distance" do
|
|
qso = create_qso(%{distance_km: Decimal.new("100")})
|
|
|
|
Radio.backfill_distances([qso])
|
|
|
|
updated = Repo.get!(Qso, qso.id)
|
|
assert updated.distance_km == Decimal.new("100")
|
|
end
|
|
|
|
test "skips QSOs missing pos2" do
|
|
qso = create_qso(%{distance_km: nil, pos2: nil})
|
|
|
|
Radio.backfill_distances([qso])
|
|
|
|
updated = Repo.get!(Qso, qso.id)
|
|
assert is_nil(updated.distance_km)
|
|
end
|
|
end
|
|
|
|
describe "get_qso!/1" do
|
|
test "returns a QSO by ID" do
|
|
qso = create_qso()
|
|
|
|
found = Radio.get_qso!(qso.id)
|
|
assert found.id == qso.id
|
|
assert found.station1 == "W5XD"
|
|
end
|
|
|
|
test "raises Ecto.NoResultsError on bad ID" do
|
|
assert_raise Ecto.NoResultsError, fn ->
|
|
Radio.get_qso!(Ecto.UUID.generate())
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_hrrr_qsos/1" do
|
|
test "returns QSOs where hrrr_queued is false and pos1 is not nil" do
|
|
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_qso(%{station1: "K5TR", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
|
|
results = Radio.unprocessed_hrrr_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
end
|
|
|
|
test "excludes QSOs already marked as hrrr_queued" do
|
|
q = create_qso()
|
|
Radio.mark_hrrr_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_hrrr_qsos() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_hrrr_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_qso(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_hrrr_qsos(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_hrrr_queued!/1" do
|
|
test "sets hrrr_queued to true for given IDs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_hrrr_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).hrrr_queued == true
|
|
assert Repo.get!(Qso, q2.id).hrrr_queued == true
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_hrrr_queued!([q1.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).hrrr_queued == true
|
|
assert Repo.get!(Qso, q2.id).hrrr_queued == false
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_hrrr_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
|
|
describe "unprocessed_terrain_qsos/1" do
|
|
test "returns QSOs where terrain_queued is false and both pos1 and pos2 are not nil" do
|
|
q1 = create_qso(%{station1: "W5XD", qso_timestamp: ~U[2026-03-28 12:00:00Z]})
|
|
_q2 = create_qso(%{station1: "NOPOS1", pos1: nil, qso_timestamp: ~U[2026-03-28 13:00:00Z]})
|
|
_q3 = create_qso(%{station1: "NOPOS2", pos2: nil, qso_timestamp: ~U[2026-03-28 14:00:00Z]})
|
|
|
|
results = Radio.unprocessed_terrain_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert q1.id in ids
|
|
assert length(ids) == 1
|
|
end
|
|
|
|
test "excludes QSOs already marked as terrain_queued" do
|
|
q = create_qso()
|
|
Radio.mark_terrain_queued!([q.id])
|
|
|
|
assert Radio.unprocessed_terrain_qsos() == []
|
|
end
|
|
|
|
test "orders by qso_timestamp ascending" do
|
|
q_late = create_qso(%{station1: "LATE", qso_timestamp: ~U[2026-03-28 20:00:00Z]})
|
|
q_early = create_qso(%{station1: "EARLY", qso_timestamp: ~U[2026-03-28 10:00:00Z]})
|
|
|
|
results = Radio.unprocessed_terrain_qsos()
|
|
ids = Enum.map(results, & &1.id)
|
|
assert ids == [q_early.id, q_late.id]
|
|
end
|
|
|
|
test "respects limit parameter" do
|
|
for i <- 1..5 do
|
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
|
create_qso(%{qso_timestamp: ts})
|
|
end
|
|
|
|
assert length(Radio.unprocessed_terrain_qsos(3)) == 3
|
|
end
|
|
end
|
|
|
|
describe "mark_terrain_queued!/1" do
|
|
test "sets terrain_queued to true for given IDs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_terrain_queued!([q1.id, q2.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).terrain_queued == true
|
|
assert Repo.get!(Qso, q2.id).terrain_queued == true
|
|
end
|
|
|
|
test "does not affect other QSOs" do
|
|
q1 = create_qso(%{station1: "A1A"})
|
|
q2 = create_qso(%{station1: "B2B"})
|
|
|
|
Radio.mark_terrain_queued!([q1.id])
|
|
|
|
assert Repo.get!(Qso, q1.id).terrain_queued == true
|
|
assert Repo.get!(Qso, q2.id).terrain_queued == false
|
|
end
|
|
|
|
test "handles empty list" do
|
|
assert Radio.mark_terrain_queued!([]) == {0, nil}
|
|
end
|
|
end
|
|
end
|