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.
314 lines
8.9 KiB
Elixir
314 lines
8.9 KiB
Elixir
defmodule Microwaveprop.Weather.IemClient do
|
|
@moduledoc false
|
|
|
|
@iem_base "https://mesonet.agron.iastate.edu"
|
|
|
|
# --- URL builders ---
|
|
|
|
@spec network_url(String.t()) :: String.t()
|
|
def network_url(network) do
|
|
"#{@iem_base}/json/network.py?network=#{network}"
|
|
end
|
|
|
|
@spec asos_url(String.t(), DateTime.t(), DateTime.t()) :: String.t()
|
|
def asos_url(station_id, start_dt, end_dt) do
|
|
"#{@iem_base}/cgi-bin/request/asos.py" <>
|
|
"?station=#{station_id}" <>
|
|
"&data=tmpf&data=dwpf&data=relh&data=sknt&data=drct" <>
|
|
"&data=mslp&data=alti&data=skyc1&data=p01i&data=wxcodes" <>
|
|
"&year1=#{start_dt.year}&month1=#{start_dt.month}&day1=#{start_dt.day}" <>
|
|
"&hour1=#{start_dt.hour}&minute1=0" <>
|
|
"&year2=#{end_dt.year}&month2=#{end_dt.month}&day2=#{end_dt.day}" <>
|
|
"&hour2=#{end_dt.hour}&minute2=59" <>
|
|
"&format=onlycomma&latlon=no&elev=no&missing=null&trace=null&direct=no&report_type=3"
|
|
end
|
|
|
|
@spec raob_url(String.t(), DateTime.t()) :: String.t()
|
|
def raob_url(station_id, dt) do
|
|
ts = format_iem_ts(dt)
|
|
"#{@iem_base}/json/raob.py?ts=#{ts}&station=#{station_id}"
|
|
end
|
|
|
|
@spec iemre_url(float(), float(), Date.t()) :: String.t()
|
|
def iemre_url(lat, lon, date) do
|
|
"#{@iem_base}/iemre/hourly/#{date}/#{lat}/#{lon}/json"
|
|
end
|
|
|
|
# --- HTTP fetchers ---
|
|
|
|
@spec fetch_network(String.t()) :: {:ok, [map()]} | {:error, term()}
|
|
def fetch_network(network) do
|
|
url = network_url(network)
|
|
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
{:ok, parse_network_json(body)}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, "IEM network HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@spec fetch_asos(String.t(), DateTime.t(), DateTime.t()) :: {:ok, [map()]} | {:error, term()}
|
|
def fetch_asos(station_id, start_dt, end_dt) do
|
|
url = asos_url(station_id, start_dt, end_dt)
|
|
|
|
Microwaveprop.Instrument.span([:iem, :fetch_asos], %{station: station_id}, fn ->
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
{:ok, parse_asos_csv(body)}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, "IEM ASOS HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@spec fetch_raob(String.t(), DateTime.t()) :: {:ok, [map()]} | {:error, term()}
|
|
def fetch_raob(station_id, dt) do
|
|
url = raob_url(station_id, dt)
|
|
|
|
Microwaveprop.Instrument.span([:iem, :fetch_raob], %{station: station_id}, fn ->
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
{:ok, parse_raob_json(body)}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, "IEM RAOB HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@spec fetch_iemre(float(), float(), Date.t()) :: {:ok, [map()]} | {:error, term()}
|
|
def fetch_iemre(lat, lon, date) do
|
|
url = iemre_url(lat, lon, date)
|
|
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: body}} ->
|
|
{:ok, parse_iemre_json(body)}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, "IEM IEMRE HTTP #{status}"}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp req_options do
|
|
defaults = [retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1]
|
|
overrides = Application.get_env(:microwaveprop, :iem_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
|
|
|
|
@doc """
|
|
Fetch current ASOS observations for all stations in the given state networks.
|
|
Returns `{:ok, [%{station_code, lat, lon, temp_f, dewpoint_f, ...}]}`.
|
|
"""
|
|
@spec fetch_current_asos([String.t()]) :: {:ok, [map()]}
|
|
def fetch_current_asos(state_networks) do
|
|
results =
|
|
state_networks
|
|
|> Task.async_stream(
|
|
fn network ->
|
|
url = "#{@iem_base}/api/1/currents.json?network=#{network}"
|
|
|
|
case Req.get(url, req_options()) do
|
|
{:ok, %{status: 200, body: %{"data" => data}}} -> {:ok, data}
|
|
{:ok, %{status: status}} -> {:error, "IEM currents HTTP #{status}"}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end,
|
|
max_concurrency: 10,
|
|
timeout: 30_000
|
|
)
|
|
|> Enum.flat_map(fn
|
|
{:ok, {:ok, data}} -> data
|
|
_ -> []
|
|
end)
|
|
|
|
observations =
|
|
Enum.flat_map(results, fn obs ->
|
|
# Skip stations with missing core data
|
|
if obs["tmpf"] && obs["dwpf"] && obs["lat"] && obs["lon"] do
|
|
[
|
|
%{
|
|
station_code: obs["station"],
|
|
lat: obs["lat"],
|
|
lon: obs["lon"],
|
|
utc_valid: parse_utc_valid(obs["utc_valid"]),
|
|
temp_f: obs["tmpf"],
|
|
dewpoint_f: obs["dwpf"],
|
|
wind_speed_kts: obs["sknt"],
|
|
sky_condition: obs["skyc1"],
|
|
sea_level_pressure_mb: obs["mslp"],
|
|
altimeter_setting: obs["alti"],
|
|
precip_1h_in: obs["phour"]
|
|
}
|
|
]
|
|
else
|
|
[]
|
|
end
|
|
end)
|
|
|
|
{:ok, observations}
|
|
end
|
|
|
|
defp parse_utc_valid(nil), do: nil
|
|
|
|
defp parse_utc_valid(str) when is_binary(str) do
|
|
case DateTime.from_iso8601(str) do
|
|
{:ok, dt, _} -> DateTime.truncate(dt, :second)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
# --- Parsers ---
|
|
|
|
@spec parse_iemre_json(map()) :: [map()]
|
|
def parse_iemre_json(json) when is_map(json) do
|
|
Map.get(json, "data", [])
|
|
end
|
|
|
|
@spec parse_network_json(map()) :: [map()]
|
|
def parse_network_json(json) when is_map(json) do
|
|
json
|
|
|> Map.get("stations", [])
|
|
|> Enum.map(fn entry ->
|
|
%{
|
|
station_code: entry["id"],
|
|
name: entry["name"],
|
|
lat: entry["lat"],
|
|
lon: entry["lon"]
|
|
}
|
|
end)
|
|
end
|
|
|
|
@spec parse_asos_csv(String.t()) :: [map()]
|
|
def parse_asos_csv(csv_text) do
|
|
csv_text
|
|
|> String.split("\n")
|
|
|> Enum.reject(fn line ->
|
|
line == "" or String.starts_with?(line, "#") or String.starts_with?(line, "station")
|
|
end)
|
|
|> Enum.map(&parse_asos_row/1)
|
|
end
|
|
|
|
@spec parse_raob_json(map()) :: [map()]
|
|
def parse_raob_json(json) when is_map(json) do
|
|
json
|
|
|> Map.get("profiles", [])
|
|
|> Enum.map(fn entry ->
|
|
%{
|
|
observed_at: parse_raob_timestamp(entry["valid"]),
|
|
profile: entry["profile"] || []
|
|
}
|
|
end)
|
|
end
|
|
|
|
# --- Private ---
|
|
|
|
defp parse_asos_row(line) do
|
|
parts = String.split(line, ",")
|
|
|
|
%{
|
|
observed_at: parse_asos_timestamp(Enum.at(parts, 1)),
|
|
temp_f: parse_float(Enum.at(parts, 2)),
|
|
dewpoint_f: parse_float(Enum.at(parts, 3)),
|
|
relative_humidity: parse_float(Enum.at(parts, 4)),
|
|
wind_speed_kts: parse_float(Enum.at(parts, 5)),
|
|
wind_direction_deg: parse_int(Enum.at(parts, 6)),
|
|
sea_level_pressure_mb: parse_float(Enum.at(parts, 7)),
|
|
altimeter_setting: parse_float(Enum.at(parts, 8)),
|
|
sky_condition: parse_nullable_string(Enum.at(parts, 9)),
|
|
precip_1h_in: parse_float(Enum.at(parts, 10)),
|
|
wx_codes: parse_nullable_string(Enum.at(parts, 11))
|
|
}
|
|
end
|
|
|
|
defp parse_asos_timestamp(str) when is_binary(str) do
|
|
# Format: "2026-03-28 18:53"
|
|
case DateTime.from_iso8601(String.trim(str) <> ":00Z") do
|
|
{:ok, dt, _} -> DateTime.truncate(dt, :second)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_asos_timestamp(_), do: nil
|
|
|
|
defp parse_raob_timestamp(str) when is_binary(str) do
|
|
# Format: "2026-03-28 12:00:00+00:00"
|
|
cleaned =
|
|
str
|
|
|> String.trim()
|
|
|> String.replace(~r/\+00:00$/, "Z")
|
|
|> String.replace(" ", "T")
|
|
|
|
case DateTime.from_iso8601(cleaned) do
|
|
{:ok, dt, _} -> DateTime.truncate(dt, :second)
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_raob_timestamp(_), do: nil
|
|
|
|
defp parse_float(nil), do: nil
|
|
defp parse_float("null"), do: nil
|
|
|
|
defp parse_float(str) do
|
|
case Float.parse(String.trim(str)) do
|
|
{val, _} -> val
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_int(nil), do: nil
|
|
defp parse_int("null"), do: nil
|
|
|
|
defp parse_int(str) do
|
|
case Integer.parse(String.trim(str)) do
|
|
{val, _} -> val
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp parse_nullable_string(nil), do: nil
|
|
defp parse_nullable_string("null"), do: nil
|
|
|
|
defp parse_nullable_string(str) do
|
|
trimmed = String.trim(str)
|
|
if trimmed == "", do: nil, else: trimmed
|
|
end
|
|
|
|
defp format_iem_ts(dt) do
|
|
y = Integer.to_string(dt.year)
|
|
mo = dt.month |> Integer.to_string() |> String.pad_leading(2, "0")
|
|
d = dt.day |> Integer.to_string() |> String.pad_leading(2, "0")
|
|
h = dt.hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
|
mi = dt.minute |> Integer.to_string() |> String.pad_leading(2, "0")
|
|
"#{y}#{mo}#{d}#{h}#{mi}"
|
|
end
|
|
end
|