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 @min_samples 50 @band_mhz 144 @impl Mix.Task 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 _ = Oban.pause_all_queues(Oban) try do do_run(argv) after # Allow `iex -S mix` workflows to keep using Oban after the task # returns; for one-shot Mix invocations the BEAM exits and this is # a no-op. _ = Oban.resume_all_queues(Oban) end end defp do_run(argv) do {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)} / #{length(hops)} positive factor vectors " <> "(#{length(hops) - length(positives)} hops dropped for missing 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, attempted} = compute_negative_factors(length(positives)) Mix.shell().info( "Computed #{length(negatives)} / #{attempted} negative factor vectors " <> "(#{attempted - length(negatives)} samples dropped for missing HRRR coverage)" ) 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(&PathParser.valid_callsign?/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 # 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 = Enum.flat_map(baselines, fn {lat, lon, time} -> case Weather.find_nearest_hrrr(lat, lon, time) do nil -> [] profile -> [Recalibrator.compute_factors(profile, time, @band_mhz)] end end) {factors, length(baselines)} 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