prop/test/microwaveprop/pskr_test.exs
Graham McInitre 3f5b4cd60b fix: accept 4-char Maidenhead grids in PSK Reporter spots
Previously required ≥6-char locators; 4-char fields (~70×100 km)
were dropped. They're less precise for HRRR calibration but still
useful for spot display and coarse path analysis. Lowers the
minimum from 6 to 4 characters.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 13:54:32 -05:00

265 lines
8.7 KiB
Elixir

defmodule Microwaveprop.PskrTest do
use ExUnit.Case, async: true
alias Microwaveprop.Pskr
@sample %{
"sq" => 30_142_870_791,
"f" => 144_174_000,
"md" => "FT8",
"rp" => -5,
"t" => 1_662_407_712,
"t_tx" => 1_662_407_697,
"sc" => "K5ABC",
"sl" => "EM12kl",
"rc" => "K7XYZ",
"rl" => "DM43st",
"sa" => 291,
"ra" => 291,
"b" => "2m"
}
describe "parse_spot/1" do
test "preserves full subsquare precision when reported" do
json = Jason.encode!(@sample)
assert {:ok, spot} = Pskr.parse_spot(json)
assert spot.band == "2m"
assert spot.mode == "FT8"
assert spot.snr_db == -5
assert spot.sender_grid == "EM12KL"
assert spot.receiver_grid == "DM43ST"
assert spot.sender_callsign == "K5ABC"
assert spot.receiver_callsign == "K7XYZ"
assert spot.sender_country == 291
assert spot.receiver_country == 291
assert %DateTime{} = spot.transmit_time
assert DateTime.to_unix(spot.transmit_time) == 1_662_407_697
end
test "accepts 4-char grids on both ends" do
sender_4 = @sample |> Map.put("sl", "EM12") |> Jason.encode!()
receiver_4 = @sample |> Map.put("rl", "DM43") |> Jason.encode!()
assert {:ok, spot} = Pskr.parse_spot(sender_4)
assert spot.sender_grid == "EM12"
assert {:ok, spot} = Pskr.parse_spot(receiver_4)
assert spot.receiver_grid == "DM43"
end
test "preserves extended-square (8-char) precision" do
json = @sample |> Map.merge(%{"sl" => "EM12kl37", "rl" => "DM43st99"}) |> Jason.encode!()
assert {:ok, spot} = Pskr.parse_spot(json)
assert spot.sender_grid == "EM12KL37"
assert spot.receiver_grid == "DM43ST99"
end
test "rejects odd-length grids (truncated 5-char)" do
json = @sample |> Map.put("sl", "EM12k") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "uses :t when :t_tx is missing" do
json = @sample |> Map.delete("t_tx") |> Jason.encode!()
assert {:ok, spot} = Pskr.parse_spot(json)
assert DateTime.to_unix(spot.transmit_time) == 1_662_407_712
end
test "rejects payloads without grids" do
json = @sample |> Map.delete("sl") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "rejects malformed JSON" do
assert {:error, _} = Pskr.parse_spot("not-json")
end
end
describe "path_key/1" do
test "buckets to the top of the hour using the full grid as reported" do
{:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample))
{hour, band, snd, rcv} = Pskr.path_key(spot)
assert band == "2m"
assert snd == "EM12KL"
assert rcv == "DM43ST"
# 1_662_407_697 → 2022-09-05T19:54:57Z → bucket at 19:00:00Z
assert hour == ~U[2022-09-05 19:00:00Z]
end
end
describe "merge_spot/2" do
test "initializes accumulator from a single spot" do
{:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample))
acc = Pskr.merge_spot(nil, spot)
assert acc.spot_count == 1
assert acc.max_snr_db == -5
assert acc.min_snr_db == -5
assert acc.modes == ["FT8"]
assert acc.sender_grid == "EM12KL"
assert acc.receiver_grid == "DM43ST"
assert is_float(acc.distance_km)
assert acc.distance_km > 0
# Midpoint should land between the two grid centers
assert acc.midpoint_lat > min(acc.sender_lat, acc.receiver_lat)
assert acc.midpoint_lat < max(acc.sender_lat, acc.receiver_lat)
end
test "increments count and tracks SNR envelope across spots" do
{:ok, first} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, louder} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -1, "md" => "FT4"}))
{:ok, quieter} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -18}))
acc =
nil
|> Pskr.merge_spot(first)
|> Pskr.merge_spot(louder)
|> Pskr.merge_spot(quieter)
assert acc.spot_count == 3
assert acc.max_snr_db == -1
assert acc.min_snr_db == -18
assert Enum.sort(acc.modes) == ["FT4", "FT8"]
assert DateTime.compare(acc.first_spot_at, acc.last_spot_at) in [:lt, :eq]
end
end
describe "parse_spot/1 edge cases" do
test "rejects empty band string" do
json = @sample |> Map.put("b", "") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "rejects payload with no timestamp fields" do
json = @sample |> Map.delete("t_tx") |> Map.delete("t") |> Jason.encode!()
assert {:error, _} = Pskr.parse_spot(json)
end
test "handles nil snr gracefully in merge" do
json = @sample |> Map.put("rp", nil) |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.max_snr_db == nil
assert acc.min_snr_db == nil
{:ok, spot2} = Pskr.parse_spot(Jason.encode!(@sample))
acc2 = Pskr.merge_spot(acc, spot2)
assert acc2.max_snr_db == -5
assert acc2.min_snr_db == -5
end
test "handles empty mode string in merge" do
json = @sample |> Map.put("md", "") |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.modes == []
end
test "handles nil mode in merge" do
json = @sample |> Map.delete("md") |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
acc = Pskr.merge_spot(nil, spot)
assert acc.modes == []
end
test "does not add duplicate modes" do
{:ok, first} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, second} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -10}))
acc = nil |> Pskr.merge_spot(first) |> Pskr.merge_spot(second)
assert acc.modes == ["FT8"]
end
end
describe "callsign tracking" do
test "collects unique sender and receiver callsigns across merges" do
{:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "sc" => "K5DEF", "rp" => -3}))
acc =
nil
|> Pskr.merge_spot(s1)
|> Pskr.merge_spot(s2)
assert Enum.sort(acc.sender_callsigns) == ["K5ABC", "K5DEF"]
assert acc.receiver_callsigns == ["K7XYZ"]
assert acc.spot_count == 2
end
test "does not add duplicate callsigns" do
{:ok, s1} = Pskr.parse_spot(Jason.encode!(@sample))
{:ok, s2} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -3}))
acc =
nil
|> Pskr.merge_spot(s1)
|> Pskr.merge_spot(s2)
assert acc.sender_callsigns == ["K5ABC"]
assert acc.receiver_callsigns == ["K7XYZ"]
end
test "handles nil and empty callsigns in payload" do
nil_call = @sample |> Map.put("sc", nil) |> Jason.encode!()
{:ok, s_nil} = Pskr.parse_spot(nil_call)
assert s_nil.sender_callsign == nil
empty_call = @sample |> Map.put("sc", "") |> Jason.encode!()
{:ok, s_empty} = Pskr.parse_spot(empty_call)
assert s_empty.sender_callsign == nil
acc = nil |> Pskr.merge_spot(s_nil) |> Pskr.merge_spot(s_empty)
assert acc.sender_callsigns == []
end
test "uppercases callsigns from payload" do
json = @sample |> Map.merge(%{"sc" => "k5abc", "rc" => "k7xyz"}) |> Jason.encode!()
{:ok, spot} = Pskr.parse_spot(json)
assert spot.sender_callsign == "K5ABC"
assert spot.receiver_callsign == "K7XYZ"
end
test "initializes callsign arrays from first spot" do
{:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample))
acc = Pskr.merge_spot(nil, spot)
assert acc.sender_callsigns == ["K5ABC"]
assert acc.receiver_callsigns == ["K7XYZ"]
end
test "falls back to topic-derived callsign when JSON payload omits sc/rc" do
# FT8/Q65 multi-decoder spots sometimes lack sc/rc in the JSON
# payload, but the MQTT topic always carries them.
payload = @sample |> Map.drop(["sc", "rc"]) |> Jason.encode!()
opts = [sender_callsign: "K5TOPIC", receiver_callsign: "K7TOPIC"]
{:ok, spot} = Pskr.parse_spot(payload, opts)
assert spot.sender_callsign == "K5TOPIC"
assert spot.receiver_callsign == "K7TOPIC"
end
test "prefers JSON callsign over topic fallback when both present" do
payload = Jason.encode!(@sample)
opts = [sender_callsign: "K5TOPIC", receiver_callsign: "K7TOPIC"]
{:ok, spot} = Pskr.parse_spot(payload, opts)
assert spot.sender_callsign == "K5ABC"
assert spot.receiver_callsign == "K7XYZ"
end
test "topic fallback with nil/empty JSON callsign prefers topic" do
payload = @sample |> Map.put("sc", "") |> Jason.encode!()
opts = [sender_callsign: "K5TOPIC"]
{:ok, spot} = Pskr.parse_spot(payload, opts)
assert spot.sender_callsign == "K5TOPIC"
assert spot.receiver_callsign == "K7XYZ"
end
end
end