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.
160 lines
5.2 KiB
Elixir
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
|