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