prop/lib/microwaveprop/rover_planning/mission.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

71 lines
2.4 KiB
Elixir

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