Two fixes: 1) Form save / rover-site add/remove no longer runs the path-matrix reconcile inline. update_mission and the show LiveView now enqueue a single RoverMissionReconcileWorker job and return — for missions with many rover sites x bands the inline path was firing hundreds of Multi inserts + Oban.inserts on the request. 2) RoverPathProfileWorker.load_path/1 now strictly matches a 4-tuple (mission_id, rover_location_id, station_id, band_mhz) with band_mhz as an integer, plus updates the Oban unique key set to include band_mhz so multi-band missions enqueue per-band jobs (not just one). Legacy/malformed args are logged and dropped instead of crashing the queue with MultipleResultsError.
253 lines
8.1 KiB
Elixir
253 lines
8.1 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, :band_mhz],
|
|
states: [:available, :scheduled, :executing, :retryable]
|
|
]
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Geo
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.ScoresFile
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.RoverPlanning.Path
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
|
|
|
# Default surface absolute humidity (g/m³) — ~7.5 is the
|
|
# PathLive fallback when no live HRRR sample is available. Used to
|
|
# produce a "baseline" cached H₂O loss; the live `/path` page
|
|
# recomputes this from current weather.
|
|
require Logger
|
|
|
|
@default_abs_humidity_gm3 7.5
|
|
|
|
@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: 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
|
|
|
|
# Legacy / malformed args (no band_mhz, or wrong types). Don't crash
|
|
# the queue — log enough context to debug and drop the job. New
|
|
# enqueues from `enqueue_paths_for/1` always include band_mhz.
|
|
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
|
|
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
|
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
|
freq_ghz = 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(band_mhz, midlat, midlon)
|
|
|
|
loss_budget =
|
|
compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db)
|
|
|
|
store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget)
|
|
|
|
{: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, loss_budget) 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,
|
|
"free_space_loss_db" => loss_budget.free_space_loss_db,
|
|
"oxygen_loss_db" => loss_budget.oxygen_loss_db,
|
|
"humidity_loss_db_baseline" => loss_budget.humidity_loss_db_baseline,
|
|
"total_baseline_loss_db" => loss_budget.total_baseline_loss_db,
|
|
"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
|
|
|
|
# Cached baseline link-loss components for a path. Mirrors the
|
|
# `compute_loss_budget` math in PathLive but without live HRRR
|
|
# weather: humidity defaults to @default_abs_humidity_gm3 and rain is
|
|
# treated as zero. The `/path` page recomputes these against current
|
|
# conditions; this snapshot is what the rover-planning show page
|
|
# renders without needing HRRR access.
|
|
defp compute_loss_budget(dist_km, band_mhz, freq_ghz, diffraction_db) do
|
|
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
|
freq_mhz = freq_ghz * 1000
|
|
|
|
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
|
o2_loss = (band_config.o2_db_km || 0.0) * dist_km
|
|
h2o_loss = (band_config.h2o_coeff || 0.0) * @default_abs_humidity_gm3 * dist_km
|
|
diffraction = diffraction_db * 1.0
|
|
total = fspl + o2_loss + h2o_loss + diffraction
|
|
|
|
%{
|
|
free_space_loss_db: Float.round(fspl, 1),
|
|
oxygen_loss_db: Float.round(o2_loss, 2),
|
|
humidity_loss_db_baseline: Float.round(h2o_loss, 2),
|
|
total_baseline_loss_db: Float.round(total, 1)
|
|
}
|
|
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
|