prop/lib/microwaveprop_web/live/rover_planning_live/path_show.ex
Graham McIntire a4f0e171e8
feat(rover-planning): worker pre-computes full /path output, PathLive renders cached
Three connected changes:

1) Extract PathLive.compute_path/4 + every helper it owned (resolve,
   profile grid lookup, sounding/ionosphere readouts, scoring, loss /
   power budgets) into Microwaveprop.Propagation.PathCompute. PathLive
   now delegates; ~410 lines of dead helpers deleted from PathLive.

2) RoverPathProfileWorker calls PathCompute.compute/4 with the
   mission's heights and PathLive's default station params (10 W TX,
   30 dBi gains). Stores the full atom-keyed compute output as a
   Base64-encoded :erlang.term_to_binary/1 blob alongside the flat
   summary fields the rover-planning show table reads. The blob
   roundtrips structs and DateTime exactly (Jason.encode would lose
   them).

3) PathLive accepts ?rover_path_id=UUID. When set, loads the cached
   Path, decodes the term (binary_to_term :safe), assigns it as
   @result, and renders normally — no compute_path call. The
   rover-planning show table now links rows directly to
   /path?rover_path_id=UUID, so a click opens the full Path
   Calculator UI from cached data without re-running terrain / HRRR /
   sounding lookups.

Bonus prod fixes folded in:
- PathShow's elevation chart attribute (data-* → data-profile JSON)
  was crashing the JS hook with 'unexpected character at line 1'.
- Station.changeset now wipes previously-resolved callsign/grid/
  lat/lon when the user types into :input — typing 'AA5' early
  resolved to a wrong location and locked it; subsequent keystrokes
  never re-resolved.
- phx-debounce=600 on the station input so QRZ doesn't get hit on
  every keystroke.
2026-05-03 14:58:16 -05:00

272 lines
11 KiB
Elixir

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"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
<.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-primary 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}. Refresh in a moment.
</div>
<div :if={@path.result} class="space-y-4">
<%!-- 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`). --%>
<div :if={@profile_payload} class="card bg-base-100 border border-base-300 p-4">
<h3 class="font-semibold mb-2">Terrain profile</h3>
<div
id={"elevation-profile-#{@path.id}"}
phx-hook="ElevationProfile"
phx-update="ignore"
data-profile={Jason.encode!(@profile_payload)}
data-station1={@profile_payload.station1 || ""}
data-station2={@profile_payload.station2 || ""}
class="w-full h-48 md:h-56 rounded-box overflow-hidden bg-base-200 p-2"
>
<canvas></canvas>
</div>
</div>
<div 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_bearing(@path.result["bearing_deg"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Min clearance</dt>
<dd class="font-mono">{format_feet(@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_feet(@path.result["max_elevation_m"])}</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Rover antenna</dt>
<dd class="font-mono">{@mission.rover_height_ft} ft</dd>
</div>
<div class="flex justify-between gap-2">
<dt class="text-base-content/60">Station antenna</dt>
<dd class="font-mono">{@mission.station_height_ft} ft</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>
<div class="card bg-base-100 border border-base-300 p-4">
<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")) || "—"}.
For current weather conditions, scoring breakdown, sounding,
and ionosphere readouts, click <strong>Recompute live</strong>.
</p>
</div>
</div>
</Layouts.app>
"""
end
end