Subscribes to mqtt.pskreporter.info:1883 over plain TCP and folds every reception report into hourly aggregates per (band, sender_grid_4, receiver_grid_4). Each spot is the empirical "propagation actually occurred on this path right now" signal we'll correlate against HRRR atmospheric state to recalibrate the scoring weights. Aggregating at the path-hour bucket collapses 50-reporter QSOs to one row instead of 50. Topic filter anchors on USA (DXCC 291) on either sender or receiver side so the broker pre-filters out everything outside our HRRR domain. Bands subscribed: VHF and up (6m, 2m, 70cm, 23cm, + microwave) — HF is dominated by ionospheric propagation, which this project doesn't model. Pskr.Client cluster-elects a singleton via :global.register_name — all replicas start the GenServer but only one connects to MQTT; the rest stay in :standby and watch for the leader's nodedown to re-run election. Off in dev/test, on by default in prod (PSKR_MQTT_ENABLED=false as kill-switch). Pskr.Aggregator buffers in memory keyed by Pskr.path_key/1 and flushes every 60s via Repo.insert_all/3 with an additive ON CONFLICT (count summed, SNR envelope by GREATEST/LEAST, modes unioned via unnest+DISTINCT). Idempotent across overlapping flushes and across leader handover. Dockerfile sets BUILD_WITHOUT_QUIC=1 — emqtt's transitive quicer dep wants CMake + OpenSSL headers we'd otherwise have to add to the builder image just to never use QUIC over plain MQTT. Base image is unchanged; the new dep compiles cleanly into the existing prop-base runtime.
106 lines
3.2 KiB
Elixir
106 lines
3.2 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 "extracts the fields needed for hourly aggregation" 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 == "EM12"
|
|
assert spot.receiver_grid == "DM43"
|
|
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 "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 and 4-char grid" do
|
|
{:ok, spot} = Pskr.parse_spot(Jason.encode!(@sample))
|
|
{hour, band, snd, rcv} = Pskr.path_key(spot)
|
|
|
|
assert band == "2m"
|
|
assert snd == "EM12"
|
|
assert rcv == "DM43"
|
|
# 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 == "EM12"
|
|
assert acc.receiver_grid == "DM43"
|
|
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
|
|
end
|