prop/lib/mix/tasks/calibrate.aprs_144.ex
Graham McIntire d67186176f
fix: resolve all dialyzer type errors across project (46→0 project errors)
Type spec fixes:
- duct_usable_* return boolean→float (delegate to duct_usable_for_band)
- sanitize/1 spec includes :unicode error tuples
- match_delete/1 broadened from :ets.match_spec()
- telemetry_event local type replaces :telemetry.event/0
- wgrib2 parse_lon_val_segment corrected to tuple spec
- preloaded Ecto assoc types in get_mission/get_contact! specs

Unmatched returns:
- _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe,
  :ets.new, and if-expression returns across 18 files

Pattern match fixes:
- markdown: restructure acc!=[] guard as direct pattern match
- path_compute/pskr/skewt_location_resolver: remove dead clauses
- calibrate.aprs_144: remove unreachable format_float catch-all
- unused.ex: suppress MapSet.union no_opaque

Also: remove unused unicode_util_compat from mix.lock
2026-06-08 17:51:13 -05:00

223 lines
7.9 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)
end