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.
This commit is contained in:
parent
a3fff198c9
commit
d1a5442afb
13 changed files with 1682 additions and 3 deletions
197
lib/microwaveprop/rover_planning.ex
Normal file
197
lib/microwaveprop/rover_planning.ex
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
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
|
||||||
71
lib/microwaveprop/rover_planning/mission.ex
Normal file
71
lib/microwaveprop/rover_planning/mission.ex
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
defmodule Microwaveprop.RoverPlanning.Mission do
|
||||||
|
@moduledoc """
|
||||||
|
A planned rover mission. Owned by a user but globally visible. Holds
|
||||||
|
the band + height parameters that drive path-profile computation, the
|
||||||
|
list of stationary endpoints (`stations`), and the matrix of computed
|
||||||
|
paths from each rover-location to each station (`paths`).
|
||||||
|
|
||||||
|
`only_known_good` toggles whether the rover endpoint set is the
|
||||||
|
`:ideal` rover-locations (default) or every rover-location.
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.RoverPlanning.Path
|
||||||
|
alias Microwaveprop.RoverPlanning.Station
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
schema "rover_missions" do
|
||||||
|
field :name, :string
|
||||||
|
field :band_mhz, :integer, default: 10_000
|
||||||
|
field :only_known_good, :boolean, default: true
|
||||||
|
field :rover_height_ft, :float, default: 8.0
|
||||||
|
field :station_height_ft, :float, default: 30.0
|
||||||
|
field :notes, :string
|
||||||
|
|
||||||
|
belongs_to :user, User
|
||||||
|
has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete
|
||||||
|
has_many :paths, Path, foreign_key: :mission_id
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@castable_fields [
|
||||||
|
:name,
|
||||||
|
:band_mhz,
|
||||||
|
:only_known_good,
|
||||||
|
:rover_height_ft,
|
||||||
|
:station_height_ft,
|
||||||
|
:notes
|
||||||
|
]
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(mission, attrs) do
|
||||||
|
mission
|
||||||
|
|> cast(attrs, @castable_fields)
|
||||||
|
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|
||||||
|
|> validate_required([:name, :band_mhz])
|
||||||
|
|> validate_length(:name, min: 1, max: 100)
|
||||||
|
|> validate_number(:band_mhz, greater_than: 0)
|
||||||
|
|> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0)
|
||||||
|
|> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0)
|
||||||
|
|> validate_length(:notes, max: 4000)
|
||||||
|
|> validate_at_least_one_station()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_at_least_one_station(changeset) do
|
||||||
|
case fetch_field(changeset, :stations) do
|
||||||
|
{_source, list} when is_list(list) and list != [] ->
|
||||||
|
kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end)
|
||||||
|
if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
add_error(changeset, :stations, "must include at least one station")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
56
lib/microwaveprop/rover_planning/path.ex
Normal file
56
lib/microwaveprop/rover_planning/path.ex
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
defmodule Microwaveprop.RoverPlanning.Path do
|
||||||
|
@moduledoc """
|
||||||
|
A computed (or pending) path-profile result for one rover-location
|
||||||
|
→ station pairing inside a mission. `result` holds the same shape
|
||||||
|
produced by `MicrowavepropWeb.PathLive`'s synchronous compute, so
|
||||||
|
the show page can render it identically.
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.Rover.Location
|
||||||
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
alias Microwaveprop.RoverPlanning.Station
|
||||||
|
|
||||||
|
@statuses [:pending, :computing, :complete, :failed]
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
schema "rover_mission_paths" do
|
||||||
|
field :status, Ecto.Enum, values: @statuses, default: :pending
|
||||||
|
field :result, :map
|
||||||
|
field :error, :string
|
||||||
|
field :computed_at, :utc_datetime
|
||||||
|
|
||||||
|
belongs_to :mission, Mission
|
||||||
|
belongs_to :rover_location, Location
|
||||||
|
belongs_to :station, Station
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@spec statuses() :: [atom()]
|
||||||
|
def statuses, do: @statuses
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(path, attrs) do
|
||||||
|
path
|
||||||
|
|> cast(attrs, [
|
||||||
|
:status,
|
||||||
|
:result,
|
||||||
|
:error,
|
||||||
|
:computed_at,
|
||||||
|
:mission_id,
|
||||||
|
:rover_location_id,
|
||||||
|
:station_id
|
||||||
|
])
|
||||||
|
|> validate_required([:mission_id, :rover_location_id, :station_id, :status])
|
||||||
|
|> validate_inclusion(:status, @statuses)
|
||||||
|
|> unique_constraint([:mission_id, :rover_location_id, :station_id],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
86
lib/microwaveprop/rover_planning/station.ex
Normal file
86
lib/microwaveprop/rover_planning/station.ex
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
defmodule Microwaveprop.RoverPlanning.Station do
|
||||||
|
@moduledoc """
|
||||||
|
A stationary endpoint for a rover mission. The user provides one of:
|
||||||
|
callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
|
||||||
|
the input into all three when possible (callsigns geocode via
|
||||||
|
`MicrowavepropWeb.LocationResolver`).
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
alias MicrowavepropWeb.LocationResolver
|
||||||
|
|
||||||
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
|
@foreign_key_type :binary_id
|
||||||
|
schema "rover_mission_stations" do
|
||||||
|
field :callsign, :string
|
||||||
|
field :grid, :string
|
||||||
|
field :input, :string
|
||||||
|
field :lat, :float
|
||||||
|
field :lon, :float
|
||||||
|
field :position, :integer, default: 0
|
||||||
|
|
||||||
|
belongs_to :mission, Mission
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
|
def changeset(station, attrs) do
|
||||||
|
station
|
||||||
|
|> cast(attrs, [:callsign, :grid, :input, :lat, :lon, :position])
|
||||||
|
|> validate_required([:input])
|
||||||
|
|> resolve_input()
|
||||||
|
|> validate_required([:lat, :lon])
|
||||||
|
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|
||||||
|
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
# When `input` looks like a callsign or grid, geocode it and populate
|
||||||
|
# the matching field. Skips resolution when the user already provided
|
||||||
|
# explicit lat/lon — they may want a free-form coordinate that doesn't
|
||||||
|
# round-trip through callsign lookup.
|
||||||
|
defp resolve_input(changeset) do
|
||||||
|
cond do
|
||||||
|
not changeset.valid? ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
get_field(changeset, :lat) && get_field(changeset, :lon) ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
input = get_field(changeset, :input) ->
|
||||||
|
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
|
||||||
|
|
||||||
|
true ->
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
|
||||||
|
changeset
|
||||||
|
|> put_change(:callsign, res.label)
|
||||||
|
|> put_change(:grid, Map.get(res, :grid))
|
||||||
|
|> put_change(:lat, res.lat)
|
||||||
|
|> put_change(:lon, res.lon)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
|
||||||
|
changeset
|
||||||
|
|> put_change(:grid, res.label)
|
||||||
|
|> put_change(:lat, res.lat)
|
||||||
|
|> put_change(:lon, res.lon)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
|
||||||
|
changeset
|
||||||
|
|> put_change(:lat, res.lat)
|
||||||
|
|> put_change(:lon, res.lon)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
|
||||||
|
defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
|
||||||
|
end
|
||||||
177
lib/microwaveprop/workers/rover_path_profile_worker.ex
Normal file
177
lib/microwaveprop/workers/rover_path_profile_worker.ex
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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],
|
||||||
|
states: [:available, :scheduled, :executing, :retryable]
|
||||||
|
]
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Geo
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.RoverPlanning.Path
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
alias Microwaveprop.Terrain.TerrainAnalysis
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@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: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
|
||||||
|
}) do
|
||||||
|
case load_path(mission_id, rover_location_id, station_id) do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
%Path{status: :complete} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
%Path{} = path ->
|
||||||
|
run(path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_path(mission_id, rover_location_id, station_id) 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)
|
||||||
|
|> preload([:rover_location, :station, :mission])
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
|
||||||
|
mark_status(path, :computing)
|
||||||
|
|
||||||
|
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
||||||
|
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
||||||
|
freq_ghz = mission.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
|
||||||
|
)
|
||||||
|
|
||||||
|
store_complete(path, profile, analysis, dist_km, bearing)
|
||||||
|
|
||||||
|
{: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) 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),
|
||||||
|
"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
|
||||||
|
|
||||||
|
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
|
||||||
203
lib/microwaveprop_web/live/rover_planning_live.ex
Normal file
203
lib/microwaveprop_web/live/rover_planning_live.ex
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverPlanningLive do
|
||||||
|
@moduledoc """
|
||||||
|
`/rover-planning` paginated table of planned rover missions.
|
||||||
|
Globally visible, owner-mutable; admins can edit/delete any mission.
|
||||||
|
"""
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.RoverPlanning
|
||||||
|
|
||||||
|
def table_options do
|
||||||
|
%{sorting: %{default_sort: [inserted_at: :desc]}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def fields do
|
||||||
|
[
|
||||||
|
id: %{label: "ID", hidden: true},
|
||||||
|
user_id: %{label: "Owner", hidden: true},
|
||||||
|
name: %{label: "Mission", sortable: true, searchable: true, renderer: &name_cell/2},
|
||||||
|
band_mhz: %{label: "Band", sortable: true, renderer: &band_cell/1},
|
||||||
|
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
|
||||||
|
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def filters, do: []
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
assign(socket,
|
||||||
|
page_title: "Rover Planning"
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", %{"id" => id}, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
%User{} = user ->
|
||||||
|
case RoverPlanning.delete_mission(user, id) do
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Mission deleted.")
|
||||||
|
|> reload()}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reload(socket) do
|
||||||
|
options = socket.assigns.options
|
||||||
|
data_provider = socket.assigns[:data_provider]
|
||||||
|
table_options = get_merged_table_options()
|
||||||
|
|
||||||
|
{resources, updated_options} = fetch_resources(options, data_provider)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign_to_socket(resources, table_options)
|
||||||
|
|> assign(:options, updated_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
|
||||||
|
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
|
||||||
|
|
||||||
|
defp scope_user(assigns) do
|
||||||
|
case assigns[:current_scope] do
|
||||||
|
%{user: %User{} = user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_modify?(scope, %{user_id: user_id}) do
|
||||||
|
case scope do
|
||||||
|
%{user: %User{is_admin: true}} -> true
|
||||||
|
%{user: %User{id: ^user_id}} when not is_nil(user_id) -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_modify?(_, _), do: false
|
||||||
|
|
||||||
|
defp actions_for(scope) do
|
||||||
|
[
|
||||||
|
buttons: fn %{record: record} ->
|
||||||
|
row_actions(record, scope)
|
||||||
|
end
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp row_actions(record, scope) do
|
||||||
|
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<.link
|
||||||
|
navigate={~p"/rover-planning/#{@record.id}"}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</.link>
|
||||||
|
<.link
|
||||||
|
:if={@can_modify?}
|
||||||
|
navigate={~p"/rover-planning/#{@record.id}/edit"}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</.link>
|
||||||
|
<button
|
||||||
|
:if={@can_modify?}
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@record.id}
|
||||||
|
data-confirm="Delete this mission?"
|
||||||
|
class="btn btn-xs btn-ghost text-error"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp name_cell(value, %{id: id}) do
|
||||||
|
assigns = %{id: id, name: value || "(unnamed)"}
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<.link navigate={~p"/rover-planning/#{@id}"} class="font-semibold hover:underline">
|
||||||
|
{@name}
|
||||||
|
</.link>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp band_cell(nil), do: "—"
|
||||||
|
|
||||||
|
defp band_cell(mhz) when is_integer(mhz) do
|
||||||
|
if mhz >= 1000 do
|
||||||
|
ghz = mhz / 1000
|
||||||
|
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
|
||||||
|
"#{label} GHz"
|
||||||
|
else
|
||||||
|
"#{mhz} MHz"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scope_cell(true) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-success badge-sm">Known good only</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scope_cell(false) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-info badge-sm">All locations</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp scope_cell(_), do: ""
|
||||||
|
|
||||||
|
defp format_ts(nil), do: ""
|
||||||
|
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
||||||
|
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
|
||||||
|
defp format_ts(other), do: to_string(other)
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||||||
|
<.header>
|
||||||
|
Rover Planning
|
||||||
|
<:subtitle>
|
||||||
|
Plan rover missions: enter your stationary stations, pick a band, and
|
||||||
|
we compute terrain path profiles from every known-good rover location.
|
||||||
|
</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.link
|
||||||
|
:if={current_user(assigns)}
|
||||||
|
navigate={~p"/rover-planning/new"}
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="w-5 h-5" /> New mission
|
||||||
|
</.link>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
|
||||||
|
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to plan a mission.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<.live_table
|
||||||
|
fields={fields()}
|
||||||
|
filters={filters()}
|
||||||
|
options={@options}
|
||||||
|
streams={@streams}
|
||||||
|
actions={actions_for(@current_scope)}
|
||||||
|
/>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
299
lib/microwaveprop_web/live/rover_planning_live/form.ex
Normal file
299
lib/microwaveprop_web/live/rover_planning_live/form.ex
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
|
@moduledoc "New / edit form for `/rover-planning` missions."
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.RoverPlanning
|
||||||
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
alias Microwaveprop.RoverPlanning.Station
|
||||||
|
|
||||||
|
@band_options [
|
||||||
|
{"902 MHz", 902},
|
||||||
|
{"1.3 GHz", 1296},
|
||||||
|
{"2.3 GHz", 2304},
|
||||||
|
{"3.4 GHz", 3400},
|
||||||
|
{"5.7 GHz", 5760},
|
||||||
|
{"10 GHz", 10_000},
|
||||||
|
{"24 GHz", 24_000},
|
||||||
|
{"47 GHz", 47_000}
|
||||||
|
]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(params, _session, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
nil ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Sign in to plan a mission.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning")}
|
||||||
|
|
||||||
|
%User{} = user ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
page_title: "Rover Mission",
|
||||||
|
band_options: @band_options,
|
||||||
|
current_user: user
|
||||||
|
)
|
||||||
|
|> apply_action(socket.assigns.live_action, params)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_action(socket, :new, _params) do
|
||||||
|
mission = %Mission{stations: [%Station{position: 0}]}
|
||||||
|
cs = Mission.changeset(mission, %{})
|
||||||
|
|
||||||
|
assign(socket,
|
||||||
|
mission: mission,
|
||||||
|
form: to_form(cs),
|
||||||
|
mode: :new
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
|
||||||
|
case RoverPlanning.get_mission(id) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Mission not found.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning")
|
||||||
|
|
||||||
|
mission ->
|
||||||
|
if can_modify?(user, mission) do
|
||||||
|
cs = Mission.changeset(mission, %{})
|
||||||
|
|
||||||
|
assign(socket,
|
||||||
|
mission: mission,
|
||||||
|
form: to_form(cs),
|
||||||
|
mode: :edit
|
||||||
|
)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "You can only edit your own missions.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"mission" => params}, socket) do
|
||||||
|
cs =
|
||||||
|
socket.assigns.mission
|
||||||
|
|> Mission.changeset(params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("add_station", _params, socket) do
|
||||||
|
params = current_params(socket)
|
||||||
|
stations = Map.get(params, "stations", %{})
|
||||||
|
next_index = next_station_index(stations)
|
||||||
|
|
||||||
|
stations =
|
||||||
|
Map.put(stations, Integer.to_string(next_index), %{
|
||||||
|
"input" => "",
|
||||||
|
"position" => Integer.to_string(next_index)
|
||||||
|
})
|
||||||
|
|
||||||
|
cs =
|
||||||
|
socket.assigns.mission
|
||||||
|
|> Mission.changeset(Map.put(params, "stations", stations))
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("remove_station", %{"index" => index}, socket) do
|
||||||
|
params = current_params(socket)
|
||||||
|
stations = Map.get(params, "stations", %{})
|
||||||
|
stations = Map.delete(stations, index)
|
||||||
|
|
||||||
|
cs =
|
||||||
|
socket.assigns.mission
|
||||||
|
|> Mission.changeset(Map.put(params, "stations", stations))
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"mission" => params}, socket) do
|
||||||
|
user = socket.assigns.current_user
|
||||||
|
|
||||||
|
result =
|
||||||
|
case socket.assigns.mode do
|
||||||
|
:new -> RoverPlanning.create_mission(user, params)
|
||||||
|
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, mission} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Mission saved — computing path profiles.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Mission not found.")}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = cs} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(cs))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_params(socket) do
|
||||||
|
case socket.assigns.form.source do
|
||||||
|
%Ecto.Changeset{params: params} when is_map(params) -> params
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp next_station_index(stations) when map_size(stations) == 0, do: 0
|
||||||
|
|
||||||
|
defp next_station_index(stations) do
|
||||||
|
stations
|
||||||
|
|> Map.keys()
|
||||||
|
|> Enum.map(fn k ->
|
||||||
|
case Integer.parse(to_string(k)) do
|
||||||
|
{n, _} -> n
|
||||||
|
:error -> -1
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.max()
|
||||||
|
|> Kernel.+(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
|
||||||
|
case assigns[:current_scope] do
|
||||||
|
%{user: %User{} = user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_modify?(%User{is_admin: true}, _), do: true
|
||||||
|
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
||||||
|
defp can_modify?(_, _), do: false
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
|
||||||
|
<.header>
|
||||||
|
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
|
||||||
|
<:subtitle>
|
||||||
|
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
|
||||||
|
</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
for={@form}
|
||||||
|
id="mission-form"
|
||||||
|
phx-change="validate"
|
||||||
|
phx-submit="save"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<.input
|
||||||
|
field={@form[:band_mhz]}
|
||||||
|
type="select"
|
||||||
|
label="Band"
|
||||||
|
options={@band_options}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:rover_height_ft]}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Rover antenna (ft)"
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:station_height_ft]}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
label="Station antenna (ft)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="mission[only_known_good]"
|
||||||
|
value="true"
|
||||||
|
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="mission[only_known_good]" value="false" />
|
||||||
|
<span>
|
||||||
|
Only check against known good locations
|
||||||
|
<span class="text-xs opacity-70 block">
|
||||||
|
When checked, paths are computed only against rover-locations marked Ideal.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="font-semibold">Stationary stations</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="add_station"
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="w-4 h-4" /> Add station
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.inputs_for :let={s} field={@form[:stations]}>
|
||||||
|
<div class="card bg-base-200 p-3 mb-2">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
<.input
|
||||||
|
field={s[:input]}
|
||||||
|
type="text"
|
||||||
|
label="Callsign / grid / lat,lon"
|
||||||
|
placeholder="W5LUA or EM13qc or 32.91, -97.06"
|
||||||
|
/>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<.input field={s[:lat]} type="number" step="any" label="Lat" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<.input field={s[:lon]} type="number" step="any" label="Lon" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="remove_station"
|
||||||
|
phx-value-index={s.index}
|
||||||
|
class="btn btn-ghost btn-sm text-error mb-2"
|
||||||
|
title="Remove station"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
|
||||||
|
Resolved callsign: {s[:callsign].value}
|
||||||
|
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</.inputs_for>
|
||||||
|
|
||||||
|
<p :if={@form[:stations].errors != []} class="text-error text-sm">
|
||||||
|
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{if @mode == :edit, do: "Save changes", else: "Create mission"}
|
||||||
|
</button>
|
||||||
|
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
264
lib/microwaveprop_web/live/rover_planning_live/show.ex
Normal file
264
lib/microwaveprop_web/live/rover_planning_live/show.ex
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
|
@moduledoc """
|
||||||
|
`/rover-planning/:id` mission detail. Renders the planned stations
|
||||||
|
and the matrix of computed (or pending) terrain path profiles from
|
||||||
|
every candidate rover-location.
|
||||||
|
"""
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
alias Microwaveprop.RoverPlanning
|
||||||
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
case RoverPlanning.get_mission_with_paths(id) do
|
||||||
|
nil ->
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Mission not found.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning")}
|
||||||
|
|
||||||
|
%Mission{} = mission ->
|
||||||
|
if connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
assign(socket,
|
||||||
|
page_title: "Mission · #{mission.name}",
|
||||||
|
mission: mission,
|
||||||
|
paths: RoverPlanning.list_paths(mission)
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:rover_path_updated, _path_id}, socket) do
|
||||||
|
{:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("delete", _params, socket) do
|
||||||
|
case current_user(socket) do
|
||||||
|
%User{} = user ->
|
||||||
|
case RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
|
||||||
|
{:ok, _} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "Mission deleted.")
|
||||||
|
|> push_navigate(to: ~p"/rover-planning")}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Sign in required.")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
|
||||||
|
case assigns[:current_scope] do
|
||||||
|
%{user: %User{} = user} -> user
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
|
||||||
|
|
||||||
|
defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
|
||||||
|
|
||||||
|
defp can_modify?(_, _), do: false
|
||||||
|
|
||||||
|
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
|
||||||
|
ghz = mhz / 1000
|
||||||
|
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp band_label(mhz), do: "#{mhz} MHz"
|
||||||
|
|
||||||
|
defp status_badge(:complete) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-success badge-sm">Complete</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_badge(:computing) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-info badge-sm">Computing</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_badge(:failed) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-error badge-sm">Failed</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_badge(_) do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-ghost badge-sm">Pending</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verdict_badge("clear") do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-success badge-xs">Clear</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verdict_badge("blocked") do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-error badge-xs">Blocked</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verdict_badge("marginal") do
|
||||||
|
assigns = %{}
|
||||||
|
~H|<span class="badge badge-warning badge-xs">Marginal</span>|
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verdict_badge(_), do: ""
|
||||||
|
|
||||||
|
defp progress_summary(paths) do
|
||||||
|
total = length(paths)
|
||||||
|
complete = Enum.count(paths, &(&1.status == :complete))
|
||||||
|
pending = Enum.count(paths, &(&1.status in [:pending, :computing]))
|
||||||
|
failed = Enum.count(paths, &(&1.status == :failed))
|
||||||
|
%{total: total, complete: complete, pending: pending, failed: failed}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_distance(km) when is_number(km), do: "#{Float.round(km, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
|
||||||
|
|
||||||
|
defp format_distance(_), do: "—"
|
||||||
|
|
||||||
|
defp format_db(nil), do: "—"
|
||||||
|
defp format_db(value) when is_number(value), do: "#{Float.round(value, 1)} dB"
|
||||||
|
|
||||||
|
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
|
||||||
|
grid = Maidenhead.from_latlon(lat, lon, 6)
|
||||||
|
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp rover_label(_), do: "—"
|
||||||
|
|
||||||
|
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
|
||||||
|
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
|
||||||
|
|
||||||
|
defp station_label(%{station: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon),
|
||||||
|
do: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}"
|
||||||
|
|
||||||
|
defp station_label(_), do: "—"
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
summary = progress_summary(assigns.paths)
|
||||||
|
assigns = assign(assigns, :summary, summary)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||||||
|
<.header>
|
||||||
|
{@mission.name}
|
||||||
|
<:subtitle>
|
||||||
|
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
|
||||||
|
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
|
||||||
|
</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
|
||||||
|
<.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
|
||||||
|
</.link>
|
||||||
|
<.link
|
||||||
|
:if={can_modify?(assigns, @mission)}
|
||||||
|
navigate={~p"/rover-planning/#{@mission.id}/edit"}
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
|
||||||
|
</.link>
|
||||||
|
<button
|
||||||
|
:if={can_modify?(assigns, @mission)}
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
data-confirm="Delete this mission?"
|
||||||
|
class="btn btn-ghost btn-sm text-error"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
||||||
|
</button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
|
||||||
|
<h3 class="font-semibold mb-2">Stationary stations</h3>
|
||||||
|
<ul class="text-sm space-y-1">
|
||||||
|
<li :for={station <- @mission.stations} class="flex flex-wrap gap-2">
|
||||||
|
<span class="font-mono">{station.callsign || station.grid || station.input}</span>
|
||||||
|
<span :if={station.grid} class="text-base-content/60">grid {station.grid}</span>
|
||||||
|
<span class="text-base-content/60 font-mono">
|
||||||
|
{Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p :if={@mission.notes && @mission.notes != ""} class="mt-3 text-sm whitespace-pre-line">
|
||||||
|
{@mission.notes}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/60 mt-3">
|
||||||
|
Scope: {if @mission.only_known_good,
|
||||||
|
do: "Known good locations only (status = ideal)",
|
||||||
|
else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 border border-base-300 p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold">Path profiles</h3>
|
||||||
|
<div class="text-xs text-base-content/70">
|
||||||
|
{@summary.complete}/{@summary.total} complete
|
||||||
|
<span :if={@summary.pending > 0}>· {@summary.pending} pending</span>
|
||||||
|
<span :if={@summary.failed > 0} class="text-error">
|
||||||
|
· {@summary.failed} failed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@paths == []} class="text-sm text-base-content/60 py-4 text-center">
|
||||||
|
No paths yet — they'll appear here as the workers run.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@paths != []} class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rover location</th>
|
||||||
|
<th>Station</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Min clearance</th>
|
||||||
|
<th>Diffraction</th>
|
||||||
|
<th>Verdict</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={path <- @paths}>
|
||||||
|
<td class="font-mono text-xs">{rover_label(path)}</td>
|
||||||
|
<td class="font-mono text-xs">{station_label(path)}</td>
|
||||||
|
<td>{status_badge(path.status)}</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result,
|
||||||
|
do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
|
||||||
|
else: "—"}
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
{if path.result, do: format_db(path.result["diffraction_db"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if path.result, do: verdict_badge(path.result["verdict"]), else: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -198,6 +198,10 @@ defmodule MicrowavepropWeb.Router do
|
||||||
live "/rover", RoverLive
|
live "/rover", RoverLive
|
||||||
live "/rover-locations", RoverLocationsLive
|
live "/rover-locations", RoverLocationsLive
|
||||||
live "/rover-locations/:id", RoverLocationsLive.Show
|
live "/rover-locations/:id", RoverLocationsLive.Show
|
||||||
|
live "/rover-planning", RoverPlanningLive
|
||||||
|
live "/rover-planning/new", RoverPlanningLive.Form, :new
|
||||||
|
live "/rover-planning/:id", RoverPlanningLive.Show
|
||||||
|
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
|
||||||
live "/algo", AlgoLive
|
live "/algo", AlgoLive
|
||||||
live "/about", AboutLive
|
live "/about", AboutLive
|
||||||
live "/privacy", PrivacyLive
|
live "/privacy", PrivacyLive
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.CreateRoverPlanning do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:rover_missions, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true, null: false
|
||||||
|
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
|
||||||
|
add :name, :string, null: false
|
||||||
|
add :band_mhz, :integer, null: false, default: 10_000
|
||||||
|
add :only_known_good, :boolean, null: false, default: true
|
||||||
|
add :rover_height_ft, :float, null: false, default: 8.0
|
||||||
|
add :station_height_ft, :float, null: false, default: 30.0
|
||||||
|
add :notes, :text
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:rover_missions, [:user_id])
|
||||||
|
create index(:rover_missions, [:inserted_at])
|
||||||
|
|
||||||
|
create table(:rover_mission_stations, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true, null: false
|
||||||
|
|
||||||
|
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :callsign, :string
|
||||||
|
add :grid, :string
|
||||||
|
add :input, :string, null: false
|
||||||
|
add :lat, :float, null: false
|
||||||
|
add :lon, :float, null: false
|
||||||
|
add :position, :integer, null: false, default: 0
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:rover_mission_stations, [:mission_id])
|
||||||
|
|
||||||
|
create table(:rover_mission_paths, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true, null: false
|
||||||
|
|
||||||
|
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :rover_location_id,
|
||||||
|
references(:rover_locations, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :station_id,
|
||||||
|
references(:rover_mission_stations, type: :binary_id, on_delete: :delete_all),
|
||||||
|
null: false
|
||||||
|
|
||||||
|
add :status, :string, null: false, default: "pending"
|
||||||
|
add :result, :map
|
||||||
|
add :error, :text
|
||||||
|
add :computed_at, :utc_datetime
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:rover_mission_paths, [:mission_id])
|
||||||
|
create index(:rover_mission_paths, [:status])
|
||||||
|
|
||||||
|
create unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
144
test/microwaveprop/rover_planning_test.exs
Normal file
144
test/microwaveprop/rover_planning_test.exs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
defmodule Microwaveprop.RoverPlanningTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.AccountsFixtures
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.RoverPlanning
|
||||||
|
alias Microwaveprop.RoverPlanning.Mission
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Oban runs inline in test, so RoverPathProfileWorker fires immediately
|
||||||
|
# when the context enqueues jobs. Stub elevation lookups so the worker
|
||||||
|
# can complete without hitting the real API.
|
||||||
|
Req.Test.stub(ElevationClient, fn conn ->
|
||||||
|
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||||
|
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||||
|
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_rover_location(attrs) do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
|
||||||
|
{:ok, loc} =
|
||||||
|
Rover.create_location(
|
||||||
|
user,
|
||||||
|
Map.merge(%{lat: 32.5, lon: -97.5, status: :ideal}, attrs)
|
||||||
|
)
|
||||||
|
|
||||||
|
loc
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_attrs(extra \\ %{}) do
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
"name" => "Test mission",
|
||||||
|
"band_mhz" => 10_000,
|
||||||
|
"only_known_good" => true,
|
||||||
|
"rover_height_ft" => 8.0,
|
||||||
|
"station_height_ft" => 30.0,
|
||||||
|
"stations" => %{
|
||||||
|
"0" => %{"input" => "EM12kp", "position" => 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extra
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_mission/2" do
|
||||||
|
test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
_ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
|
||||||
|
|
||||||
|
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||||||
|
assert mission.user_id == user.id
|
||||||
|
assert [station] = mission.stations
|
||||||
|
assert station.grid == "EM12KP"
|
||||||
|
assert is_float(station.lat)
|
||||||
|
assert is_float(station.lon)
|
||||||
|
|
||||||
|
paths = RoverPlanning.list_paths(mission)
|
||||||
|
# only_known_good=true → only the :ideal location pairs with the station.
|
||||||
|
# Oban runs inline in test so the worker has already completed.
|
||||||
|
assert length(paths) == 1
|
||||||
|
assert hd(paths).status in [:complete, :failed]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with only_known_good=false, all rover-locations get a path entry" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
|
||||||
|
|
||||||
|
attrs = valid_attrs(%{"only_known_good" => false})
|
||||||
|
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||||||
|
assert length(RoverPlanning.list_paths(mission)) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "missing stations returns a changeset error" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
attrs = valid_attrs() |> Map.delete("stations")
|
||||||
|
|
||||||
|
assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
|
||||||
|
refute cs.valid?
|
||||||
|
assert {_, _} = cs.errors[:stations] || hd(cs.errors)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_mission/3" do
|
||||||
|
test "owner can update a mission and paths get rebuilt" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
{:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||||||
|
|
||||||
|
[original] = RoverPlanning.list_paths(mission)
|
||||||
|
|
||||||
|
attrs = valid_attrs(%{"name" => "Updated"})
|
||||||
|
assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
|
||||||
|
assert updated.name == "Updated"
|
||||||
|
|
||||||
|
# Old path was deleted by replace, fresh row inserted with new id.
|
||||||
|
[fresh] = RoverPlanning.list_paths(updated)
|
||||||
|
refute fresh.id == original.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owner update is denied" do
|
||||||
|
owner = AccountsFixtures.user_fixture()
|
||||||
|
other = AccountsFixtures.user_fixture()
|
||||||
|
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||||
|
|
||||||
|
assert {:error, :not_found} =
|
||||||
|
RoverPlanning.update_mission(other, mission.id, valid_attrs())
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin can update any mission" do
|
||||||
|
owner = AccountsFixtures.user_fixture()
|
||||||
|
admin = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
|
||||||
|
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
|
||||||
|
|
||||||
|
assert updated.name == "By admin"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_mission/2" do
|
||||||
|
test "owner can delete; non-owner cannot" do
|
||||||
|
owner = AccountsFixtures.user_fixture()
|
||||||
|
other = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||||||
|
|
||||||
|
assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
|
||||||
|
assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
|
||||||
|
refute Repo.get(Mission, mission.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -130,9 +130,7 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|
|
||||||
html =
|
html =
|
||||||
render_click(
|
render_click(element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|))
|
||||||
element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
|
|
||||||
)
|
|
||||||
|
|
||||||
refute html =~ ~s(value="2025-01-01")
|
refute html =~ ~s(value="2025-01-01")
|
||||||
refute html =~ ~s(value="2026-01-01")
|
refute html =~ ~s(value="2026-01-01")
|
||||||
|
|
|
||||||
115
test/microwaveprop_web/live/rover_planning_live_test.exs
Normal file
115
test/microwaveprop_web/live/rover_planning_live_test.exs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Microwaveprop.AccountsFixtures
|
||||||
|
alias Microwaveprop.Rover
|
||||||
|
alias Microwaveprop.RoverPlanning
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Req.Test.stub(ElevationClient, fn conn ->
|
||||||
|
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||||
|
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||||
|
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_mission(user, name) do
|
||||||
|
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
|
||||||
|
{:ok, mission} =
|
||||||
|
RoverPlanning.create_mission(user, %{
|
||||||
|
"name" => name,
|
||||||
|
"band_mhz" => 10_000,
|
||||||
|
"only_known_good" => true,
|
||||||
|
"rover_height_ft" => 8.0,
|
||||||
|
"station_height_ft" => 30.0,
|
||||||
|
"stations" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
|
||||||
|
})
|
||||||
|
|
||||||
|
mission
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "index" do
|
||||||
|
test "anonymous visitor sees the table and a sign-in prompt", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||||
|
assert html =~ "Rover Planning"
|
||||||
|
assert html =~ "Sign in"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logged-in user sees the New mission button", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||||
|
assert html =~ "New mission"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "table shows existing missions and links to the show page", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
mission = create_mission(user, "Visible mission")
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
|
||||||
|
assert html =~ "Visible mission"
|
||||||
|
assert html =~ ~s(href="/rover-planning/#{mission.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "show" do
|
||||||
|
test "renders mission details + path table", %{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
mission = create_mission(user, "Detail mission")
|
||||||
|
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||||||
|
assert html =~ "Detail mission"
|
||||||
|
assert html =~ "Stationary stations"
|
||||||
|
assert html =~ "Path profiles"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects when mission missing", %{conn: conn} do
|
||||||
|
missing = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
|
||||||
|
live(conn, ~p"/rover-planning/#{missing}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "form" do
|
||||||
|
test "anonymous user gets redirected to /rover-planning", %{conn: conn} do
|
||||||
|
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
|
||||||
|
live(conn, ~p"/rover-planning/new")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logged-in user can create a mission with a callsign-input station",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
|
||||||
|
{:ok, lv, html} = live(conn, ~p"/rover-planning/new")
|
||||||
|
assert html =~ "Only check against known good locations"
|
||||||
|
assert html =~ ~s(checked)
|
||||||
|
|
||||||
|
{:error, {:live_redirect, %{to: redirect_to}}} =
|
||||||
|
lv
|
||||||
|
|> form("#mission-form",
|
||||||
|
mission: %{
|
||||||
|
name: "From form",
|
||||||
|
band_mhz: "10000",
|
||||||
|
only_known_good: "true",
|
||||||
|
rover_height_ft: "8.0",
|
||||||
|
station_height_ft: "30.0",
|
||||||
|
stations: %{
|
||||||
|
"0" => %{input: "EM12kp"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert redirect_to =~ ~r{^/rover-planning/[0-9a-f-]+$}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue