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.
219 lines
6.9 KiB
Elixir
219 lines
6.9 KiB
Elixir
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
|