prop/test/microwaveprop/weather/nexrad_client_test.exs
Graham McIntire bbf6481603
perf(nexrad): route fetch_decoded_frame through NexradCache
Telemetry puts nexrad_decode_png at 1.76 s/call × 37,847 calls/h
— ~18.5 h of CPU across the cluster every real hour, the single
biggest CPU consumer in the system. The map-click path
(fetch_rain_cells) already cached decoded frames by 5-min
bucket, but the per-contact CommonVolumeRadarWorker path
(fetch_decoded_frame) went straight to network + decode on every
call. Backfill means many contacts share a 5-min window, so the
same 66 MB frame was being decoded dozens of times.

Wire fetch_decoded_frame through NexradCache keyed on the
rounded timestamp. Add a 20-entry size cap in NexradCache so
backfill processing contacts in random timestamp order can't
grow the ETS table to hundreds of GB. Each frame is ~66 MB, so
20 = ~1.3 GB worst case, well under typical pod memory.

Expected impact: cuts sustained decode load by an order of
magnitude depending on backfill temporal locality; map-click
path is unchanged.
2026-04-19 09:05:55 -05:00

219 lines
6.9 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
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
end
end