256 lines
7.4 KiB
Elixir
256 lines
7.4 KiB
Elixir
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
|