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 {:error, :viewport_too_large} -> payload_too_large(conn) _ -> 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 payload_too_large(conn) do conn |> put_status(413) |> json(%{error: "viewport_too_large", detail: "Requested bounds exceed the maximum supported viewport area."}) 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 -> <> <> 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() <> 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: <> defp encode_value_f32(_), do: @f32_nan defp encode_f32(v) when is_number(v), do: <> defp encode_f32(_), do: @f32_nan end