prop/test/microwaveprop/weather/nexrad_client_test.exs
Graham McIntire dc8353a9e9
test: coverage round 4 (84.39% → 84.44%) + 6 new property tests
30 unit tests + 6 property tests across two parallel agents.

- ContactLive.Show + HrrrNativeClient + NexradClient: sort_observations
  + sort_soundings actual ordering per field, closest_observations
  capped-at-5 proximity, nil-pos2 half_dist=0 path, HrrrNativeClient
  scrambled-level density + surface finiteness, NexradClient cache
  population + zero-byte + year-boundary URL rounding. Properties:
  sort_observations preservation, haversine symmetry, build_native
  surface_temp_k finiteness.

- PathLive + Viewshed + GefsFetchWorker + SnmpClient: GPS source
  URL preservation, QRZ 404 surfacing, propagation_updated same-midpoint
  no-op; Viewshed effective_reach_km BLOCKED boundaries + find_reach_km
  zero max_range; GefsFetchWorker 502/400/410 + wind_u/wind_v aliases
  + nil profile; SnmpClient fully-qualified OID + double-dot drop +
  empty poll + unknown radio type. Properties: destination_point
  round-trip < 0.5 km, valid_time = run_time + fh*3600 invariant,
  parse_snmpget_output totality.
2026-04-24 10:32:05 -05:00

