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 Microwaveprop.Instrument.span([:uwyo, :fetch_sounding], %{station: station_code}, fn -> 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) 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: #
(.*?)<\/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