prop/lib/microwaveprop/weather/uwyo_sounding_client.ex
Graham McIntire 8ea0c3b94a
Ingest Canadian radiosondes via UWYO + plans for RDPS/HRDPS
IEM's RAOB endpoint stopped keeping Canadian stations current — most
stalled around Sep 2024 — which leaves a gap in refractivity/ducting
calibration over all the CW*/CY* stations already in weather_stations.
MSC's own tephi CSV is fixed-width and rendering-focused; the WMO TEMP
bulletins would need a full decoder. University of Wyoming serves the
same stations in a clean space-delimited format, is current, and
accepts the 3-letter station code (drop the leading C from Canadian
ICAO). Its output shape matches IemClient.parse_raob_json/1 exactly,
so it drops straight into Weather.upsert_sounding/2.

- New UwyoSoundingClient with URL builder, Req-based fetch_sounding,
  and a fixed-width parse_sounding_html that extracts PRES/HGHT/TEMP/
  DWPT/DRCT/SKNT from the <PRE> block and DateTime from the <H2> header.
- New CanadianSoundingFetchWorker: Oban batch worker that finds all
  sounding stations whose code starts with C, picks the most recently
  publishable 00Z or 12Z slot (90 min publish delay), calls UWYO per
  station, and upserts with SoundingParams-derived refractivity/
  ducting fields.
- Oban cron at 01:30Z and 13:30Z (dev + prod) to drive the worker.
- Req.Test plug wiring in config/test.exs.
- Captured Goose Bay fixture as test/support/fixtures/uwyo_sounding_yyr.html.

Also drops two implementation plans under docs/plans/:
- 2026-04-13-rdps-vertical-profiles.md: ~2 day plan for RDPS 10 km
  Canadian ducting outside HRRR's Lambert footprint.
- 2026-04-13-hrdps-canadian-prop-grid.md: ~5 day plan for the full
  HRDPS 2.5 km Canadian prop grid. Explicitly recommends doing RDPS
  first.

TDD throughout, 19 new tests, 1318/1318 passing, credo strict clean.
2026-04-13 09:14:34 -05:00

158 lines
5.1 KiB
Elixir

