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.
This commit is contained in:
parent
d3b96725c8
commit
210e941db8
4 changed files with 127 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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|<span class="badge badge-success badge-sm font-mono">{@score}</span>|
|
||||
end
|
||||
|
||||
defp propagation_badge(score) when is_integer(score) and score >= 40 do
|
||||
assigns = %{score: score}
|
||||
~H|<span class="badge badge-warning badge-sm font-mono">{@score}</span>|
|
||||
end
|
||||
|
||||
defp propagation_badge(score) when is_integer(score) do
|
||||
assigns = %{score: score}
|
||||
~H|<span class="badge badge-error badge-sm font-mono">{@score}</span>|
|
||||
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
|
|||
>
|
||||
<div class="bg-base-200 px-3 py-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="font-mono text-sm">
|
||||
{rover_location_heading(location)}
|
||||
<.link
|
||||
:if={location && location.id}
|
||||
navigate={~p"/rover-locations/#{location.id}"}
|
||||
class="link link-hover"
|
||||
>
|
||||
{rover_location_heading(location)}
|
||||
</.link>
|
||||
<span :if={!(location && location.id)}>
|
||||
{rover_location_heading(location)}
|
||||
</span>
|
||||
<span
|
||||
:if={location && location.notes && location.notes != ""}
|
||||
class="text-base-content/60 text-xs"
|
||||
|
|
@ -441,6 +467,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|||
<th>Status</th>
|
||||
<th>Distance</th>
|
||||
<th>Verdict</th>
|
||||
<th>Propagation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -464,6 +491,11 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|||
<td>
|
||||
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{if path.result,
|
||||
do: propagation_badge(path.result["propagation_score"]),
|
||||
else: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue