diff --git a/lib/microwaveprop_web/live/rover_planning_live/path_show.ex b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex new file mode 100644 index 00000000..ab9c8339 --- /dev/null +++ b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex @@ -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""" + + <.header> + Path profile + <:subtitle> + {Maidenhead.from_latlon(@rover.lat, @rover.lon, 10)} + → {station_endpoint(@station)} + · {band_label(@path.band_mhz || @mission.band_mhz)} + {@verdict_text} + + <: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 + 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 + + + + +
+ Path is still {@path.status}. Try again in a moment. +
+ +
+
+

Geometry

+
+
+
Distance
+
{format_distance(@path.result["distance_km"])}
+
+
+
Bearing
+
+ {format_db(@path.result["bearing_deg"]) |> String.replace(" dB", "°")} +
+
+
+
Min clearance
+
{format_meters(@path.result["min_clearance_m"])}
+
+
+
Max elevation
+
{format_meters(@path.result["max_elevation_m"])}
+
+
+
+ +
+

Cached link budget

+
+
+
Free-space loss
+
{format_db(@path.result["free_space_loss_db"])}
+
+
+
Oxygen absorption
+
{format_db(@path.result["oxygen_loss_db"])}
+
+
+
H₂O (baseline 7.5 g/m³)
+
{format_db(@path.result["humidity_loss_db_baseline"])}
+
+
+
Diffraction
+
{format_db(@path.result["diffraction_db"])}
+
+
+
Total baseline
+
{format_db(@path.result["total_baseline_loss_db"])}
+
+
+

+ Recompute live for HRRR-driven humidity / rain. +

+
+ +
+

Propagation forecast

+

+ Cached score (midpoint, mission band): + + {(@path.result["propagation_score"] || "—") |> to_string()} + + / 100 +

+

+ Computed at {(@path.computed_at && + Calendar.strftime(@path.computed_at, "%Y-%m-%d %H:%M UTC")) || "—"} +

+
+
+
+ """ + end +end diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex index e08ed031..14fe8dd0 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -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 diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 9cccb9cc..a63859f6 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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 diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs index 199086b4..1b7f47df 100644 --- a/test/microwaveprop_web/live/rover_planning_live_test.exs +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -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 . + [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