diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex index ccc9ed1c..06675a26 100644 --- a/lib/microwaveprop/workers/rover_path_profile_worker.ex +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -126,15 +126,22 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do reraise e, __STACKTRACE__ end - defp store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget) do + defp store_complete(path, _profile, analysis, dist_km, bearing, propagation_score, loss_budget) do + # Store the FULL per-point analysis (beam height, Fresnel-zone radius, + # clearance) so the elevation-profile chart on the cached path-detail + # page can render the same way `/path` does — line-of-sight + Fresnel + # overlay — without recomputing TerrainAnalysis on click. points = - Enum.map(profile, fn p -> + Enum.map(analysis.points, fn p -> %{ "lat" => p.lat, "lon" => p.lon, "d" => p.d, + "dist_km" => p.dist_km, "elev" => p.elev, - "dist_km" => p.dist_km + "beam" => Map.get(p, :beam, 0.0), + "r1" => Map.get(p, :r1, 0.0), + "clearance" => Map.get(p, :clearance, 0.0) } end) @@ -147,7 +154,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do "fresnel_hit_count" => analysis.fresnel_hit_count, "obstructed_count" => analysis.obstructed_count, "verdict" => to_string(analysis.verdict), - "sample_count" => length(profile), + "sample_count" => length(points), "propagation_score" => propagation_score, "free_space_loss_db" => loss_budget.free_space_loss_db, "oxygen_loss_db" => loss_budget.oxygen_loss_db, diff --git a/lib/microwaveprop_web/live/rover_planning_live/path_show.ex b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex index db56cd93..1fbddd34 100644 --- a/lib/microwaveprop_web/live/rover_planning_live/path_show.ex +++ b/lib/microwaveprop_web/live/rover_planning_live/path_show.ex @@ -1,12 +1,13 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do @moduledoc """ - Stable permalink for one rover-planning Path. Resolves the saved - rover-location, station, band, and antenna heights into the full - `/path` URL so the operator sees the same terrain chart, conditions - readout, link / power budget, and forecast that the Path Calculator - renders. The cached `result` map on the Path row is still consulted - by the rover-planning show table — this LiveView just bridges the - row click back to the canonical `/path` view. + 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 @@ -27,16 +28,99 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do |> push_navigate(to: ~p"/rover-planning/#{mission_id}")} %Path{} = path -> - {:ok, push_navigate(socket, to: live_path_url(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 - # Builds the `/path` URL with the rover/station/band/heights baked - # in. Source uses a 10-char Maidenhead grid (max precision) so the - # rover spot resolves precisely; destination prefers the station's - # callsign, falling back to grid or coordinates. - defp live_path_url(%Path{} = path) do - %Path{rover_location: rover, station: station, mission: %Mission{} = mission} = path + 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 @@ -53,31 +137,137 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do "/path?" <> query end - 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 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 - @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""" - -

Opening Path Calculator…

+ + <.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, fed from the cached point list. --%> +
+

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