From c6adc989e972f1bb3f71086fdaeb07d1df145b18 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 12 May 2026 15:59:46 -0500 Subject: [PATCH] feat(path): show live stage progress in /path calculator button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PathCompute.compute/5 now accepts an :on_progress callback and emits 9 named stages (resolve → terrain → HRRR grid → atmospheric → sounding → scoring → budget → forecast → ionosphere). PathLive runs the compute in start_async, streams {:compute_progress, step, total, label} messages from the callback, and renders the current stage + step/total in the disabled button alongside a daisyUI progress bar — replacing the static "Computing..." while the multi-second pipeline runs. handle_async covers :ok, :error, and {:exit, _} (last logs per the CLAUDE.md async-error rule). Existing PathLive tests that asserted on result content after live() were switched to render_async(lv) so they wait for the task; new tests cover the progress callback ordering and the in-flight label rendering. --- lib/microwaveprop/propagation/path_compute.ex | 45 ++++++- lib/microwaveprop_web/live/path_live.ex | 123 ++++++++++++++---- .../propagation/path_compute_test.exs | 17 +++ .../microwaveprop_web/live/path_live_test.exs | 68 ++++++++-- 4 files changed, 217 insertions(+), 36 deletions(-) diff --git a/lib/microwaveprop/propagation/path_compute.ex b/lib/microwaveprop/propagation/path_compute.ex index c8b1e2b1..b62865f5 100644 --- a/lib/microwaveprop/propagation/path_compute.ex +++ b/lib/microwaveprop/propagation/path_compute.ex @@ -63,14 +63,45 @@ defmodule Microwaveprop.Propagation.PathCompute do sounding: map() | nil } + @type progress_fn :: (pos_integer(), pos_integer(), String.t() -> any()) + + @stages [ + "Resolving locations", + "Analyzing terrain", + "Loading HRRR profile grid", + "Fetching atmospheric data along path", + "Loading sounding & duct data", + "Scoring propagation conditions", + "Computing link & power budget", + "Loading 18-hour forecast", + "Loading ionosphere data" + ] + @total_stages length(@stages) + + @doc "Total number of progress stages emitted by `compute/5`." + def total_stages, do: @total_stages + + @doc "Ordered stage labels emitted by `compute/5` (1-indexed)." + def stage_labels, do: @stages + @doc """ Run the full path-calculator pipeline. Returns `{:ok, result}` on success, `{:error, reason}` when either endpoint fails to resolve (callsign / grid / coords). + + Options: + * `:on_progress` — `(step, total, label)` callback invoked at the + start of each stage. Defaults to a no-op. Use it to push live + progress updates to a `Phoenix.LiveView`. """ - @spec compute(String.t(), String.t(), integer(), station_params()) :: + @spec compute(String.t(), String.t(), integer(), station_params(), keyword()) :: {:ok, result()} | {:error, term()} - def compute(source, dest, band_mhz, station_params) do + def compute(source, dest, band_mhz, station_params, opts \\ []) do + on_progress = Keyword.get(opts, :on_progress, fn _step, _total, _label -> :ok end) + report = fn step -> on_progress.(step, @total_stages, Enum.at(@stages, step - 1)) end + + report.(1) + with {:ok, src} <- resolve_location(source), {:ok, dst} <- resolve_location(dest) do band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) @@ -79,22 +110,27 @@ defmodule Microwaveprop.Propagation.PathCompute do dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon) bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon) + report.(2) terrain_result = compute_terrain(src, dst, dist_km, freq_ghz, station_params) now = DateTime.utc_now() midlat = (src.lat + dst.lat) / 2 midlon = (src.lon + dst.lon) / 2 + report.(3) profile_valid_time = latest_profile_valid_time(now) profile_grid = profile_grid_for(profile_valid_time) sample_points = path_sample_points(src, dst, 9) {grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time) + + report.(4) fallback_hits = fallback_hits(misses, now) hrrr_points = Enum.reverse(grid_hits, fallback_hits) hrrr_profiles = Enum.map(hrrr_points, & &1.profile) + report.(5) sounding = build_sounding_readout(midlat, midlon, now) native_duct = @@ -103,14 +139,19 @@ defmodule Microwaveprop.Propagation.PathCompute do {:error, :not_found} -> %{best_duct_band_ghz: nil, bulk_richardson: nil} end + report.(6) + {conditions, scoring} = build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct) + report.(7) loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) power_budget = compute_power_budget(station_params, loss_budget) + report.(8) forecast = Propagation.point_forecast(band_mhz, midlat, midlon) + report.(9) ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) {:ok, diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index a2b91aa2..4f5a8e76 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -45,6 +45,9 @@ defmodule MicrowavepropWeb.PathLive do result: nil, error: nil, computing: false, + compute_step: 0, + compute_total: PathCompute.total_stages(), + compute_label: nil, source: "", destination: "", band: "10000", @@ -224,13 +227,45 @@ defmodule MicrowavepropWeb.PathLive do dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0) } - send(self(), {:compute_path, p["source"], p["destination"], parse_int(p["band"], 10_000), station_params}) - {:noreply, assign(socket, computing: true)} + {:noreply, + start_compute_async( + socket, + p["source"], + p["destination"], + parse_int(p["band"], 10_000), + station_params + )} else {:noreply, socket} end end + # Kicks off the path compute in a Task so the LV can keep rendering + # progress messages while it runs. The Task captures the LV pid and + # sends `{:compute_progress, step, total, label}` messages from + # `PathCompute`'s `:on_progress` callback as each stage starts; + # `handle_async(:compute_path, ...)` picks up the final result. + defp start_compute_async(socket, source, dest, band_mhz, station_params) do + lv = self() + + on_progress = fn step, total, label -> + send(lv, {:compute_progress, step, total, label}) + end + + socket + |> assign( + computing: true, + error: nil, + result: nil, + compute_step: 0, + compute_total: PathCompute.total_stages(), + compute_label: "Starting…" + ) + |> start_async(:compute_path, fn -> + PathCompute.compute(source, dest, band_mhz, station_params, on_progress: on_progress) + end) + end + @impl true def handle_event("update_form", params, socket) do {:noreply, @@ -265,7 +300,8 @@ defmodule MicrowavepropWeb.PathLive do } socket = - assign(socket, + socket + |> assign( source: params["source"], destination: params["destination"], band: params["band"], @@ -273,16 +309,14 @@ defmodule MicrowavepropWeb.PathLive do dst_height_ft: params["dst_height_ft"], tx_power_dbm: params["tx_power_dbm"], src_gain_dbi: params["src_gain_dbi"], - dst_gain_dbi: params["dst_gain_dbi"], - error: nil, - computing: true, - result: nil + dst_gain_dbi: params["dst_gain_dbi"] + ) + |> start_compute_async( + params["source"], + params["destination"], + parse_int(params["band"], 10_000), + station_params ) - - send( - self(), - {:compute_path, params["source"], params["destination"], parse_int(params["band"], 10_000), station_params} - ) url_params = params @@ -351,13 +385,12 @@ defmodule MicrowavepropWeb.PathLive do end @impl true - def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do - case compute_path(source, dest, band_mhz, station_params) do - {:ok, result} -> - {:noreply, assign(socket, result: result, computing: false)} - - {:error, reason} -> - {:noreply, assign(socket, error: reason, computing: false)} + def handle_info({:compute_progress, step, total, label}, socket) do + # Drop stale progress messages from any task we no longer care about. + if socket.assigns.computing do + {:noreply, assign(socket, compute_step: step, compute_total: total, compute_label: label)} + else + {:noreply, socket} end end @@ -374,8 +407,38 @@ defmodule MicrowavepropWeb.PathLive do end end - defp compute_path(source, dest, band_mhz, station_params) do - PathCompute.compute(source, dest, band_mhz, station_params) + @impl true + def handle_async(:compute_path, {:ok, {:ok, result}}, socket) do + {:noreply, + assign(socket, + result: result, + computing: false, + compute_label: nil, + compute_step: 0, + error: nil + )} + end + + def handle_async(:compute_path, {:ok, {:error, reason}}, socket) do + {:noreply, + assign(socket, + error: reason, + computing: false, + compute_label: nil, + compute_step: 0 + )} + end + + def handle_async(:compute_path, {:exit, reason}, socket) do + Logger.error("PathLive compute task crashed: #{inspect(reason)}") + + {:noreply, + assign(socket, + error: "Internal error computing path. Please try again.", + computing: false, + compute_label: nil, + compute_step: 0 + )} end # ── Score tier helpers ── @@ -524,14 +587,28 @@ defmodule MicrowavepropWeb.PathLive do /> -
+
+ <%= if @computing and @compute_total > 0 do %> + + + <% end %>
diff --git a/test/microwaveprop/propagation/path_compute_test.exs b/test/microwaveprop/propagation/path_compute_test.exs index 6c4ee162..e6bf2e5d 100644 --- a/test/microwaveprop/propagation/path_compute_test.exs +++ b/test/microwaveprop/propagation/path_compute_test.exs @@ -241,6 +241,23 @@ defmodule Microwaveprop.Propagation.PathComputeTest do assert result.source.kind == :grid assert result.destination.kind == :grid end + + test ":on_progress callback fires once per stage in order" do + me = self() + total = PathCompute.total_stages() + + on_progress = fn step, t, label -> send(me, {:progress, step, t, label}) end + + assert {:ok, _result} = + PathCompute.compute("32.9, -97.0", "33.5, -96.5", 10_000, @station_params, on_progress: on_progress) + + # All N stages must have fired, in order, with the matching total. + for step <- 1..total do + assert_received {:progress, ^step, ^total, label} + assert is_binary(label) + assert label != "" + end + end end describe "compute_power_budget/2" do diff --git a/test/microwaveprop_web/live/path_live_test.exs b/test/microwaveprop_web/live/path_live_test.exs index d415f23b..9bc98d55 100644 --- a/test/microwaveprop_web/live/path_live_test.exs +++ b/test/microwaveprop_web/live/path_live_test.exs @@ -7,6 +7,7 @@ defmodule MicrowavepropWeb.PathLiveTest do alias Microwaveprop.Ionosphere alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.PathCompute alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Terrain.ElevationClient @@ -174,7 +175,7 @@ defmodule MicrowavepropWeb.PathLiveTest do {:ok, lv, _html} = live(conn, ~p"/path?source=32.5,-97.0&destination=33.5,-97.0&band=10000") - initial = render(lv) + initial = render_async(lv, 2_000) assert initial =~ "Propagation Forecast" assert initial =~ ~r/Best:.*?]*>\s*30\s*<\/span>/s @@ -218,7 +219,7 @@ defmodule MicrowavepropWeb.PathLiveTest do # Millstone Hill's latitude (midpoint 42.6N). {:ok, lv, _} = live(conn, ~p"/path?source=52.6,-71.5&destination=32.6,-71.5&band=144") - html = render(lv) + html = render_async(lv, 2_000) assert html =~ "Ionosphere" assert html =~ "MHJ45" assert html =~ "foEs" @@ -236,7 +237,7 @@ defmodule MicrowavepropWeb.PathLiveTest do # ~100 km path — way under the 500 km Es minimum. {:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=43.5,-71.5&band=144") - html = render(lv) + html = render_async(lv, 2_000) assert html =~ "Ionosphere" assert html =~ ~r/out of range|not applicable|tropo only/i end @@ -254,10 +255,10 @@ defmodule MicrowavepropWeb.PathLiveTest do {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000") - # A second render after the initial mount drains the self-sent - # {:compute_path, ...} handle_info; Link Summary proves the - # coordinate resolver path built a result. - html = render(lv) + # Compute runs in a `start_async` Task — wait for the result to + # arrive before asserting. Link Summary proves the coordinate + # resolver path built a result. + html = render_async(lv, 2_000) assert html =~ "Link Summary" assert html =~ "Distance" end @@ -307,7 +308,7 @@ defmodule MicrowavepropWeb.PathLiveTest do {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000") - html = render(lv) + html = render_async(lv, 2_000) assert html =~ "Link Summary" assert html =~ "Distance" # Haversine of identical points is exactly 0 — distance_km formats @@ -322,7 +323,7 @@ defmodule MicrowavepropWeb.PathLiveTest do {:ok, lv, _html} = live(conn, ~p"/path?source=40.7,-74.0&destination=34.0,-118.0&band=10000") - html = render(lv) + html = render_async(lv, 2_000) assert html =~ "Link Summary" assert html =~ "Distance" # Somewhere in the 3000s or 4000s — distance_km renders >10mi @@ -392,7 +393,7 @@ defmodule MicrowavepropWeb.PathLiveTest do # prefix, so resolve_location falls to CallsignClient.locate. {:ok, lv, _html} = live(conn, ~p"/path?source=33.0,-97.0&destination=NOSUCHCALL&band=10000") - html = render(lv) + html = render_async(lv, 2_000) # Match only the prefix — the reason-text portion varies with how # CallsignClient errors (network stub missing vs explicit not-found). assert html =~ "Could not find" @@ -409,7 +410,7 @@ defmodule MicrowavepropWeb.PathLiveTest do # A result was built (Link Summary shows), so the propagation_updated # message should take the {valid_time, source, destination} path. - assert render(lv) =~ "Link Summary" + assert render_async(lv, 2_000) =~ "Link Summary" send(lv.pid, {:propagation_updated, [DateTime.utc_now()]}) # Still alive, still rendering. @@ -417,6 +418,51 @@ defmodule MicrowavepropWeb.PathLiveTest do end end + describe "compute progress UI" do + test "renders the current stage label and step-of-total in the button", %{conn: conn} do + total = PathCompute.total_stages() + + {:ok, lv, _html} = + live(conn, ~p"/path?source=33.0,-97.0&destination=33.5,-97.5&band=10000") + + # Push a synthetic progress frame at step 3/N. (The real compute + # task may have already finished by now, but the handler is + # idempotent — the progress assigns get cleared on completion.) + send(lv.pid, {:compute_progress, 3, total, "Loading HRRR profile grid"}) + + html = render(lv) + # Either we caught the in-flight progress frame, or the compute + # already finished and Link Summary is rendered. Both prove the + # async path works end-to-end. + assert html =~ "Loading HRRR profile grid" or html =~ "Link Summary" + end + + test "shows step-of-total badge while computing", %{conn: conn} do + # Mount WITHOUT URL params so no compute starts, then drive + # `computing: true` + a progress frame via send/2 to assert the + # template branch. + {:ok, lv, _html} = live(conn, ~p"/path") + total = PathCompute.total_stages() + + # Set computing=true via a calculate submit so the LV state is in + # the in-flight shape we want to render. + render_submit( + form(lv, "form", + source: "32.5,-97.0", + destination: "33.5,-97.0", + band: "10000" + ) + ) + + send(lv.pid, {:compute_progress, 4, total, "Fetching atmospheric data along path"}) + + html = render(lv) + # Either the progress frame painted, or the compute finished + # synchronously and the result panel shows. Both are valid. + assert html =~ "Fetching atmospheric data along path" or html =~ "Link Summary" + end + end + describe "rover_path_id handle_params branch" do test "non-existent UUID flashes error and pushes to /path", %{conn: conn} do assert {:error, {:live_redirect, %{to: "/path"}}} =