Mission now carries bands_mhz ({:array, :integer}) — operator picks
one or more bands as multi-checkboxes. enqueue_paths_for builds the
cross product (rover x station x band) and persists each tuple as its
own Path row keyed by (mission_id, rover_location_id, station_id,
band_mhz). The path-profile worker reads band_mhz from the path
itself (legacy single-band jobs without band_mhz in args still resolve
to their unique row).
replace_mission_paths/1 is now a thin alias for reconcile_mission_paths/1
which diffs desired vs actual: stale tuples (old band that the user
unchecked, station they removed, rover-site they deleted) get dropped,
new tuples become :pending and enqueue, surviving :complete rows are
left in place — no more wholesale destruction of already-computed
paths on every edit.
The show table gains a Band column, and band_label() in the mission
summary becomes bands_label() (joins the list with commas).
138 lines
4.7 KiB
Elixir
138 lines
4.7 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
|
|
`:good` 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 :bands_mhz, {:array, :integer}, default: []
|
|
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,
|
|
:bands_mhz,
|
|
:only_known_good,
|
|
:rover_height_ft,
|
|
:station_height_ft,
|
|
:notes
|
|
]
|
|
|
|
@doc """
|
|
Returns the bands the mission's path matrix should iterate over.
|
|
Prefers `bands_mhz` (canonical, possibly empty) and falls back to
|
|
`[band_mhz]` for legacy rows. Always returns a non-empty list when
|
|
the mission is valid.
|
|
"""
|
|
@spec bands(t()) :: [integer()]
|
|
def bands(%__MODULE__{bands_mhz: list}) when is_list(list) and list != [], do: Enum.uniq(list)
|
|
|
|
def bands(%__MODULE__{band_mhz: primary}) when is_integer(primary), do: [primary]
|
|
def bands(_), do: []
|
|
|
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
|
def changeset(mission, attrs) do
|
|
mission
|
|
|> cast(prepare_bands_attr(attrs), @castable_fields)
|
|
|> normalize_bands()
|
|
|> 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_bands_mhz()
|
|
|> 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
|
|
|
|
# The multi-checkbox form sends a hidden empty-string sentinel so
|
|
# the key always exists even when nothing is checked. Drop those
|
|
# sentinels here so Ecto's `{:array, :integer}` cast doesn't choke.
|
|
defp prepare_bands_attr(%{"bands_mhz" => list} = attrs) when is_list(list) do
|
|
cleaned =
|
|
list
|
|
|> Enum.reject(fn v -> v == "" or v == nil end)
|
|
|> Enum.uniq()
|
|
|
|
Map.put(attrs, "bands_mhz", cleaned)
|
|
end
|
|
|
|
defp prepare_bands_attr(attrs), do: attrs
|
|
|
|
# Treats `bands_mhz` as the canonical multi-band list. When the
|
|
# form submits it (multi-checkbox), use that. When only `band_mhz`
|
|
# is present (legacy single-band form path), seed bands_mhz from it.
|
|
# Also keeps `band_mhz` in sync as the "primary band" so older
|
|
# readers stay correct.
|
|
defp normalize_bands(changeset) do
|
|
bands = get_change(changeset, :bands_mhz) || get_field(changeset, :bands_mhz)
|
|
primary = get_change(changeset, :band_mhz) || get_field(changeset, :band_mhz)
|
|
|
|
cond do
|
|
is_list(bands) and bands != [] ->
|
|
cleaned = bands |> Enum.uniq() |> Enum.sort()
|
|
|
|
changeset
|
|
|> put_change(:bands_mhz, cleaned)
|
|
|> put_change(:band_mhz, hd(cleaned))
|
|
|
|
is_integer(primary) and primary > 0 ->
|
|
put_change(changeset, :bands_mhz, [primary])
|
|
|
|
true ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_bands_mhz(changeset) do
|
|
case get_field(changeset, :bands_mhz) do
|
|
list when is_list(list) and list != [] ->
|
|
if Enum.all?(list, &(is_integer(&1) and &1 > 0)),
|
|
do: changeset,
|
|
else: add_error(changeset, :bands_mhz, "must be a list of positive integers")
|
|
|
|
_ ->
|
|
add_error(changeset, :bands_mhz, "must include at least one band")
|
|
end
|
|
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
|