SolarIndexWorker 36→100% (date-arg clause), HrrrFetchWorker 38→42% (batch skip, backoff), IemClient 65→74% (fetch_current_asos).
392 lines
11 KiB
Elixir
392 lines
11 KiB
Elixir
defmodule Microwaveprop.Weather.IemClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.IemClient
|
|
|
|
describe "parse_asos_csv/1" do
|
|
test "parses valid CSV rows including precipitation" do
|
|
csv = """
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KDFW,2026-03-28 18:53,75.0,55.0,49.12,12,180,1013.2,29.92,SCT,0.03,RA
|
|
KDFW,2026-03-28 19:53,76.0,54.0,46.00,10,190,1013.0,29.91,FEW,null,null
|
|
"""
|
|
|
|
rows = IemClient.parse_asos_csv(csv)
|
|
assert length(rows) == 2
|
|
|
|
first = hd(rows)
|
|
assert first.temp_f == 75.0
|
|
assert first.dewpoint_f == 55.0
|
|
assert first.relative_humidity == 49.12
|
|
assert first.wind_speed_kts == 12.0
|
|
assert first.wind_direction_deg == 180
|
|
assert first.sea_level_pressure_mb == 1013.2
|
|
assert first.altimeter_setting == 29.92
|
|
assert first.sky_condition == "SCT"
|
|
assert first.observed_at == ~U[2026-03-28 18:53:00Z]
|
|
assert first.precip_1h_in == 0.03
|
|
assert first.wx_codes == "RA"
|
|
|
|
second = Enum.at(rows, 1)
|
|
assert second.precip_1h_in == nil
|
|
assert second.wx_codes == nil
|
|
end
|
|
|
|
test "handles null values in CSV" do
|
|
csv = """
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KDFW,2026-03-28 18:53,null,null,null,null,null,null,null,null,null,null
|
|
"""
|
|
|
|
rows = IemClient.parse_asos_csv(csv)
|
|
assert length(rows) == 1
|
|
|
|
first = hd(rows)
|
|
assert first.temp_f == nil
|
|
assert first.dewpoint_f == nil
|
|
assert first.sky_condition == nil
|
|
assert first.precip_1h_in == nil
|
|
assert first.wx_codes == nil
|
|
end
|
|
|
|
test "skips comment lines and empty lines" do
|
|
csv = """
|
|
#DEBUG something
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KDFW,2026-03-28 18:53,75.0,55.0,49.0,12,180,1013.2,29.92,SCT,0.01,
|
|
|
|
"""
|
|
|
|
rows = IemClient.parse_asos_csv(csv)
|
|
assert length(rows) == 1
|
|
end
|
|
end
|
|
|
|
describe "parse_raob_json/1" do
|
|
test "parses valid RAOB JSON response" do
|
|
json = %{
|
|
"profiles" => [
|
|
%{
|
|
"station" => "KFWD",
|
|
"valid" => "2026-03-28 12:00:00+00:00",
|
|
"profile" => [
|
|
%{"pres" => 1013.0, "hght" => 171.0, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180.0, "sknt" => 10.0},
|
|
%{"pres" => 925.0, "hght" => 800.0, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200.0, "sknt" => 15.0}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
result = IemClient.parse_raob_json(json)
|
|
assert length(result) == 1
|
|
|
|
sounding = hd(result)
|
|
assert sounding.observed_at == ~U[2026-03-28 12:00:00Z]
|
|
assert length(sounding.profile) == 2
|
|
assert hd(sounding.profile)["pres"] == 1013.0
|
|
end
|
|
|
|
test "returns empty list for empty profiles" do
|
|
json = %{"profiles" => []}
|
|
assert IemClient.parse_raob_json(json) == []
|
|
end
|
|
|
|
test "handles missing profile key gracefully" do
|
|
json = %{}
|
|
assert IemClient.parse_raob_json(json) == []
|
|
end
|
|
end
|
|
|
|
describe "asos_url/3" do
|
|
test "builds correct URL for date range" do
|
|
url =
|
|
IemClient.asos_url(
|
|
"KDFW",
|
|
~U[2026-03-28 12:00:00Z],
|
|
~U[2026-03-28 18:00:00Z]
|
|
)
|
|
|
|
assert url =~ "station=KDFW"
|
|
assert url =~ "year1=2026"
|
|
assert url =~ "month1=3"
|
|
assert url =~ "day1=28"
|
|
assert url =~ "hour1=12"
|
|
assert url =~ "year2=2026"
|
|
assert url =~ "hour2=18"
|
|
assert url =~ "format=onlycomma"
|
|
assert url =~ "data=p01i"
|
|
assert url =~ "data=wxcodes"
|
|
end
|
|
end
|
|
|
|
describe "raob_url/2" do
|
|
test "builds correct URL for sounding fetch" do
|
|
url = IemClient.raob_url("FWD", ~U[2026-03-28 12:00:00Z])
|
|
assert url =~ "station=FWD"
|
|
assert url =~ "ts=202603281200"
|
|
end
|
|
end
|
|
|
|
describe "network_url/1" do
|
|
test "builds correct URL for state ASOS network" do
|
|
url = IemClient.network_url("NY_ASOS")
|
|
assert url == "https://mesonet.agron.iastate.edu/json/network.py?network=NY_ASOS"
|
|
end
|
|
|
|
test "builds correct URL for RAOB network" do
|
|
url = IemClient.network_url("RAOB")
|
|
assert url == "https://mesonet.agron.iastate.edu/json/network.py?network=RAOB"
|
|
end
|
|
end
|
|
|
|
describe "fetch_network/1" do
|
|
test "returns parsed stations on success" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"stations" => [
|
|
%{"id" => "KJFK", "name" => "JFK Intl", "lat" => 40.64, "lon" => -73.78}
|
|
]
|
|
})
|
|
end)
|
|
|
|
assert {:ok, [station]} = IemClient.fetch_network("NY_ASOS")
|
|
assert station.station_code == "KJFK"
|
|
assert station.lat == 40.64
|
|
end
|
|
|
|
test "returns error on non-200 status" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
assert {:error, "IEM network HTTP 503"} = IemClient.fetch_network("NY_ASOS")
|
|
end
|
|
end
|
|
|
|
describe "fetch_asos/3" do
|
|
test "returns parsed observations on success" do
|
|
csv = """
|
|
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
|
KDFW,2026-03-28 18:53,75.0,55.0,49.12,12,180,1013.2,29.92,SCT,0.05,RA BR
|
|
"""
|
|
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.text(conn, csv)
|
|
end)
|
|
|
|
assert {:ok, [obs]} =
|
|
IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z])
|
|
|
|
assert obs.temp_f == 75.0
|
|
assert obs.observed_at == ~U[2026-03-28 18:53:00Z]
|
|
assert obs.precip_1h_in == 0.05
|
|
assert obs.wx_codes == "RA BR"
|
|
end
|
|
|
|
test "returns error on non-200 status" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "error")
|
|
end)
|
|
|
|
assert {:error, "IEM ASOS HTTP 500"} =
|
|
IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "fetch_raob/2" do
|
|
test "returns parsed soundings on success" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"profiles" => [
|
|
%{
|
|
"station" => "FWD",
|
|
"valid" => "2026-03-28 12:00:00+00:00",
|
|
"profile" => [
|
|
%{"pres" => 1013.0, "hght" => 171.0, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180.0, "sknt" => 10.0}
|
|
]
|
|
}
|
|
]
|
|
})
|
|
end)
|
|
|
|
assert {:ok, [sounding]} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z])
|
|
assert sounding.observed_at == ~U[2026-03-28 12:00:00Z]
|
|
assert length(sounding.profile) == 1
|
|
end
|
|
|
|
test "returns error on non-200 status" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
assert {:error, "IEM RAOB HTTP 404"} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "fetch_current_asos/1" do
|
|
test "returns parsed observations from multiple networks" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"data" => [
|
|
%{
|
|
"station" => "KDFW",
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"utc_valid" => "2026-03-28T18:00:00Z",
|
|
"tmpf" => 75.0,
|
|
"dwpf" => 55.0,
|
|
"sknt" => 10,
|
|
"skyc1" => "CLR",
|
|
"mslp" => 1013.2,
|
|
"alti" => 29.92,
|
|
"phour" => 0.0
|
|
}
|
|
]
|
|
})
|
|
end)
|
|
|
|
assert {:ok, observations} = IemClient.fetch_current_asos(["TX_ASOS"])
|
|
assert length(observations) == 1
|
|
assert hd(observations).station_code == "KDFW"
|
|
assert hd(observations).temp_f == 75.0
|
|
end
|
|
|
|
test "skips observations with missing core data" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"data" => [
|
|
%{"station" => "KDFW", "lat" => nil, "lon" => nil, "tmpf" => nil, "dwpf" => nil}
|
|
]
|
|
})
|
|
end)
|
|
|
|
assert {:ok, []} = IemClient.fetch_current_asos(["TX_ASOS"])
|
|
end
|
|
end
|
|
|
|
describe "parse_network_json/1" do
|
|
test "parses station list from network JSON response" do
|
|
json = %{
|
|
"stations" => [
|
|
%{
|
|
"id" => "KJFK",
|
|
"name" => "New York/JFK Intl",
|
|
"lat" => 40.6399,
|
|
"lon" => -73.7787
|
|
},
|
|
%{
|
|
"id" => "KLGA",
|
|
"name" => "New York/LaGuardia",
|
|
"lat" => 40.7772,
|
|
"lon" => -73.8726
|
|
}
|
|
]
|
|
}
|
|
|
|
stations = IemClient.parse_network_json(json)
|
|
assert length(stations) == 2
|
|
|
|
first = hd(stations)
|
|
assert first.station_code == "KJFK"
|
|
assert first.name == "New York/JFK Intl"
|
|
assert first.lat == 40.6399
|
|
assert first.lon == -73.7787
|
|
end
|
|
|
|
test "returns empty list when no stations" do
|
|
assert IemClient.parse_network_json(%{"stations" => []}) == []
|
|
end
|
|
|
|
test "returns empty list when stations key missing" do
|
|
assert IemClient.parse_network_json(%{}) == []
|
|
end
|
|
|
|
test "handles stations with extra fields gracefully" do
|
|
json = %{
|
|
"stations" => [
|
|
%{
|
|
"id" => "KDFW",
|
|
"name" => "Dallas/Fort Worth",
|
|
"lat" => 32.897,
|
|
"lon" => -97.038,
|
|
"elevation" => 171.0,
|
|
"state" => "TX",
|
|
"country" => "US",
|
|
"network" => "TX_ASOS"
|
|
}
|
|
]
|
|
}
|
|
|
|
stations = IemClient.parse_network_json(json)
|
|
assert length(stations) == 1
|
|
|
|
station = hd(stations)
|
|
assert station.station_code == "KDFW"
|
|
assert station.lat == 32.897
|
|
assert station.lon == -97.038
|
|
end
|
|
end
|
|
|
|
describe "iemre_url/3" do
|
|
test "builds correct URL for lat/lon and date" do
|
|
url = IemClient.iemre_url(32.875, -97.0, ~D[2026-03-28])
|
|
assert url == "https://mesonet.agron.iastate.edu/iemre/hourly/2026-03-28/32.875/-97.0/json"
|
|
end
|
|
end
|
|
|
|
describe "parse_iemre_json/1" do
|
|
test "extracts data array from JSON response" do
|
|
json = %{
|
|
"data" => [
|
|
%{"utc_hour" => 0, "p01m_mm" => 0.0, "skyc_pct" => 25.0},
|
|
%{"utc_hour" => 1, "p01m_mm" => 0.5, "skyc_pct" => 50.0}
|
|
]
|
|
}
|
|
|
|
result = IemClient.parse_iemre_json(json)
|
|
assert length(result) == 2
|
|
assert hd(result)["utc_hour"] == 0
|
|
end
|
|
|
|
test "returns empty list for missing data key" do
|
|
assert IemClient.parse_iemre_json(%{}) == []
|
|
end
|
|
|
|
test "returns empty list for empty data array" do
|
|
assert IemClient.parse_iemre_json(%{"data" => []}) == []
|
|
end
|
|
end
|
|
|
|
describe "fetch_iemre/3" do
|
|
test "returns parsed hourly data on success" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Req.Test.json(conn, %{
|
|
"data" => [
|
|
%{"utc_hour" => 0, "p01m_mm" => 0.0},
|
|
%{"utc_hour" => 1, "p01m_mm" => 1.2}
|
|
]
|
|
})
|
|
end)
|
|
|
|
assert {:ok, data} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28])
|
|
assert length(data) == 2
|
|
assert Enum.at(data, 1)["p01m_mm"] == 1.2
|
|
end
|
|
|
|
test "returns error on non-200 status" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
assert {:error, "IEM IEMRE HTTP 503"} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28])
|
|
end
|
|
|
|
test "returns error on 404" do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
assert {:error, "IEM IEMRE HTTP 404"} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28])
|
|
end
|
|
end
|
|
end
|