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).
This commit is contained in:
Graham McIntire 2026-05-01 13:10:57 -05:00
parent 81e3a54a97
commit af08e72816
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 23 additions and 2 deletions

View file

@ -71,6 +71,7 @@ defmodule Microwaveprop.Aprs do
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
@ -96,6 +97,9 @@ defmodule Microwaveprop.Aprs do
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: %{}

View file

@ -43,6 +43,15 @@ defmodule Microwaveprop.Aprs.PathParser do
# rather than `qA[A-Za-z]` which would over-match `qAa`, `qAd`, …
@q_constructs ~w(qAC qAX qAU qAo qAO qAS qAr qAR qAZ qAI)
@doc """
Returns `true` if `token` matches the APRS callsign shape used by this
parser (1-2 alpha/digit prefix, one digit, 1-3 alpha suffix, optional
-SSID 0..99). Exposed so callers (e.g. the calibration Mix task) can
pre-filter path tokens with the same rule the parser itself applies.
"""
@spec valid_callsign?(String.t()) :: boolean()
def valid_callsign?(token) when is_binary(token), do: Regex.match?(@callsign_regex, token)
@spec parse_hops(
sender_callsign :: String.t(),
sender_pos :: position(),

View file

@ -35,7 +35,6 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
alias Microwaveprop.Weather
@factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a
@callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/
@min_samples 50
@band_mhz 144
@ -43,6 +42,8 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
def run(argv) do
Mix.Task.run("app.start")
# Guard runs BEFORE the Oban pause so a misconfig exits without
# leaving queues paused. Subsequent failures are inside try/after.
if BandConfig.get(@band_mhz) == nil do
Mix.raise("BandConfig has no #{@band_mhz} MHz entry; cannot calibrate.")
end
@ -132,7 +133,7 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
|> Enum.map(&String.trim/1)
|> Enum.map(&String.trim_trailing(&1, "*"))
end)
|> Enum.filter(&Regex.match?(@callsign_regex, &1))
|> Enum.filter(&PathParser.valid_callsign?/1)
|> Enum.uniq()
end
@ -169,6 +170,13 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
# Use a wide contact-pool draw (5_000 minimum) so the random baseline
# samples don't cluster geographically when n is small. Backtest's
# default :sample_size is 5_000.
#
# v1 limitation: negatives sample from the full contacts table (all
# bands, dominantly 10 GHz tropo). For a 144 MHz fit this means the
# trainer separates "verified VHF receive" from "anywhere a 10 GHz
# contact happened with timestamp jitter". A future iteration should
# draw negatives from APRS coverage where no `*` digi handled the
# frame in the same window — true band-matched negatives.
baselines = Backtest.random_baseline(n, sample_size: max(n, 5_000))
factors =