prop/lib/microwaveprop/weather/iem_client.ex
Graham McIntire 6489f08138
Add weather data schema, context, sounding params, and IEM ingestion
Store surface observations (ASOS) and upper-air soundings (RAOB) alongside
QSOs for atmospheric propagation correlation. Three new tables: weather_stations,
surface_observations, and soundings with JSONB profiles and pre-computed derived
parameters (refractivity, gradients, duct detection, stability indices).

Includes IEM API client for historical data import and import script seeded
with 95 ASOS + 9 sounding stations from PropCast coverage area.
2026-03-28 15:57:19 -05:00

159 lines
4.2 KiB
Elixir

defmodule Microwaveprop.Weather.IemClient do
@moduledoc false
@iem_base "https://mesonet.agron.iastate.edu"
# --- URL builders ---
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" <>
"&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
# --- HTTP fetchers ---
def fetch_asos(station_id, start_dt, end_dt) do
url = asos_url(station_id, start_dt, end_dt)
case Req.get(url) 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) 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
# --- Parsers ---
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))
}
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