- 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).
224 lines
8 KiB
Elixir
224 lines
8 KiB
Elixir
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
|