prop/lib/microwaveprop_web/controllers/weather_tile_controller.ex
Graham McIntire ac3a2517d9
feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.

Also drops "— Unified" from the algo.md H1.
2026-04-30 12:08:48 -05:00

146 lines
4.9 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
# 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
)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, 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
_ -> 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 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