New AsosAdjustmentWorker runs every 10 minutes: - Fetches latest ASOS observations from all ~2900 US stations via IEM bulk currents API (parallel fetch across 51 state networks) - For each grid point within 75km of a reporting station, re-scores using fresh ASOS data (temp, dewpoint, wind, sky, pressure, precip) with HRRR refractivity gradient from the last hourly computation - Pushes updated scores to the map via PubSub Also stores HRRR profiles in the database during grid computation so the data persists for reference and ASOS blending.
297 lines
7.9 KiB
Elixir
297 lines
7.9 KiB
Elixir
defmodule Microwaveprop.Weather.IemClient do
|
|
@moduledoc false
|
|
|
|
@iem_base "https://mesonet.agron.iastate.edu"
|
|
|
|
# --- URL builders ---
|
|
|
|
def network_url(network) do
|
|
"#{@iem_base}/json/network.py?network=#{network}"
|
|
end
|
|
|
|
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
|
|
|
|
def raob_url(station_id, dt) do
|
|
ts = format_iem_ts(dt)
|
|
"#{@iem_base}/json/raob.py?ts=#{ts}&station=#{station_id}"
|
|
end
|
|
|
|
def iemre_url(lat, lon, date) do
|
|
"#{@iem_base}/iemre/hourly/#{date}/#{lat}/#{lon}/json"
|
|
end
|
|
|
|
# --- HTTP fetchers ---
|
|
|
|
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
|
|
|
|
def fetch_asos(station_id, start_dt, end_dt) do
|
|
url = asos_url(station_id, start_dt, end_dt)
|
|
|
|
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
|
|
|
|
def fetch_raob(station_id, dt) do
|
|
url = raob_url(station_id, dt)
|
|
|
|
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
|
|
|
|
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, ...}]}`.
|
|
"""
|
|
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 ---
|
|
|
|
def parse_iemre_json(json) when is_map(json) do
|
|
Map.get(json, "data", [])
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|