IEM's RAOB endpoint stopped keeping Canadian stations current — most stalled around Sep 2024 — which leaves a gap in refractivity/ducting calibration over all the CW*/CY* stations already in weather_stations. MSC's own tephi CSV is fixed-width and rendering-focused; the WMO TEMP bulletins would need a full decoder. University of Wyoming serves the same stations in a clean space-delimited format, is current, and accepts the 3-letter station code (drop the leading C from Canadian ICAO). Its output shape matches IemClient.parse_raob_json/1 exactly, so it drops straight into Weather.upsert_sounding/2. - New UwyoSoundingClient with URL builder, Req-based fetch_sounding, and a fixed-width parse_sounding_html that extracts PRES/HGHT/TEMP/ DWPT/DRCT/SKNT from the <PRE> block and DateTime from the <H2> header. - New CanadianSoundingFetchWorker: Oban batch worker that finds all sounding stations whose code starts with C, picks the most recently publishable 00Z or 12Z slot (90 min publish delay), calls UWYO per station, and upserts with SoundingParams-derived refractivity/ ducting fields. - Oban cron at 01:30Z and 13:30Z (dev + prod) to drive the worker. - Req.Test plug wiring in config/test.exs. - Captured Goose Bay fixture as test/support/fixtures/uwyo_sounding_yyr.html. Also drops two implementation plans under docs/plans/: - 2026-04-13-rdps-vertical-profiles.md: ~2 day plan for RDPS 10 km Canadian ducting outside HRRR's Lambert footprint. - 2026-04-13-hrdps-canadian-prop-grid.md: ~5 day plan for the full HRDPS 2.5 km Canadian prop grid. Explicitly recommends doing RDPS first. TDD throughout, 19 new tests, 1318/1318 passing, credo strict clean.
122 lines
4.4 KiB
Elixir
122 lines
4.4 KiB
Elixir
defmodule Microwaveprop.Weather.UwyoSoundingClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.UwyoSoundingClient
|
|
|
|
@fixture File.read!("test/support/fixtures/uwyo_sounding_yyr.html")
|
|
|
|
describe "sounding_url/2" do
|
|
test "builds the correct URL for a 3-letter station code + datetime" do
|
|
url = UwyoSoundingClient.sounding_url("YYR", ~U[2026-04-13 00:00:00Z])
|
|
|
|
assert url ==
|
|
"https://weather.uwyo.edu/cgi-bin/sounding" <>
|
|
"?region=naconf&TYPE=TEXT%3ALIST" <>
|
|
"&YEAR=2026&MONTH=04&FROM=1300&TO=1300&STNM=YYR"
|
|
end
|
|
|
|
test "pads month and day-hour to two digits" do
|
|
url = UwyoSoundingClient.sounding_url("WSE", ~U[2026-01-05 12:00:00Z])
|
|
|
|
assert url =~ "MONTH=01"
|
|
assert url =~ "FROM=0512"
|
|
assert url =~ "TO=0512"
|
|
assert url =~ "STNM=WSE"
|
|
end
|
|
end
|
|
|
|
describe "parse_sounding_html/1" do
|
|
test "returns one sounding entry for a valid response" do
|
|
assert [sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
assert sounding.observed_at == ~U[2026-04-13 00:00:00Z]
|
|
assert is_list(sounding.profile)
|
|
assert length(sounding.profile) > 20
|
|
end
|
|
|
|
test "parses the surface level with all fields populated" do
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
surface = hd(sounding.profile)
|
|
|
|
assert surface["pres"] == 1016.0
|
|
assert surface["hght"] == 36.0
|
|
assert surface["tmpc"] == -3.5
|
|
assert surface["dwpc"] == -9.5
|
|
assert surface["drct"] == 145.0
|
|
assert surface["sknt"] == 1.0
|
|
end
|
|
|
|
test "each level has the same key set (pres, hght, tmpc, dwpc, drct, sknt)" do
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(@fixture)
|
|
|
|
Enum.each(sounding.profile, fn level ->
|
|
assert Map.has_key?(level, "pres")
|
|
assert Map.has_key?(level, "hght")
|
|
assert Map.has_key?(level, "tmpc")
|
|
assert Map.has_key?(level, "dwpc")
|
|
assert Map.has_key?(level, "drct")
|
|
assert Map.has_key?(level, "sknt")
|
|
end)
|
|
end
|
|
|
|
test "levels with missing temp/dewpoint are omitted" do
|
|
# Lines like ' 1000.0 92' (only PRES + HGHT, rest blank) must not
|
|
# appear in the parsed profile because SoundingParams.derive/1 needs temp.
|
|
html = """
|
|
<H2>71119 WSE Edmonton Stony Plain Observations at 00Z 13 Apr 2026</H2>
|
|
<PRE>
|
|
-----------------------------------------------------------------------------
|
|
PRES HGHT TEMP DWPT RELH MIXR DRCT SKNT THTA THTE THTV
|
|
hPa m C C % g/kg deg knot K K K
|
|
-----------------------------------------------------------------------------
|
|
1000.0 92
|
|
920.0 766 2.2 -3.8 65 3.15 85 6 282.0 291.1 282.5
|
|
</PRE>
|
|
"""
|
|
|
|
[sounding] = UwyoSoundingClient.parse_sounding_html(html)
|
|
assert length(sounding.profile) == 1
|
|
assert hd(sounding.profile)["pres"] == 920.0
|
|
end
|
|
|
|
test "returns an empty list when the response contains no PRE block" do
|
|
html = "Sorry, the server is too busy to process your request.\n"
|
|
assert UwyoSoundingClient.parse_sounding_html(html) == []
|
|
end
|
|
|
|
test "returns an empty list when the header is missing" do
|
|
html = "<HTML><PRE> 1000.0 30 25.0 20.0 50 0.0 180 10 0 0 0</PRE></HTML>"
|
|
assert UwyoSoundingClient.parse_sounding_html(html) == []
|
|
end
|
|
end
|
|
|
|
describe "fetch_sounding/2" do
|
|
test "returns parsed sounding on a 200 response" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Req.Test.text(conn, @fixture)
|
|
end)
|
|
|
|
assert {:ok, [sounding]} =
|
|
UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
|
|
|
assert sounding.observed_at == ~U[2026-04-13 00:00:00Z]
|
|
refute sounding.profile == []
|
|
end
|
|
|
|
test "returns an empty list when the server is busy" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Req.Test.text(conn, "Sorry, the server is too busy to process your request.\n")
|
|
end)
|
|
|
|
assert {:ok, []} = UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
|
end
|
|
|
|
test "returns an error on a non-200 response" do
|
|
Req.Test.stub(UwyoSoundingClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
|
end)
|
|
|
|
assert {:error, "UWYO sounding HTTP 503"} =
|
|
UwyoSoundingClient.fetch_sounding("YYR", ~U[2026-04-13 00:00:00Z])
|
|
end
|
|
end
|
|
end
|