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.
186 lines
6.6 KiB
Elixir
186 lines
6.6 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
|
|
@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
|