feat(rover-planning): row click opens cached path detail, not /path recompute
New /rover-planning/:id/paths/:path_id route renders the worker-stored result map directly: geometry, link-budget components, propagation score, computed_at. The 'Recompute live' button hands off to /path when the operator wants HRRR-driven loss against current weather.
This commit is contained in:
parent
fe2024275b
commit
7ab2aa8a11
4 changed files with 227 additions and 42 deletions
212
lib/microwaveprop_web/live/rover_planning_live/path_show.ex
Normal file
212
lib/microwaveprop_web/live/rover_planning_live/path_show.ex
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
||||
@moduledoc """
|
||||
Renders a single rover-planning Path's cached `result` (terrain
|
||||
analysis + baseline link budget + propagation score). Avoids the
|
||||
live recompute that `/path` would do — values come straight from
|
||||
the row the worker stored. The "Open in Path Calculator" button
|
||||
hands off to the live `/path` flow when the operator wants to see
|
||||
the live HRRR-driven loss budget.
|
||||
"""
|
||||
use MicrowavepropWeb, :live_view
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Radio.Maidenhead
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
alias Microwaveprop.RoverPlanning.Path
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => mission_id, "path_id" => path_id}, _session, socket) do
|
||||
case load_path(mission_id, path_id) do
|
||||
nil ->
|
||||
{:ok,
|
||||
socket
|
||||
|> put_flash(:error, "Path profile not found.")
|
||||
|> push_navigate(to: ~p"/rover-planning/#{mission_id}")}
|
||||
|
||||
%Path{} = path ->
|
||||
{:ok,
|
||||
assign(socket,
|
||||
page_title: "Path · #{path.mission.name}",
|
||||
path: path,
|
||||
mission: path.mission,
|
||||
rover: path.rover_location,
|
||||
station: path.station
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_path(mission_id, path_id) do
|
||||
with {:ok, mission_uuid} <- Ecto.UUID.cast(mission_id),
|
||||
{:ok, path_uuid} <- Ecto.UUID.cast(path_id) do
|
||||
Path
|
||||
|> where([p], p.id == ^path_uuid and p.mission_id == ^mission_uuid)
|
||||
|> preload([:rover_location, :station, :mission])
|
||||
|> Repo.one()
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
||||
ghz = mhz / 1000
|
||||
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
||||
end
|
||||
|
||||
defp band_label(mhz) when is_integer(mhz), do: "#{mhz} MHz"
|
||||
defp band_label(_), do: "—"
|
||||
|
||||
defp format_distance(km) when is_number(km), do: "#{Float.round(km * 1.0, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
|
||||
|
||||
defp format_distance(_), do: "—"
|
||||
|
||||
defp format_db(nil), do: "—"
|
||||
defp format_db(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} dB"
|
||||
|
||||
defp format_meters(nil), do: "—"
|
||||
defp format_meters(v) when is_number(v), do: "#{Float.round(v * 1.0, 1)} m"
|
||||
|
||||
defp verdict_label("CLEAR"), do: {"Clear", "badge badge-success"}
|
||||
defp verdict_label("BLOCKED"), do: {"Blocked", "badge badge-error"}
|
||||
defp verdict_label("FRESNEL_PARTIAL"), do: {"Fresnel partial", "badge badge-warning"}
|
||||
defp verdict_label("FRESNEL_MINOR"), do: {"Fresnel minor", "badge badge-info"}
|
||||
defp verdict_label(_), do: {"—", "badge"}
|
||||
|
||||
defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
|
||||
defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
|
||||
|
||||
defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
|
||||
do: Maidenhead.from_latlon(lat, lon, 8)
|
||||
|
||||
defp station_endpoint(_), do: ""
|
||||
|
||||
# Live /path link for when the user wants the HRRR-driven recompute
|
||||
# rather than the cached snapshot we render here.
|
||||
defp live_path_url(%Mission{} = mission, %Path{} = path, rover, station) do
|
||||
src = Maidenhead.from_latlon(rover.lat, rover.lon, 10)
|
||||
dst = station_endpoint(station)
|
||||
band = path.band_mhz || mission.band_mhz
|
||||
|
||||
query =
|
||||
URI.encode_query(%{
|
||||
"source" => src,
|
||||
"destination" => dst,
|
||||
"band" => Integer.to_string(band),
|
||||
"src_height_ft" => Float.to_string((mission.rover_height_ft || 8.0) * 1.0),
|
||||
"dst_height_ft" => Float.to_string((mission.station_height_ft || 30.0) * 1.0)
|
||||
})
|
||||
|
||||
"/path?" <> query
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
{verdict_text, verdict_class} =
|
||||
verdict_label((assigns.path.result || %{})["verdict"])
|
||||
|
||||
assigns = assign(assigns, verdict_text: verdict_text, verdict_class: verdict_class)
|
||||
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-4xl">
|
||||
<.header>
|
||||
Path profile
|
||||
<:subtitle>
|
||||
<span class="font-mono">{Maidenhead.from_latlon(@rover.lat, @rover.lon, 10)}</span>
|
||||
→ <span class="font-mono">{station_endpoint(@station)}</span>
|
||||
· {band_label(@path.band_mhz || @mission.band_mhz)}
|
||||
<span class={"ml-2 #{@verdict_class}"}>{@verdict_text}</span>
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/rover-planning/#{@mission.id}"}
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to mission
|
||||
</.link>
|
||||
<.link
|
||||
href={live_path_url(@mission, @path, @rover, @station)}
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
<.icon name="hero-arrow-top-right-on-square" class="w-4 h-4" /> Recompute live
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div :if={!@path.result} class="alert alert-warning">
|
||||
Path is still {@path.status}. Try again in a moment.
|
||||
</div>
|
||||
|
||||
<div :if={@path.result} class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<h3 class="font-semibold mb-2">Geometry</h3>
|
||||
<dl class="text-sm space-y-1">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Distance</dt>
|
||||
<dd class="font-mono">{format_distance(@path.result["distance_km"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Bearing</dt>
|
||||
<dd class="font-mono">
|
||||
{format_db(@path.result["bearing_deg"]) |> String.replace(" dB", "°")}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Min clearance</dt>
|
||||
<dd class="font-mono">{format_meters(@path.result["min_clearance_m"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Max elevation</dt>
|
||||
<dd class="font-mono">{format_meters(@path.result["max_elevation_m"])}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300 p-4">
|
||||
<h3 class="font-semibold mb-2">Cached link budget</h3>
|
||||
<dl class="text-sm space-y-1">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Free-space loss</dt>
|
||||
<dd class="font-mono">{format_db(@path.result["free_space_loss_db"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Oxygen absorption</dt>
|
||||
<dd class="font-mono">{format_db(@path.result["oxygen_loss_db"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">H₂O (baseline 7.5 g/m³)</dt>
|
||||
<dd class="font-mono">{format_db(@path.result["humidity_loss_db_baseline"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt class="text-base-content/60">Diffraction</dt>
|
||||
<dd class="font-mono">{format_db(@path.result["diffraction_db"])}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2 font-semibold border-t border-base-300 pt-1 mt-1">
|
||||
<dt>Total baseline</dt>
|
||||
<dd class="font-mono">{format_db(@path.result["total_baseline_loss_db"])}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-xs text-base-content/60 mt-2">
|
||||
Recompute live for HRRR-driven humidity / rain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 border border-base-300 p-4 md:col-span-2">
|
||||
<h3 class="font-semibold mb-2">Propagation forecast</h3>
|
||||
<p class="text-sm">
|
||||
Cached score (midpoint, mission band):
|
||||
<span class="font-mono ml-1">
|
||||
{(@path.result["propagation_score"] || "—") |> to_string()}
|
||||
</span>
|
||||
<span class="text-base-content/60 ml-1">/ 100</span>
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Computed at {(@path.computed_at &&
|
||||
Calendar.strftime(@path.computed_at, "%Y-%m-%d %H:%M UTC")) || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -322,40 +322,12 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
|||
defp stationary_grid(_), do: nil
|
||||
|
||||
# Endpoint string used for /path?destination=…. Prefers callsign, then
|
||||
# any stored or derived grid (PathLive's LocationResolver re-resolves
|
||||
# both back to the same point), falling back to bare coordinates only
|
||||
# when nothing else is available.
|
||||
defp station_endpoint(%{callsign: c}) when is_binary(c) and c != "", do: c
|
||||
defp station_endpoint(%{grid: g}) when is_binary(g) and g != "", do: g
|
||||
|
||||
defp station_endpoint(%{lat: lat, lon: lon}) when is_number(lat) and is_number(lon),
|
||||
do: Maidenhead.from_latlon(lat, lon, 8)
|
||||
|
||||
defp station_endpoint(_), do: ""
|
||||
|
||||
# Builds the /path URL pre-filling source (rover-location, max-precision
|
||||
# grid), destination (station endpoint), band, and rover/station heights
|
||||
# so the user lands on a ready-to-compute Path Calculator screen.
|
||||
defp path_url(_mission, %{rover_location: nil}), do: nil
|
||||
defp path_url(_mission, %{station: nil}), do: nil
|
||||
|
||||
defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station} = path)
|
||||
when is_number(lat) and is_number(lon) do
|
||||
src = Maidenhead.from_latlon(lat, lon, 10)
|
||||
dst = station_endpoint(station)
|
||||
band = path.band_mhz || mission.band_mhz
|
||||
|
||||
query =
|
||||
URI.encode_query(%{
|
||||
"source" => src,
|
||||
"destination" => dst,
|
||||
"band" => Integer.to_string(band),
|
||||
"src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
|
||||
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
|
||||
})
|
||||
|
||||
"/path?" <> query
|
||||
end
|
||||
# Each row links to the CACHED stored-path detail (mission/paths/:id).
|
||||
# That view renders straight from the worker-computed result map, so
|
||||
# the user doesn't pay the full /path recompute cost. /path is still
|
||||
# one click away inside that view for the live HRRR recompute.
|
||||
defp path_url(%Mission{id: mission_id}, %{id: path_id}) when not is_nil(path_id),
|
||||
do: "/rover-planning/#{mission_id}/paths/#{path_id}"
|
||||
|
||||
defp path_url(_, _), do: nil
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
live "/rover-planning/new", RoverPlanningLive.Form, :new
|
||||
live "/rover-planning/:id", RoverPlanningLive.Show
|
||||
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
|
||||
live "/rover-planning/:id/paths/:path_id", RoverPlanningLive.PathShow
|
||||
live "/algo", AlgoLive
|
||||
live "/about", AboutLive
|
||||
live "/privacy", PrivacyLive
|
||||
|
|
|
|||
|
|
@ -313,21 +313,21 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
|||
refute html =~ "Diffraction"
|
||||
end
|
||||
|
||||
test "path rows link to /path with mission params prefilled", %{conn: conn} do
|
||||
test "path rows link to the cached stored-path detail page", %{conn: conn} do
|
||||
user = AccountsFixtures.user_fixture()
|
||||
mission = create_mission(user, "Click-through mission")
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||
|
||||
# Each path row carries a phx-click that navigates to /path with
|
||||
# the mission's band + heights and the rover/station endpoints
|
||||
# filled in. Assert the click handler is present on the <tr>.
|
||||
[path | _] = Repo.all(Path)
|
||||
rendered = render(lv)
|
||||
|
||||
# Each row navigates to the cached path-detail page so the user
|
||||
# doesn't pay the live /path recompute cost. The live link is
|
||||
# still available from inside that page.
|
||||
assert rendered =~ "phx-click"
|
||||
assert rendered =~ "/path?"
|
||||
assert rendered =~ "band=10000"
|
||||
assert rendered =~ "src_height_ft=8.0"
|
||||
assert rendered =~ "dst_height_ft=30.0"
|
||||
assert rendered =~ "/rover-planning/#{mission.id}/paths/#{path.id}"
|
||||
refute rendered =~ "/path?"
|
||||
end
|
||||
|
||||
test "groups paths by rover location", %{conn: conn} do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue