prop/lib/microwaveprop/pskr/spot_hourly.ex
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

68 lines
2.4 KiB
Elixir

defmodule Microwaveprop.Pskr.SpotHourly do
@moduledoc """
Hourly aggregate of PSK Reporter spots between two 4-character
Maidenhead grid squares on a single band.
A single QSO often produces 50+ reception reports (one per
monitoring station that decoded it). Storing each report verbatim
would dwarf the rest of the database — instead `Pskr.Aggregator`
collapses every spot inside `(hour_utc, band, sender_grid,
receiver_grid)` into one row, retaining `spot_count`, SNR
envelope, and the set of modes observed. Downstream weather
correlation cares about *did propagation occur in this cell during
this hour*, not about each individual reporter — so the lossy
aggregation is the desired shape, not just a space optimization.
`midpoint_lat/lon` is the great-circle midpoint of the path,
pre-computed at insert time so HRRR / IEMRE grid lookups are a
cheap `WHERE midpoint_lat BETWEEN …` rather than a per-row
recomputation.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "pskr_spots_hourly" do
field :hour_utc, :utc_datetime
field :band, :string
field :sender_grid, :string
field :receiver_grid, :string
field :sender_country, :integer
field :receiver_country, :integer
field :sender_lat, :float
field :sender_lon, :float
field :receiver_lat, :float
field :receiver_lon, :float
field :midpoint_lat, :float
field :midpoint_lon, :float
field :distance_km, :float
field :spot_count, :integer, default: 0
field :max_snr_db, :integer
field :min_snr_db, :integer
field :modes, {:array, :string}, default: []
field :first_spot_at, :utc_datetime
field :last_spot_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@cast_fields ~w(hour_utc band sender_grid receiver_grid sender_country receiver_country
sender_lat sender_lon receiver_lat receiver_lon midpoint_lat midpoint_lon
distance_km spot_count max_snr_db min_snr_db modes first_spot_at
last_spot_at)a
@required_fields ~w(hour_utc band sender_grid receiver_grid spot_count)a
@spec changeset(t(), map()) :: Ecto.Changeset.t()
def changeset(record, attrs) do
record
|> cast(attrs, @cast_fields)
|> validate_required(@required_fields)
|> unique_constraint([:hour_utc, :band, :sender_grid, :receiver_grid])
end
end