prop/lib/microwaveprop/rover_planning.ex
Graham McIntire d1a5442afb
feat(rover-planning): /rover-planning page with path-profile worker
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
  notes, "only check against known good locations" toggle (default on),
  and a dynamic list of stationary stations entered as callsigns,
  Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
  LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
  matrix of computed path profiles (distance, min clearance, diffraction,
  verdict) populated as the worker completes each pairing

After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.

Admins can edit/delete any mission; owners can edit their own.
2026-05-03 11:04:11 -05:00

197 lines
5.4 KiB
Elixir

defmodule Microwaveprop.RoverPlanning do
@moduledoc """
Context for the `/rover-planning` feature: planned rover missions
with stationary endpoints and per-rover-location terrain path
profiles. Mutations are scoped to a user (or admin); reads are
global.
After a successful `create_mission/2` or
`replace_mission_paths/1`, the context enqueues one
`RoverPathProfileWorker` job per (rover-location, station) pair
so the show page can render results as they land.
"""
import Ecto.Query
alias Ecto.Multi
alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.RoverPlanning.Station
alias Microwaveprop.Workers.RoverPathProfileWorker
@spec list_missions() :: [Mission.t()]
def list_missions do
Mission
|> order_by([m], desc: m.inserted_at)
|> preload([:user, :stations])
|> Repo.all()
end
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} ->
Mission
|> Repo.get(uuid)
|> Repo.preload([:user, stations: from(s in Station, order_by: s.position)])
:error ->
nil
end
end
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission_with_paths(id) do
case get_mission(id) do
nil -> nil
mission -> Repo.preload(mission, paths: [:rover_location, :station])
end
end
@spec create_mission(User.t(), map()) ::
{:ok, Mission.t()} | {:error, Ecto.Changeset.t()}
def create_mission(%User{} = user, attrs) do
changeset = Mission.changeset(%Mission{user_id: user.id}, attrs)
case Repo.insert(changeset) do
{:ok, mission} ->
mission = Repo.preload(mission, [:user, :stations])
:ok = enqueue_paths_for(mission)
{:ok, mission}
{:error, _} = error ->
error
end
end
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_mission(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
{:ok, mission} ->
mission
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|> Mission.changeset(attrs)
|> Repo.update()
|> case do
{:ok, updated} ->
updated = Repo.preload(updated, [:user, :stations], force: true)
:ok = replace_mission_paths(updated)
{:ok, updated}
other ->
other
end
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
{:ok, Mission.t()} | {:error, :not_found}
def delete_mission(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, mission} -> Repo.delete(mission)
{:error, :not_found} -> {:error, :not_found}
end
end
@spec list_paths(Mission.t()) :: [Path.t()]
def list_paths(%Mission{id: mission_id}) do
Repo.all(
from(p in Path,
where: p.mission_id == ^mission_id,
preload: [:rover_location, :station],
order_by: [asc: p.station_id, asc: p.rover_location_id]
)
)
end
@doc """
Wipes the existing path matrix for a mission and re-enqueues all
pairings. Called after a mission edit when the station list or
`only_known_good` toggle could have shifted the matrix.
"""
@spec replace_mission_paths(Mission.t()) :: :ok
def replace_mission_paths(%Mission{} = mission) do
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
enqueue_paths_for(mission)
end
defp enqueue_paths_for(%Mission{} = mission) do
rovers = candidate_rover_locations(mission)
stations = mission.stations || []
pairs =
for rover <- rovers, station <- stations do
%{rover: rover, station: station}
end
case pairs do
[] -> :ok
_ -> insert_pending_paths_and_jobs(mission, pairs)
end
end
defp candidate_rover_locations(%Mission{only_known_good: true}) do
Repo.all(from(l in Location, where: l.status == :ideal))
end
defp candidate_rover_locations(%Mission{only_known_good: false}) do
Repo.all(Location)
end
defp insert_pending_paths_and_jobs(mission, pairs) do
{:ok, _} =
pairs
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
attrs = %{
mission_id: mission.id,
rover_location_id: r.id,
station_id: s.id,
status: :pending
}
Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
on_conflict: :nothing,
conflict_target: [:mission_id, :rover_location_id, :station_id]
)
end)
|> Repo.transaction()
enqueue_jobs(mission, pairs)
:ok
end
defp enqueue_jobs(mission, pairs) do
for %{rover: r, station: s} <- pairs do
%{
"mission_id" => mission.id,
"rover_location_id" => r.id,
"station_id" => s.id
}
|> RoverPathProfileWorker.new()
|> Oban.insert()
end
:ok
end
defp fetch_owned(%User{is_admin: true}, id) do
case get_mission(id) do
nil -> {:error, :not_found}
mission -> {:ok, mission}
end
end
defp fetch_owned(%User{id: user_id}, id) do
case get_mission(id) do
%Mission{user_id: ^user_id} = mission -> {:ok, mission}
_ -> {:error, :not_found}
end
end
end