From 210e941db8915ef0be50afe44c4fa19956969bc6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 13:35:54 -0500 Subject: [PATCH] feat(rover-planning): propagation score column + linked location heading The path-profile worker now also looks up the propagation grid score for the path midpoint at the mission's band before flipping status to :complete, so the row never appears 'done' until both terrain and propagation prediction have run. The show table renders that score as a red/yellow/green badge in a new Propagation column. Each rover-location group heading is now a link to /rover-locations/:id so the user can jump straight to the spot's detail page from the mission view. --- .../workers/rover_path_profile_worker.ex | 27 +++++++++- .../live/rover_planning_live/show.ex | 34 +++++++++++- test/microwaveprop/rover_planning_test.exs | 15 ++++++ .../live/rover_planning_live_test.exs | 54 +++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex index e14a6c09..cee7b4cc 100644 --- a/lib/microwaveprop/workers/rover_path_profile_worker.ex +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -23,6 +23,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do import Ecto.Query alias Microwaveprop.Geo + alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Repo alias Microwaveprop.RoverPlanning.Path alias Microwaveprop.Terrain.ElevationClient @@ -84,7 +85,11 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do ant_ht_b: station_height_m ) - store_complete(path, profile, analysis, dist_km, bearing) + midlat = (rover.lat + station.lat) / 2 + midlon = (rover.lon + station.lon) / 2 + propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon) + + store_complete(path, profile, analysis, dist_km, bearing, propagation_score) {:error, reason} -> store_failed(path, "elevation profile failed: #{inspect(reason)}") @@ -96,7 +101,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do reraise e, __STACKTRACE__ end - defp store_complete(path, profile, analysis, dist_km, bearing) do + defp store_complete(path, profile, analysis, dist_km, bearing, propagation_score) do points = Enum.map(profile, fn p -> %{ @@ -118,6 +123,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do "obstructed_count" => analysis.obstructed_count, "verdict" => to_string(analysis.verdict), "sample_count" => length(profile), + "propagation_score" => propagation_score, "path_points" => points } @@ -167,6 +173,23 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do end end + # Look up the propagation grid score (0-100) for the midpoint of a + # path at the most recent forecast time available on disk. Returns + # nil when no scores file exists for this band yet — callers (the + # show page, primarily) render that as "—" without distinguishing + # "no data" from "score really is 0". + defp lookup_propagation_score(band_mhz, lat, lon) do + case ScoresFile.list_valid_times(band_mhz) do + [] -> + nil + + times -> + now = DateTime.utc_now() + time = Enum.min_by(times, &abs(DateTime.diff(&1, now))) + ScoresFile.read_point(band_mhz, time, lat, lon) + end + end + defp broadcast(%Path{mission_id: mission_id} = path) do Phoenix.PubSub.broadcast( Microwaveprop.PubSub, diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex index f0913041..df906f6c 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -172,6 +172,23 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do defp verdict_badge(_), do: "" + defp propagation_badge(score) when is_integer(score) and score >= 70 do + assigns = %{score: score} + ~H|{@score}| + end + + defp propagation_badge(score) when is_integer(score) and score >= 40 do + assigns = %{score: score} + ~H|{@score}| + end + + defp propagation_badge(score) when is_integer(score) do + assigns = %{score: score} + ~H|{@score}| + end + + defp propagation_badge(_), do: "—" + defp progress_summary(paths) do Enum.reduce(paths, %{total: 0, complete: 0, pending: 0, failed: 0}, &tally_path/2) end @@ -420,7 +437,16 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do >
- {rover_location_heading(location)} + <.link + :if={location && location.id} + navigate={~p"/rover-locations/#{location.id}"} + class="link link-hover" + > + {rover_location_heading(location)} + + + {rover_location_heading(location)} + Status Distance Verdict + Propagation @@ -464,6 +491,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} + + {if path.result, + do: propagation_badge(path.result["propagation_score"]), + else: "—"} + diff --git a/test/microwaveprop/rover_planning_test.exs b/test/microwaveprop/rover_planning_test.exs index c325fec7..2c54f249 100644 --- a/test/microwaveprop/rover_planning_test.exs +++ b/test/microwaveprop/rover_planning_test.exs @@ -69,6 +69,21 @@ defmodule Microwaveprop.RoverPlanningTest do assert hd(paths).status in [:complete, :failed] end + test "completed path stores a propagation_score key (may be nil without scores file)" do + user = AccountsFixtures.user_fixture() + _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) + + assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) + [path] = RoverPlanning.list_paths(mission) + # The worker is the gate: status only flips to :complete after the + # path-calculator pipeline (terrain + propagation lookup) has run. + # The score itself is nil in the test env (no .prop file on disk), + # but the key MUST be present so the show-page can render "—" + # without a key-existence check at every cell. + assert path.status == :complete + assert Map.has_key?(path.result, "propagation_score") + end + test "with only_known_good=false, all rover-locations get a path entry" do user = AccountsFixtures.user_fixture() _ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs index 52b34248..2bbf215c 100644 --- a/test/microwaveprop_web/live/rover_planning_live_test.exs +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -190,6 +190,60 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do assert html =~ "Fresnel" end + test "propagation column renders score badge for completed paths", %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "Score column mission") + + [path | _] = Repo.all(Path) + + {:ok, _} = + path + |> Ecto.Changeset.change(%{ + status: :complete, + result: %{ + "distance_km" => 12.0, + "verdict" => "CLEAR", + "propagation_score" => 78 + } + }) + |> Repo.update() + + {:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}") + assert html =~ "Propagation" + assert html =~ "78" + end + + test "propagation column renders dash when score is missing", %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "No-score mission") + + [path | _] = Repo.all(Path) + + {:ok, _} = + path + |> Ecto.Changeset.change(%{ + status: :complete, + result: %{"distance_km" => 12.0, "verdict" => "CLEAR", "propagation_score" => nil} + }) + |> Repo.update() + + {:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}") + assert html =~ "Propagation" + end + + test "rover-location group heading links to /rover-locations/:id", %{conn: conn} do + user = AccountsFixtures.user_fixture() + mission = create_mission(user, "Linked heading mission") + + [path | _] = Repo.all(Path) + loc_id = path.rover_location_id + + {:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}") + # The heading should be a clickable link to the rover-location's + # detail page so the user can quickly inspect / edit the spot. + assert html =~ ~s(href="/rover-locations/#{loc_id}") + end + test "path table omits min clearance + diffraction columns", %{conn: conn} do user = AccountsFixtures.user_fixture() mission = create_mission(user, "Trim columns mission")