prop/test/microwaveprop/pskr_test.exs
Graham McIntire cfc5f7583f
feat(pskr): ingest PSK Reporter MQTT firehose for weather correlation
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.
2026-05-04 09:24:20 -05:00

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