defmodule Microwaveprop.Workers.RoverPathProfileWorker do @moduledoc """ Pre-computes one rover-planning path so the live `/path` page can render instantly when the operator clicks through. Calls the same `Microwaveprop.Propagation.PathCompute.compute/4` pipeline `/path` uses (terrain analysis, 9 HRRR profile samples, sounding + ionosphere readouts, scoring, link + power budgets, forecast) and stores the full output on the matching `RoverPlanning.Path`. The map is persisted in two parallel forms: * Flat string-keyed fields (`distance_km`, `verdict`, `propagation_score`, `total_baseline_loss_db`, `path_points`, …) — what the rover-planning show table reads at-a-glance. * `term` — the entire atom-keyed compute output, encoded with `:erlang.term_to_binary/1` + Base64. PathLive decodes this when handed `?rover_path_id=UUID` and renders without recomputing. """ use Oban.Pro.Worker, queue: :rover_path, max_attempts: 5, unique: [ period: 300, keys: [:mission_id, :rover_location_id, :station_id, :band_mhz], states: :incomplete ] import Ecto.Query alias Microwaveprop.Propagation.PathCompute alias Microwaveprop.Propagation.ScoresFile alias Microwaveprop.Repo alias Microwaveprop.RoverPlanning.Path require Logger @impl Oban.Worker def backoff(%Oban.Job{attempt: attempt}) do min(60 * Integer.pow(2, attempt - 1), 3600) end @impl Oban.Pro.Worker def process(%Oban.Job{args: args}) do case load_path(args) do nil -> :ok %Path{status: :complete} -> :ok %Path{} = path -> run(path) end end defp load_path(%{ "mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id, "band_mhz" => band_mhz }) when is_integer(band_mhz) 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) |> where([p], p.band_mhz == ^band_mhz) |> preload([:rover_location, :station, :mission]) |> Repo.one() end defp load_path(args) do Logger.warning("RoverPathProfileWorker: dropping job with unexpected args (no integer band_mhz): #{inspect(args)}") nil end defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do mark_status(path, :computing) band_mhz = path.band_mhz || mission.band_mhz src_height_ft = mission.rover_height_ft || 8.0 dst_height_ft = mission.station_height_ft || 30.0 station_params = %{ src_height_ft: src_height_ft, dst_height_ft: dst_height_ft, src_height_m: src_height_ft * 0.3048, dst_height_m: dst_height_ft * 0.3048, # Defaults match PathLive's form fallback (10 W TX, 30 dBi gains # at each end). Operators can override on the live `/path` page; # the cached snapshot uses these so power-budget numbers are # representative for a typical microwave home station. tx_power_dbm: 20.0, tx_power_mw: :math.pow(10, 20.0 / 10), src_gain_dbi: 30.0, dst_gain_dbi: 30.0 } src = "#{rover.lat},#{rover.lon}" dst = "#{station.lat},#{station.lon}" case PathCompute.compute(src, dst, band_mhz, station_params) do {:ok, result} -> store_complete(path, result, band_mhz, rover, station) {:error, reason} -> store_failed(path, "compute 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, result, band_mhz, rover, station) do flat = flat_summary(result, band_mhz, rover, station) full_term = result |> :erlang.term_to_binary() |> Base.encode64() path |> Path.changeset(%{ status: :complete, result: Map.put(flat, "term", full_term), 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 # The handful of fields the rover-planning show table renders inline # (distance, verdict, propagation score, total baseline loss). Lifted # out of the full compute output so the table can read them directly # without round-tripping the term blob. defp flat_summary(result, band_mhz, rover, station) do analysis = result.terrain && result.terrain.analysis midlat = (rover.lat + station.lat) / 2 midlon = (rover.lon + station.lon) / 2 result |> geometry_summary(analysis) |> Map.merge(loss_summary(result.loss_budget)) |> Map.put("propagation_score", lookup_propagation_score(band_mhz, midlat, midlon)) |> Map.put("path_points", path_points(analysis)) end defp geometry_summary(result, nil) do %{ "distance_km" => result.dist_km, "bearing_deg" => result.bearing, "max_elevation_m" => nil, "min_clearance_m" => nil, "diffraction_db" => 0.0, "fresnel_hit_count" => 0, "obstructed_count" => 0, "verdict" => nil, "sample_count" => nil } end defp geometry_summary(result, analysis) do %{ "distance_km" => result.dist_km, "bearing_deg" => result.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(analysis.points) } end defp loss_summary(loss) do %{ "free_space_loss_db" => loss.fspl, "oxygen_loss_db" => loss.o2, "humidity_loss_db_baseline" => loss.h2o, "total_baseline_loss_db" => loss.total } end defp path_points(nil), do: [] defp path_points(analysis) do Enum.map(analysis.points, fn p -> %{ "lat" => p.lat, "lon" => p.lon, "d" => p.d, "dist_km" => p.dist_km, "elev" => p.elev, "beam" => Map.get(p, :beam, 0.0), "r1" => Map.get(p, :r1, 0.0), "clearance" => Map.get(p, :clearance, 0.0) } 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 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