feat(rover-planning): multi-band missions + smart reconcile
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).
This commit is contained in:
parent
b247e8a719
commit
2c88fba1df
9 changed files with 342 additions and 51 deletions
|
|
@ -112,16 +112,46 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Wipes the existing path matrix for a mission and re-enqueues all
|
Reconciles the path matrix for a mission against the current set of
|
||||||
pairings. Called after a mission edit when the station list or
|
(rover_location × station × band) tuples. Deletes paths for tuples
|
||||||
`only_known_good` toggle could have shifted the matrix.
|
that no longer apply, leaves :complete paths in place when they're
|
||||||
|
still relevant, and enqueues new pending paths for any tuple missing
|
||||||
|
from the table. Called after a mission edit, or after the user
|
||||||
|
adds/removes a rover site.
|
||||||
|
|
||||||
|
Aliased as `replace_mission_paths/1` for backwards compatibility
|
||||||
|
with callers that expected the previous destructive behaviour;
|
||||||
|
internally both go through the same reconcile path.
|
||||||
"""
|
"""
|
||||||
@spec replace_mission_paths(Mission.t()) :: :ok
|
@spec reconcile_mission_paths(Mission.t()) :: :ok
|
||||||
def replace_mission_paths(%Mission{} = mission) do
|
def reconcile_mission_paths(%Mission{} = mission) do
|
||||||
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
|
desired = MapSet.new(desired_tuples(mission))
|
||||||
enqueue_paths_for(mission)
|
|
||||||
|
actual_paths =
|
||||||
|
Repo.all(from(p in Path, where: p.mission_id == ^mission.id))
|
||||||
|
|
||||||
|
actual = MapSet.new(actual_paths, &{&1.rover_location_id, &1.station_id, &1.band_mhz})
|
||||||
|
|
||||||
|
stale_ids =
|
||||||
|
actual_paths
|
||||||
|
|> Enum.reject(&MapSet.member?(desired, {&1.rover_location_id, &1.station_id, &1.band_mhz}))
|
||||||
|
|> Enum.map(& &1.id)
|
||||||
|
|
||||||
|
if stale_ids != [] do
|
||||||
|
Repo.delete_all(from(p in Path, where: p.id in ^stale_ids))
|
||||||
|
end
|
||||||
|
|
||||||
|
desired
|
||||||
|
|> MapSet.difference(actual)
|
||||||
|
|> Enum.to_list()
|
||||||
|
|> insert_pending_paths_and_jobs(mission)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec replace_mission_paths(Mission.t()) :: :ok
|
||||||
|
defdelegate replace_mission_paths(mission), to: __MODULE__, as: :reconcile_mission_paths
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Walks every mission and re-enqueues any (rover_location × station)
|
Walks every mission and re-enqueues any (rover_location × station)
|
||||||
pair whose `Path` is missing or not `:complete`. Existing complete
|
pair whose `Path` is missing or not `:complete`. Existing complete
|
||||||
|
|
@ -143,17 +173,22 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_paths_for(%Mission{} = mission) do
|
defp enqueue_paths_for(%Mission{} = mission) do
|
||||||
|
mission
|
||||||
|
|> desired_tuples()
|
||||||
|
|> insert_pending_paths_and_jobs(mission)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cross product of (rover_location_id, station_id, band_mhz) for the
|
||||||
|
# mission's current scope. The reconcile + initial-create paths both
|
||||||
|
# go through this same source of truth.
|
||||||
|
@spec desired_tuples(Mission.t()) :: [{Ecto.UUID.t(), Ecto.UUID.t(), integer()}]
|
||||||
|
defp desired_tuples(%Mission{} = mission) do
|
||||||
rovers = candidate_rover_locations(mission)
|
rovers = candidate_rover_locations(mission)
|
||||||
stations = mission.stations || []
|
stations = mission.stations || []
|
||||||
|
bands = Mission.bands(mission)
|
||||||
|
|
||||||
pairs =
|
for rover <- rovers, station <- stations, band <- bands do
|
||||||
for rover <- rovers, station <- stations do
|
{rover.id, station.id, band}
|
||||||
%{rover: rover, station: station}
|
|
||||||
end
|
|
||||||
|
|
||||||
case pairs do
|
|
||||||
[] -> :ok
|
|
||||||
_ -> insert_pending_paths_and_jobs(mission, pairs)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -171,34 +206,41 @@ defmodule Microwaveprop.RoverPlanning do
|
||||||
Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
|
Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp insert_pending_paths_and_jobs(mission, pairs) do
|
defp insert_pending_paths_and_jobs([], _mission), do: :ok
|
||||||
|
|
||||||
|
defp insert_pending_paths_and_jobs(tuples, mission) when is_list(tuples) do
|
||||||
{:ok, _} =
|
{:ok, _} =
|
||||||
pairs
|
tuples
|
||||||
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
|
|> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi ->
|
||||||
attrs = %{
|
attrs = %{
|
||||||
mission_id: mission.id,
|
mission_id: mission.id,
|
||||||
rover_location_id: r.id,
|
rover_location_id: rover_id,
|
||||||
station_id: s.id,
|
station_id: station_id,
|
||||||
|
band_mhz: band_mhz,
|
||||||
status: :pending
|
status: :pending
|
||||||
}
|
}
|
||||||
|
|
||||||
Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
|
Multi.insert(
|
||||||
|
multi,
|
||||||
|
{:path, rover_id, station_id, band_mhz},
|
||||||
|
Path.changeset(%Path{}, attrs),
|
||||||
on_conflict: :nothing,
|
on_conflict: :nothing,
|
||||||
conflict_target: [:mission_id, :rover_location_id, :station_id]
|
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|> Repo.transaction()
|
|> Repo.transaction()
|
||||||
|
|
||||||
enqueue_jobs(mission, pairs)
|
enqueue_jobs(tuples, mission)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp enqueue_jobs(mission, pairs) do
|
defp enqueue_jobs(tuples, mission) do
|
||||||
for %{rover: r, station: s} <- pairs do
|
for {rover_id, station_id, band_mhz} <- tuples do
|
||||||
%{
|
%{
|
||||||
"mission_id" => mission.id,
|
"mission_id" => mission.id,
|
||||||
"rover_location_id" => r.id,
|
"rover_location_id" => rover_id,
|
||||||
"station_id" => s.id
|
"station_id" => station_id,
|
||||||
|
"band_mhz" => band_mhz
|
||||||
}
|
}
|
||||||
|> RoverPathProfileWorker.new()
|
|> RoverPathProfileWorker.new()
|
||||||
|> Oban.insert()
|
|> Oban.insert()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ defmodule Microwaveprop.RoverPlanning.Mission do
|
||||||
schema "rover_missions" do
|
schema "rover_missions" do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :band_mhz, :integer, default: 10_000
|
field :band_mhz, :integer, default: 10_000
|
||||||
|
field :bands_mhz, {:array, :integer}, default: []
|
||||||
field :only_known_good, :boolean, default: true
|
field :only_known_good, :boolean, default: true
|
||||||
field :rover_height_ft, :float, default: 8.0
|
field :rover_height_ft, :float, default: 8.0
|
||||||
field :station_height_ft, :float, default: 30.0
|
field :station_height_ft, :float, default: 30.0
|
||||||
|
|
@ -38,26 +39,92 @@ defmodule Microwaveprop.RoverPlanning.Mission do
|
||||||
@castable_fields [
|
@castable_fields [
|
||||||
:name,
|
:name,
|
||||||
:band_mhz,
|
:band_mhz,
|
||||||
|
:bands_mhz,
|
||||||
:only_known_good,
|
:only_known_good,
|
||||||
:rover_height_ft,
|
:rover_height_ft,
|
||||||
:station_height_ft,
|
:station_height_ft,
|
||||||
:notes
|
: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()
|
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
|
||||||
def changeset(mission, attrs) do
|
def changeset(mission, attrs) do
|
||||||
mission
|
mission
|
||||||
|> cast(attrs, @castable_fields)
|
|> cast(prepare_bands_attr(attrs), @castable_fields)
|
||||||
|
|> normalize_bands()
|
||||||
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|
||||||
|> validate_required([:name, :band_mhz])
|
|> validate_required([:name, :band_mhz])
|
||||||
|> validate_length(:name, min: 1, max: 100)
|
|> validate_length(:name, min: 1, max: 100)
|
||||||
|> validate_number(:band_mhz, greater_than: 0)
|
|> validate_number(:band_mhz, greater_than: 0)
|
||||||
|
|> validate_bands_mhz()
|
||||||
|> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.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_number(:station_height_ft, greater_than_or_equal_to: 0.0)
|
||||||
|> validate_length(:notes, max: 4000)
|
|> validate_length(:notes, max: 4000)
|
||||||
|> validate_at_least_one_station()
|
|> validate_at_least_one_station()
|
||||||
end
|
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
|
defp validate_at_least_one_station(changeset) do
|
||||||
case fetch_field(changeset, :stations) do
|
case fetch_field(changeset, :stations) do
|
||||||
{_source, list} when is_list(list) and list != [] ->
|
{_source, list} when is_list(list) and list != [] ->
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ defmodule Microwaveprop.RoverPlanning.Path do
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
schema "rover_mission_paths" do
|
schema "rover_mission_paths" do
|
||||||
|
field :band_mhz, :integer
|
||||||
field :status, Ecto.Enum, values: @statuses, default: :pending
|
field :status, Ecto.Enum, values: @statuses, default: :pending
|
||||||
field :result, :map
|
field :result, :map
|
||||||
field :error, :string
|
field :error, :string
|
||||||
|
|
@ -39,6 +40,7 @@ defmodule Microwaveprop.RoverPlanning.Path do
|
||||||
def changeset(path, attrs) do
|
def changeset(path, attrs) do
|
||||||
path
|
path
|
||||||
|> cast(attrs, [
|
|> cast(attrs, [
|
||||||
|
:band_mhz,
|
||||||
:status,
|
:status,
|
||||||
:result,
|
:result,
|
||||||
:error,
|
:error,
|
||||||
|
|
@ -47,9 +49,10 @@ defmodule Microwaveprop.RoverPlanning.Path do
|
||||||
:rover_location_id,
|
:rover_location_id,
|
||||||
:station_id
|
:station_id
|
||||||
])
|
])
|
||||||
|> validate_required([:mission_id, :rover_location_id, :station_id, :status])
|
|> validate_required([:mission_id, :rover_location_id, :station_id, :band_mhz, :status])
|
||||||
|> validate_inclusion(:status, @statuses)
|
|> validate_inclusion(:status, @statuses)
|
||||||
|> unique_constraint([:mission_id, :rover_location_id, :station_id],
|
|> validate_number(:band_mhz, greater_than: 0)
|
||||||
|
|> unique_constraint([:mission_id, :rover_location_id, :station_id, :band_mhz],
|
||||||
name: :rover_mission_paths_unique
|
name: :rover_mission_paths_unique
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,8 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(%Oban.Job{
|
def perform(%Oban.Job{args: args}) do
|
||||||
args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
|
case load_path(args) do
|
||||||
}) do
|
|
||||||
case load_path(mission_id, rover_location_id, station_id) do
|
|
||||||
nil ->
|
nil ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
|
|
@ -59,21 +57,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_path(mission_id, rover_location_id, station_id) do
|
defp load_path(
|
||||||
|
%{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id} = args
|
||||||
|
) do
|
||||||
|
band_mhz = Map.get(args, "band_mhz")
|
||||||
|
|
||||||
Path
|
Path
|
||||||
|> where([p], p.mission_id == ^mission_id)
|
|> where([p], p.mission_id == ^mission_id)
|
||||||
|> where([p], p.rover_location_id == ^rover_location_id)
|
|> where([p], p.rover_location_id == ^rover_location_id)
|
||||||
|> where([p], p.station_id == ^station_id)
|
|> where([p], p.station_id == ^station_id)
|
||||||
|
|> maybe_filter_band(band_mhz)
|
||||||
|> preload([:rover_location, :station, :mission])
|
|> preload([:rover_location, :station, :mission])
|
||||||
|> Repo.one()
|
|> Repo.one()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Legacy queued jobs (pre-multi-band) didn't include band_mhz in
|
||||||
|
# their args. The single Path row that existed for that tuple is the
|
||||||
|
# only candidate, so loading without the band filter is unambiguous.
|
||||||
|
defp maybe_filter_band(query, nil), do: query
|
||||||
|
defp maybe_filter_band(query, band) when is_integer(band), do: where(query, [p], p.band_mhz == ^band)
|
||||||
|
|
||||||
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
|
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
|
||||||
mark_status(path, :computing)
|
mark_status(path, :computing)
|
||||||
|
|
||||||
|
band_mhz = path.band_mhz || mission.band_mhz
|
||||||
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
|
||||||
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
|
||||||
freq_ghz = mission.band_mhz / 1000
|
freq_ghz = band_mhz / 1000
|
||||||
dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
|
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)
|
bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
|
||||||
|
|
||||||
|
|
@ -94,10 +104,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
||||||
|
|
||||||
midlat = (rover.lat + station.lat) / 2
|
midlat = (rover.lat + station.lat) / 2
|
||||||
midlon = (rover.lon + station.lon) / 2
|
midlon = (rover.lon + station.lon) / 2
|
||||||
propagation_score = lookup_propagation_score(mission.band_mhz, midlat, midlon)
|
propagation_score = lookup_propagation_score(band_mhz, midlat, midlon)
|
||||||
|
|
||||||
loss_budget =
|
loss_budget =
|
||||||
compute_loss_budget(dist_km, mission.band_mhz, freq_ghz, analysis.diffraction_db)
|
compute_loss_budget(dist_km, band_mhz, freq_ghz, analysis.diffraction_db)
|
||||||
|
|
||||||
store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget)
|
store_complete(path, profile, analysis, dist_km, bearing, propagation_score, loss_budget)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,19 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
|
||||||
defp can_modify?(_, _), do: false
|
defp can_modify?(_, _), do: false
|
||||||
|
|
||||||
|
# Multi-checkbox values come from the form as a list of strings (or
|
||||||
|
# ints, depending on whether the changeset has cast them yet). Tolerate
|
||||||
|
# both, falling back to "not checked" for nil / empty bands lists.
|
||||||
|
defp band_checked?(values, target) when is_list(values) do
|
||||||
|
Enum.any?(values, fn
|
||||||
|
v when is_integer(v) -> v == target
|
||||||
|
v when is_binary(v) -> v == Integer.to_string(target)
|
||||||
|
_ -> false
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp band_checked?(_, _), do: false
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
|
@ -201,13 +214,41 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
|
||||||
>
|
>
|
||||||
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
|
<.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">
|
<div>
|
||||||
<.input
|
<label class="label">
|
||||||
field={@form[:band_mhz]}
|
<span class="label-text">Bands</span>
|
||||||
type="select"
|
<span class="label-text-alt text-base-content/60">
|
||||||
label="Band"
|
Pick one or more — paths recompute per band
|
||||||
options={@band_options}
|
</span>
|
||||||
/>
|
</label>
|
||||||
|
<%!--
|
||||||
|
Hidden zero-value field so an unchecked-everywhere submit
|
||||||
|
still parses as an empty list (Phoenix drops the key
|
||||||
|
otherwise). Mission.changeset rejects an empty bands_mhz
|
||||||
|
with a friendly error.
|
||||||
|
--%>
|
||||||
|
<input type="hidden" name="mission[bands_mhz][]" value="" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
:for={{label, value} <- @band_options}
|
||||||
|
class="cursor-pointer flex items-center gap-2 border border-base-300 rounded px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="mission[bands_mhz][]"
|
||||||
|
value={value}
|
||||||
|
checked={band_checked?(@form[:bands_mhz].value, value)}
|
||||||
|
class="checkbox checkbox-sm checkbox-primary"
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p :if={@form[:bands_mhz].errors != []} class="text-error text-sm mt-1">
|
||||||
|
{Phoenix.HTML.html_escape(Enum.map_join(@form[:bands_mhz].errors, ", ", &elem(&1, 0)))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:rover_height_ft]}
|
field={@form[:rover_height_ft]}
|
||||||
type="number"
|
type="number"
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,14 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
|
|
||||||
defp band_label(mhz), do: "#{mhz} MHz"
|
defp band_label(mhz), do: "#{mhz} MHz"
|
||||||
|
|
||||||
|
defp bands_label(%Mission{} = mission) do
|
||||||
|
case Mission.bands(mission) do
|
||||||
|
[] -> "—"
|
||||||
|
[single] -> band_label(single)
|
||||||
|
list -> Enum.map_join(list, ", ", &band_label/1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp status_badge(:complete) do
|
defp status_badge(:complete) do
|
||||||
assigns = %{}
|
assigns = %{}
|
||||||
~H|<span class="badge badge-success badge-sm">Complete</span>|
|
~H|<span class="badge badge-success badge-sm">Complete</span>|
|
||||||
|
|
@ -244,11 +252,16 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp group_to_pair({_id, [%{rover_location: location} | _] = group}) do
|
defp group_to_pair({_id, [%{rover_location: location} | _] = group}) do
|
||||||
{location, Enum.sort_by(group, &station_position/1)}
|
{location, Enum.sort_by(group, &sort_key/1)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp group_to_pair({_id, []}), do: {nil, []}
|
defp group_to_pair({_id, []}), do: {nil, []}
|
||||||
|
|
||||||
|
# Sort within each rover-location group by station position (so all
|
||||||
|
# bands for a station cluster together) then ascending band — gives
|
||||||
|
# 902 → 1296 → 2304 → … in each station block.
|
||||||
|
defp sort_key(path), do: {station_position(path), path.band_mhz || 0}
|
||||||
|
|
||||||
defp station_position(%{station: %{position: pos}}) when is_integer(pos), do: pos
|
defp station_position(%{station: %{position: pos}}) when is_integer(pos), do: pos
|
||||||
defp station_position(_), do: 0
|
defp station_position(_), do: 0
|
||||||
|
|
||||||
|
|
@ -326,16 +339,17 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
defp path_url(_mission, %{rover_location: nil}), do: nil
|
defp path_url(_mission, %{rover_location: nil}), do: nil
|
||||||
defp path_url(_mission, %{station: nil}), do: nil
|
defp path_url(_mission, %{station: nil}), do: nil
|
||||||
|
|
||||||
defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station})
|
defp path_url(%Mission{} = mission, %{rover_location: %{lat: lat, lon: lon}, station: station} = path)
|
||||||
when is_number(lat) and is_number(lon) do
|
when is_number(lat) and is_number(lon) do
|
||||||
src = Maidenhead.from_latlon(lat, lon, 10)
|
src = Maidenhead.from_latlon(lat, lon, 10)
|
||||||
dst = station_endpoint(station)
|
dst = station_endpoint(station)
|
||||||
|
band = path.band_mhz || mission.band_mhz
|
||||||
|
|
||||||
query =
|
query =
|
||||||
URI.encode_query(%{
|
URI.encode_query(%{
|
||||||
"source" => src,
|
"source" => src,
|
||||||
"destination" => dst,
|
"destination" => dst,
|
||||||
"band" => Integer.to_string(mission.band_mhz),
|
"band" => Integer.to_string(band),
|
||||||
"src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
|
"src_height_ft" => Float.to_string(mission.rover_height_ft * 1.0),
|
||||||
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
|
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
|
||||||
})
|
})
|
||||||
|
|
@ -356,7 +370,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
<.header>
|
<.header>
|
||||||
{@mission.name}
|
{@mission.name}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
|
{bands_label(@mission)} · {length(@mission.stations)} station(s) ·
|
||||||
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
|
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
<:actions>
|
<:actions>
|
||||||
|
|
@ -520,6 +534,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Station</th>
|
<th>Station</th>
|
||||||
|
<th>Band</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Distance</th>
|
<th>Distance</th>
|
||||||
<th>Loss</th>
|
<th>Loss</th>
|
||||||
|
|
@ -541,6 +556,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
|
||||||
<div>{primary}</div>
|
<div>{primary}</div>
|
||||||
<div :if={secondary} class="opacity-60">{secondary}</div>
|
<div :if={secondary} class="opacity-60">{secondary}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="font-mono text-xs">
|
||||||
|
{band_label(path.band_mhz || @mission.band_mhz)}
|
||||||
|
</td>
|
||||||
<td>{status_badge(path.status)}</td>
|
<td>{status_badge(path.status)}</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
{if path.result, do: format_distance(path.result["distance_km"]), else: "—"}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.AddMultiBandRoverPlanning do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# Mission carries the SET of bands the operator wants to evaluate.
|
||||||
|
# `band_mhz` stays as a denormalized "primary band" for any legacy
|
||||||
|
# readers; new code reads `bands_mhz`.
|
||||||
|
alter table(:rover_missions) do
|
||||||
|
add :bands_mhz, {:array, :integer}, default: [], null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
execute(
|
||||||
|
"UPDATE rover_missions SET bands_mhz = ARRAY[band_mhz] WHERE coalesce(array_length(bands_mhz, 1), 0) = 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each computed path is now scoped to a specific band — the worker
|
||||||
|
# uses this for its loss budget + propagation-score lookup.
|
||||||
|
alter table(:rover_mission_paths) do
|
||||||
|
add :band_mhz, :integer
|
||||||
|
end
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
UPDATE rover_mission_paths p
|
||||||
|
SET band_mhz = m.band_mhz
|
||||||
|
FROM rover_missions m
|
||||||
|
WHERE p.mission_id = m.id AND p.band_mhz IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
alter table(:rover_mission_paths) do
|
||||||
|
modify :band_mhz, :integer, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
drop_if_exists(
|
||||||
|
unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
create unique_index(
|
||||||
|
:rover_mission_paths,
|
||||||
|
[:mission_id, :rover_location_id, :station_id, :band_mhz],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists(
|
||||||
|
unique_index(
|
||||||
|
:rover_mission_paths,
|
||||||
|
[:mission_id, :rover_location_id, :station_id, :band_mhz],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
create unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
|
||||||
|
name: :rover_mission_paths_unique
|
||||||
|
)
|
||||||
|
|
||||||
|
alter table(:rover_mission_paths) do
|
||||||
|
remove :band_mhz
|
||||||
|
end
|
||||||
|
|
||||||
|
alter table(:rover_missions) do
|
||||||
|
remove :bands_mhz
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -148,6 +148,49 @@ defmodule Microwaveprop.RoverPlanningTest do
|
||||||
assert path.status == :complete
|
assert path.status == :complete
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "multi-band mission inserts one path per (rover, station, band)" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
valid_attrs(%{
|
||||||
|
"bands_mhz" => [10_000, 24_000, 47_000],
|
||||||
|
# Form may still include band_mhz (legacy primary band); normalize
|
||||||
|
# should overwrite it from bands_mhz in any case.
|
||||||
|
"band_mhz" => 10_000
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||||||
|
assert mission.bands_mhz == [10_000, 24_000, 47_000]
|
||||||
|
|
||||||
|
paths = RoverPlanning.list_paths(mission)
|
||||||
|
assert length(paths) == 3
|
||||||
|
assert Enum.sort(Enum.map(paths, & &1.band_mhz)) == [10_000, 24_000, 47_000]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reconcile drops paths whose band is no longer requested" do
|
||||||
|
user = AccountsFixtures.user_fixture()
|
||||||
|
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||||||
|
|
||||||
|
attrs = valid_attrs(%{"bands_mhz" => [10_000, 24_000]})
|
||||||
|
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||||||
|
assert length(RoverPlanning.list_paths(mission)) == 2
|
||||||
|
|
||||||
|
# User narrows the mission to a single band — reconcile should
|
||||||
|
# remove the now-unused 24 GHz row, keep the 10 GHz row.
|
||||||
|
mission =
|
||||||
|
mission
|
||||||
|
|> Mission.changeset(%{"bands_mhz" => [10_000]})
|
||||||
|
|> Repo.update!()
|
||||||
|
|> Repo.preload([:user, :stations], force: true)
|
||||||
|
|
||||||
|
:ok = RoverPlanning.replace_mission_paths(mission)
|
||||||
|
|
||||||
|
paths = RoverPlanning.list_paths(mission)
|
||||||
|
assert length(paths) == 1
|
||||||
|
assert hd(paths).band_mhz == 10_000
|
||||||
|
end
|
||||||
|
|
||||||
test "with only_known_good=false, all rover-locations get a path entry" do
|
test "with only_known_good=false, all rover-locations get a path entry" do
|
||||||
user = AccountsFixtures.user_fixture()
|
user = AccountsFixtures.user_fixture()
|
||||||
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||||||
|
|
|
||||||
|
|
@ -590,7 +590,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
|> form("#mission-form",
|
|> form("#mission-form",
|
||||||
mission: %{
|
mission: %{
|
||||||
name: "Some name",
|
name: "Some name",
|
||||||
band_mhz: "10000",
|
bands_mhz: ["10000"],
|
||||||
rover_height_ft: "8.0",
|
rover_height_ft: "8.0",
|
||||||
station_height_ft: "30.0",
|
station_height_ft: "30.0",
|
||||||
only_known_good: "true",
|
only_known_good: "true",
|
||||||
|
|
@ -653,7 +653,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|
||||||
|> form("#mission-form",
|
|> form("#mission-form",
|
||||||
mission: %{
|
mission: %{
|
||||||
name: "From form",
|
name: "From form",
|
||||||
band_mhz: "10000",
|
bands_mhz: ["10000"],
|
||||||
only_known_good: "true",
|
only_known_good: "true",
|
||||||
rover_height_ft: "8.0",
|
rover_height_ft: "8.0",
|
||||||
station_height_ft: "30.0",
|
station_height_ft: "30.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue