prop/lib/microwaveprop/aprs.ex
Graham McIntire af08e72816
polish(aprs): consolidate callsign regex, document raise, note negative-sample v1 limit
- Extract PathParser.valid_callsign?/1 and reuse from the calibration
  Mix task instead of duplicating the regex.
- Document raise behavior on Aprs.recent_packets_with_paths/1 and
  Aprs.station_positions/1 (already noted in moduledoc; @doc reinforces
  for callers reading the function head).
- Note in the BandConfig guard comment why it sits before the Oban pause
  (so a misconfig exits without leaving queues paused).
- Reword the band-mismatched-negatives note as a v1 limitation (no TODO
  tag — codebase convention is zero TODOs).
2026-05-01 13:10:57 -05:00

130 lines
4.4 KiB
Elixir

defmodule Microwaveprop.Aprs do
@moduledoc """
Read-only access to aprs.me's `packets` table for 144 MHz calibration.
This module never writes, never schemas, never mirrors. Each call
issues a SELECT against `Microwaveprop.AprsRepo` (which connects to
aprs.me's database) and returns raw maps for downstream parsing by
`Microwaveprop.Aprs.PathParser` and consumption by the
`Calibrate.Aprs144` mix task.
aprs.me retains 24 h of packet history in production
(`PACKET_RETENTION_DAYS=1`), so any "historical" query is bounded by
that window.
Tests run against a local `aprsme_test` database whose schema mirrors
aprs.me's partitioned `packets` table; rows are inserted via raw SQL
in a setup block under the `Ecto.Adapters.SQL.Sandbox`.
> #### Error semantics {: .warning}
>
> Both queries call `Ecto.Adapters.SQL.query!/3` and will raise on
> connection or SQL failures. Today the only caller is the
> `Calibrate.Aprs144` mix task, where a hard exit is the right
> ergonomic. Async callers (`Task.async_stream`, LiveView
> `start_async`, etc.) MUST catch and log per the project's
> swallowed-error rule in CLAUDE.md.
"""
alias Ecto.Adapters.SQL
alias Microwaveprop.AprsRepo
@type packet_row :: %{
id: binary(),
sender: String.t(),
base_callsign: String.t(),
lat: float(),
lon: float(),
path: String.t(),
received_at: DateTime.t()
}
@type position :: {lat :: float(), lon :: float(), last_heard_at :: DateTime.t()}
@recent_packets_sql """
SELECT id, sender, base_callsign, lat, lon, path, received_at
FROM packets
WHERE has_position = true
AND lat IS NOT NULL
AND lon IS NOT NULL
AND path IS NOT NULL
AND path <> ''
AND is_item = false
AND is_object = false
AND sender IS NOT NULL
AND received_at >= $1
ORDER BY received_at ASC
LIMIT $2
"""
@station_positions_sql """
SELECT DISTINCT ON (base_callsign) base_callsign, lat, lon, received_at
FROM packets
WHERE base_callsign = ANY($1)
AND has_position = true
AND lat IS NOT NULL
AND lon IS NOT NULL
ORDER BY base_callsign, received_at DESC
"""
@doc """
Returns recent position-bearing packets with non-empty paths, oldest first.
Filters out item/object packets and rows missing position or path.
Raises on connection or SQL failure — see moduledoc on async-context safety.
## Options
* `:since` — `%DateTime{}`. Default `now - 1h`.
* `:limit` — non-neg integer. Default `50_000`.
"""
@spec recent_packets_with_paths(keyword()) :: [packet_row()]
def recent_packets_with_paths(opts \\ []) do
since = Keyword.get_lazy(opts, :since, fn -> DateTime.add(DateTime.utc_now(), -3600, :second) end)
limit = Keyword.get(opts, :limit, 50_000)
# Force the bound to UTC so a non-UTC %DateTime{} doesn't silently
# match a wrong window after `to_naive` strips the tz tag.
naive_since = since |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_naive()
%Postgrex.Result{rows: rows} =
SQL.query!(AprsRepo, @recent_packets_sql, [naive_since, limit])
Enum.map(rows, &decode_packet_row/1)
end
@doc """
Returns the most recent known position for each callsign in `callsigns`.
Callsigns with no positioned packets simply don't appear in the result.
Empty input list short-circuits to `%{}` without querying.
Raises on connection or SQL failure — see moduledoc on async-context safety.
"""
@spec station_positions([String.t()]) :: %{String.t() => position()}
def station_positions([]), do: %{}
def station_positions(callsigns) when is_list(callsigns) do
%Postgrex.Result{rows: rows} = SQL.query!(AprsRepo, @station_positions_sql, [callsigns])
Map.new(rows, fn [base_callsign, lat, lon, received_at] ->
{base_callsign, {to_float(lat), to_float(lon), to_utc_datetime(received_at)}}
end)
end
defp decode_packet_row([id, sender, base_callsign, lat, lon, path, received_at]) do
%{
id: id,
sender: sender,
base_callsign: base_callsign,
lat: Decimal.to_float(lat),
lon: Decimal.to_float(lon),
path: path,
received_at: DateTime.from_naive!(received_at, "Etc/UTC")
}
end
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp to_utc_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC")
end