defmodule MicrowavepropWeb.WeatherMapLive do @moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map." use MicrowavepropWeb, :live_view alias Microwaveprop.Weather @initial_bounds %{ "south" => 29.5, "north" => 36.3, "west" => -101.5, "east" => -92.5 } @layers [ # Surface %{ id: "temperature", label: "Temperature", unit: "°C", group: "Surface", desc: "2m air temperature. Warm, humid air increases refractivity and ducting potential at lower frequencies." }, %{ id: "dewpoint_depression", label: "Td Depression", unit: "°C", group: "Surface", desc: "Temperature minus dewpoint. Small values (< 5°C) mean moist boundary layer — favorable for ducting at 10 GHz, but increases absorption above 24 GHz." }, %{ id: "surface_rh", label: "Humidity", unit: "%", group: "Surface", desc: "Relative humidity from surface T and Td. High RH supports refractivity gradients that bend microwave signals." }, %{ id: "pwat", label: "PWAT", unit: "mm", group: "Surface", desc: "Precipitable water — total moisture in the atmospheric column. High values signal rain fade risk for 24 GHz and above." }, %{ id: "surface_refractivity", label: "Refractivity", unit: "N", group: "Surface", desc: "Radio refractivity (N-units) at the surface. Typical values 280–380. Higher N means the atmosphere bends signals more toward the ground." }, %{ id: "refractivity_gradient", label: "N-Gradient", unit: "N/km", group: "Surface", desc: "Minimum refractivity gradient in the profile. Standard is −40 N/km. Below −157 N/km signals are trapped in a duct. More negative = stronger ducting." }, %{ id: "bl_height", label: "BL Height", unit: "m", group: "Surface", desc: "Planetary boundary layer height. Shallow BL (< 500m) concentrates moisture and heat near the surface, favoring temperature inversions and ducting." }, # Upper Air %{ id: "temp_850mb", label: "T @ 850mb", unit: "°C", group: "Upper Air", desc: "Temperature at 850 mb (~1500m altitude). Warm 850mb air over cool surface air indicates a capping inversion — a classic ducting setup." }, %{ id: "dewpoint_850mb", label: "Td @ 850mb", unit: "°C", group: "Upper Air", desc: "Dewpoint at 850 mb. A sharp moisture drop between the surface and 850mb creates an elevated refractivity gradient that can form ducts." }, %{ id: "temp_700mb", label: "T @ 700mb", unit: "°C", group: "Upper Air", desc: "Temperature at 700 mb (~3000m altitude). The classic capping diagnostic over the southern Plains: ≥10°C indicates a moderate cap, ≥12°C strong. Combined with warm 850 mb, signals a 'loaded gun' setup that suppresses convection until forcing arrives." }, %{ id: "dewpoint_700mb", label: "Td @ 700mb", unit: "°C", group: "Upper Air", desc: "Dewpoint at 700 mb. Wide depressions (T-Td > 10°C) signal the elevated mixed layer (EML) plume — a hallmark cap mechanism off the Mexican plateau." }, %{ id: "lapse_rate", label: "Lapse Rate", unit: "°C/km", group: "Upper Air", desc: "Temperature decrease per km from surface to 700mb. Low rates (< 5 °C/km) mean stable air that preserves inversions. High rates (> 8) mean convective mixing that destroys them." }, %{ id: "mid_lapse_rate", label: "Mid Lapse 850-700", unit: "°C/km", group: "Upper Air", desc: "Lapse rate across the 850→700 mb layer. Steep values (≥7 °C/km) above a warm moist boundary layer indicate an elevated mixed layer — the textbook cap structure that holds back convection until the inversion is broken." }, %{ id: "inversion_strength", label: "Inversion", unit: "°C", group: "Upper Air", desc: "Strongest temperature increase between adjacent levels. Inversions trap microwave signals. > 3°C is significant, > 5°C is strong." }, %{ id: "inversion_base_m", label: "Inv. Base", unit: "m", group: "Upper Air", desc: "Height (m AGL) where the strongest inversion begins. Surface-based inversions (< 200m) create surface ducts. Elevated inversions create elevated ducts." }, # Ducting %{ id: "ducting", label: "Ducting", unit: "", group: "Ducting", desc: "Whether a trapping layer (modified refractivity decreasing with height) was detected in the profile. Green = duct present, signals can travel far beyond line of sight." }, %{ id: "duct_base_m", label: "Duct Base", unit: "m", group: "Ducting", desc: "Height of the lowest duct base. Surface ducts (< 100m) are most effective for ground-based stations. Elevated ducts require antennas near the duct height." }, %{ id: "duct_strength", label: "Duct Strength", unit: "M", group: "Ducting", desc: "Duct trapping strength in modified refractivity (M) units. > 10 M is moderate, > 20 M is strong. Stronger ducts trap a wider range of frequencies." } ] @default_layer "temperature" @valid_layer_ids Enum.map(@layers, & &1.id) @impl true def mount(_params, _session, socket) do _ = if connected?(socket) do :ok = Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "weather:updated") Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:pipeline") end # Default the timeline cursor to the valid_time closest to "now" so # users land on current conditions, not a +11h forecast hour (which # the underlying cache can advance to while the worker is walking # f00→f18). # # Mount stays cheap — no grid materialisation. The dead render # would otherwise spend ~5–10 s materialising 92k cells through # `WeatherLayers.derive`, blocking the HTTP response and tripping # the LiveView socket join timeout. The JS hook fires `map_bounds` # via `requestAnimationFrame` immediately after mount, and the # existing `handle_event("map_bounds", ...)` path drives the # initial layer paint via `update_weather`. valid_times = Weather.available_weather_valid_times() initial_vt = pick_initial_valid_time(valid_times) {:ok, assign(socket, page_title: "Weather Map", layers: @layers, selected_layer: @default_layer, initial_data_json: "[]", valid_time: initial_vt, valid_times: valid_times, initial_valid_times_json: Jason.encode!(Enum.map(valid_times, &DateTime.to_iso8601/1)), initial_selected_time: initial_vt && DateTime.to_iso8601(initial_vt), initial_utc_clock: Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"), bounds: @initial_bounds, grid_visible: false, radar_visible: false, weather_flush_timer: nil )} end @impl true def handle_params(params, _uri, socket) do selected = pick_layer(params["layer"], socket.assigns[:selected_layer]) {:noreply, assign(socket, :selected_layer, selected)} end defp pick_layer(layer, _current) when layer in @valid_layer_ids, do: layer defp pick_layer(_layer, current) when is_binary(current), do: current defp pick_layer(_layer, _current), do: @default_layer defp pick_initial_valid_time([]), do: nil defp pick_initial_valid_time(valid_times) do now = DateTime.utc_now() Enum.min_by(valid_times, fn vt -> abs(DateTime.diff(vt, now, :second)) end) end @impl true def handle_event("select_layer", %{"layer" => layer_id}, socket) do if layer_id in @valid_layer_ids do {:noreply, push_patch(socket, to: ~p"/weather?layer=#{layer_id}")} else {:noreply, socket} end end def handle_event("toggle_grid", _params, socket) do visible = !socket.assigns.grid_visible socket = socket |> assign(:grid_visible, visible) |> push_event("toggle_grid", %{visible: visible}) {:noreply, socket} end def handle_event("toggle_radar", _params, socket) do visible = !socket.assigns.radar_visible socket = socket |> assign(:radar_visible, visible) |> push_event("toggle_radar", %{visible: visible}) {:noreply, socket} end def handle_event("map_bounds", bounds, socket) do # Debounce the repaint. Cache miss on `weather_grid_at` runs a # derive pass over hrrr_profiles rows inside the viewport — full # CONUS is hundreds of KB of JSON and a non-trivial Ecto query. # Running it on every moveend burst during a wheel-zoom was # visibly laggy. 150ms matches /map's throttle. {:noreply, socket |> assign(:bounds, bounds) |> schedule_weather_flush()} end def handle_event("select_time", %{"time" => time_str}, socket) do with {:ok, time, _} <- DateTime.from_iso8601(time_str), true <- time in socket.assigns.valid_times do data = Weather.weather_grid_at(time, socket.assigns.bounds) socket = socket |> assign(:valid_time, time) |> push_event("update_weather", %{data: data}) {:noreply, socket} else _ -> {:noreply, socket} end end def handle_event("point_detail", %{"lat" => lat, "lon" => lon}, socket) do detail = if socket.assigns.valid_time do Weather.weather_point_detail(lat, lon, socket.assigns.valid_time) end payload = detail || %{} {:noreply, push_event(socket, "point_detail", payload)} end @impl true def handle_info(:flush_weather_bounds, socket) do data = case socket.assigns.valid_time do nil -> Weather.latest_weather_grid(socket.assigns.bounds) vt -> Weather.weather_grid_at(vt, socket.assigns.bounds) end {:noreply, socket |> assign(:weather_flush_timer, nil) |> push_event("update_weather", %{data: data})} end def handle_info({:weather_updated, _valid_time}, socket) do valid_times = Weather.available_weather_valid_times() # Keep whatever the user has scrubbed to if it's still available, # otherwise fall back to the latest on-disk time (GridCache-backed # hot path). Never block mount/refresh on a ProfilesFile read for # the happy path. selected = cond do socket.assigns.valid_time in valid_times -> socket.assigns.valid_time valid_times != [] -> List.last(valid_times) true -> nil end data = if selected, do: Weather.weather_grid_at(selected, socket.assigns.bounds), else: [] socket = socket |> assign(:valid_times, valid_times) |> assign(:valid_time, selected) |> push_event("update_weather", %{data: data}) |> push_event("update_timeline", %{ times: Enum.map(valid_times, &DateTime.to_iso8601/1), selected: selected && DateTime.to_iso8601(selected) }) {:noreply, socket} end # A new forecast hour has landed. Re-enumerate the available times # so the timeline picks up the extra hour, but keep the currently # selected time where it is. def handle_info({:propagation_pipeline_progress, _progress}, socket) do valid_times = Weather.available_weather_valid_times() if valid_times == socket.assigns.valid_times do {:noreply, socket} else socket = socket |> assign(:valid_times, valid_times) |> push_event("update_timeline", %{ times: Enum.map(valid_times, &DateTime.to_iso8601/1), selected: socket.assigns.valid_time && DateTime.to_iso8601(socket.assigns.valid_time) }) {:noreply, socket} end end @group_order ["Surface", "Upper Air", "Ducting"] @weather_flush_debounce_ms 150 defp schedule_weather_flush(%{assigns: %{weather_flush_timer: ref}} = socket) when is_reference(ref) do _ = Process.cancel_timer(ref) do_schedule_weather_flush(socket) end defp schedule_weather_flush(socket), do: do_schedule_weather_flush(socket) defp do_schedule_weather_flush(socket) do ref = Process.send_after(self(), :flush_weather_bounds, @weather_flush_debounce_ms) assign(socket, :weather_flush_timer, ref) end defp group_layers(layers) do layers |> Enum.group_by(& &1.group) |> Enum.sort_by(fn {group, _} -> Enum.find_index(@group_order, &(&1 == group)) || 99 end) end defp layer_description(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{desc: desc} -> desc _ -> nil end end defp selected_layer_label(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{label: label} -> label _ -> "" end end defp selected_layer_group_label(layers, selected_id) do case Enum.find(layers, &(&1.id == selected_id)) do %{group: group} -> group _ -> "" end end @impl true def render(assigns) do ~H"""