prop/lib/microwaveprop/workers/rover_path_profile_worker.ex
Graham McIntire 210e941db8
feat(rover-planning): propagation score column + linked location heading
The path-profile worker now also looks up the propagation grid score for
the path midpoint at the mission's band before flipping status to
:complete, so the row never appears 'done' until both terrain and
propagation prediction have run. The show table renders that score as a
red/yellow/green badge in a new Propagation column.

Each rover-location group heading is now a link to /rover-locations/:id
so the user can jump straight to the spot's detail page from the
mission view.
2026-05-03 13:35:54 -05:00

200 lines
5.8 KiB
Elixir

defmodule Microwaveprop.Workers.RoverPathProfileWorker do
@moduledoc """
Computes a terrain path profile for one rover-location → station
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
ITU-R P.526 analysis at the mission's band + heights) and stores
the result as JSON on the matching `RoverPlanning.Path`.
The HRRR-along-path step from `/path` is intentionally skipped here:
it depends on real-time profile grids that aren't worth pinning to a
stored mission result. The rendered show page can re-fetch live
weather on demand if needed.
"""
use Oban.Worker,
queue: :terrain,
max_attempts: 5,
unique: [
period: 300,
keys: [:mission_id, :rover_location_id, :station_id],
states: [:available, :scheduled, :executing, :retryable]
]
import Ecto.Query
alias Microwaveprop.Geo
alias Microwaveprop.Propagation.ScoresFile
alias Microwaveprop.Repo
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
require Logger
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(60 * Integer.pow(2, attempt - 1), 3600)
end
@impl Oban.Worker
def perform(%Oban.Job{
args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
}) do
case load_path(mission_id, rover_location_id, station_id) do
nil ->
:ok
%Path{status: :complete} ->
:ok
%Path{} = path ->
run(path)
end
end
defp load_path(mission_id, rover_location_id, station_id) do
Path
|> where([p], p.mission_id == ^mission_id)
|> where([p], p.rover_location_id == ^rover_location_id)
|> where([p], p.station_id == ^station_id)
|> preload([:rover_location, :station, :mission])
|> Repo.one()
end
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
mark_status(path, :computing)
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
freq_ghz = mission.band_mhz / 1000
dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
case ElevationClient.fetch_elevation_profile(
rover.lat,
rover.lon,
station.lat,
station.lon,
64,
download: true
) do
{:ok, profile} ->
analysis =
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
ant_ht_a: rover_height_m,
ant_ht_b: station_height_m
)
midlat = (rover.lat + station.lat) / 2
midlon = (rover.lon + station.lon) / 2
propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon)
store_complete(path, profile, analysis, dist_km, bearing, propagation_score)
{:error, reason} ->
store_failed(path, "elevation profile failed: #{inspect(reason)}")
end
rescue
e ->
Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
store_failed(path, "exception: #{Exception.message(e)}")
reraise e, __STACKTRACE__
end
defp store_complete(path, profile, analysis, dist_km, bearing, propagation_score) do
points =
Enum.map(profile, fn p ->
%{
"lat" => p.lat,
"lon" => p.lon,
"d" => p.d,
"elev" => p.elev,
"dist_km" => p.dist_km
}
end)
result = %{
"distance_km" => dist_km,
"bearing_deg" => bearing,
"max_elevation_m" => analysis.max_elevation_m,
"min_clearance_m" => analysis.min_clearance_m,
"diffraction_db" => analysis.diffraction_db,
"fresnel_hit_count" => analysis.fresnel_hit_count,
"obstructed_count" => analysis.obstructed_count,
"verdict" => to_string(analysis.verdict),
"sample_count" => length(profile),
"propagation_score" => propagation_score,
"path_points" => points
}
path
|> Path.changeset(%{
status: :complete,
result: result,
error: nil,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
|> case do
{:ok, updated} ->
broadcast(updated)
:ok
{:error, cs} ->
{:error, "store update failed: #{inspect(cs.errors)}"}
end
end
defp store_failed(path, message) do
path
|> Path.changeset(%{
status: :failed,
error: message,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
|> case do
{:ok, updated} ->
broadcast(updated)
{:error, message}
{:error, cs} ->
{:error, "store update failed: #{inspect(cs.errors)}"}
end
end
defp mark_status(path, status) do
path
|> Path.changeset(%{status: status})
|> Repo.update()
|> case do
{:ok, _} -> :ok
_ -> :ok
end
end
# Look up the propagation grid score (0-100) for the midpoint of a
# path at the most recent forecast time available on disk. Returns
# nil when no scores file exists for this band yet — callers (the
# show page, primarily) render that as "—" without distinguishing
# "no data" from "score really is 0".
defp lookup_propagation_score(band_mhz, lat, lon) do
case ScoresFile.list_valid_times(band_mhz) do
[] ->
nil
times ->
now = DateTime.utc_now()
time = Enum.min_by(times, &abs(DateTime.diff(&1, now)))
ScoresFile.read_point(band_mhz, time, lat, lon)
end
end
defp broadcast(%Path{mission_id: mission_id} = path) do
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"rover_planning:#{mission_id}",
{:rover_path_updated, path.id}
)
end
end