From 81e3a54a9763f78f7f2bd9da90f82c79e0c800b5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 1 May 2026 13:03:47 -0500 Subject: [PATCH] refactor(aprs): mix calibrate.aprs_144 surfaces silent drops + guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Print ' / ' 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. --- lib/mix/tasks/calibrate.aprs_144.ex | 58 ++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/lib/mix/tasks/calibrate.aprs_144.ex b/lib/mix/tasks/calibrate.aprs_144.ex index 72583ac0..42ef1445 100644 --- a/lib/mix/tasks/calibrate.aprs_144.ex +++ b/lib/mix/tasks/calibrate.aprs_144.ex @@ -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