The /path calculator showed "0 / 9 HRRR points" in production despite
the on-disk profile store being current. Root cause: profile_from_cell/2
treated the cell's :profile key as a wrapper sub-map and called
Map.put_new on it — but the :profile key actually holds the vertical
pressure-level LIST. Every point sample crashed with BadMapError, the
crash propagated as {:exit, _} through Task.async_stream, and the
consumer silently dropped all 9 results.
Fix: stop wrapping. Cells are already flat HrrrProfile-shaped maps;
just stamp lat/lon (from the caller, since cells don't carry their
own coords — those are the map key) and valid_time onto the cell.
Audit + log every other async error path so the next silent failure
isn't invisible:
- PathLive HRRR point lookup
- Propagation.point_forecast per-hour reads
- Viewshed ray crashes
- IemClient ASOS network fetches
- RtmaClient range-download tasks
- Recalibrator factor-vector batches (positive + negative samples)
- MapLive forecast preload tasks
- RoverLive station resolution
LiveViews already had handle_async/3 exit clauses with logging. The
gap was always in Task.async_stream consumers that wrote {:exit, _} -> []
without surfacing the reason.
Add the rule to CLAUDE.md and project memory so this never repeats.
Also fix a pre-existing skewt_svg.ex compiler warning where
@critical_label_min_dy was used before being defined.
266 lines
7.9 KiB
Elixir
266 lines
7.9 KiB
Elixir
defmodule MicrowavepropWeb.RoverLive do
|
|
@moduledoc """
|
|
Rover planner: enter stationary stations you want to work, select a band,
|
|
and the map shows the HRRR propagation heatmap with your stations plotted.
|
|
"""
|
|
use MicrowavepropWeb, :live_view
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Radio.CallsignClient
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
|
|
require Logger
|
|
|
|
@band_options BandConfig.band_options()
|
|
|
|
@default_band 10_000
|
|
@initial_bounds %{
|
|
"south" => 29.5,
|
|
"north" => 36.3,
|
|
"west" => -101.5,
|
|
"east" => -92.5
|
|
}
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
_ =
|
|
if connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
|
end
|
|
|
|
initial_scores = Propagation.latest_scores(@default_band, @initial_bounds)
|
|
|
|
{:ok,
|
|
assign(socket,
|
|
page_title: "Rover Planner",
|
|
band_options: @band_options,
|
|
band: @default_band,
|
|
stations: [],
|
|
station_input: "",
|
|
resolving: false,
|
|
url_loaded: false,
|
|
initial_scores_json: Jason.encode!(initial_scores),
|
|
bounds: @initial_bounds
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
if socket.assigns.url_loaded do
|
|
{:noreply, socket}
|
|
else
|
|
band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band
|
|
|
|
socket = assign(socket, band: band, url_loaded: true)
|
|
|
|
# Load stations from URL: ?stations=W5ISP,K5TR,EM00cd
|
|
case params["stations"] do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
stations_str ->
|
|
calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
|
|
send(self(), {:resolve_station_list, calls})
|
|
{:noreply, assign(socket, resolving: true)}
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Events ──
|
|
|
|
@impl true
|
|
def handle_event("add_station", %{"callsign" => input}, socket) do
|
|
input = String.trim(input)
|
|
|
|
if input == "" do
|
|
{:noreply, socket}
|
|
else
|
|
send(self(), {:resolve_station, input})
|
|
{:noreply, assign(socket, resolving: true, station_input: "")}
|
|
end
|
|
end
|
|
|
|
def handle_event("remove_station", %{"index" => idx_str}, socket) do
|
|
idx = String.to_integer(idx_str)
|
|
stations = List.delete_at(socket.assigns.stations, idx)
|
|
socket = assign(socket, stations: stations)
|
|
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
|
|
{:noreply, push_url(socket)}
|
|
end
|
|
|
|
def handle_event("select_band", %{"band" => band_str}, socket) do
|
|
band = String.to_integer(band_str)
|
|
scores = Propagation.latest_scores(band, socket.assigns.bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(band: band)
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|
|
{:noreply, push_url(socket)}
|
|
end
|
|
|
|
def handle_event("map_bounds", bounds, socket) do
|
|
scores = Propagation.latest_scores(socket.assigns.band, bounds)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:bounds, bounds)
|
|
|> push_event("update_scores", %{scores: scores})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# ── Async ──
|
|
|
|
def handle_info({:resolve_station_list, calls}, socket) do
|
|
stations =
|
|
calls
|
|
|> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000)
|
|
|> Enum.zip(calls)
|
|
|> Enum.flat_map(fn
|
|
{{:ok, {:ok, s}}, _call} ->
|
|
[s]
|
|
|
|
{{:ok, {:error, reason}}, call} ->
|
|
Logger.warning("RoverLive station resolve failed: call=#{inspect(call)} reason=#{inspect(reason)}")
|
|
[]
|
|
|
|
{{:exit, reason}, call} ->
|
|
Logger.error("RoverLive station resolve crashed: call=#{inspect(call)} reason=#{inspect(reason)}")
|
|
[]
|
|
end)
|
|
|
|
socket =
|
|
socket
|
|
|> assign(stations: stations, resolving: false)
|
|
|> push_event("stations_updated", %{stations: station_data(stations)})
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:resolve_station, input}, socket) do
|
|
case resolve_station(input) do
|
|
{:ok, station} ->
|
|
stations = socket.assigns.stations ++ [station]
|
|
socket = assign(socket, stations: stations, resolving: false)
|
|
socket = push_event(socket, "stations_updated", %{stations: station_data(stations)})
|
|
{:noreply, push_url(socket)}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")}
|
|
end
|
|
end
|
|
|
|
def handle_info({:propagation_updated, _valid_times}, socket) do
|
|
scores = Propagation.latest_scores(socket.assigns.band, socket.assigns.bounds)
|
|
{:noreply, push_event(socket, "update_scores", %{scores: scores})}
|
|
end
|
|
|
|
# ── Helpers ──
|
|
|
|
defp resolve_station(input) do
|
|
input = String.trim(input)
|
|
|
|
if Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input) and String.length(input) >= 4 do
|
|
case Maidenhead.to_latlon(input) do
|
|
{:ok, {lat, lon}} ->
|
|
{:ok, %{label: String.upcase(input), lat: lat, lon: lon, type: :grid}}
|
|
|
|
:error ->
|
|
{:error, "invalid grid"}
|
|
end
|
|
else
|
|
case CallsignClient.locate(input) do
|
|
{:ok, info} ->
|
|
{:ok, %{label: String.upcase(info.callsign), lat: info.lat, lon: info.lon, type: :callsign}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp push_url(socket) do
|
|
labels = Enum.map_join(socket.assigns.stations, ",", & &1.label)
|
|
|
|
params =
|
|
then(%{"band" => to_string(socket.assigns.band)}, fn p ->
|
|
if labels == "", do: p, else: Map.put(p, "stations", labels)
|
|
end)
|
|
|
|
push_patch(socket, to: ~p"/rover?#{params}", replace: true)
|
|
end
|
|
|
|
defp station_data(stations) do
|
|
Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end)
|
|
end
|
|
|
|
# ── Render ──
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="relative w-screen h-screen overflow-hidden">
|
|
<div
|
|
id="rover-map"
|
|
phx-hook="RoverMap"
|
|
phx-update="ignore"
|
|
data-band={@band}
|
|
data-scores={@initial_scores_json}
|
|
class="absolute inset-0 z-0"
|
|
>
|
|
</div>
|
|
|
|
<%!-- Sidebar --%>
|
|
<div class="absolute top-2 left-2 z-[1000] w-80 max-h-[calc(100vh-1rem)] overflow-y-auto bg-base-100/95 shadow-lg rounded-box border border-base-300 p-3 flex flex-col gap-3">
|
|
<div class="font-bold text-sm">Rover Planner</div>
|
|
|
|
<select phx-change="select_band" name="band" class="select select-bordered select-sm w-full">
|
|
<%= for {label, value} <- @band_options do %>
|
|
<option value={value} selected={value == to_string(@band)}>{label}</option>
|
|
<% end %>
|
|
</select>
|
|
|
|
<form phx-submit="add_station" class="flex gap-1">
|
|
<input
|
|
type="text"
|
|
name="callsign"
|
|
value={@station_input}
|
|
placeholder="Add station (call or grid)"
|
|
class="input input-bordered input-sm flex-1"
|
|
/>
|
|
<button type="submit" class="btn btn-sm btn-primary" disabled={@resolving}>
|
|
<%= if @resolving do %>
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
<% else %>
|
|
Add
|
|
<% end %>
|
|
</button>
|
|
</form>
|
|
|
|
<%= if @stations != [] do %>
|
|
<div class="text-xs opacity-60">Stationary Stations ({length(@stations)})</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<%= for {station, idx} <- Enum.with_index(@stations) do %>
|
|
<div class="badge badge-outline gap-1">
|
|
{station.label}
|
|
<button
|
|
type="button"
|
|
phx-click="remove_station"
|
|
phx-value-index={idx}
|
|
class="cursor-pointer opacity-50 hover:opacity-100"
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|