prop/lib/microwaveprop_web/controllers/weather_tile_controller.ex
Graham McIntire e3d430f8c4
feat(weather): add duct cutoff band map layer
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
2026-05-15 19:51:24 -05:00

158 lines
5.3 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
{: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 -> <<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