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