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

71 lines
2.6 KiB
Elixir

defmodule Microwaveprop.Pskr.SpotHourly do
@moduledoc """
Hourly aggregate of PSK Reporter spots between two Maidenhead
grid locators on a single band. Locator precision is preserved
from the source spot (4/6/8/10 chars), so two spotters in
different subsquares of the same field generate distinct rows
even though they share an `EM12` prefix.
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