feat(aprs): mix calibrate.aprs_144 task for fitting 144 MHz weights

Pulls verified RF hops from aprs.me's 24h packet retention, computes
factor vectors at each hop's midpoint via the existing Recalibrator,
and runs gradient descent against random-baseline negatives.

Dry-run: prints proposed weights but does NOT mutate band_config.ex.
Refuses to fit on <50 positive or negative samples.
This commit is contained in:
Graham McIntire 2026-05-01 12:57:41 -05:00
parent a7f5bd5f18
commit 7a8623aed8
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 235 additions and 0 deletions

View file

@ -0,0 +1,186 @@
defmodule Mix.Tasks.Calibrate.Aprs144 do
@shortdoc "Fit 144 MHz scoring weights from aprs.me's last 24h of verified RF hops"
@moduledoc """
Pulls recent position-bearing APRS packets from aprs.me via
`Microwaveprop.AprsRepo`, parses TNC2 paths into verified RF hops via
`Microwaveprop.Aprs.PathParser`, computes a 10-element propagation factor
vector at each hop's midpoint via `Microwaveprop.Propagation.Recalibrator`,
and runs gradient descent against random-baseline negatives to fit new
144 MHz weights.
This is a dry-run: the proposed weights are printed but `band_config.ex`
is NOT modified. Operators copy the weights into the 144 MHz block
manually after reviewing the train/val loss numbers.
## Usage
mix calibrate.aprs_144
mix calibrate.aprs_144 --since-hours 6 --epochs 3000 --lr 0.005
## Options
* `--since-hours` packet window in hours (default: 24, max recent
retention in aprs.me prod)
* `--epochs` gradient-descent iterations (default: 2000)
* `--lr` learning rate (default: 0.01)
* `--max-packets` cap on packets pulled from aprs.me (default: 50_000)
"""
use Mix.Task
alias Microwaveprop.Aprs
alias Microwaveprop.Aprs.PathParser
alias Microwaveprop.Backtest
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Recalibrator
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
@impl Mix.Task
def run(argv) do
Mix.Task.run("app.start")
_ = Oban.pause_all_queues(Oban)
{opts, _, _} =
OptionParser.parse(argv,
switches: [
since_hours: :integer,
epochs: :integer,
lr: :float,
max_packets: :integer
]
)
since_hours = Keyword.get(opts, :since_hours, 24)
epochs = Keyword.get(opts, :epochs, 2000)
learning_rate = Keyword.get(opts, :lr, 0.01)
max_packets = Keyword.get(opts, :max_packets, 50_000)
Mix.shell().info("APRS-144 calibration")
Mix.shell().info(" since_hours: #{since_hours}")
Mix.shell().info(" epochs: #{epochs}")
Mix.shell().info(" learning_rate: #{learning_rate}")
Mix.shell().info(" max_packets: #{max_packets}")
Mix.shell().info("")
since = DateTime.add(DateTime.utc_now(), -since_hours * 3600, :second)
packets = Aprs.recent_packets_with_paths(since: since, limit: max_packets)
Mix.shell().info("Pulled #{length(packets)} packets from aprs.me")
callsigns = collect_callsigns(packets)
positions = Aprs.station_positions(callsigns)
Mix.shell().info("Resolved #{map_size(positions)} / #{length(callsigns)} digi positions")
lookup = build_lookup(positions)
hops = parse_all_hops(packets, lookup)
Mix.shell().info("Parsed #{length(hops)} verified RF hops")
positives = compute_positive_factors(hops)
Mix.shell().info("Computed #{length(positives)} positive factor vectors with HRRR coverage")
if length(positives) < @min_samples do
Mix.shell().info("")
Mix.shell().info("Refusing to fit: only #{length(positives)} positive samples (need >= #{@min_samples})")
else
negatives = compute_negative_factors(length(positives))
Mix.shell().info("Computed #{length(negatives)} negative factor vectors")
if length(negatives) < @min_samples do
Mix.shell().info("")
Mix.shell().info("Refusing to fit: only #{length(negatives)} negative samples (need >= #{@min_samples})")
else
result = Recalibrator.train(positives, negatives, learning_rate: learning_rate, epochs: epochs)
print_results(result, positives, negatives)
end
end
end
# ── Private ──────────────────────────────────────────────────────
defp collect_callsigns(packets) do
packets
|> Enum.flat_map(fn %{path: path} ->
path
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.map(&String.trim_trailing(&1, "*"))
end)
|> Enum.filter(&Regex.match?(@callsign_regex, &1))
|> Enum.uniq()
end
defp build_lookup(positions) do
fn callsign ->
case Map.get(positions, callsign) do
nil -> nil
{lat, lon, _heard_at} -> {lat, lon}
end
end
end
defp parse_all_hops(packets, lookup) do
Enum.flat_map(packets, fn pkt ->
PathParser.parse_hops(pkt.sender, {pkt.lat, pkt.lon}, pkt.path, pkt.received_at, lookup)
end)
end
defp compute_positive_factors(hops) do
Enum.flat_map(hops, fn hop ->
{src_lat, src_lon} = hop.src_pos
{dst_lat, dst_lon} = hop.dst_pos
mid_lat = (src_lat + dst_lat) / 2.0
mid_lon = (src_lon + dst_lon) / 2.0
case Weather.find_nearest_hrrr(mid_lat, mid_lon, hop.heard_at) do
nil -> []
profile -> [Recalibrator.compute_factors(profile, hop.heard_at, @band_mhz)]
end
end)
end
defp compute_negative_factors(n) do
n
|> Backtest.random_baseline(sample_size: n)
|> Enum.flat_map(fn {lat, lon, time} ->
case Weather.find_nearest_hrrr(lat, lon, time) do
nil -> []
profile -> [Recalibrator.compute_factors(profile, time, @band_mhz)]
end
end)
end
defp print_results(result, positives, negatives) do
Mix.shell().info("")
Mix.shell().info("APRS-144 calibration result")
Mix.shell().info(" positives: #{length(positives)} hops with HRRR coverage")
Mix.shell().info(" negatives: #{length(negatives)} random-baseline samples")
Mix.shell().info(" train loss: #{format_float(result.train_loss)}")
Mix.shell().info(" val loss: #{format_float(result.val_loss)}")
Mix.shell().info(" initial loss: #{format_float(result.initial_loss)}")
Mix.shell().info("")
current_weights = BandConfig.weights()
Mix.shell().info("Current 144 MHz weights (BandConfig defaults — no per-band override):")
print_weights(current_weights)
Mix.shell().info("")
Mix.shell().info("Proposed 144 MHz weights (this fit):")
print_weights(result.weights)
end
defp print_weights(weights) do
Enum.each(@factor_keys, fn key ->
value = Map.get(weights, key, 0.0)
label = key |> Atom.to_string() |> Kernel.<>(":")
formatted = "~-13s ~.4f" |> :io_lib.format([label, value * 1.0]) |> IO.iodata_to_binary()
Mix.shell().info(" " <> formatted)
end)
end
defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4)
defp format_float(f), do: to_string(f)
end

