prop/lib/microwaveprop/weather/tile_renderer.ex
Graham McIntire a14f14485e
perf(weather): serve /weather from chunked scalar artifacts
Move the /weather render path off raw HRRR profile decoding + per-cell
SoundingParams + WeatherLayers derivation. The dominant cost was
re-deriving the same scalar fields on every viewport pan and timeline
scrub.

Server side:

- New Microwaveprop.Weather.ScalarFile persists pre-derived scalar
  rows on NFS, bucketed into 5°×5° chunk files under
  <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport
  reads only decode the chunks that overlap the requested bounds;
  point-detail clicks read exactly one chunk.
- weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile;
  ProfilesFile is now the cold-start fallback only.
- warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0
  also persist the scalar artifact, and the cold-derive path kicks
  off a per-valid_time-locked async materialization so the next
  reader gets the cheap path.
- NotifyListener.handle_propagation_ready/1 fires
  Weather.materialize_scalar_file/1 in a detached Task on the Rust
  pipeline's NOTIFY propagation_ready, so steady-state forecast hours
  arrive with their scalar artifact already on disk.
- available_weather_valid_times/0 unions ScalarFile + ProfilesFile so
  the timeline survives an aggressive retention sweep on either side.

Browser side (finding #8):

- Mount the legend Leaflet control once and patch its inner content
  on layer changes instead of remove + re-add on every renderLayer.
- renderTimeline now keys off the rendered button set: selection-only
  updates take an applyTimelineSelection patch path that restyles
  existing buttons in place. Full innerHTML rebuild only fires when
  timelineData itself changes.

Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting
interpolate_pair/2 from the inner anonymous function.
2026-04-28 17:15:36 -05:00

290 lines
7.7 KiB
Elixir

defmodule Microwaveprop.Weather.TileRenderer do
@moduledoc false
alias Microwaveprop.Weather
@tile_size 256
@grid_step 0.125
@half_step @grid_step / 2.0
@overlay_alpha 0.55
@continuous_scales %{
"refractivity_gradient" => [
{-500, {0, 255, 163}},
{-300, {125, 255, 212}},
{-200, {255, 229, 102}},
{-100, {255, 144, 68}},
{0, {255, 79, 79}}
],
"bl_height" => [
{0, {0, 255, 163}},
{500, {125, 255, 212}},
{1000, {255, 229, 102}},
{2000, {255, 144, 68}},
{3000, {255, 79, 79}}
],
"dewpoint_depression" => [
{0, {0, 255, 163}},
{5, {125, 255, 212}},
{10, {255, 229, 102}},
{20, {255, 144, 68}},
{30, {255, 79, 79}}
],
"pwat" => [
{0, {139, 90, 43}},
{15, {194, 165, 116}},
{30, {173, 216, 230}},
{45, {65, 105, 225}},
{60, {0, 0, 180}}
],
"temperature" => [
{-20, {0, 0, 200}},
{0, {100, 149, 237}},
{15, {255, 255, 150}},
{30, {255, 140, 0}},
{45, {200, 0, 0}}
],
"surface_rh" => [
{0, {255, 144, 68}},
{30, {255, 229, 102}},
{60, {125, 255, 212}},
{80, {0, 255, 163}},
{100, {0, 200, 130}}
],
"surface_refractivity" => [
{250, {255, 79, 79}},
{300, {255, 144, 68}},
{330, {255, 229, 102}},
{360, {125, 255, 212}},
{400, {0, 255, 163}}
],
"temp_850mb" => [
{-20, {0, 0, 200}},
{-5, {100, 149, 237}},
{5, {255, 255, 150}},
{15, {255, 140, 0}},
{30, {200, 0, 0}}
],
"dewpoint_850mb" => [
{-20, {139, 90, 43}},
{-5, {194, 165, 116}},
{5, {173, 216, 230}},
{15, {65, 105, 225}},
{25, {0, 0, 180}}
],
"temp_700mb" => [
{-10, {0, 100, 200}},
{0, {100, 180, 230}},
{5, {200, 230, 200}},
{8, {255, 229, 102}},
{10, {255, 144, 68}},
{14, {200, 0, 0}}
],
"dewpoint_700mb" => [
{-30, {139, 90, 43}},
{-15, {194, 165, 116}},
{-5, {173, 216, 230}},
{5, {65, 105, 225}},
{15, {0, 0, 180}}
],
"lapse_rate" => [
{3, {0, 255, 163}},
{5, {125, 255, 212}},
{6.5, {255, 229, 102}},
{8, {255, 144, 68}},
{9.5, {255, 79, 79}}
],
"mid_lapse_rate" => [
{3, {0, 255, 163}},
{5, {125, 255, 212}},
{6.5, {255, 229, 102}},
{7.5, {255, 144, 68}},
{9, {255, 79, 79}}
],
"inversion_strength" => [
{0, {255, 229, 102}},
{1, {255, 200, 80}},
{3, {255, 144, 68}},
{5, {255, 79, 79}},
{8, {180, 0, 0}}
],
"inversion_base_m" => [
{0, {0, 255, 163}},
{100, {125, 255, 212}},
{300, {255, 229, 102}},
{800, {255, 144, 68}},
{1500, {255, 79, 79}}
],
"duct_base_m" => [
{0, {0, 255, 163}},
{100, {125, 255, 212}},
{300, {255, 229, 102}},
{800, {255, 144, 68}},
{1500, {255, 79, 79}}
],
"duct_strength" => [
{0, {125, 255, 212}},
{5, {255, 229, 102}},
{10, {255, 144, 68}},
{20, {255, 79, 79}},
{30, {180, 0, 0}}
],
"surface_pressure_mb" => [
{980, {255, 79, 79}},
{1000, {255, 144, 68}},
{1015, {255, 229, 102}},
{1030, {125, 255, 212}},
{1045, {0, 255, 163}}
]
}
@boolean_scales %{
"ducting" => {0, 255, 163}
}
@layer_fields Map.new(Map.keys(@continuous_scales) ++ Map.keys(@boolean_scales), &{&1, String.to_atom(&1)})
@spec render_svg(DateTime.t(), String.t(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
binary()
def render_svg(%DateTime{} = valid_time, layer_id, z, x, y) do
bounds =
z
|> tile_bounds(x, y)
|> pad_bounds(@half_step)
rows = Weather.weather_grid_at(valid_time, bounds)
IO.iodata_to_binary([
~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{@tile_size} #{@tile_size}" shape-rendering="crispEdges">),
render_rows(rows, layer_id, z, x, y),
"</svg>"
])
end
@spec empty_svg() :: binary()
def empty_svg do
~s(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 #{@tile_size} #{@tile_size}"></svg>)
end
defp render_rows(rows, layer_id, z, x, y) do
Enum.map(rows, fn row -> render_row(row, layer_id, z, x, y) end)
end
defp render_row(row, layer_id, z, x, y) do
with {:ok, {fill, alpha}} <- color_for_layer(row, layer_id),
{:ok, {px, py, width, height}} <- rect_for_row(row, z, x, y) do
~s(<rect x="#{fmt(px)}" y="#{fmt(py)}" width="#{fmt(width)}" height="#{fmt(height)}" fill="#{fill}" fill-opacity="#{alpha}" />)
else
_ -> []
end
end
defp color_for_layer(row, layer_id) do
field = Map.get(@layer_fields, layer_id)
case Map.fetch(@boolean_scales, layer_id) do
{:ok, {r, g, b}} ->
if row[field] do
{:ok, {"rgb(#{r},#{g},#{b})", fmt(@overlay_alpha)}}
else
:skip
end
:error ->
with value when is_number(value) <- row[field],
scale when is_list(scale) <- Map.get(@continuous_scales, layer_id),
{r, g, b} <- interpolate(value, scale) do
{:ok, {"rgb(#{r},#{g},#{b})", fmt(@overlay_alpha)}}
else
_ -> :skip
end
end
end
defp interpolate(value, [{min_value, min_color} | _]) when value <= min_value, do: min_color
defp interpolate(value, scale) do
max_value = scale |> List.last() |> elem(0)
if value >= max_value do
scale |> List.last() |> elem(1)
else
scale
|> Enum.chunk_every(2, 1, :discard)
|> Enum.find_value(&interpolate_pair(&1, value))
end
end
defp interpolate_pair([{v1, c1}, {v2, c2}], value) when value >= v1 and value <= v2 do
t = (value - v1) / (v2 - v1)
{lerp(elem(c1, 0), elem(c2, 0), t), lerp(elem(c1, 1), elem(c2, 1), t), lerp(elem(c1, 2), elem(c2, 2), t)}
end
defp interpolate_pair(_pair, _value), do: nil
defp lerp(a, b, t), do: round(a + (b - a) * t)
defp rect_for_row(%{lat: lat, lon: lon}, z, tile_x, tile_y) do
x1 = world_x(lon - @half_step, z) - tile_x * @tile_size
x2 = world_x(lon + @half_step, z) - tile_x * @tile_size
y1 = world_y(lat + @half_step, z) - tile_y * @tile_size
y2 = world_y(lat - @half_step, z) - tile_y * @tile_size
left = max(min(x1, x2), 0.0)
right = min(max(x1, x2), @tile_size * 1.0)
top = max(min(y1, y2), 0.0)
bottom = min(max(y1, y2), @tile_size * 1.0)
width = right - left
height = bottom - top
if width <= 0.0 or height <= 0.0 do
:skip
else
{:ok, {left, top, width, height}}
end
end
defp tile_bounds(z, x, y) do
%{
"west" => tile_to_lon(x, z),
"east" => tile_to_lon(x + 1, z),
"north" => tile_to_lat(y, z),
"south" => tile_to_lat(y + 1, z)
}
end
defp pad_bounds(%{"west" => west, "east" => east, "north" => north, "south" => south}, pad) do
%{
"west" => west - pad,
"east" => east + pad,
"north" => min(north + pad, 85.0511),
"south" => max(south - pad, -85.0511)
}
end
defp tile_to_lon(x, z), do: x / :math.pow(2, z) * 360.0 - 180.0
defp tile_to_lat(y, z) do
n = :math.pi() - 2.0 * :math.pi() * y / :math.pow(2, z)
:math.atan(:math.sinh(n)) * 180.0 / :math.pi()
end
defp world_x(lon, z) do
(lon + 180.0) / 360.0 * @tile_size * :math.pow(2, z)
end
defp world_y(lat, z) do
lat =
lat
|> min(85.0511)
|> max(-85.0511)
lat_rad = lat * :math.pi() / 180.0
(1.0 - :math.log(:math.tan(lat_rad) + 1.0 / :math.cos(lat_rad)) / :math.pi()) / 2.0 *
@tile_size * :math.pow(2, z)
end
defp fmt(value) when is_integer(value), do: Integer.to_string(value)
defp fmt(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 2)
end