prop/test/microwaveprop/weather/uwyo_sounding_client_test.exs
Graham McIntire 8ea0c3b94a
Ingest Canadian radiosondes via UWYO + plans for RDPS/HRDPS
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.
2026-04-13 09:14:34 -05:00

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