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")