prop/lib/microwaveprop_web/live/map_live.ex
Graham McIntire ed67efb256
Add MRMS rain mosaic, fix beacons crash, fix UTC clock flash
MRMS
----
Layer the NOAA MRMS PrecipRate product onto the score grid so rain fade
updates every 2 minutes instead of every hour alongside HRRR. New modules:

- Microwaveprop.Weather.MrmsClient: fetches the latest .grib2.gz off the
  NCEP mirror (Req auto-decompresses so no gunzip step), writes the raw
  GRIB2 to a temp file, and calls the existing wgrib2 wrapper with the
  0.125 propagation grid spec to get interpolated cells. Returns a
  %{{lat, lon} => mm_per_hour} map with missing-value sentinels dropped.

- Microwaveprop.Weather.MrmsCache: ETS-backed GenServer mirroring
  ScoreCache/GridCache. Caches a single "current" entry keyed by
  valid_time with PubSub broadcast so peer nodes stay in sync and only
  the Oban leader pays the fetch + regrid cost.

- Microwaveprop.Workers.MrmsFetchWorker: cron every 2 minutes, short-
  circuits when the cached valid_time already matches the newest file.

Microwaveprop.Propagation.AsosNudge.compute/4 now takes an optional
rain_grid. When a cell has MRMS rain >= 0.1 mm/hr it gets patched onto
the HRRR profile's `precip_mm` field (the scorer already reads it there)
and the cell is re-scored even with no ASOS station nearby. Cells with
MRMS rain below the threshold aren't touched so dry cells keep their
raw HRRR scores (which have the wind/sky/native-gradient signal that
isn't persisted on HrrrProfile rows and would otherwise be lost).

AsosAdjustmentWorker pulls MrmsCache on every tick and passes the grid
through to AsosNudge.compute/4. Also skips the IemClient error branch
that can never happen and handles the ASOS-empty + MRMS-empty case
explicitly. MrmsCache wired into the supervision tree; MrmsFetchWorker
cron entry added to config.exs and dev.exs.

Four new AsosNudge cases cover MRMS-only re-scoring, threshold gating,
and the wet/dry score delta.

Beacons 500
-----------
Beacon.format_freq/1 and format_mw/1 crashed on whole-number floats
(e.g. 24192.0) because `frac == 0.0` could become false under float
rounding while `trim_trailing_zeros/1` stripped the decimal point,
leaving a 1-element list that couldn't be destructured as [_, frac].
Shared format_number/1 helper handles integer input directly and
pattern-matches both the "int-only" and "int + frac" shapes.

Added stream_data property tests covering the whole microwave range for
both integers and floats to catch this class of bug before prod.

UTC clock flash
---------------
The /weather and /map UTC clocks were empty until the JS hook mounted
post-WebSocket, producing a several-second blank spot on initial load
and a clobber risk on sidebar re-renders. Mount now computes a
server-rendered `initial_utc_clock` string and the template seeds the
element with that plus `phx-update="ignore"` so LiveView morphdom won't
overwrite what the hook writes.
2026-04-12 14:49:20 -05:00

666 lines
22 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.MapLive do
@moduledoc false
use MicrowavepropWeb, :live_view
use LiveStash
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Terrain.Viewshed
require Logger
@default_band 10_000
# Default map center when no visitor geolocation is available — DFW (EM12).
@default_center %{lat: 32.897, lon: -97.038}
@default_zoom 7
@impl true
def mount(_params, session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
end
socket = assign(socket, :initial_utc_clock, Calendar.strftime(DateTime.utc_now(), "%H:%M UTC"))
case LiveStash.recover_state(socket) do
{:recovered, socket} ->
{:ok, socket}
_ ->
bands = BandConfig.all_bands()
valid_times = Propagation.available_valid_times(@default_band)
selected_time = closest_to_now(valid_times)
center = initial_center(session)
bounds = bounds_around(center)
initial_scores = Propagation.scores_at(@default_band, selected_time, bounds)
# preload_forecast runs on the first map_bounds event instead of
# here — mount only knows a hardcoded fallback bounding box, so
# preloading now populates the forecast cache with scores for the
# wrong viewport and forecast-hour clicks show a small square of
# coverage instead of the full map.
{:ok,
assign(socket,
page_title: "Propagation Prediction Map",
bands: bands,
selected_band: @default_band,
initial_scores_json: Jason.encode!(initial_scores),
valid_times: valid_times,
selected_time: selected_time,
bounds: bounds,
initial_center: center,
initial_zoom: @default_zoom,
grid_visible: false,
antenna_height_ft: 33
)}
end
end
# Prefer the visitor's Cloudflare geolocation when present; fall back to DFW.
defp initial_center(%{"cf_lat" => lat, "cf_lon" => lon}) when is_float(lat) and is_float(lon) do
%{lat: lat, lon: lon}
end
defp initial_center(_session), do: @default_center
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
# around a given center so the initial HRRR score query still returns a
# useful tile set for the visible area.
defp bounds_around(%{lat: lat, lon: lon}) do
%{
"south" => lat - 3.4,
"north" => lat + 3.4,
"west" => lon - 4.5,
"east" => lon + 4.5
}
end
@impl true
def handle_event("select_band", %{"value" => band}, socket) do
band = if is_binary(band), do: String.to_integer(band), else: band
valid_times = Propagation.available_valid_times(band)
selected_time = closest_to_now(valid_times)
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
send(self(), :preload_forecast)
socket =
socket
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|> LiveStash.stash_assigns([:selected_band, :selected_time])
|> push_event("update_scores", %{scores: scores})
|> push_event("update_band_info", %{band_info: band_info(band)})
|> push_timeline()
{:noreply, socket}
end
def handle_event("select_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} ->
scores = Propagation.scores_at(socket.assigns.selected_band, time, socket.assigns.bounds)
socket =
socket
|> assign(:selected_time, time)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
# Fast path: the client already has the preloaded hour's scores in its
# local cache and only needs the server to remember which time is selected
# (used later for point_detail and forecast state on reconnect).
def handle_event("set_selected_time", %{"time" => time_str}, socket) do
case DateTime.from_iso8601(time_str) do
{:ok, time, _} -> {:noreply, assign(socket, :selected_time, time)}
_ -> {:noreply, socket}
end
end
def handle_event("set_antenna_height", %{"height_ft" => value}, socket) do
height =
case Integer.parse(value) do
{h, _} when h >= 0 and h <= 200 -> h
_ -> 8
end
{:noreply, assign(socket, :antenna_height_ft, height)}
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("point_detail", %{"lat" => lat, "lon" => lon}, socket) do
band = socket.assigns.selected_band
detail = Propagation.point_detail(band, lat, lon, socket.assigns.selected_time)
forecast = Propagation.point_forecast(band, lat, lon)
forecast_data =
Enum.map(forecast, fn f ->
%{time: DateTime.to_iso8601(f.valid_time), score: f.score}
end)
# Push the detail immediately with rain_scatter as :pending — NEXRAD
# fetching is slow on a cold 5-min window so we don't want to block the
# LiveView process. The :rain_scatter async task pushes its own event.
payload =
if detail,
do: detail |> Map.put(:forecast, forecast_data) |> Map.put(:rain_scatter, :pending),
else: %{}
socket =
socket
|> push_event("point_detail", payload)
|> start_async({:rain_scatter, lat, lon, band}, fn ->
fetch_rain_scatter(lat, lon, band)
end)
{:noreply, socket}
end
def handle_event("map_bounds", bounds, socket) do
scores = Propagation.scores_at(socket.assigns.selected_band, socket.assigns.selected_time, bounds)
# Refresh the forecast cache for the new viewport — the client cleared its
# local cache in sendBounds() so any timeline scrub would otherwise miss.
send(self(), :preload_forecast)
socket =
socket
|> assign(:bounds, bounds)
|> push_event("update_scores", %{scores: scores})
{:noreply, socket}
end
def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do
band_mhz = socket.assigns.selected_band
band_config = BandConfig.get(band_mhz)
freq_ghz = band_mhz / 1_000
ant_height_m = socket.assigns.antenna_height_ft * 0.3048
score =
case Propagation.point_detail(band_mhz, lat, lon) do
%{score: s} -> s
_ -> 50
end
max_range_km = score_range_km(score, band_config)
socket =
start_async(socket, :viewshed, fn ->
Viewshed.compute(lat, lon,
freq_ghz: freq_ghz,
ant_height_m: ant_height_m,
max_range_km: max_range_km,
score: score
)
end)
{:noreply, socket}
end
@impl true
def handle_info(:preload_forecast, socket) do
band = socket.assigns.selected_band
bounds = socket.assigns.bounds
selected = socket.assigns.selected_time
hours =
socket.assigns.valid_times
|> Enum.reject(&(selected && DateTime.compare(&1, selected) == :eq))
|> Enum.map(fn t ->
%{time: DateTime.to_iso8601(t), scores: Propagation.scores_at(band, t, bounds)}
end)
{:noreply, push_event(socket, "preload_forecast", %{hours: hours})}
end
def handle_info({:propagation_updated, _valid_times}, socket) do
band = socket.assigns.selected_band
valid_times = Propagation.available_valid_times(band)
# Stay on the same selected time if still available, else pick earliest
selected_time =
if socket.assigns.selected_time in valid_times do
socket.assigns.selected_time
else
closest_to_now(valid_times)
end
scores = Propagation.scores_at(band, selected_time, socket.assigns.bounds)
send(self(), :preload_forecast)
socket =
socket
|> assign(valid_times: valid_times, selected_time: selected_time)
|> push_event("update_scores", %{scores: scores})
|> push_timeline()
{:noreply, socket}
end
@impl true
def handle_async(:viewshed, {:ok, result}, socket) do
points = Enum.map(result.boundary, fn p -> %{lat: p.lat, lon: p.lon} end)
Logger.info("Viewshed complete: #{length(points)} boundary points")
socket =
push_event(socket, "viewshed_result", %{
origin: result.origin,
points: points
})
{:noreply, socket}
end
def handle_async(:viewshed, {:exit, reason}, socket) do
Logger.warning("Viewshed computation failed: #{inspect(reason)}")
{:noreply, socket}
end
def handle_async({:rain_scatter, lat, lon, _band}, {:ok, scatter}, socket) do
{:noreply, push_event(socket, "rain_scatter_update", %{lat: lat, lon: lon, rain_scatter: scatter})}
end
def handle_async({:rain_scatter, _lat, _lon, _band}, {:exit, reason}, socket) do
Logger.warning("Rain scatter fetch failed: #{inspect(reason)}")
{:noreply, socket}
end
defp push_timeline(socket) do
times =
Enum.map(socket.assigns.valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
selected =
if socket.assigns.selected_time,
do: DateTime.to_iso8601(socket.assigns.selected_time)
push_event(socket, "update_timeline", %{times: times, selected: selected})
end
defp format_time_label(dt) do
# Show as "HH:MM" in UTC
Calendar.strftime(dt, "%H:%M")
end
defp closest_to_now([]), do: nil
defp closest_to_now(times) do
now = DateTime.utc_now()
Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end)
end
defp score_range_km(score, config) do
cond do
score >= 80 -> config.exceptional_range_km
score >= 65 -> config.extended_range_km
score >= 50 -> config.typical_range_km
score >= 33 -> round(config.typical_range_km * 0.6)
true -> round(config.typical_range_km * 0.3)
end
end
defp fetch_rain_scatter(lat, lon, band_mhz) do
alias Microwaveprop.Propagation.RainScatter
alias Microwaveprop.Weather.NexradClient
freq_ghz = band_mhz / 1000.0
case NexradClient.fetch_rain_cells(lat, lon) do
{:ok, rain_cells} ->
cells = RainScatter.find_scatter_cells(rain_cells, lat, lon, freq_ghz)
classification = RainScatter.classify(cells)
%{cells: cells, classification: to_string(classification)}
{:error, _} ->
%{cells: [], classification: "none"}
end
end
defp band_info(band_mhz) do
config = BandConfig.get(band_mhz)
%{
band_label: config.label,
typical_range_km: config.typical_range_km,
extended_range_km: config.extended_range_km,
exceptional_range_km: config.exceptional_range_km,
humidity_effect: to_string(config.humidity_effect),
freq_mhz: config.freq_mhz
}
end
defp selected_label(bands, selected_band) do
case Enum.find(bands, &(&1.freq_mhz == selected_band)) do
nil -> "Select Band"
band -> band.label
end
end
@impl true
def render(assigns) do
~H"""
<div class="flex w-screen h-screen overflow-hidden">
<%!-- Map container --%>
<div class="relative flex-1 min-w-0">
<%!-- Full-page map --%>
<div
id="propagation-map"
phx-hook="PropagationMap"
phx-update="ignore"
data-scores={@initial_scores_json}
data-band-info={Jason.encode!(band_info(@selected_band))}
data-valid-times={
Jason.encode!(
Enum.map(@valid_times, fn t ->
%{time: DateTime.to_iso8601(t), label: format_time_label(t)}
end)
)
}
data-selected-time={if @selected_time, do: DateTime.to_iso8601(@selected_time), else: ""}
data-center-lat={@initial_center.lat}
data-center-lon={@initial_center.lon}
data-zoom={@initial_zoom}
class="absolute inset-0 z-0"
>
</div>
<%!-- Mobile-only floating controls --%>
<div
id="map-controls"
class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]"
>
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="utc-clock"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="mobile-panel-toggle"
class="btn btn-xs btn-square btn-ghost"
onclick="document.getElementById('panel-extras').classList.toggle('hidden')"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
</div>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1"
>
<li :for={band <- @bands}>
<button
onclick="document.activeElement.blur()"
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<div id="panel-extras" class="hidden flex-col gap-2">
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
<.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
Path Calculator
</.link>
<.link navigate="/beacons" class="btn btn-xs btn-ghost justify-start">
Beacons
</.link>
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
Weather Map
</.link>
<.link navigate="/algo" class="btn btn-xs btn-ghost justify-start">
Scoring Algorithm
</.link>
<.link navigate="/submit" class="btn btn-xs btn-ghost justify-start">
Submit a Contact
</.link>
<.link navigate="/contacts" class="btn btn-xs btn-ghost justify-start">
Contact Training Data
</.link>
<.link navigate="/contacts/map" class="btn btn-xs btn-ghost justify-start">
Contact Map
</.link>
<.link navigate="/about" class="btn btn-xs btn-ghost justify-start">
About
</.link>
</div>
</div>
</div>
</div>
<%!-- Point detail floating panel --%>
<div
id="detail-panel"
phx-update="ignore"
class={[
"bg-neutral text-neutral-content shadow-lg border border-base-300 overflow-hidden overflow-y-auto z-[1001]",
"fixed bottom-0 left-0 right-0 max-h-[50vh] rounded-t-2xl",
"md:absolute md:top-auto md:right-auto md:bottom-14 md:left-3 md:max-h-[70vh] md:w-80 md:rounded-box"
]}
style="display:none;"
>
</div>
<%!-- Bottom timeline bar --%>
<div
id="forecast-timeline"
phx-update="ignore"
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1000] max-w-[calc(100vw-1rem)] md:bottom-4"
>
</div>
<%!-- Sidebar expand button (visible when sidebar is collapsed) --%>
<button
id="sidebar-expand"
class="hidden md:hidden absolute top-3 right-3 z-[1000] btn btn-sm btn-neutral shadow-lg"
onclick="document.getElementById('sidebar').style.display='flex';this.style.display='none';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-bars-3" class="size-4" />
</button>
</div>
<%!-- Desktop right sidebar --%>
<div
id="sidebar"
data-theme="dark"
class="hidden md:flex flex-col w-56 shrink-0 h-full bg-neutral text-neutral-content border-l border-base-300 overflow-hidden"
>
<%!-- Sidebar controls --%>
<div class="p-3 flex flex-col gap-2 shrink-0">
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>
<div class="font-normal text-xs opacity-70">Propagation Prediction Map</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
id="utc-clock-desktop"
phx-hook="UtcClock"
phx-update="ignore"
class="font-mono text-xs opacity-70 tabular-nums"
>
{@initial_utc_clock}
</div>
<button
id="sidebar-collapse"
class="btn btn-xs btn-square btn-ghost"
title="Collapse sidebar"
onclick="document.getElementById('sidebar').style.display='none';document.getElementById('sidebar-expand').style.display='flex';window.dispatchEvent(new Event('sidebar-toggle'));"
>
<.icon name="hero-chevron-right" class="size-4" />
</button>
</div>
</div>
<%!-- Band selector --%>
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-sm w-full justify-between">
{selected_label(@bands, @selected_band)}
<.icon name="hero-chevron-down" class="size-3" />
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-44 mt-1 p-1 z-10"
>
<li :for={band <- @bands}>
<button
onclick="document.activeElement.blur()"
phx-click={
JS.dispatch("show-loading", to: "#propagation-map")
|> JS.push("select_band", value: %{value: band.freq_mhz})
}
class={[if(@selected_band == band.freq_mhz, do: "active")]}
>
{band.label}
</button>
</li>
</ul>
</div>
<%!-- Grid toggle --%>
<label class="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
class="toggle toggle-sm toggle-primary"
checked={@grid_visible}
phx-click="toggle_grid"
/>
<span class="text-sm">Grid squares</span>
</label>
<%!-- Antenna height --%>
<form phx-change="set_antenna_height" class="flex items-center gap-2 px-1">
<label class="text-sm whitespace-nowrap">Antenna height</label>
<input
type="number"
name="height_ft"
min="0"
max="200"
step="1"
value={@antenna_height_ft}
class="input input-xs w-16 text-right"
/>
<span class="text-xs opacity-70">ft</span>
</form>
<%!-- Navigation --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<li>
<.link navigate="/path">Path Calculator</.link>
</li>
<li>
<.link navigate="/beacons">Beacons</.link>
</li>
<li>
<.link navigate="/weather">Weather Map</.link>
</li>
<li>
<.link navigate="/algo">Scoring Algorithm</.link>
</li>
<li>
<.link navigate="/submit">Submit a Contact</.link>
</li>
<li>
<.link navigate="/contacts">Contact Training Data</.link>
</li>
<li>
<.link navigate="/contacts/map">Contact Map</.link>
</li>
<li>
<.link navigate="/about">About</.link>
</li>
</ul>
<%!-- Auth --%>
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
<%= if @current_scope && @current_scope.user do %>
<li class="menu-title text-xs opacity-70">{@current_scope.user.callsign}</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<Layouts.flash_group flash={@flash} />
"""
end
end