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.
198 lines
6.8 KiB
Elixir
198 lines
6.8 KiB
Elixir
defmodule Microwaveprop.Pskr do
|
|
@moduledoc """
|
|
Ingestion of the PSK Reporter MQTT firehose
|
|
(`mqtt.pskreporter.info:1883`).
|
|
|
|
## Why we listen
|
|
|
|
Each MQTT message is a single reception report — *station X heard
|
|
station Y at SNR Z on band B at time T*. Read across millions of
|
|
reports, this is a continuous, geolocated, timestamped record of
|
|
*did propagation actually occur on this path right now?* That's
|
|
the empirical signal we correlate against HRRR atmospheric state
|
|
to recalibrate the propagation scoring weights.
|
|
|
|
## What we keep
|
|
|
|
Topic structure (from the broker landing page):
|
|
|
|
pskr/filter/v2/<band>/<mode>/<sendercall>/<receivercall>/<sl>/<rl>/<sa>/<ra>
|
|
|
|
We subscribe with country-side wildcards anchored on USA (DXCC 291),
|
|
so the broker pre-filters to spots involving at least one CONUS
|
|
station — anything else can't be cross-referenced with HRRR
|
|
anyway.
|
|
|
|
Per-spot payload (verbatim keys, also documented at
|
|
http://mqtt.pskreporter.info/):
|
|
|
|
sq f md rp t t_tx sc sl rc rl sa ra b
|
|
|
|
## What we discard
|
|
|
|
The aggregator collapses every spot inside `(hour_utc, band,
|
|
sender_grid_4, receiver_grid_4)` into one `pskr_spots_hourly`
|
|
row. We keep counts + SNR envelope + the set of modes — the
|
|
individual report identity, sequence number, and exact intra-hour
|
|
timestamp are dropped on the floor. A 50-reporter QSO becomes a
|
|
single bumped count, not 50 rows; that's the design, not a
|
|
limitation.
|
|
"""
|
|
|
|
alias Microwaveprop.Geo
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
|
|
@typedoc "Parsed PSK Reporter spot, post-`parse_spot/1`."
|
|
@type spot :: %{
|
|
band: String.t(),
|
|
mode: String.t(),
|
|
frequency_hz: pos_integer(),
|
|
snr_db: integer(),
|
|
transmit_time: DateTime.t(),
|
|
sender_grid: String.t(),
|
|
receiver_grid: String.t(),
|
|
sender_country: integer() | nil,
|
|
receiver_country: integer() | nil
|
|
}
|
|
|
|
@typedoc "Bucket key used by the aggregator's accumulator map."
|
|
@type path_key :: {DateTime.t(), String.t(), String.t(), String.t()}
|
|
|
|
@doc """
|
|
Decodes a single MQTT payload (binary JSON) into a normalized
|
|
spot map. Truncates locators to 4 characters since hourly
|
|
aggregation is at subsquare resolution. Returns `{:error, reason}`
|
|
for any payload missing the fields we need — the caller should
|
|
count and discard.
|
|
"""
|
|
@spec parse_spot(binary()) :: {:ok, spot()} | {:error, term()}
|
|
def parse_spot(payload) when is_binary(payload) do
|
|
with {:ok, json} <- Jason.decode(payload),
|
|
{:ok, sender_grid} <- fetch_grid(json, "sl"),
|
|
{:ok, receiver_grid} <- fetch_grid(json, "rl"),
|
|
{:ok, band} <- fetch_string(json, "b"),
|
|
{:ok, time_unix} <- fetch_time(json),
|
|
{:ok, transmit_time} <- DateTime.from_unix(time_unix) do
|
|
{:ok,
|
|
%{
|
|
band: band,
|
|
mode: Map.get(json, "md", ""),
|
|
frequency_hz: Map.get(json, "f"),
|
|
snr_db: Map.get(json, "rp"),
|
|
transmit_time: transmit_time,
|
|
sender_grid: sender_grid,
|
|
receiver_grid: receiver_grid,
|
|
sender_country: Map.get(json, "sa"),
|
|
receiver_country: Map.get(json, "ra")
|
|
}}
|
|
end
|
|
end
|
|
|
|
@doc "Bucket a spot to (top-of-hour, band, sender 4-char, receiver 4-char)."
|
|
@spec path_key(spot()) :: path_key()
|
|
def path_key(%{transmit_time: t, band: band, sender_grid: snd, receiver_grid: rcv}) do
|
|
{%{t | minute: 0, second: 0, microsecond: {0, 0}}, band, snd, rcv}
|
|
end
|
|
|
|
@doc """
|
|
Fold a spot into an accumulator row. Pass `nil` as the first
|
|
argument to seed a fresh row. The result is an attribute map
|
|
ready to feed into `Repo.insert_all/3` / `Ecto.Changeset.cast/3`.
|
|
|
|
Pre-computes sender/receiver lat-lon, midpoint, and great-circle
|
|
distance once at first sight — they don't change for subsequent
|
|
spots on the same path, so the merge clause is cheap.
|
|
"""
|
|
@spec merge_spot(map() | nil, spot()) :: map()
|
|
def merge_spot(nil, spot) do
|
|
{hour, band, snd, rcv} = path_key(spot)
|
|
{snd_lat, snd_lon} = grid_center(snd)
|
|
{rcv_lat, rcv_lon} = grid_center(rcv)
|
|
distance = Geo.haversine_km(snd_lat, snd_lon, rcv_lat, rcv_lon)
|
|
|
|
%{
|
|
hour_utc: hour,
|
|
band: band,
|
|
sender_grid: snd,
|
|
receiver_grid: rcv,
|
|
sender_country: spot.sender_country,
|
|
receiver_country: spot.receiver_country,
|
|
sender_lat: snd_lat,
|
|
sender_lon: snd_lon,
|
|
receiver_lat: rcv_lat,
|
|
receiver_lon: rcv_lon,
|
|
midpoint_lat: (snd_lat + rcv_lat) / 2,
|
|
midpoint_lon: (snd_lon + rcv_lon) / 2,
|
|
distance_km: distance,
|
|
spot_count: 1,
|
|
max_snr_db: spot.snr_db,
|
|
min_snr_db: spot.snr_db,
|
|
modes: maybe_mode_list(spot.mode),
|
|
first_spot_at: spot.transmit_time,
|
|
last_spot_at: spot.transmit_time
|
|
}
|
|
end
|
|
|
|
def merge_spot(%{} = acc, spot) do
|
|
%{
|
|
acc
|
|
| spot_count: acc.spot_count + 1,
|
|
max_snr_db: max_snr(acc.max_snr_db, spot.snr_db),
|
|
min_snr_db: min_snr(acc.min_snr_db, spot.snr_db),
|
|
modes: add_mode(acc.modes, spot.mode),
|
|
first_spot_at: earliest(acc.first_spot_at, spot.transmit_time),
|
|
last_spot_at: latest(acc.last_spot_at, spot.transmit_time)
|
|
}
|
|
end
|
|
|
|
defp fetch_grid(json, key) do
|
|
case Map.get(json, key) do
|
|
grid when is_binary(grid) and byte_size(grid) >= 4 ->
|
|
head = grid |> String.slice(0, 4) |> String.upcase()
|
|
if Maidenhead.valid?(head), do: {:ok, head}, else: {:error, {:bad_grid, key, grid}}
|
|
|
|
other ->
|
|
{:error, {:missing_grid, key, other}}
|
|
end
|
|
end
|
|
|
|
defp fetch_string(json, key) do
|
|
case Map.get(json, key) do
|
|
v when is_binary(v) and v != "" -> {:ok, v}
|
|
other -> {:error, {:missing, key, other}}
|
|
end
|
|
end
|
|
|
|
# `t_tx` is the transmission time and is the right anchor for
|
|
# hour-bucketing — that's when propagation actually carried the
|
|
# signal. `t` (PSKReporter receive time) drifts a few seconds and
|
|
# would split spots that crossed an hour boundary onto two rows.
|
|
defp fetch_time(json) do
|
|
case {Map.get(json, "t_tx"), Map.get(json, "t")} do
|
|
{n, _} when is_integer(n) -> {:ok, n}
|
|
{_, n} when is_integer(n) -> {:ok, n}
|
|
_ -> {:error, :no_timestamp}
|
|
end
|
|
end
|
|
|
|
defp grid_center(grid), do: Geo.maidenhead_center(grid) || {0.0, 0.0}
|
|
|
|
defp maybe_mode_list(""), do: []
|
|
defp maybe_mode_list(nil), do: []
|
|
defp maybe_mode_list(mode), do: [mode]
|
|
|
|
defp add_mode(modes, ""), do: modes
|
|
defp add_mode(modes, nil), do: modes
|
|
defp add_mode(modes, mode), do: if(mode in modes, do: modes, else: [mode | modes])
|
|
|
|
defp max_snr(nil, b), do: b
|
|
defp max_snr(a, nil), do: a
|
|
defp max_snr(a, b), do: max(a, b)
|
|
|
|
defp min_snr(nil, b), do: b
|
|
defp min_snr(a, nil), do: a
|
|
defp min_snr(a, b), do: min(a, b)
|
|
|
|
defp earliest(a, b), do: if(DateTime.before?(a, b), do: a, else: b)
|
|
defp latest(a, b), do: if(DateTime.after?(a, b), do: a, else: b)
|
|
end
|