prop/lib/microwaveprop_web/controllers/weather_tile_controller.ex
Graham McIntire 7c37bf67fe
refactor(web): drop the unreachable viewport_too_large guard
Raising the area cap to 4600 put it above the largest area clamp/1 can
emit (45° lat × 100° lon = 4500 sq deg), so :viewport_too_large became
unreachable and the 413 handlers in both cell controllers became dead
code.

Remove the cap, the error branch, the max_viewport_area_sq_deg/0
accessor and both payload_too_large/1 handlers. The clamp is what
bounds request cost: post-clamp nothing can ask for more than the full
grid (~92k cells, ~1.4 MB), which is materialized once per valid_time
and served from cache, so a global viewport costs no more than a fully
zoomed-out map.

Replaces the cap test with one asserting the clamp never lets absurd
input escape the supported extent — the property the guard was
standing in for.
2026-08-05 17:51:55 -05:00

151 lines
5.1 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 MicrowavepropWeb.WeatherTileController do
use MicrowavepropWeb, :controller
alias Microwaveprop.Weather
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.GridBounds
plug RateLimiter, anon_limit: 120, auth_limit: 600
# Layer fields the binary cell-pack can carry. Used both as the
# whitelist for ?layers=... and as the default if the param is absent.
@cell_fields ~w(
temperature dewpoint_depression surface_rh surface_pressure_mb
surface_refractivity refractivity_gradient bl_height pwat
temp_850mb dewpoint_850mb temp_700mb dewpoint_700mb
lapse_rate mid_lapse_rate inversion_strength inversion_base_m
ducting duct_base_m duct_strength duct_cutoff_ghz
)a
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
def cells(conn, %{"time" => time_param} = params) do
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
{:ok, bounds} <- parse_bounds(params),
{:ok, bounds} <- GridBounds.clamp(bounds),
{:ok, layers} <- parse_layers(params["layers"]) do
rows = read_rows(valid_time, bounds, params["source"])
conn
|> put_resp_header("content-type", "application/octet-stream")
|> put_resp_header("cache-control", "public, max-age=60")
|> send_resp(200, encode_binary(rows, layers))
else
_ -> bad_request(conn)
end
end
def cells(conn, _params), do: bad_request(conn)
defp read_rows(valid_time, bounds, "hrdps"), do: Weather.weather_grid_hrdps_at(valid_time, bounds)
defp read_rows(valid_time, bounds, _other), do: Weather.weather_grid_at(valid_time, bounds)
defp bad_request(conn) do
conn
|> put_status(400)
|> json(%{error: "invalid params"})
end
defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
with {:ok, south} <- parse_float(s),
{:ok, north} <- parse_float(n),
{:ok, west} <- parse_float(w),
{:ok, east} <- parse_float(e) do
{:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
end
end
defp parse_bounds(_), do: :error
defp parse_float(value) when is_binary(value) do
case Float.parse(value) do
{f, ""} -> {:ok, f}
_ -> :error
end
end
defp parse_float(_), do: :error
defp parse_layers(nil), do: {:ok, @cell_fields}
defp parse_layers(value) when is_binary(value) do
fields =
value
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.map(&safe_atom/1)
cond do
Enum.any?(fields, &is_nil/1) -> :error
Enum.empty?(fields) -> {:ok, @cell_fields}
true -> {:ok, fields}
end
end
defp parse_layers(_), do: :error
defp safe_atom(name) when is_binary(name) do
if name in Enum.map(@cell_fields, &Atom.to_string/1) do
String.to_existing_atom(name)
end
end
# Custom binary cell-pack format. Designed for cheap parsing in JS via
# DataView + Float32Array (no library, no JSON parse). The header is
# padded so all f32 arrays land on 4-byte boundaries — DataView reads
# tolerate misalignment but typed-array views over an ArrayBuffer
# don't.
#
# "WCEL" (4 bytes) — magic
# u8 — version (1)
# u32 LE — cell count
# u8 — layer count
# for each layer:
# u8 — name length
# u8 × name_length — name (UTF-8)
# u8 × N — zero padding to next 4-byte boundary
# f32 LE × cell_count — latitudes
# f32 LE × cell_count — longitudes
# for each layer:
# f32 LE × cell_count — values (NaN = null; booleans 0.0/1.0)
defp encode_binary(rows, layers) do
cell_count = length(rows)
layer_names = Enum.map(layers, &Atom.to_string/1)
header =
<<"WCEL", 1::8, cell_count::little-32, length(layer_names)::8>> <>
Enum.map_join(layer_names, fn n -> <<byte_size(n)::8>> <> n end)
pad_size = padding_to_align(byte_size(header), 4)
header_padded = header <> :binary.copy(<<0>>, pad_size)
lats = rows |> Enum.map(&encode_f32(&1.lat)) |> IO.iodata_to_binary()
lons = rows |> Enum.map(&encode_f32(&1.lon)) |> IO.iodata_to_binary()
values =
layers
|> Enum.map(fn field ->
Enum.map(rows, fn row -> encode_value_f32(Map.get(row, field)) end)
end)
|> IO.iodata_to_binary()
<<header_padded::binary, lats::binary, lons::binary, values::binary>>
end
defp padding_to_align(size, align) do
rem = rem(size, align)
if rem == 0, do: 0, else: align - rem
end
# f32 quiet NaN bit pattern in little-endian — used for null values so
# the JS side can check Number.isNaN on a Float32Array entry.
@f32_nan <<0::8, 0::8, 0xC0::8, 0x7F::8>>
defp encode_value_f32(nil), do: @f32_nan
defp encode_value_f32(true), do: <<1.0::little-float-32>>
defp encode_value_f32(false), do: <<0.0::little-float-32>>
defp encode_value_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_value_f32(_), do: @f32_nan
defp encode_f32(v) when is_number(v), do: <<v * 1.0::little-float-32>>
defp encode_f32(_), do: @f32_nan
end