prop/lib/microwaveprop_web/controllers/scores_controller.ex
Graham McIntire ec1529fa6b
fix: propagation overlay broken by iolist→binary crash in encode_binary
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.
2026-06-02 10:44:09 -05:00

122 lines
4.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.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