prop/lib/microwaveprop/weather/uwyo_sounding_client.ex
Graham McIntire 2da74c5cd8
feat(telemetry): wide instrumentation + bump hrrr to 2 per pod
Config:
- runtime.exs hrrr queue 1 → 2 (6 concurrent HRRR jobs across 3 pods)

New helper Microwaveprop.Instrument.span/3 wraps :telemetry.span with
a [:microwaveprop | event_suffix] prefix so metrics can key off it.

Spans added (each emits duration + result tag):
- HrrrClient.fetch_grid / fetch_profile / fetch_idx
- NexradClient.fetch_frame + decode_png
- IemClient.fetch_asos / fetch_raob
- GefsClient.fetch_grid_profiles
- NarrClient.fetch_profile_at
- UwyoSoundingClient.fetch_sounding
- ElevationClient.fetch_elevation_profile
- Weather.upsert_hrrr_profiles_batch / upsert_gefs_profiles_batch
- Propagation.replace_scores
- PropagationGridWorker.compute_scores_algorithm
- TerrainAnalysis.analyse
- CommonVolumeRadarWorker.aggregate_stats

Telemetry catalog (MicrowavepropWeb.Telemetry):
- Oban job duration / queue_time / count / exception by (worker, queue, state)
- Per-span summary metrics for every instrumented phase above
- Periodic (10s) poller emits oban queue depth by (queue, state) — drops
  into the /admin/dashboard Metrics tab immediately

Also drops the now-redundant "fetching n0q frame" and "fetching <url>"
info lines from CommonVolumeRadarWorker / NexradClient; the span events
cover that and the worker's "ingested" line stays for per-job signal.
2026-04-18 16:33:34 -05:00

160 lines
5.2 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
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:
# <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