feat(rover-planning): cached path detail renders chart + numbers without live recompute
Worker now stores the full per-point analysis (beam height, Fresnel-zone radius, clearance) on Path.result.path_points, and PathShow renders a real cached page: terrain elevation chart fed by the same ElevationProfile JS hook /path uses (feet on Y, miles on X, LOS + Fresnel overlay), distance / bearing / clearance / antenna heights all in feet, baseline link-budget breakdown, and the cached midpoint propagation score with computed_at timestamp. A Recompute live button hands off to /path for the time-varying bits (current HRRR conditions, scoring breakdown, sounding, ionosphere) that aren't worth caching.
This commit is contained in:
parent
2851f467a3
commit
2327fabe29
2 changed files with 237 additions and 40 deletions
|
|
@ -126,15 +126,22 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
reraise e, __STACKTRACE__
|
reraise e, __STACKTRACE__
|
||||||
end
|
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 =
|
points =
|
||||||
Enum.map(profile, fn p ->
|
Enum.map(analysis.points, fn p ->
|
||||||
%{
|
%{
|
||||||
"lat" => p.lat,
|
"lat" => p.lat,
|
||||||
"lon" => p.lon,
|
"lon" => p.lon,
|
||||||
"d" => p.d,
|
"d" => p.d,
|
||||||
|
"dist_km" => p.dist_km,
|
||||||
"elev" => p.elev,
|
"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)
|
end)
|
||||||
|
|
||||||
|
|
@ -147,7 +154,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
"fresnel_hit_count" => analysis.fresnel_hit_count,
|
"fresnel_hit_count" => analysis.fresnel_hit_count,
|
||||||
"obstructed_count" => analysis.obstructed_count,
|
"obstructed_count" => analysis.obstructed_count,
|
||||||
"verdict" => to_string(analysis.verdict),
|
"verdict" => to_string(analysis.verdict),
|
||||||
"sample_count" => length(profile),
|
"sample_count" => length(points),
|
||||||
"propagation_score" => propagation_score,
|
"propagation_score" => propagation_score,
|
||||||
"free_space_loss_db" => loss_budget.free_space_loss_db,
|
"free_space_loss_db" => loss_budget.free_space_loss_db,
|
||||||
"oxygen_loss_db" => loss_budget.oxygen_loss_db,
|
"oxygen_loss_db" => loss_budget.oxygen_loss_db,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Stable permalink for one rover-planning Path. Resolves the saved
|
Renders one rover-planning Path's cached `result` map directly.
|
||||||
rover-location, station, band, and antenna heights into the full
|
The terrain analysis, baseline link budget, propagation score, and
|
||||||
`/path` URL so the operator sees the same terrain chart, conditions
|
per-point profile (with beam + Fresnel-zone radius) all come from
|
||||||
readout, link / power budget, and forecast that the Path Calculator
|
the worker-stored row — opening this page does no heavy compute.
|
||||||
renders. The cached `result` map on the Path row is still consulted
|
|
||||||
by the rover-planning show table — this LiveView just bridges the
|
Live HRRR-driven conditions, scoring breakdown, sounding, and
|
||||||
row click back to the canonical `/path` view.
|
ionosphere readouts aren't cached (they're time-varying); a
|
||||||
|
prominent "Recompute live" button hands off to `/path` for those.
|
||||||
"""
|
"""
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
|
@ -27,16 +28,99 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
||||||
|> push_navigate(to: ~p"/rover-planning/#{mission_id}")}
|
|> push_navigate(to: ~p"/rover-planning/#{mission_id}")}
|
||||||
|
|
||||||
%Path{} = path ->
|
%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
|
||||||
end
|
end
|
||||||
|
|
||||||
# Builds the `/path` URL with the rover/station/band/heights baked
|
defp load_path(mission_id, path_id) do
|
||||||
# in. Source uses a 10-char Maidenhead grid (max precision) so the
|
with {:ok, mission_uuid} <- Ecto.UUID.cast(mission_id),
|
||||||
# rover spot resolves precisely; destination prefers the station's
|
{:ok, path_uuid} <- Ecto.UUID.cast(path_id) do
|
||||||
# callsign, falling back to grid or coordinates.
|
Path
|
||||||
defp live_path_url(%Path{} = path) do
|
|> where([p], p.id == ^path_uuid and p.mission_id == ^mission_uuid)
|
||||||
%Path{rover_location: rover, station: station, mission: %Mission{} = mission} = path
|
|> 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)
|
src = Maidenhead.from_latlon(rover.lat, rover.lon, 10)
|
||||||
dst = station_endpoint(station)
|
dst = station_endpoint(station)
|
||||||
band = path.band_mhz || mission.band_mhz
|
band = path.band_mhz || mission.band_mhz
|
||||||
|
|
@ -53,31 +137,137 @@ defmodule MicrowavepropWeb.RoverPlanningLive.PathShow do
|
||||||
"/path?" <> query
|
"/path?" <> query
|
||||||
end
|
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
|
@impl true
|
||||||
def render(assigns) do
|
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"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-5xl">
|
||||||
<p class="text-sm text-base-content/60 p-8">Opening Path Calculator…</p>
|
<.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, fed from the cached point list. --%>
|
||||||
|
<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-points={Jason.encode!(@profile_payload.points)}
|
||||||
|
data-freq-mhz={@profile_payload.freq_mhz}
|
||||||
|
data-dist-km={@profile_payload.dist_km}
|
||||||
|
data-verdict={@profile_payload.verdict || "CLEAR"}
|
||||||
|
data-ducts="[]"
|
||||||
|
data-station1={@profile_payload.station1 || ""}
|
||||||
|
data-station2={@profile_payload.station2 || ""}
|
||||||
|
class="h-[300px]"
|
||||||
|
>
|
||||||
|
</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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue