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: #

71816 YYR Goose Bay Observations at 00Z 13 Apr 2026

defp parse_h2_header(html) do case Regex.run(~r/

\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>/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