prop/lib/microwaveprop/weather/iem_client.ex
Graham McIntire e0d9900608
Add real-time ASOS adjustments between hourly HRRR updates
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.
2026-03-31 09:10:12 -05:00

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