From af08e72816a18c42c72050099dd30b28d5049360 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 1 May 2026 13:10:57 -0500 Subject: [PATCH] polish(aprs): consolidate callsign regex, document raise, note negative-sample v1 limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- lib/microwaveprop/aprs.ex | 4 ++++ lib/microwaveprop/aprs/path_parser.ex | 9 +++++++++ lib/mix/tasks/calibrate.aprs_144.ex | 12 ++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/microwaveprop/aprs.ex b/lib/microwaveprop/aprs.ex index 3c9e9412..9d10d651 100644 --- a/lib/microwaveprop/aprs.ex +++ b/lib/microwaveprop/aprs.ex @@ -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: %{} diff --git a/lib/microwaveprop/aprs/path_parser.ex b/lib/microwaveprop/aprs/path_parser.ex index 59d44c0c..12dfb138 100644 --- a/lib/microwaveprop/aprs/path_parser.ex +++ b/lib/microwaveprop/aprs/path_parser.ex @@ -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(), diff --git a/lib/mix/tasks/calibrate.aprs_144.ex b/lib/mix/tasks/calibrate.aprs_144.ex index 42ef1445..acd27617 100644 --- a/lib/mix/tasks/calibrate.aprs_144.ex +++ b/lib/mix/tasks/calibrate.aprs_144.ex @@ -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 =