defmodule Microwaveprop.Weather.UwyoSoundingClient do
@moduledoc """
Client for University of Wyoming atmospheric sounding archive
(`weather.uwyo.edu`), which serves near-real-time radiosonde data for
the global WMO network — including Canadian stations that the IEM RAOB
endpoint no longer keeps current.
Produces the same shape as `Microwaveprop.Weather.IemClient.parse_raob_json/1`:
`{:ok, [%{observed_at: DateTime.t(), profile: [map()]}]}`, where each
profile level is `%{"pres", "hght", "tmpc", "dwpc", "drct", "sknt"}` with
string keys so `Microwaveprop.Weather.SoundingParams.derive/1` can consume
it directly.
Station codes are UWYO's 3-letter form — for Canadian ICAO codes (CWSE,
CYYR, ...) the caller must strip the leading `C`.
"""
@base "https://weather.uwyo.edu/cgi-bin/sounding"
@type profile_level :: %{String.t() => float() | nil}
@type sounding :: %{observed_at: DateTime.t(), profile: [profile_level()]}
@spec sounding_url(String.t(), DateTime.t()) :: String.t()
def sounding_url(station_code, %DateTime{} = dt) do
month = dt.month |> Integer.to_string() |> String.pad_leading(2, "0")
day = dt.day |> Integer.to_string() |> String.pad_leading(2, "0")
hour = dt.hour |> Integer.to_string() |> String.pad_leading(2, "0")
dayhour = day <> hour
"#{@base}?region=naconf&TYPE=TEXT%3ALIST" <>
"&YEAR=#{dt.year}&MONTH=#{month}&FROM=#{dayhour}&TO=#{dayhour}&STNM=#{station_code}"
end
@spec fetch_sounding(String.t(), DateTime.t()) :: {:ok, [sounding()]} | {:error, term()}
def fetch_sounding(station_code, %DateTime{} = dt) do
url = sounding_url(station_code, dt)
case Req.get(url, req_options()) do
{:ok, %{status: 200, body: body}} -> {:ok, parse_sounding_html(body)}
{:ok, %{status: status}} -> {:error, "UWYO sounding HTTP #{status}"}
{:error, reason} -> {:error, reason}
end
end
@spec parse_sounding_html(String.t()) :: [sounding()]
def parse_sounding_html(html) when is_binary(html) do
with {:ok, observed_at} <- parse_h2_header(html),
{:ok, pre_text} <- extract_pre_block(html) do
profile =
pre_text
|> String.split("\n")
|> Enum.flat_map(&parse_profile_row/1)
[%{observed_at: observed_at, profile: profile}]
else
_ -> []
end
end
# ---------- Private ----------
defp req_options do
defaults = [retry: &retry?/2, max_retries: 3, retry_delay: &retry_delay/1]
overrides = Application.get_env(:microwaveprop, :uwyo_req_options, [])
Keyword.merge(defaults, overrides)
end
defp retry?(_request, response) do
case response do
%Req.Response{status: status} when status in [429, 500, 502, 503, 504] -> true
%{__exception__: true} -> true
_ -> false
end
end
defp retry_delay(n) do
base = Integer.pow(2, n) * 1_000
jitter = :rand.uniform(1_000)
base + jitter
end
# Header line example:
# <H2>71816 YYR Goose Bay Observations at 00Z 13 Apr 2026</H2>
defp parse_h2_header(html) do
case Regex.run(~r/<H2>\s*\d+\s+\w+.*?Observations at (\d{2})Z (\d{1,2}) (\w+) (\d{4})/i, html) do
[_, hh, dd, mon, yyyy] ->
with {:ok, month} <- month_num(mon),
{day, ""} <- Integer.parse(dd),
{hour, ""} <- Integer.parse(hh),
{year, ""} <- Integer.parse(yyyy),
{:ok, dt} <- DateTime.new(Date.new!(year, month, day), Time.new!(hour, 0, 0)) do
{:ok, dt}
else
_ -> :error
end
_ ->
:error
end
end
defp extract_pre_block(html) do
case Regex.run(~r/<PRE>(.*?)<\/PRE>/s, html) do
[_, body] -> {:ok, body}
_ -> :error
end
end
# UWYO rows are fixed-width, 7 chars per column. We care about the first 8
# columns: PRES, HGHT, TEMP, DWPT, RELH, MIXR, DRCT, SKNT. Any line without
# both a numeric pressure AND a numeric temperature is a header/separator
# line or an incomplete observation and is skipped.
defp parse_profile_row(line) when is_binary(line) do
if String.length(line) < 28 do
[]
else
pres = parse_column(line, 0)
hght = parse_column(line, 7)
tmpc = parse_column(line, 14)
dwpc = parse_column(line, 21)
drct = parse_column(line, 42)
sknt = parse_column(line, 49)
if is_number(pres) and is_number(tmpc) do
[%{"pres" => pres, "hght" => hght, "tmpc" => tmpc, "dwpc" => dwpc, "drct" => drct, "sknt" => sknt}]
else
[]
end
end
end
defp parse_column(line, offset) do
field = line |> String.slice(offset, 7) |> String.trim()
if field == "" do
nil
else
case Float.parse(field) do
{val, ""} -> val
_ -> nil
end
end
end
defp month_num("Jan"), do: {:ok, 1}
defp month_num("Feb"), do: {:ok, 2}
defp month_num("Mar"), do: {:ok, 3}
defp month_num("Apr"), do: {:ok, 4}
defp month_num("May"), do: {:ok, 5}
defp month_num("Jun"), do: {:ok, 6}
defp month_num("Jul"), do: {:ok, 7}
defp month_num("Aug"), do: {:ok, 8}
defp month_num("Sep"), do: {:ok, 9}
defp month_num("Oct"), do: {:ok, 10}
defp month_num("Nov"), do: {:ok, 11}
defp month_num("Dec"), do: {:ok, 12}
defp month_num(_), do: :error
end