prop/test/microwaveprop/weather/nexrad_client_test.exs
Graham McIntire 764643bc62
test: expand coverage and refactor to idiomatic style
Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
  + `if/else` with a classifier that pattern-matches the error reason
  directly, returning `:transient | :permanent`, and dispatch via
  `case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
  step on the row/col comprehensions so an image box whose centre
  lands outside the raster collapses to an empty iteration instead
  of firing Elixir 1.19's ambiguous-range warning. Adds a regression
  test for the right-edge box.

Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
  duct_thickness, best_duct_freq, duct_usable_for_band,
  distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
  unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
  tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
  handling; parse_snmpget_output OID normalisation and junk-line
  tolerance.
- HrrrNativeClient native_level_count, native_variables,
  native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
  SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
  Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
  monotonic in RH, finite across the operating envelope) + idempotent
  nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
  output; latlon_to_pixel round-trip for every grid cell.

Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
  current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.

Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
2026-04-23 13:28:42 -05:00

286 lines
9.3 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 "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
end