From 7a8623aed8e92dc00415911c8e597281086d4154 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 1 May 2026 12:57:41 -0500 Subject: [PATCH] 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. --- lib/mix/tasks/calibrate.aprs_144.ex | 186 +++++++++++++++++++++ test/mix/tasks/calibrate_aprs_144_test.exs | 49 ++++++ 2 files changed, 235 insertions(+) create mode 100644 lib/mix/tasks/calibrate.aprs_144.ex create mode 100644 test/mix/tasks/calibrate_aprs_144_test.exs diff --git a/lib/mix/tasks/calibrate.aprs_144.ex b/lib/mix/tasks/calibrate.aprs_144.ex new file mode 100644 index 00000000..72583ac0 --- /dev/null +++ b/lib/mix/tasks/calibrate.aprs_144.ex @@ -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 diff --git a/test/mix/tasks/calibrate_aprs_144_test.exs b/test/mix/tasks/calibrate_aprs_144_test.exs new file mode 100644 index 00000000..f71411f9 --- /dev/null +++ b/test/mix/tasks/calibrate_aprs_144_test.exs @@ -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