prop/lib/mix/tasks/hrrr_backfill.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

168 lines
5.2 KiB
Elixir

defmodule Mix.Tasks.HrrrBackfill do
@moduledoc """
Re-fetches HRRR profiles for all QSO-linked contacts with the current
(finer) pressure level set. Replaces existing profiles with updated data.
Usage:
mix hrrr_backfill # backfill all contacts missing HRRR or with < 13 levels
mix hrrr_backfill --all # re-fetch all contacts regardless of level count
mix hrrr_backfill --limit 100 # process at most 100 hours
"""
use Mix.Task
import Ecto.Query
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.SoundingParams
require Logger
@min_levels 13
@impl Mix.Task
def run(args) do
Mix.Task.run("app.start")
_ = Oban.pause_all_queues(Oban)
{opts, _, _} = OptionParser.parse(args, switches: [all: :boolean, limit: :integer])
refetch_all? = Keyword.get(opts, :all, false)
limit = Keyword.get(opts, :limit, nil)
# Get all distinct (rounded_hour, path_points) combinations for contacts
contacts =
Repo.all(
from(c in Contact,
where: not is_nil(c.pos1) and c.qso_timestamp >= ^~U[2014-01-01 00:00:00Z],
select: %{id: c.id, pos1: c.pos1, pos2: c.pos2, qso_timestamp: c.qso_timestamp},
order_by: [desc: c.qso_timestamp]
)
)
# Group by rounded HRRR hour to batch requests
by_hour =
contacts
|> Enum.flat_map(fn c ->
hour = HrrrClient.nearest_hrrr_hour(c.qso_timestamp)
c
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} ->
{rlat, rlon} = Weather.round_to_hrrr_grid(lat, lon)
{hour, {rlat, rlon}}
end)
end)
|> Enum.group_by(fn {hour, _} -> hour end, fn {_, point} -> point end)
|> Enum.map(fn {hour, points} -> {hour, Enum.uniq(points)} end)
|> Enum.sort_by(fn {hour, _} -> hour end, {:desc, DateTime})
# Filter out points that already have enough levels
by_hour =
if refetch_all? do
by_hour
else
by_hour
|> Enum.map(fn {hour, points} ->
needed = filter_points_needing_backfill(hour, points)
{hour, needed}
end)
|> Enum.reject(fn {_hour, points} -> points == [] end)
end
by_hour = if limit, do: Enum.take(by_hour, limit), else: by_hour
total = length(by_hour)
total_points = Enum.reduce(by_hour, 0, fn {_, pts}, acc -> acc + length(pts) end)
skipped = length(contacts) - total_points
Logger.info("HRRR backfill: #{total} hours, #{total_points} points to fetch (#{skipped} already up to date)")
by_hour
|> Enum.with_index(1)
|> Enum.each(fn {{hour, points}, idx} ->
fetch_and_store_hour(hour, points, idx, total)
# Rate limit to avoid overwhelming NOAA
if idx < total, do: Process.sleep(500)
end)
Logger.info("HRRR backfill complete")
end
defp fetch_and_store_hour(hour, points, idx, total) do
Logger.info("HRRR backfill [#{idx}/#{total}]: #{hour} (#{length(points)} points)")
case HrrrClient.fetch_grid(points, hour) do
{:ok, grid_data} ->
Enum.each(grid_data, fn {{lat, lon}, data} ->
store_profile(lat, lon, hour, data)
end)
Logger.info("HRRR backfill [#{idx}/#{total}]: saved #{map_size(grid_data)} profiles")
{:error, reason} ->
Logger.warning("HRRR backfill [#{idx}/#{total}]: failed - #{inspect(reason)}")
end
end
defp filter_points_needing_backfill(hour, points) do
# Query level counts for all points at this hour in one query
point_tuples = Enum.map(points, fn {lat, lon} -> {lat, lon} end)
lats = Enum.map(point_tuples, &elem(&1, 0))
lons = Enum.map(point_tuples, &elem(&1, 1))
existing =
from(h in HrrrProfile,
where: h.valid_time == ^hour and h.lat in ^lats and h.lon in ^lons,
select: {h.lat, h.lon, fragment("array_length(profile, 1)")}
)
|> Repo.all()
|> Map.new(fn {lat, lon, count} -> {{lat, lon}, count || 0} end)
Enum.filter(points, fn {lat, lon} ->
case Map.get(existing, {lat, lon}) do
nil -> true
count when count < @min_levels -> true
_ -> false
end
end)
end
defp store_profile(lat, lon, valid_time, data) do
params = SoundingParams.derive(data.profile)
attrs =
maybe_add_derived(
%{
valid_time: valid_time,
lat: lat,
lon: lon,
run_time: data.run_time,
profile: data.profile,
hpbl_m: data.hpbl_m,
pwat_mm: data.pwat_mm,
surface_temp_c: data.surface_temp_c,
surface_dewpoint_c: data.surface_dewpoint_c,
surface_pressure_mb: data.surface_pressure_mb
},
params
)
Weather.upsert_hrrr_profile(attrs)
end
defp maybe_add_derived(attrs, nil), do: attrs
defp maybe_add_derived(attrs, params) do
Map.merge(attrs, %{
surface_refractivity: params.surface_refractivity,
min_refractivity_gradient: params.min_refractivity_gradient,
ducting_detected: params.ducting_detected,
duct_characteristics: params.duct_characteristics
})
end
end