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

127 lines
4.3 KiB
Elixir

defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
@moduledoc """
Twice-daily batch fetcher for Canadian radiosonde soundings via the
University of Wyoming archive. IEM's RAOB endpoint has not been keeping
Canadian stations current (most stalled Sep 2024), so this worker fills
the gap — crucial because soundings drive the refractivity / ducting
calibration in `SoundingParams.derive/1`.
Finds every `weather_stations` row of type `sounding` whose `station_code`
starts with `C` (Canadian ICAO prefix), drops the leading `C` to get the
UWYO 3-letter code, fetches the latest available 00Z or 12Z sounding,
and upserts it into `soundings`.
"""
use Oban.Worker,
queue: :weather,
max_attempts: 3,
unique: [period: 3600, states: [:available, :scheduled, :executing, :retryable]]
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.Weather
alias Microwaveprop.Weather.SoundingParams
alias Microwaveprop.Weather.Station
alias Microwaveprop.Weather.UwyoSoundingClient
require Logger
# UWYO publishes ~90 minutes after the synoptic hour
@publish_delay_seconds 5_400
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
sounding_time =
case args do
%{"sounding_time" => iso} when is_binary(iso) ->
{:ok, dt, _} = DateTime.from_iso8601(iso)
DateTime.truncate(dt, :second)
_ ->
most_recent_sounding_time(DateTime.utc_now())
end
stations = canadian_sounding_stations()
Logger.info("CanadianSoundings: fetching #{length(stations)} stations for #{DateTime.to_iso8601(sounding_time)}")
Enum.each(stations, &fetch_station(&1, sounding_time))
:ok
end
@doc """
Return the most recently-publishable 00Z or 12Z sounding time, given
the current UTC instant. UWYO needs ~90 minutes after launch before the
decoded profile lands on the site, so we step back from synoptic hour
until we find one that's at least 90 minutes old.
"""
@spec most_recent_sounding_time(DateTime.t()) :: DateTime.t()
def most_recent_sounding_time(%DateTime{} = now) do
cutoff = DateTime.add(now, -@publish_delay_seconds, :second)
if cutoff.hour >= 12 do
%{cutoff | hour: 12, minute: 0, second: 0, microsecond: {0, 0}}
else
%{cutoff | hour: 0, minute: 0, second: 0, microsecond: {0, 0}}
end
end
# ---------- Internal ----------
defp canadian_sounding_stations do
Repo.all(from(s in Station, where: s.station_type == "sounding" and ilike(s.station_code, "c%")))
end
defp fetch_station(%Station{station_code: code} = station, sounding_time) do
uwyo_code = String.slice(code, 1..-1//1)
if Weather.has_sounding?(station.id, sounding_time) do
Logger.debug("CanadianSoundings: #{code} already has sounding for #{sounding_time}")
:ok
else
do_fetch(station, code, uwyo_code, sounding_time)
end
end
defp do_fetch(station, code, uwyo_code, sounding_time) do
case UwyoSoundingClient.fetch_sounding(uwyo_code, sounding_time) do
{:ok, [%{profile: profile} | _]} when profile != [] ->
attrs = build_attrs(sounding_time, profile)
_ = Weather.upsert_sounding(station, attrs)
Logger.info("CanadianSoundings: #{code} ingested sounding with #{length(profile)} levels")
{:ok, _} ->
Logger.info("CanadianSoundings: #{code} no data for #{sounding_time}")
{:error, reason} ->
Logger.warning("CanadianSoundings: #{code} failed: #{inspect(reason)}")
end
end
defp build_attrs(sounding_time, profile) do
base = %{observed_at: sounding_time, profile: profile, level_count: length(profile)}
case SoundingParams.derive(profile) do
nil ->
base
params ->
Map.merge(base, %{
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