prop/lib/microwaveprop_web/controllers/scores_controller.ex
Graham McIntire d67186176f
fix: resolve all dialyzer type errors across project (46→0 project errors)
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
2026-06-08 17:51:13 -05:00

121 lines
4.1 KiB
Elixir
Raw Permalink 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