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: "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: "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." } ] @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). # # Hot-path mount: when the closest-to-now time happens to be the # cached latest, use `latest_weather_grid/1` so mount stays under # the LV socket join timeout (~10s). A ProfilesFile read for any # other hour is a single ~2 MB ETF decode, also fine for mount. valid_times = Weather.available_weather_valid_times() latest_vt = Weather.latest_grid_valid_time() initial_vt = pick_initial_valid_time(valid_times) data = cond do is_nil(initial_vt) -> [] initial_vt == latest_vt -> Weather.latest_weather_grid(@initial_bounds) true -> Weather.weather_grid_at(initial_vt, @initial_bounds) end {:ok, assign(socket, page_title: "Weather Map", layers: @layers, selected_layer: "refractivity_gradient", initial_data_json: Jason.encode!(data), 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 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 {:noreply, assign(socket, :selected_layer, layer_id)} 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"""