Type spec fixes: - duct_usable_* return boolean→float (delegate to duct_usable_for_band) - sanitize/1 spec includes :unicode error tuples - match_delete/1 broadened from :ets.match_spec() - telemetry_event local type replaces :telemetry.event/0 - wgrib2 parse_lon_val_segment corrected to tuple spec - preloaded Ecto assoc types in get_mission/get_contact! specs Unmatched returns: - _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe, :ets.new, and if-expression returns across 18 files Pattern match fixes: - markdown: restructure acc!=[] guard as direct pattern match - path_compute/pskr/skewt_location_resolver: remove dead clauses - calibrate.aprs_144: remove unreachable format_float catch-all - unused.ex: suppress MapSet.union no_opaque Also: remove unused unicode_util_compat from mix.lock
121 lines
4.1 KiB
Elixir
121 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
|