prop/lib/microwaveprop/workers/weather_fetch_worker.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

198 lines
6.7 KiB
Elixir

defmodule Microwaveprop.Workers.WeatherFetchWorker do
@moduledoc false
use Oban.Worker,
queue: :weather,
max_attempts: 20,
unique: [period: :infinity, states: [:available, :scheduled, :executing, :retryable]]
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.IemClient
alias Microwaveprop.Weather.SoundingParams
alias Microwaveprop.Weather.Station
alias Microwaveprop.Weather.SurfaceObservation
require Logger
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(120 * Integer.pow(2, attempt - 1), _six_hours = 21_600)
end
@impl Oban.Worker
def perform(%Oban.Job{args: %{"fetch_type" => "asos"} = args}) do
%{
"station_id" => station_id,
"station_code" => station_code,
"start_dt" => start_dt_str,
"end_dt" => end_dt_str
} = args
{:ok, start_dt, _} = DateTime.from_iso8601(start_dt_str)
{:ok, end_dt, _} = DateTime.from_iso8601(end_dt_str)
case Repo.get(Station, station_id) do
nil ->
Logger.warning("WeatherFetch ASOS: station #{station_id} not found, skipping")
:ok
station ->
fetch_asos(station, station_id, station_code, start_dt, end_dt, start_dt_str, end_dt_str)
end
end
def perform(%Oban.Job{args: %{"fetch_type" => "raob"} = args}) do
%{
"station_id" => station_id,
"station_code" => station_code,
"sounding_time" => sounding_time_str
} = args
contact_id = Map.get(args, "contact_id")
{:ok, sounding_time, _} = DateTime.from_iso8601(sounding_time_str)
case Repo.get(Station, station_id) do
nil ->
Logger.warning("WeatherFetch RAOB: station #{station_id} not found, skipping")
:ok
station ->
fetch_raob(station, station_id, station_code, sounding_time, sounding_time_str, contact_id)
end
end
# -- ASOS helpers --
defp fetch_asos(station, station_id, station_code, start_dt, end_dt, start_dt_str, end_dt_str) do
if Weather.has_surface_observations?(station.id, start_dt, end_dt) do
Logger.debug("WeatherFetch ASOS: #{station_code} already has obs for #{start_dt_str}..#{end_dt_str}")
:ok
else
Logger.info("WeatherFetch ASOS: fetching #{station_code} for #{start_dt_str}..#{end_dt_str}")
do_fetch_asos(station, station_id, station_code, start_dt, end_dt)
end
end
defp do_fetch_asos(station, station_id, station_code, start_dt, end_dt) do
case IemClient.fetch_asos(station_code, start_dt, end_dt) do
{:ok, rows} ->
ingest_asos_rows(station, station_id, station_code, rows, start_dt, end_dt)
{:error, reason} ->
Logger.warning("WeatherFetch ASOS: #{station_code} failed: #{inspect(reason)}")
{:error, reason}
end
end
defp ingest_asos_rows(station, station_id, station_code, rows, start_dt, end_dt) do
count = Enum.count(rows, & &1.observed_at)
Enum.each(rows, fn row ->
if row.observed_at, do: Weather.upsert_surface_observation(station, row)
end)
if count == 0, do: store_asos_stub(station, start_dt, end_dt)
Logger.info("WeatherFetch ASOS: #{station_code} ingested #{count} observations")
_ =
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"contact_enrichment:weather",
{:weather_ready, %{station_id: station_id}}
)
:ok
end
defp store_asos_stub(station, start_dt, end_dt) do
midpoint = DateTime.add(start_dt, div(DateTime.diff(end_dt, start_dt), 2))
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{station_id: station.id, observed_at: midpoint})
|> Repo.insert(on_conflict: :nothing, conflict_target: [:station_id, :observed_at])
end
# -- RAOB helpers --
defp fetch_raob(station, station_id, station_code, sounding_time, sounding_time_str, contact_id) do
if Weather.has_sounding?(station.id, sounding_time) do
Logger.debug("WeatherFetch RAOB: #{station_code} already has sounding for #{sounding_time_str}")
_ = broadcast_sounding_fetched(station_id, contact_id)
:ok
else
Logger.info("WeatherFetch RAOB: fetching #{station_code} for #{sounding_time_str}")
do_fetch_raob(station, station_id, station_code, sounding_time, sounding_time_str, contact_id)
end
end
defp do_fetch_raob(station, station_id, station_code, sounding_time, sounding_time_str, contact_id) do
case IemClient.fetch_raob(station_code, sounding_time) do
{:ok, [parsed | _]} ->
ingest_raob(station, station_id, station_code, parsed, contact_id)
{:ok, []} ->
_ = Weather.upsert_sounding(station, %{observed_at: sounding_time, profile: [], level_count: 0})
Logger.info("WeatherFetch RAOB: #{station_code} no data for #{sounding_time_str}, stored stub")
_ = broadcast_sounding_fetched(station_id, contact_id)
:ok
{:error, reason} ->
Logger.warning("WeatherFetch RAOB: #{station_code} failed: #{inspect(reason)}")
{:error, reason}
end
end
defp ingest_raob(station, station_id, station_code, parsed, contact_id) do
sounding_attrs = build_sounding_attrs(parsed)
_ = Weather.upsert_sounding(station, sounding_attrs)
Logger.info("WeatherFetch RAOB: #{station_code} ingested sounding with #{length(parsed.profile)} levels")
_ = broadcast_sounding_fetched(station_id, contact_id)
:ok
end
defp broadcast_sounding_fetched(station_id, contact_id) do
_ =
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"contact_enrichment:weather",
{:weather_ready, %{station_id: station_id}}
)
if contact_id do
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"contact_enrichment:#{contact_id}",
{:sounding_fetched, %{station_id: station_id, contact_id: contact_id}}
)
end
end
defp build_sounding_attrs(parsed) do
case SoundingParams.derive(parsed.profile) do
nil ->
%{observed_at: parsed.observed_at, profile: parsed.profile, level_count: length(parsed.profile)}
params ->
%{
observed_at: parsed.observed_at,
profile: parsed.profile,
level_count: params.level_count,
surface_pressure_mb: params.surface_pressure_mb,
surface_temp_c: params.surface_temp_c,
surface_dewpoint_c: params.surface_dewpoint_c,
surface_refractivity: params.surface_refractivity,
min_refractivity_gradient: params.min_refractivity_gradient,
boundary_layer_depth_m: params.boundary_layer_depth_m,
precipitable_water_mm: params.precipitable_water_mm,
k_index: params.k_index,
lifted_index: params.lifted_index,
ducting_detected: params.ducting_detected,
duct_characteristics: params.duct_characteristics
}
end
end
end