478 lines
15 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Weather.NexradClientTest do
use ExUnit.Case, async: false
use ExUnitProperties
alias Microwaveprop.Weather.NexradCache
alias Microwaveprop.Weather.NexradClient
describe "round_to_5min/1" do
test "rounds down to nearest 5-minute boundary" do
dt = ~U[2022-08-20 14:07:32Z]
assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:05:00Z]
end
test "already on a 5-minute boundary stays unchanged" do
dt = ~U[2022-08-20 14:10:00Z]
assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:10:00Z]
end
test "59 minutes rounds down to 55" do
dt = ~U[2022-08-20 14:59:59Z]
assert NexradClient.round_to_5min(dt) == ~U[2022-08-20 14:55:00Z]
end
end
describe "frame_url/1" do
test "builds correct IEM n0q URL from datetime" do
dt = ~U[2022-08-20 14:05:00Z]
url = NexradClient.frame_url(dt)
assert url ==
"https://mesonet.agron.iastate.edu/archive/data/2022/08/20/GIS/uscomp/n0q_202208201405.png"
end
test "pads single-digit month and day" do
dt = ~U[2022-01-05 03:00:00Z]
url = NexradClient.frame_url(dt)
assert url ==
"https://mesonet.agron.iastate.edu/archive/data/2022/01/05/GIS/uscomp/n0q_202201050300.png"
end
end
describe "latlon_to_pixel/2" do
test "converts coordinates to pixel positions" do
# Top-left corner: lat=50, lon=-126
{x, y} = NexradClient.latlon_to_pixel(50.0, -126.0)
assert x == 0
assert y == 0
end
test "bottom-right corner" do
# lon = -65 -> x = (-65 - -126) / 0.005 = 61 / 0.005 = 12200
# lat = 23 -> y = (50 - 23) / 0.005 = 27 / 0.005 = 5400
{x, y} = NexradClient.latlon_to_pixel(23.0, -65.0)
assert x == 12_200
assert y == 5400
end
test "DFW area" do
# DFW: lat ~32.9, lon ~-97.0
{x, y} = NexradClient.latlon_to_pixel(32.9, -97.0)
# x = (-97 - -126) / 0.005 = 29 / 0.005 = 5800
assert x == 5800
# y = (50 - 32.9) / 0.005 = 17.1 / 0.005 = 3420
assert y == 3420
end
end
describe "extract_box/4" do
test "extracts stats from a box of pixel values" do
# Create a simple 100x100 image (all zeros except a small region)
width = 100
height = 100
pixels = :binary.copy(<<0>>, width * height)
# Put some non-zero values around pixel (50, 50) - a 5x5 block
pixels =
Enum.reduce(48..52, pixels, fn y, acc ->
Enum.reduce(48..52, acc, fn x, inner_acc ->
offset = y * width + x
<<pre::binary-size(offset), _::8, post::binary>> = inner_acc
<<pre::binary, 120::8, post::binary>>
end)
end)
result = NexradClient.extract_box(pixels, width, 50, 50, 5)
assert result.pixel_count == 25
assert result.max_reflectivity_dbz > 0
assert result.mean_reflectivity_dbz > 0
assert result.texture_variance == 0.0
end
test "returns zeros for an empty box" do
width = 100
height = 100
pixels = :binary.copy(<<0>>, width * height)
result = NexradClient.extract_box(pixels, width, 50, 50, 5)
assert result.pixel_count == 0
assert result.mean_reflectivity_dbz == 0.0
assert result.max_reflectivity_dbz == 0.0
assert result.texture_variance == 0.0
end
test "computes texture variance for mixed values" do
width = 100
height = 100
pixels = :binary.copy(<<0>>, width * height)
# Place alternating values: half 100, half 200
pixels =
Enum.reduce(48..52, pixels, fn y, acc ->
Enum.reduce(48..52, acc, fn x, inner_acc ->
offset = y * width + x
value = if rem(x + y, 2) == 0, do: 100, else: 200
<<pre::binary-size(offset), _::8, post::binary>> = inner_acc
<<pre::binary, value::8, post::binary>>
end)
end)
result = NexradClient.extract_box(pixels, width, 50, 50, 5)
assert result.pixel_count == 25
# Variance should be non-zero since we have mixed values
assert result.texture_variance > 0.0
end
test "handles box at image boundary by clamping" do
width = 100
height = 100
pixels = :binary.copy(<<50>>, width * height)
# Corner case: near edge
result = NexradClient.extract_box(pixels, width, 2, 2, 25)
# Should not crash; pixel_count depends on how many pixels are in bounds
assert result.pixel_count > 0
end
end
describe "fetch_decoded_frame/1 caching" do
# Minimal 20×20 indexed-color PNG identical to the NexradWorkerTest
# fixture. Used here just to give the decoder something valid to
# chew on; caching semantics don't care about the contents.
defp minimal_png(width \\ 20, height \\ 20) do
signature = <<137, 80, 78, 71, 13, 10, 26, 10>>
ihdr_data = <<width::32, height::32, 8, 3, 0, 0, 0>>
ihdr_chunk = <<byte_size(ihdr_data)::32, "IHDR", ihdr_data::binary, 0::32>>
raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>>
compressed = :zlib.compress(raw)
idat_chunk = <<byte_size(compressed)::32, "IDAT", compressed::binary, 0::32>>
iend_chunk = <<0::32, "IEND", 0::32>>
signature <> ihdr_chunk <> idat_chunk <> iend_chunk
end
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "first call fetches+decodes; second call for same 5-min bucket is served from cache" do
test_pid = self()
Req.Test.stub(NexradClient, fn conn ->
send(test_pid, :http_fetched)
Plug.Conn.send_resp(conn, 200, minimal_png())
end)
ts = ~U[2022-08-20 14:07:00Z]
{:ok, pixels1, width1} = NexradClient.fetch_decoded_frame(ts)
{:ok, pixels2, width2} = NexradClient.fetch_decoded_frame(ts)
# The cache is keyed on the 5-min rounded timestamp, so a
# second call within the same bucket reuses the decoded pixels.
assert pixels1 == pixels2
assert width1 == width2
assert_received :http_fetched
refute_received :http_fetched
end
test "different 5-min buckets hit the network separately" do
test_pid = self()
Req.Test.stub(NexradClient, fn conn ->
send(test_pid, :http_fetched)
Plug.Conn.send_resp(conn, 200, minimal_png())
end)
{:ok, _, _} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z])
{:ok, _, _} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:10:00Z])
assert_received :http_fetched
assert_received :http_fetched
end
end
describe "pixel_to_dbz/1" do
test "value 0 maps to 0 (no echo)" do
assert NexradClient.pixel_to_dbz(0) == 0.0
end
test "value 255 maps to approximately 95 dBZ" do
dbz = NexradClient.pixel_to_dbz(255)
assert_in_delta dbz, 95.0, 1.0
end
test "mid-range value produces reasonable dBZ" do
dbz = NexradClient.pixel_to_dbz(128)
assert dbz > 0.0
assert dbz < 95.0
end
property "is monotonic non-decreasing across [1, 255]" do
check all(
a <- integer(1..254),
delta <- integer(0..254)
) do
b = min(a + delta, 255)
assert NexradClient.pixel_to_dbz(a) <= NexradClient.pixel_to_dbz(b)
end
end
property "output is in [-30.0, 95.0] for any byte value [1, 255]" do
check all(value <- integer(1..255)) do
dbz = NexradClient.pixel_to_dbz(value)
assert dbz >= -30.0
assert dbz <= 95.0 + 1.0e-9
end
end
end
describe "latlon_to_pixel/2 invariants" do
property "matching lat/lon corners snap to pixel corners exactly" do
# The CONUS grid is a 12201×5401 frame anchored at (50N, -126W)
# with 0.005° resolution. Every cell center whose (lat, lon)
# is aligned with the grid must round-trip to the integer pixel
# coordinates the module's constants imply.
check all(
x <- integer(0..12_200),
y <- integer(0..5400)
) do
lat = 50.0 - y * 0.005
lon = -126.0 + x * 0.005
{px, py} = NexradClient.latlon_to_pixel(lat, lon)
assert px == x
assert py == y
end
end
end
describe "fetch_frame/2 error paths" do
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "HTTP 500 returns {:error, _} with status message" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "boom")
end)
assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
assert msg =~ "500"
end
test "HTTP 404 returns {:error, _}" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
assert {:error, msg} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
assert msg =~ "404"
end
test "transport timeout bubbles up as {:error, _}" do
Req.Test.stub(NexradClient, fn conn ->
Req.Test.transport_error(conn, :timeout)
end)
assert {:error, reason} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
assert reason
end
end
describe "fetch_decoded_frame/1 error paths" do
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "HTTP 503 returns {:error, _} and does not populate cache" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 503, "unavailable")
end)
ts = ~U[2022-08-20 14:05:00Z]
assert {:error, msg} = NexradClient.fetch_decoded_frame(ts)
assert msg =~ "503"
# Cache must not carry forward a 503 — next call must hit the network
# again (the stub is still in place, so we still get the same error).
assert {:error, _} = NexradClient.fetch_decoded_frame(ts)
end
test "malformed PNG body returns {:error, _} from the decoder" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, "not a png")
end)
assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z])
# decode_png_to_pixels wraps any exception into this string
assert is_binary(reason)
assert reason =~ "PNG decode failed"
end
end
describe "frame_url/1 round-trip property" do
property "url embeds the date path and a 12-digit timestamp for any rounded 5-min input" do
check all(
year <- integer(2015..2030),
month <- integer(1..12),
day <- integer(1..28),
hour <- integer(0..23),
minute_bucket <- integer(0..11)
) do
dt = %DateTime{
year: year,
month: month,
day: day,
hour: hour,
minute: minute_bucket * 5,
second: 0,
microsecond: {0, 0},
std_offset: 0,
utc_offset: 0,
zone_abbr: "UTC",
time_zone: "Etc/UTC"
}
url = NexradClient.frame_url(dt)
date_path =
"#{year}/#{String.pad_leading(Integer.to_string(month), 2, "0")}/" <>
String.pad_leading(Integer.to_string(day), 2, "0")
file_ts =
[year, month, day, hour, minute_bucket * 5]
|> Enum.map(&Integer.to_string/1)
|> Enum.zip([4, 2, 2, 2, 2])
|> Enum.map_join(fn {str, w} -> String.pad_leading(str, w, "0") end)
assert String.contains?(url, date_path)
assert String.contains?(url, "n0q_#{file_ts}.png")
end
end
end
describe "extract_box/5 edge cases" do
test "box partially off the left edge returns whatever pixels fall inside" do
# 4-row × 10-col frame, every pixel hot.
pixels = :binary.copy(<<128>>, 40)
# Centre x=0 → x_min=0, x_max=2. Centre y=0 → y_min=0, y_max=2.
result = NexradClient.extract_box(pixels, 10, 0, 0, 2)
assert result.pixel_count > 0
assert result.max_reflectivity_dbz > 0.0
end
test "box entirely off the right edge returns no-data (no pixels)" do
pixels = :binary.copy(<<128>>, 40)
# width=10, cx=50 → x_min=48, x_max=9 → empty range after clamp.
result = NexradClient.extract_box(pixels, 10, 50, 2, 2)
assert result.pixel_count == 0
assert result.mean_reflectivity_dbz == 0.0
assert result.max_reflectivity_dbz == 0.0
assert result.texture_variance == 0.0
end
test "zero pixels in box yields the no-echo stats tuple" do
pixels = :binary.copy(<<0>>, 40)
result = NexradClient.extract_box(pixels, 10, 5, 2, 2)
# Every pixel is 0 → none enter the filtered dbz_values list.
assert result.pixel_count == 0
assert result.mean_reflectivity_dbz == 0.0
end
end
describe "fetch_decoded_frame/1 cache population" do
# Minimal 20×20 indexed-color PNG identical to the earlier fixture.
defp tiny_png(width \\ 20, height \\ 20) do
signature = <<137, 80, 78, 71, 13, 10, 26, 10>>
ihdr_data = <<width::32, height::32, 8, 3, 0, 0, 0>>
ihdr_chunk = <<byte_size(ihdr_data)::32, "IHDR", ihdr_data::binary, 0::32>>
raw = for _ <- 1..height, into: <<>>, do: <<0, 0::size(width)-unit(8)>>
compressed = :zlib.compress(raw)
idat_chunk = <<byte_size(compressed)::32, "IDAT", compressed::binary, 0::32>>
iend_chunk = <<0::32, "IEND", 0::32>>
signature <> ihdr_chunk <> idat_chunk <> iend_chunk
end
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "successful fetch populates the cache (second fetch is a :hit)" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, tiny_png())
end)
ts = ~U[2022-08-20 14:07:00Z]
rounded = NexradClient.round_to_5min(ts)
assert NexradCache.fetch(rounded) == :miss
{:ok, _pixels, _width} = NexradClient.fetch_decoded_frame(ts)
assert {:ok, _pixels2, _width2} = NexradCache.fetch(rounded)
end
end
describe "fetch_decoded_frame/1 empty body" do
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "zero-byte 200 response surfaces a PNG decode error" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, "")
end)
assert {:error, reason} = NexradClient.fetch_decoded_frame(~U[2022-08-20 14:05:00Z])
assert is_binary(reason)
assert reason =~ "PNG decode failed"
end
end
describe "frame_url/1 year boundaries" do
test "Dec 31 23:55 produces a URL anchored in the old year" do
dt = ~U[2022-12-31 23:55:00Z]
url = NexradClient.frame_url(dt)
assert url =~ "2022/12/31"
assert url =~ "n0q_202212312355.png"
refute url =~ "2023/"
end
test "Jan 1 00:00 of the following year flips cleanly into the new year" do
dt = ~U[2023-01-01 00:00:00Z]
url = NexradClient.frame_url(dt)
assert url =~ "2023/01/01"
assert url =~ "n0q_202301010000.png"
refute url =~ "2022/"
end
test "rounding 23:59 Dec 31 stays in Dec 31 (no rollover via round_to_5min)" do
dt = ~U[2022-12-31 23:59:59Z]
rounded = NexradClient.round_to_5min(dt)
assert rounded.year == 2022
assert rounded.month == 12
assert rounded.day == 31
assert rounded.hour == 23
assert rounded.minute == 55
url = NexradClient.frame_url(rounded)
assert url =~ "2022/12/31"
end
end
end