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:
Graham McIntire 2026-05-03 14:08:49 -05:00
parent b247e8a719
commit 2c88fba1df
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
9 changed files with 342 additions and 51 deletions

View file

@ -112,16 +112,46 @@ defmodule Microwaveprop.RoverPlanning do
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.
Reconciles the path matrix for a mission against the current set of
(rover_location × station × band) tuples. Deletes paths for tuples
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
def replace_mission_paths(%Mission{} = mission) do
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
enqueue_paths_for(mission)
@spec reconcile_mission_paths(Mission.t()) :: :ok
def reconcile_mission_paths(%Mission{} = mission) do
desired = MapSet.new(desired_tuples(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
@spec replace_mission_paths(Mission.t()) :: :ok
defdelegate replace_mission_paths(mission), to: __MODULE__, as: :reconcile_mission_paths
@doc """
Walks every mission and re-enqueues any (rover_location × station)
pair whose `Path` is missing or not `:complete`. Existing complete
@ -143,17 +173,22 @@ defmodule Microwaveprop.RoverPlanning do
end
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)
stations = mission.stations || []
bands = Mission.bands(mission)
pairs =
for rover <- rovers, station <- stations do
%{rover: rover, station: station}
end
case pairs do
[] -> :ok
_ -> insert_pending_paths_and_jobs(mission, pairs)
for rover <- rovers, station <- stations, band <- bands do
{rover.id, station.id, band}
end
end
@ -171,34 +206,41 @@ defmodule Microwaveprop.RoverPlanning do
Repo.all(from(l in Location, order_by: [desc: l.inserted_at]))
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, _} =
pairs
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
tuples
|> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi ->
attrs = %{
mission_id: mission.id,
rover_location_id: r.id,
station_id: s.id,
rover_location_id: rover_id,
station_id: station_id,
band_mhz: band_mhz,
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,
conflict_target: [:mission_id, :rover_location_id, :station_id]
conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz]
)
end)
|> Repo.transaction()
enqueue_jobs(mission, pairs)
enqueue_jobs(tuples, mission)
:ok
end
defp enqueue_jobs(mission, pairs) do
for %{rover: r, station: s} <- pairs do
defp enqueue_jobs(tuples, mission) do
for {rover_id, station_id, band_mhz} <- tuples do
%{
"mission_id" => mission.id,
"rover_location_id" => r.id,
"station_id" => s.id
"rover_location_id" => rover_id,
"station_id" => station_id,
"band_mhz" => band_mhz
}
|> RoverPathProfileWorker.new()
|> Oban.insert()

View file

@ -21,6 +21,7 @@ defmodule Microwaveprop.RoverPlanning.Mission do
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
@ -38,26 +39,92 @@ defmodule Microwaveprop.RoverPlanning.Mission do
@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(attrs, @castable_fields)
|> 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 != [] ->

View file

@ -18,6 +18,7 @@ defmodule Microwaveprop.RoverPlanning.Path do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_mission_paths" do
field :band_mhz, :integer
field :status, Ecto.Enum, values: @statuses, default: :pending
field :result, :map
field :error, :string
@ -39,6 +40,7 @@ defmodule Microwaveprop.RoverPlanning.Path do
def changeset(path, attrs) do
path
|> cast(attrs, [
:band_mhz,
:status,
:result,
:error,
@ -47,9 +49,10 @@ defmodule Microwaveprop.RoverPlanning.Path do
:rover_location_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)
|> 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
)
end

View file

@ -44,10 +44,8 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
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
def perform(%Oban.Job{args: args}) do
case load_path(args) do
nil ->
:ok
@ -59,21 +57,33 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
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
|> where([p], p.mission_id == ^mission_id)
|> where([p], p.rover_location_id == ^rover_location_id)
|> where([p], p.station_id == ^station_id)
|> maybe_filter_band(band_mhz)
|> preload([:rover_location, :station, :mission])
|> Repo.one()
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
mark_status(path, :computing)
band_mhz = path.band_mhz || mission.band_mhz
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
freq_ghz = 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)
@ -94,10 +104,10 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
midlat = (rover.lat + station.lat) / 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 =
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)

View file

@ -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?(_, _), 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
def render(assigns) do
~H"""
@ -201,13 +214,41 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Form do
>
<.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}
/>
<div>
<label class="label">
<span class="label-text">Bands</span>
<span class="label-text-alt text-base-content/60">
Pick one or more paths recompute per band
</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
field={@form[:rover_height_ft]}
type="number"

View file

@ -161,6 +161,14 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
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
assigns = %{}
~H|<span class="badge badge-success badge-sm">Complete</span>|
@ -244,11 +252,16 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
end
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
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(_), do: 0
@ -326,16 +339,17 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
defp path_url(_mission, %{rover_location: 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
src = Maidenhead.from_latlon(lat, lon, 10)
dst = station_endpoint(station)
band = path.band_mhz || mission.band_mhz
query =
URI.encode_query(%{
"source" => src,
"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),
"dst_height_ft" => Float.to_string(mission.station_height_ft * 1.0)
})
@ -356,7 +370,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
<.header>
{@mission.name}
<: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
</:subtitle>
<:actions>
@ -520,6 +534,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
<thead>
<tr>
<th>Station</th>
<th>Band</th>
<th>Status</th>
<th>Distance</th>
<th>Loss</th>
@ -541,6 +556,9 @@ defmodule MicrowavepropWeb.RoverPlanningLive.Show do
<div>{primary}</div>
<div :if={secondary} class="opacity-60">{secondary}</div>
</td>
<td class="font-mono text-xs">
{band_label(path.band_mhz || @mission.band_mhz)}
</td>
<td>{status_badge(path.status)}</td>
<td class="text-xs">
{if path.result, do: format_distance(path.result["distance_km"]), else: ""}

View file

@ -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

View file

@ -148,6 +148,49 @@ defmodule Microwaveprop.RoverPlanningTest do
assert path.status == :complete
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
user = AccountsFixtures.user_fixture()
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})

View file

@ -590,7 +590,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|> form("#mission-form",
mission: %{
name: "Some name",
band_mhz: "10000",
bands_mhz: ["10000"],
rover_height_ft: "8.0",
station_height_ft: "30.0",
only_known_good: "true",
@ -653,7 +653,7 @@ defmodule MicrowavepropWeb.RoverPlanningLiveTest do
|> form("#mission-form",
mission: %{
name: "From form",
band_mhz: "10000",
bands_mhz: ["10000"],
only_known_good: "true",
rover_height_ft: "8.0",
station_height_ft: "30.0",