refactor(aprs): mix calibrate.aprs_144 surfaces silent drops + guards

- Print '<computed> / <attempted>' for both positive (HRRR-coverage) and
  negative (random-baseline → HRRR) factor compute stages so an operator
  can see how much of the corpus survived each filter.
- Guard at task entry: Mix.raise if BandConfig has no 144 MHz entry.
  Without it the Recalibrator silently falls back to 10 GHz physics.
- random_baseline now draws from sample_size: max(n, 5_000) so small-n
  fits don't cluster geographically.
- Wrap body in try/after Oban.resume_all_queues so iex -S mix workflows
  don't leave Oban paused after the task returns.
This commit is contained in:
Graham McIntire 2026-05-01 13:03:47 -05:00
parent 7a8623aed8
commit 81e3a54a97
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -42,8 +42,24 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
@impl Mix.Task
def run(argv) do
Mix.Task.run("app.start")
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: [
@ -60,10 +76,10 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
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(" 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(" max_packets: #{max_packets}")
Mix.shell().info("")
since = DateTime.add(DateTime.utc_now(), -since_hours * 3600, :second)
@ -79,14 +95,22 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
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")
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 = compute_negative_factors(length(positives))
Mix.shell().info("Computed #{length(negatives)} negative factor vectors")
{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("")
@ -142,14 +166,20 @@ defmodule Mix.Tasks.Calibrate.Aprs144 do
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)
# 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.
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