prop/test/microwaveprop_web/controllers/weather_tile_controller_test.exs
Graham McIntire af8b787915
perf(weather): binary cell-pack + canvas overlay + lazy layer prefetch
Three changes that shrink first-paint and make layer toggles instant:

1. Server: /weather/cells now supports ?format=bin and ?layers=a,b,c. The
   binary "WCEL" pack is ~3× smaller than JSON (Float32 columns, no key
   overhead) and parses with DataView + typed-array views — essentially
   memcpy on the client.

2. Client: replaced JSON parse + per-cell SVG <rect> with a custom
   Leaflet canvas layer. The pack is columnar (lats/lons + Float32Array
   per layer) and each draw is one fillRect per cell against a
   precomputed 256-step color LUT. At low zoom (CONUS, ~80k cells) SVG
   paint cost dominated; canvas keeps it under one frame.

3. Lazy prefetch: the initial fetch only requests the *current* layer
   (~1/19 the bytes). Right after paint, a single background fetch
   pulls every other layer into the same pack. Layer switches that land
   after the prefetch finishes are pure recolor — no network, no
   server CPU.

The /weather/tiles endpoint stays for backward compat. The JSON path of
/weather/cells stays too — the binary path is opt-in via ?format=bin.
2026-04-29 13:41:21 -05:00

202 lines
5.7 KiB
Elixir

defmodule MicrowavepropWeb.WeatherTileControllerTest do
use MicrowavepropWeb.ConnCase, async: false
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Weather.GridCache
setup do
GridCache.clear()
on_exit(fn ->
GridCache.clear()
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
end)
:ok
end
test "serves an SVG weather tile for the requested viewport", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
lat = 33.0
lon = -97.0
ProfilesFile.write!(valid_time, %{
{lat, lon} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: []
}
})
conn =
get(
conn,
~p"/weather/tiles/0/0/0?layer=temperature&time=#{DateTime.to_iso8601(valid_time)}"
)
assert response(conn, 200) =~ "<svg"
assert response(conn, 200) =~ "<rect"
assert ["image/svg+xml; charset=utf-8"] = get_resp_header(conn, "content-type")
end
test "returns an empty svg for invalid params", %{conn: conn} do
conn = get(conn, ~p"/weather/tiles/6/18/25?layer=bogus&time=not-a-time")
body = response(conn, 200)
assert body =~ "<svg"
refute body =~ "<rect"
end
describe "GET /weather/cells" do
test "returns viewport cells as JSON", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
ProfilesFile.write!(valid_time, %{
{33.0, -97.0} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: []
},
{33.125, -97.0} => %{
surface_temp_c: 23.0,
surface_dewpoint_c: 13.0,
surface_pressure_mb: 1011.0,
hpbl_m: 700.0,
pwat_mm: 26.0,
profile: []
}
})
time = DateTime.to_iso8601(valid_time)
conn =
get(
conn,
~p"/weather/cells?time=#{time}&south=32.5&north=33.5&west=-97.5&east=-96.5"
)
assert ["application/json; charset=utf-8"] = get_resp_header(conn, "content-type")
body = json_response(conn, 200)
assert is_list(body["cells"])
assert length(body["cells"]) == 2
assert body["valid_time"] == time
first = hd(body["cells"])
assert is_number(first["lat"])
assert is_number(first["lon"])
assert Map.has_key?(first, "temperature")
assert Map.has_key?(first, "dewpoint_depression")
end
test "returns 400 for missing or invalid params", %{conn: conn} do
conn = get(conn, ~p"/weather/cells?time=not-a-time")
assert json_response(conn, 400)
end
test "returns empty cells when no data exists for the valid_time", %{conn: conn} do
valid_time = ~U[2099-04-28 12:00:00Z]
time = DateTime.to_iso8601(valid_time)
conn =
get(
conn,
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0"
)
body = json_response(conn, 200)
assert body["cells"] == []
end
test "with ?layers=foo,bar only includes those fields per cell", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
ProfilesFile.write!(valid_time, %{
{33.0, -97.0} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: []
}
})
time = DateTime.to_iso8601(valid_time)
conn =
get(
conn,
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat"
)
body = json_response(conn, 200)
[cell | _] = body["cells"]
assert Map.has_key?(cell, "lat")
assert Map.has_key?(cell, "lon")
assert Map.has_key?(cell, "temperature")
assert Map.has_key?(cell, "pwat")
refute Map.has_key?(cell, "dewpoint_depression")
refute Map.has_key?(cell, "surface_rh")
end
test "with ?format=bin returns the binary cell pack", %{conn: conn} do
valid_time = ~U[2026-04-28 12:00:00Z]
ProfilesFile.write!(valid_time, %{
{33.0, -97.0} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: []
},
{33.125, -97.0} => %{
surface_temp_c: 23.0,
surface_dewpoint_c: 13.0,
surface_pressure_mb: 1011.0,
hpbl_m: 700.0,
pwat_mm: 26.0,
profile: []
}
})
time = DateTime.to_iso8601(valid_time)
conn =
get(
conn,
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat&format=bin"
)
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
body = response(conn, 200)
# Magic + version + cell count + layer count + 2 layer names + lats + lons + 2 fields
assert <<"WCEL", 1::8, cell_count::little-32, 2::8, rest::binary>> = body
assert cell_count == 2
# Two layer names: "temperature" (11), "pwat" (4)
<<11::8, "temperature", 4::8, "pwat", padded_rest::binary>> = rest
# The header so far is: 4(magic) + 1 + 4 + 1 + (1+11) + (1+4) = 27 bytes.
# Pad to 4-byte boundary: 1 byte of zero padding → 28 bytes.
<<0::8, lats_and_more::binary>> = padded_rest
<<lat1::little-float-32, lat2::little-float-32, rest_after_lats::binary>> =
lats_and_more
assert_in_delta lat1, 33.0, 0.001
assert_in_delta lat2, 33.125, 0.001
<<_lon1::little-float-32, _lon2::little-float-32, _rest::binary>> = rest_after_lats
end
end
end