Two regressions from the recent performance commits: 1. ScoresController.encode_binary/1 (9cce257d): Enum.reduce produces iolists, but <<lats::binary>> requires actual binaries. Caused HTTP 500 on every /scores/cells request when score files exist on disk, making the entire propagation heatmap overlay invisible. 2. ScoreCache.valid_times/1 (15f4175c): Pipe syntax feeds @table as the first argument to :ets.foldl/3, creating a 4-argument call that crashes at runtime.
122 lines
4.1 KiB
Elixir
122 lines
4.1 KiB
Elixir
defmodule MicrowavepropWeb.ScoresController do
|
||
@moduledoc """
|
||
HTTP endpoint serving propagation score grids in a compact binary
|
||
cell-pack format. Replaces the old `update_scores` LiveView push for
|
||
the /map overlay so the client can fetch directly (cacheable, doesn't
|
||
block the LiveView channel) and decode via DataView + typed-array
|
||
views without a JSON parse.
|
||
|
||
Rate-limited per IP and bounds-capped to the propagation grid extent
|
||
so a malicious caller cannot trigger global / oversized binary
|
||
responses.
|
||
"""
|
||
use MicrowavepropWeb, :controller
|
||
|
||
alias Microwaveprop.Propagation
|
||
alias MicrowavepropWeb.Api.RateLimiter
|
||
alias MicrowavepropWeb.GridBounds
|
||
|
||
plug RateLimiter, anon_limit: 120, auth_limit: 600
|
||
|
||
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||
def cells(conn, %{"band" => band_param} = params) do
|
||
with {band_mhz, ""} <- Integer.parse(band_param),
|
||
{:ok, valid_time} <- parse_optional_time(params["time"]),
|
||
{:ok, bounds} <- parse_bounds(params),
|
||
{:ok, bounds} <- GridBounds.clamp(bounds) do
|
||
scores =
|
||
if valid_time do
|
||
Propagation.scores_at(band_mhz, valid_time, bounds)
|
||
else
|
||
Propagation.latest_scores(band_mhz, bounds)
|
||
end
|
||
|
||
conn
|
||
|> put_resp_header("content-type", "application/octet-stream")
|
||
|> put_resp_header("cache-control", "public, max-age=60")
|
||
|> send_resp(200, encode_binary(scores))
|
||
else
|
||
{:error, :viewport_too_large} -> payload_too_large(conn)
|
||
_ -> bad_request(conn)
|
||
end
|
||
end
|
||
|
||
def cells(conn, _params), do: bad_request(conn)
|
||
|
||
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_optional_time(nil), do: {:ok, nil}
|
||
|
||
defp parse_optional_time(value) when is_binary(value) do
|
||
case DateTime.from_iso8601(value) do
|
||
{:ok, dt, _offset} -> {:ok, dt}
|
||
_ -> :error
|
||
end
|
||
end
|
||
|
||
defp parse_optional_time(_), do: :error
|
||
|
||
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: {:ok, nil}
|
||
|
||
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
|
||
|
||
# Binary cell-pack format. Each entry is a 0-100 score at a (lat, lon).
|
||
# Layout chosen so the f32 lat/lon arrays land on 4-byte boundaries
|
||
# for typed-array decoding in JS — DataView reads tolerate
|
||
# misalignment but Float32Array views over an ArrayBuffer don't.
|
||
#
|
||
# "PSCR" (4 bytes) — magic
|
||
# u8 — version (1)
|
||
# u8 × 3 — reserved padding (zero)
|
||
# u32 LE — cell count
|
||
# f32 LE × cell_count — latitudes
|
||
# f32 LE × cell_count — longitudes
|
||
# u8 × cell_count — scores (0..100)
|
||
defp encode_binary(scores) do
|
||
cell_count = length(scores)
|
||
|
||
header = <<"PSCR", 1::8, 0::8, 0::8, 0::8, cell_count::little-32>>
|
||
|
||
{lats, lons, score_bytes} =
|
||
Enum.reduce(scores, {[], [], []}, fn s, {lats_acc, lons_acc, scs_acc} ->
|
||
{[<<s.lat * 1.0::little-float-32>> | lats_acc],
|
||
[<<s.lon * 1.0::little-float-32>> | lons_acc],
|
||
[<<clamp_score(s.score)::8>> | scs_acc]}
|
||
end)
|
||
|
||
<<header::binary, IO.iodata_to_binary(lats)::binary,
|
||
IO.iodata_to_binary(lons)::binary, IO.iodata_to_binary(score_bytes)::binary>>
|
||
end
|
||
|
||
defp clamp_score(s) when is_integer(s) and s < 0, do: 0
|
||
defp clamp_score(s) when is_integer(s) and s > 100, do: 100
|
||
defp clamp_score(s) when is_integer(s), do: s
|
||
defp clamp_score(s) when is_float(s), do: s |> round() |> clamp_score()
|
||
defp clamp_score(_), do: 0
|
||
end
|