defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do @moduledoc """ Renders one rover-planning Path's cached `result` map directly. The terrain analysis, baseline link budget, propagation score, and per-point profile (with beam + Fresnel-zone radius) all come from the worker-stored row — opening this page does no heavy compute. Live HRRR-driven conditions, scoring breakdown, sounding, and ionosphere readouts aren't cached (they're time-varying); a prominent "Recompute live" button hands off to `/path` for those. """ 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, profile_payload: profile_payload(path) )} 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 # Shape the cached path_points into the structure the ElevationProfile # JS hook expects. Renders the same earth/terrain/LOS/Fresnel chart # `/path` shows — feet on the Y-axis, miles on the X-axis — without # re-running TerrainAnalysis on every click. defp profile_payload(%Path{result: %{"path_points" => points}} = path) when is_list(points) and points != [] do %{ points: Enum.map(points, fn p -> %{ dist_km: p["dist_km"] || p["d"] || 0.0, elev: p["elev"] || 0.0, beam: p["beam"] || 0.0, r1: p["r1"] || 0.0, building_m: 0, canopy_m: 0 } end), freq_mhz: path.band_mhz || path.mission.band_mhz, dist_km: path.result["distance_km"] || 0.0, verdict: path.result["verdict"], ducts: [], station1: station_callsign(path.rover_location), station2: station_callsign(path.station) } end defp profile_payload(_), do: nil defp station_callsign(%{callsign: c}) when is_binary(c) and c != "", do: c defp station_callsign(_), do: nil 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: "" 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" # Heights and clearances render in feet — meters are a curiosity for # microwave operators on the consumer side of this page. defp format_feet(nil), do: "—" defp format_feet(v) when is_number(v), do: "#{(v * 3.28084) |> Float.round(0) |> trunc()} ft" defp format_bearing(nil), do: "—" defp format_bearing(deg) when is_number(deg), do: "#{Float.round(deg * 1.0, 1)}°" 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 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-primary btn-sm" > <.icon name="hero-arrow-top-right-on-square" class="w-4 h-4" /> Recompute live
Path is still {@path.status}. Refresh in a moment.
<%!-- Elevation profile chart — feet on Y, miles on X. Same JS hook /path uses (single data-profile attribute, not multiple data-* — the hook reads `el.dataset.profile`). --%>

Terrain profile

Geometry

Distance
{format_distance(@path.result["distance_km"])}
Bearing
{format_bearing(@path.result["bearing_deg"])}
Min clearance
{format_feet(@path.result["min_clearance_m"])}
Max elevation
{format_feet(@path.result["max_elevation_m"])}
Rover antenna
{@mission.rover_height_ft} ft
Station antenna
{@mission.station_height_ft} ft

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")) || "—"}. For current weather conditions, scoring breakdown, sounding, and ionosphere readouts, click Recompute live.

""" end end