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
|
||||
|
||||
@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
|
||||
rovers = candidate_rover_locations(mission)
|
||||
stations = mission.stations || []
|
||||
|
||||
pairs =
|
||||
for rover <- rovers, station <- stations do
|
||||
%{rover: rover, station: station}
|
||||
mission
|
||||
|> desired_tuples()
|
||||
|> insert_pending_paths_and_jobs(mission)
|
||||
end
|
||||
|
||||
case pairs do
|
||||
[] -> :ok
|
||||
_ -> insert_pending_paths_and_jobs(mission, pairs)
|
||||
# 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)
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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 != [] ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: "—"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue