feat(rover): show current pipeline step next to Calculate spinner

Compute.run now accepts a `progress` callback and reports human-readable
labels before each pipeline step (loading grid, looking up elevation,
checking road access, scoring cells, etc.). RoverLive feeds it via
send/2 to itself and renders the latest label as small text to the left
of the Calculate button while it's running.
This commit is contained in:
Graham McIntire 2026-04-26 12:02:39 -05:00
parent 88d785be50
commit 9f8c4d3b36
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 80 additions and 16 deletions

View file

@ -90,6 +90,7 @@ defmodule Microwaveprop.Rover.Compute do
Keyword.get(deps, :buildings_clutter_lookup, &default_building_clutter/1)
hilltop_snap = Keyword.get(deps, :hilltop_snap, &Hilltop.snap/1)
progress = Keyword.get(deps, :progress, fn _ -> :ok end)
%{
home: home,
@ -105,6 +106,7 @@ defmodule Microwaveprop.Rover.Compute do
radius_km = max_distance_km * 1.0
bbox = bbox_around(home, radius_km)
progress.("Loading propagation grid")
raw_cells = time_step("scores_at", fn -> scores_at.(band_mhz, valid_time, bbox) end)
in_radius =
@ -117,17 +119,26 @@ defmodule Microwaveprop.Rover.Compute do
)
points = Enum.map(in_radius, &{&1.lat, &1.lon})
progress.("Looking up elevation")
elev_map = time_step("elev_lookup", fn -> elev_lookup.(points) end)
progress.("Loading building footprints")
_ = time_step("buildings_load", fn -> BuildingsLoader.ensure_loaded_for_bbox(bbox) end)
progress.("Computing path clearance")
clearance_map = time_step("clearance", fn -> clearance_lookup.(in_radius, selected_stations) end)
progress.("Measuring terrain prominence")
prominence_map = time_step("prominence", fn -> prominence_lookup.(in_radius) end)
road_map =
if Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true),
do: time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end),
else: %{}
if Application.get_env(:microwaveprop, :rover_road_proximity_enabled, true) do
progress.("Checking road access")
time_step("road_proximity", fn -> fetch_road_map(road_lookup, in_radius, bbox) end)
else
%{}
end
progress.("Scanning building clutter")
clutter_map = time_step("building_clutter", fn -> buildings_clutter_lookup.(in_radius) end)
progress.("Scoring cells")
home_elev = home.elev_m || 0

View file

@ -66,6 +66,7 @@ defmodule MicrowavepropWeb.RoverLive do
station_form_error: nil,
home_form_error: nil,
scoring_loading: false,
scoring_step: nil,
url_loaded?: false
)}
end
@ -324,7 +325,12 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply,
socket
|> assign(top_candidates: cands, selected_candidate: nil, scoring_loading: false)
|> assign(
top_candidates: cands,
selected_candidate: nil,
scoring_loading: false,
scoring_step: nil
)
|> apply_scoring_warnings(warnings)
|> push_event("candidate_paths", %{candidate: nil, paths: []})
|> push_event("rover_results", %{
@ -338,10 +344,15 @@ defmodule MicrowavepropWeb.RoverLive do
{:noreply,
socket
|> assign(top_candidates: [], scoring_loading: false)
|> assign(top_candidates: [], scoring_loading: false, scoring_step: nil)
|> put_flash(:error, "Calculate failed: #{inspect(reason)}")}
end
@impl true
def handle_info({:rover_progress, label}, socket) do
{:noreply, assign(socket, scoring_step: label)}
end
defp apply_scoring_warnings(socket, []), do: clear_flash(socket)
defp apply_scoring_warnings(socket, warnings) do
@ -693,10 +704,12 @@ defmodule MicrowavepropWeb.RoverLive do
defp start_scoring(socket) do
args = scoring_args(socket)
pid = self()
progress = fn label -> send(pid, {:rover_progress, label}) end
socket
|> assign(scoring_loading: true)
|> start_async(:scoring, fn -> Compute.run(args) end)
|> assign(scoring_loading: true, scoring_step: "Starting…")
|> start_async(:scoring, fn -> Compute.run(args, progress: progress) end)
end
defp scoring_args(socket) do
@ -908,15 +921,23 @@ defmodule MicrowavepropWeb.RoverLive do
{band_label(@band)} · Forecast +{@forecast_hour}h
</span>
</div>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
<div class="flex items-center gap-3">
<span
:if={@scoring_loading and @scoring_step}
class="text-xs text-base-content/60 font-mono"
>
{@scoring_step}
</span>
<button
type="button"
phx-click="calculate"
class="btn btn-primary btn-sm"
disabled={@scoring_loading}
>
<span :if={@scoring_loading} class="loading loading-spinner loading-xs"></span>
{if @scoring_loading, do: "Calculating…", else: "Calculate"}
</button>
</div>
</header>
<div class="flex flex-1 min-h-0">

View file

@ -129,6 +129,38 @@ defmodule Microwaveprop.Rover.ComputeTest do
assert ref_score - tall_score >= 5.5
end
test "calls progress callback with each pipeline step label" do
home = %{lat: 33.0, lon: -96.0, elev_m: 200}
stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}]
grid = build_grid(home.lat, home.lon)
test_pid = self()
progress = fn label -> send(test_pid, {:progress, label}) end
Compute.run(
%{
home: home,
stations: stations,
band_mhz: 10_000,
valid_time: ~U[2026-04-25 12:00:00Z],
mode: :ssb,
max_distance_km: 65.0,
min_elev_gain: 0
},
scores_at: fn _, _, _ -> grid end,
elev_lookup: fn points -> Map.new(points, fn p -> {p, 250} end) end,
clearance_lookup: fn _, _ -> %{} end,
prominence_lookup: fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end,
road_lookup: fn _, _ -> {:error, :stubbed} end,
progress: progress
)
assert_received {:progress, "Loading propagation grid"}
assert_received {:progress, "Looking up elevation"}
assert_received {:progress, "Computing path clearance"}
assert_received {:progress, "Scoring cells"}
end
test "filters cells failing min_elev_gain" do
home = %{lat: 33.0, lon: -96.0, elev_m: 500}
stations = [%{callsign: "W5LUA", lat: 33.1, lon: -96.6, selected: true}]