View file

@ -0,0 +1,49 @@
defmodule Mix.Tasks.Calibrate.Aprs144Test do
@moduledoc """
Smoke test for the APRS-144 calibration mix task. The task is mostly
orchestration: Aprs query + PathParser + Recalibrator. Each piece has
its own unit tests, so this only confirms the wiring holds together
and that the empty-corpus path exits via the "refusing to fit" branch
rather than crashing.
"""
use Microwaveprop.DataCase, async: false
alias Ecto.Adapters.SQL.Sandbox
alias Microwaveprop.AprsRepo
alias Mix.Tasks.Calibrate.Aprs144
setup do
pid = Sandbox.start_owner!(AprsRepo, shared: true)
on_exit(fn -> Sandbox.stop_owner(pid) end)
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
:ok
end
test "empty aprs DB walks the refuse-to-fit branch without raising" do
output =
ExUnit.CaptureIO.capture_io(fn ->
# Tiny knobs so the task is fast even if it ever did reach training.
Aprs144.run(["--since-hours", "1", "--epochs", "1", "--lr", "0.1", "--max-packets", "10"])
end)
# Mix.shell() messages land in the test mailbox; collect them all.
messages = collect_mix_messages()
combined = Enum.join(messages, "\n") <> "\n" <> output
assert combined =~ "APRS-144 calibration"
assert combined =~ "Pulled 0 packets"
assert combined =~ "Refusing to fit"
end
defp collect_mix_messages(acc \\ []) do
receive do
{:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc])
after
0 -> Enum.reverse(acc)
end
end
end