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.
79 lines
2.2 KiB
Elixir
79 lines
2.2 KiB
Elixir
defmodule Microwaveprop.Pskr.AggregatorTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Ecto.Adapters.SQL.Sandbox
|
|
alias Microwaveprop.Pskr.Aggregator
|
|
alias Microwaveprop.Pskr.SpotHourly
|
|
alias Microwaveprop.Repo
|
|
|
|
@sample %{
|
|
"f" => 144_174_000,
|
|
"md" => "FT8",
|
|
"rp" => -5,
|
|
"t_tx" => 1_662_407_697,
|
|
"sc" => "K5ABC",
|
|
"sl" => "EM12kl",
|
|
"rc" => "K7XYZ",
|
|
"rl" => "DM43st",
|
|
"sa" => 291,
|
|
"ra" => 291,
|
|
"b" => "2m"
|
|
}
|
|
|
|
setup do
|
|
pid = start_supervised!({Aggregator, name: :"agg_#{:erlang.unique_integer([:positive])}", flush_ms: 0})
|
|
Sandbox.allow(Repo, self(), pid)
|
|
%{agg: pid}
|
|
end
|
|
|
|
test "ingest collapses repeats on the same path-hour", %{agg: agg} do
|
|
payloads = [
|
|
Jason.encode!(@sample),
|
|
Jason.encode!(%{@sample | "rp" => -3}),
|
|
Jason.encode!(%{@sample | "rp" => -12, "md" => "FT4"})
|
|
]
|
|
|
|
Enum.each(payloads, &Aggregator.ingest(agg, &1))
|
|
|
|
assert Aggregator.flush(agg) == 1
|
|
[row] = Repo.all(SpotHourly)
|
|
|
|
assert row.spot_count == 3
|
|
assert row.max_snr_db == -3
|
|
assert row.min_snr_db == -12
|
|
assert Enum.sort(row.modes) == ["FT4", "FT8"]
|
|
assert row.band == "2m"
|
|
assert row.sender_grid == "EM12"
|
|
assert row.receiver_grid == "DM43"
|
|
assert row.distance_km > 0
|
|
end
|
|
|
|
test "two paths in the same hour produce two rows", %{agg: agg} do
|
|
Aggregator.ingest(agg, Jason.encode!(@sample))
|
|
Aggregator.ingest(agg, Jason.encode!(%{@sample | "sl" => "FN31pr"}))
|
|
|
|
assert Aggregator.flush(agg) == 2
|
|
assert Repo.aggregate(SpotHourly, :count) == 2
|
|
end
|
|
|
|
test "second flush merges into existing row via upsert", %{agg: agg} do
|
|
Aggregator.ingest(agg, Jason.encode!(@sample))
|
|
assert Aggregator.flush(agg) == 1
|
|
|
|
Aggregator.ingest(agg, Jason.encode!(%{@sample | "rp" => 5}))
|
|
assert Aggregator.flush(agg) == 1
|
|
|
|
[row] = Repo.all(SpotHourly)
|
|
assert row.spot_count == 2
|
|
assert row.max_snr_db == 5
|
|
assert row.min_snr_db == -5
|
|
end
|
|
|
|
test "malformed payloads are dropped silently", %{agg: agg} do
|
|
Aggregator.ingest(agg, "not json")
|
|
Aggregator.ingest(agg, Jason.encode!(%{"oops" => true}))
|
|
Aggregator.ingest(agg, Jason.encode!(@sample))
|
|
|
|
assert Aggregator.flush(agg) == 1
|
|
end
|
|
end
|