prop/lib/microwaveprop/pskr.ex
Graham McIntire b924cc2fc2
feat(pskr): preserve full locator precision from MQTT spots
Spots that include subsquare (EM12kl), extended-square (EM12kl37),
or extended-extended-subsquare (EM12kl37ab) precision now keep
that resolution end-to-end. Previously we truncated everything to
4 chars at parse time, which collapsed distinct paths into the same
row whenever two spotters shared a field.

The aggregation key uses the full grid as reported, so two
spotters in different subsquares of `EM12` produce two rows —
correct, since they are physically different paths. Any spot that
fails Maidenhead validation (odd length, illegal char at position
N) is still dropped via the same error path.

Storage normalizes to uppercase so the unique index doesn't split
on case. Maidenhead.valid?/1 already accepts both cases on input.
2026-05-04 12:42:46 -05:00

210 lines
7.4 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, receiver_grid)` 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.
Locator precision is preserved as reported: a spotter who shares
`EM12kl` keeps subsquare resolution, while a `EM12kl37` reporter
keeps extended-square resolution. The bucket key uses the full
grid, so two spotters in different subsquares of the same field
produce two rows — that's the right behavior, since they're
literally different paths.
"""
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. Locators are kept at the precision the spotter
reported (4/6/8/10 chars) and uppercased for consistent storage.
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_grid, receiver_grid) at full reported precision."
@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
# Keep locators at the precision the spotter reported. Maidenhead
# validity already enforces minimum 4 chars + even length + the
# right alphabet at each position, so anything that passes is a
# legal 4/6/8/10-char locator we can store as-is. Uppercase for
# consistent indexing — the unique key is case-sensitive.
defp fetch_grid(json, key) do
case Map.get(json, key) do
grid when is_binary(grid) ->
upper = String.upcase(grid)
if Maidenhead.valid?(upper), do: {:ok, upper}, 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